Problem

Ghost is a two-person word game where players alternate appending letters to a word. The first person who spells out a word, or creates a prefix for which there is no possible continuation, loses.

Given a dictionary of words, determine the letters the first player should start with, such that with optimal play they cannot lose.

Examples

Example 1

1
2
3
Input: dictionary = ["cat", "calf", "dog", "bear"]
Output: ['b']
Explanation: If player 1 starts with 'b', they can force a win. Starting with 'b' leads to "bear" path where player 1 can control the game optimally.

Example 2

1
2
3
Input: dictionary = ["a", "ab", "abc"]
Output: []
Explanation: No matter what letter player 1 starts with, player 2 can force them to lose with optimal play.

Example 3

1
2
3
Input: dictionary = ["ghost", "ghoul", "go", "good"]
Output: ['g']
Explanation: Starting with 'g' allows player 1 to control the game optimally.

Example 4

1
2
3
Input: dictionary = ["cat", "car", "card", "care", "careful"]
Output: ['c']
Explanation: Starting with 'c' gives player 1 a winning strategy.

Method 1 - Game Theory with Trie and Minimax

Intuition

This is a classic game theory problem. We need to build a trie of all words and then use minimax algorithm to determine winning positions. A position is winning for the current player if they can make a move that puts the opponent in a losing position. A position is losing if all moves lead to winning positions for the opponent.

Approach

  1. Build a trie from all dictionary words
  2. Mark terminal nodes (complete words) in the trie
  3. Use dynamic programming with game theory:
    • A node is losing if it’s a terminal word (current player loses by completing a word)
    • A node is losing if it has no children (no valid continuation)
    • A node is winning if at least one child leads to a losing position for opponent
    • A node is losing if all children lead to winning positions for opponent
  4. Check all possible starting letters (a-z) to see which ones lead to winning positions
  5. Return the letters that guarantee a win for player 1

Code

C++

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
class Solution {
public:
    vector<char> ghostGame(vector<string>& dictionary) {
        TrieNode* root = buildTrie(dictionary);
        computeWinLose(root);
        
        vector<char> ans;
        for (int i = 0; i < 26; i++) {
            if (root->children[i] && !root->children[i]->isWinning) {
                ans.push_back('a' + i);
            }
        }
        return ans;
    }
    
private:
    struct TrieNode {
        TrieNode* children[26];
        bool isWord;
        bool isWinning;
        
        TrieNode() : isWord(false), isWinning(false) {
            for (int i = 0; i < 26; i++) {
                children[i] = nullptr;
            }
        }
    };
    
    TrieNode* buildTrie(vector<string>& words) {
        TrieNode* root = new TrieNode();
        for (string& word : words) {
            TrieNode* curr = root;
            for (char c : word) {
                int idx = c - 'a';
                if (!curr->children[idx]) {
                    curr->children[idx] = new TrieNode();
                }
                curr = curr->children[idx];
            }
            curr->isWord = true;
        }
        return root;
    }
    
    void computeWinLose(TrieNode* node) {
        if (!node) return;
        
        if (node->isWord) {
            node->isWinning = false;
            return;
        }
        
        bool hasChildren = false;
        bool allChildrenWinning = true;
        
        for (int i = 0; i < 26; i++) {
            if (node->children[i]) {
                hasChildren = true;
                computeWinLose(node->children[i]);
                if (!node->children[i]->isWinning) {
                    allChildrenWinning = false;
                }
            }
        }
        
        if (!hasChildren) {
            node->isWinning = false;
        } else {
            node->isWinning = !allChildrenWinning;
        }
    }
};

Go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
type TrieNode struct {
    children [26]*TrieNode
    isWord   bool
    isWinning bool
}

func ghostGame(dictionary []string) []byte {
    root := buildTrie(dictionary)
    computeWinLose(root)
    
    var ans []byte
    for i := 0; i < 26; i++ {
        if root.children[i] != nil && !root.children[i].isWinning {
            ans = append(ans, byte('a' + i))
        }
    }
    return ans
}

func buildTrie(words []string) *TrieNode {
    root := &TrieNode{}
    for _, word := range words {
        curr := root
        for _, c := range word {
            idx := c - 'a'
            if curr.children[idx] == nil {
                curr.children[idx] = &TrieNode{}
            }
            curr = curr.children[idx]
        }
        curr.isWord = true
    }
    return root
}

func computeWinLose(node *TrieNode) {
    if node == nil {
        return
    }
    
    if node.isWord {
        node.isWinning = false
        return
    }
    
    hasChildren := false
    allChildrenWinning := true
    
    for i := 0; i < 26; i++ {
        if node.children[i] != nil {
            hasChildren = true
            computeWinLose(node.children[i])
            if !node.children[i].isWinning {
                allChildrenWinning = false
            }
        }
    }
    
    if !hasChildren {
        node.isWinning = false
    } else {
        node.isWinning = !allChildrenWinning
    }
}

Java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
    public List<Character> ghostGame(String[] dictionary) {
        TrieNode root = buildTrie(dictionary);
        computeWinLose(root);
        
        List<Character> ans = new ArrayList<>();
        for (int i = 0; i < 26; i++) {
            if (root.children[i] != null && !root.children[i].isWinning) {
                ans.add((char)('a' + i));
            }
        }
        return ans;
    }
    
    class TrieNode {
        TrieNode[] children = new TrieNode[26];
        boolean isWord = false;
        boolean isWinning = false;
    }
    
    private TrieNode buildTrie(String[] words) {
        TrieNode root = new TrieNode();
        for (String word : words) {
            TrieNode curr = root;
            for (char c : word.toCharArray()) {
                int idx = c - 'a';
                if (curr.children[idx] == null) {
                    curr.children[idx] = new TrieNode();
                }
                curr = curr.children[idx];
            }
            curr.isWord = true;
        }
        return root;
    }
    
    private void computeWinLose(TrieNode node) {
        if (node == null) return;
        
        if (node.isWord) {
            node.isWinning = false;
            return;
        }
        
        boolean hasChildren = false;
        boolean allChildrenWinning = true;
        
        for (int i = 0; i < 26; i++) {
            if (node.children[i] != null) {
                hasChildren = true;
                computeWinLose(node.children[i]);
                if (!node.children[i].isWinning) {
                    allChildrenWinning = false;
                }
            }
        }
        
        if (!hasChildren) {
            node.isWinning = false;
        } else {
            node.isWinning = !allChildrenWinning;
        }
    }
}

Kotlin

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
    fun ghostGame(dictionary: Array<String>): List<Char> {
        val root = buildTrie(dictionary)
        computeWinLose(root)
        
        val ans = mutableListOf<Char>()
        for (i in 0..25) {
            if (root.children[i] != null && !root.children[i]!!.isWinning) {
                ans.add('a' + i)
            }
        }
        return ans
    }
    
    class TrieNode {
        val children = Array<TrieNode?>(26) { null }
        var isWord = false
        var isWinning = false
    }
    
    private fun buildTrie(words: Array<String>): TrieNode {
        val root = TrieNode()
        for (word in words) {
            var curr = root
            for (c in word) {
                val idx = c - 'a'
                if (curr.children[idx] == null) {
                    curr.children[idx] = TrieNode()
                }
                curr = curr.children[idx]!!
            }
            curr.isWord = true
        }
        return root
    }
    
    private fun computeWinLose(node: TrieNode?) {
        if (node == null) return
        
        if (node.isWord) {
            node.isWinning = false
            return
        }
        
        var hasChildren = false
        var allChildrenWinning = true
        
        for (i in 0..25) {
            if (node.children[i] != null) {
                hasChildren = true
                computeWinLose(node.children[i])
                if (!node.children[i]!!.isWinning) {
                    allChildrenWinning = false
                }
            }
        }
        
        if (!hasChildren) {
            node.isWinning = false
        } else {
            node.isWinning = !allChildrenWinning
        }
    }
}

Python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution:
    def ghostGame(self, dictionary: List[str]) -> List[str]:
        root = self.buildTrie(dictionary)
        self.computeWinLose(root)
        
        ans = []
        for i in range(26):
            if root.children[i] and not root.children[i].isWinning:
                ans.append(chr(ord('a') + i))
        return ans
    
    class TrieNode:
        def __init__(self):
            self.children = [None] * 26
            self.isWord = False
            self.isWinning = False
    
    def buildTrie(self, words: List[str]) -> 'TrieNode':
        root = self.TrieNode()
        for word in words:
            curr = root
            for c in word:
                idx = ord(c) - ord('a')
                if not curr.children[idx]:
                    curr.children[idx] = self.TrieNode()
                curr = curr.children[idx]
            curr.isWord = True
        return root
    
    def computeWinLose(self, node: 'TrieNode') -> None:
        if not node:
            return
        
        if node.isWord:
            node.isWinning = False
            return
        
        hasChildren = False
        allChildrenWinning = True
        
        for i in range(26):
            if node.children[i]:
                hasChildren = True
                self.computeWinLose(node.children[i])
                if not node.children[i].isWinning:
                    allChildrenWinning = False
        
        if not hasChildren:
            node.isWinning = False
        else:
            node.isWinning = not allChildrenWinning

Rust

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
impl Solution {
    pub fn ghost_game(dictionary: Vec<String>) -> Vec<char> {
        let mut root = Self::build_trie(&dictionary);
        Self::compute_win_lose(&mut root);
        
        let mut ans = Vec::new();
        for i in 0..26 {
            if let Some(child) = &root.children[i] {
                if !child.is_winning {
                    ans.push((b'a' + i as u8) as char);
                }
            }
        }
        ans
    }
    
    fn build_trie(words: &Vec<String>) -> TrieNode {
        let mut root = TrieNode::new();
        for word in words {
            let mut curr = &mut root;
            for c in word.chars() {
                let idx = (c as u8 - b'a') as usize;
                if curr.children[idx].is_none() {
                    curr.children[idx] = Some(Box::new(TrieNode::new()));
                }
                curr = curr.children[idx].as_mut().unwrap();
            }
            curr.is_word = true;
        }
        root
    }
    
    fn compute_win_lose(node: &mut TrieNode) {
        if node.is_word {
            node.is_winning = false;
            return;
        }
        
        let mut has_children = false;
        let mut all_children_winning = true;
        
        for i in 0..26 {
            if let Some(child) = &mut node.children[i] {
                has_children = true;
                Self::compute_win_lose(child);
                if !child.is_winning {
                    all_children_winning = false;
                }
            }
        }
        
        if !has_children {
            node.is_winning = false;
        } else {
            node.is_winning = !all_children_winning;
        }
    }
}

struct TrieNode {
    children: [Option<Box<TrieNode>>; 26],
    is_word: bool,
    is_winning: bool,
}

impl TrieNode {
    fn new() -> Self {
        TrieNode {
            children: Default::default(),
            is_word: false,
            is_winning: false,
        }
    }
}

TypeScript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
    ghostGame(dictionary: string[]): string[] {
        const root = this.buildTrie(dictionary);
        this.computeWinLose(root);
        
        const ans: string[] = [];
        for (let i = 0; i < 26; i++) {
            if (root.children[i] && !root.children[i]!.isWinning) {
                ans.push(String.fromCharCode('a'.charCodeAt(0) + i));
            }
        }
        return ans;
    }
    
    private buildTrie(words: string[]): TrieNode {
        const root = new TrieNode();
        for (const word of words) {
            let curr = root;
            for (const c of word) {
                const idx = c.charCodeAt(0) - 'a'.charCodeAt(0);
                if (!curr.children[idx]) {
                    curr.children[idx] = new TrieNode();
                }
                curr = curr.children[idx]!;
            }
            curr.isWord = true;
        }
        return root;
    }
    
    private computeWinLose(node: TrieNode | null): void {
        if (!node) return;
        
        if (node.isWord) {
            node.isWinning = false;
            return;
        }
        
        let hasChildren = false;
        let allChildrenWinning = true;
        
        for (let i = 0; i < 26; i++) {
            if (node.children[i]) {
                hasChildren = true;
                this.computeWinLose(node.children[i]);
                if (!node.children[i]!.isWinning) {
                    allChildrenWinning = false;
                }
            }
        }
        
        if (!hasChildren) {
            node.isWinning = false;
        } else {
            node.isWinning = !allChildrenWinning;
        }
    }
}

class TrieNode {
    children: (TrieNode | null)[] = new Array(26).fill(null);
    isWord: boolean = false;
    isWinning: boolean = false;
}

Complexity

  • ⏰ Time complexity: O(N * M + 26 * T), where N is the number of words, M is the average word length for building the trie, and T is the number of trie nodes for computing win/lose states
  • 🧺 Space complexity: O(T), where T is the total number of nodes in the trie, which can be up to N * M in the worst case