Problem overview
The method findWords receives a grid of letters, board, with R rows and C columns, and a vector of strings, words. It returns every word that can be traced on the board. A word is traced along a path of cells: its first letter is in some cell, and each next letter is in a cell next to the previous one, horizontally or vertically but not diagonally. A cell can be used only once within the same word. Each word is returned once, in any order, and the answer is empty when no word can be traced.
In the first example, the board is
o a a n
e t a e
i h k r
i f l v
and words = ["oath", "pea", "eat", "rain"]. The word "oath" starts at the top-left o, goes right to a, then down to t and down again to h. The word "eat" starts at the e at the end of the second row and goes left through a to t. The board has no p, and no a touches the r, so "pea" and "rain" are not found. The answer is eat and oath.
Approach: search the board once, following a trie of the words
The direct method is to run a path search on the board for every word. That repeats work: the words "oath" and "oat" both begin with the same three letters, and each search walks that start again. With up to 30,000 words, the repetition adds up.
A trie (prefix tree) stores all the words at once. Each node stands for a prefix, and its children map each next letter to the node for the longer prefix. Words that start the same way share the same path from the root.
The search uses the trie as a guide. From every cell, a depth-first search carries a trie node, the prefix traced so far. At each cell it asks whether the node has a child for the cell's letter. If it does not, no word continues this path, and the search stops immediately. If it does, the path moves one level down the trie, and the search continues into the four neighbours. One search from a cell looks for every word at once, and it only follows paths that begin some word.
The search is backtracking: it marks a cell as used while the path goes through it, and undoes the mark when the path leaves the cell, so the cell is free again for other paths. With words of at most L letters, a path is at most L cells long and each step has at most four choices (usually three, since the path just came from one neighbour). That gives O(R · C · 4^L) time, plus the time to build the trie, whatever the number of words.
Step by step
The lesson first defines the trie node, then builds the trie in findWords, starts a search from each cell, and finally writes the search itself, dfs.
- Give
TrieNodeits children. Anunordered_map<char, TrieNode*>namedchildrenmaps each next letter to a pointer to the child node. It starts out empty. - Store the finished word on the node. A
string word = ""holds the whole word at the node where a word ends, and the empty string means that no word ends here. Every word has at least one letter, so""can't be confused with a real word. Keeping the word itself instead of a flag lets the search put it in the answer the moment its path is complete. - Create the root.
TrieNode* root = new TrieNode()stands for the empty prefix. - Insert each word. Loop over
string& word : words. - Start at the root.
TrieNode* node = rootis the walk's current position for this word. - Loop over the letters with
for (char c : word). - Create a missing child. If
!node->children.count(c), store anew TrieNode()innode->children[c]. - Move down:
node = node->children[c]. The child exists now, whether it was already there or was just created. - Mark the word's end. After the letter loop, set
node->word = word. - Prepare the answer. Declare
vector<string> found. The helper adds to this one list, so searches from every cell collect into the same place. - Loop over the rows,
rowfrom 0 whilerow < board.size(). - Loop over the columns,
colfrom 0 whilecol < board[0].size(). Every row has the same length, so the first row gives the number of columns. - Search from the cell:
dfs(board, row, col, root, found). The board and the list are passed by reference, so the helper changes these, not copies. - Return
found. Each word is in it at most once, because the helper clears a word as soon as it finds it. - Reject cells off the board. At the top of
dfs, ifroworcolis below 0 or past the last row or column, return. The calls below step to all four neighbours without checking them first, so edge cells send some calls off the board, and they end here. - Remember the letter.
char letter = board[row][col]is needed again at the end, to put it back. - Stop if no word continues. If
!node->children.count(letter), return. This check keeps the search small. - Go down the trie.
TrieNode* child = node->children[letter]stands for the path so far plus this cell's letter. - Check for a finished word. If
child->word != "", the path spells exactly that word. - Record it:
found.push_back(child->word). - Clear it:
child->word = "". A word can often be traced along several paths. Once cleared, the next path that spells it adds nothing, so each word appears once. - Mark the cell as used:
board[row][col] = '#'. No word contains#, so a call that comes back to this cell stops at thechildrencheck. - Search down and up:
dfswithrow + 1and withrow - 1, the samecol, andchildas the node. The path now includes this cell, so the neighbours continue fromchild. - Search right and left:
dfswithcol + 1andcol - 1, the samerow. - Put the letter back:
board[row][col] = letter. Undoing the mark when the search leaves a cell is what makes this backtracking.
The solution in C++
struct TrieNode {
unordered_map<char, TrieNode*> children;
string word = "";
};
class Solution {
public:
vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
TrieNode* root = new TrieNode();
for (string& word : words) {
TrieNode* node = root;
for (char c : word) {
if (!node->children.count(c)) {
node->children[c] = new TrieNode();
}
node = node->children[c];
}
node->word = word;
}
vector<string> found;
for (int row = 0; row < board.size(); row++) {
for (int col = 0; col < board[0].size(); col++) {
dfs(board, row, col, root, found);
}
}
return found;
}
void dfs(vector<vector<char>>& board, int row, int col, TrieNode* node, vector<string>& found) {
if (row < 0 || row >= board.size() || col < 0 || col >= board[0].size()) {
return;
}
char letter = board[row][col];
if (!node->children.count(letter)) {
return;
}
TrieNode* child = node->children[letter];
if (child->word != "") {
found.push_back(child->word);
child->word = "";
}
board[row][col] = '#';
dfs(board, row + 1, col, child, found);
dfs(board, row - 1, col, child, found);
dfs(board, row, col + 1, child, found);
dfs(board, row, col - 1, child, found);
board[row][col] = letter;
}
};
The C++ details worth a closer look:
- Pointers in the map.
childrenstoresTrieNode*pointers, so each child is a separate node made withnewthat stays alive until it is deleted. The lesson never deletes the trie, so its memory is not returned after the call; a long-running program would delete the nodes. countversus[].children.count(c)only asks whether the key exists, whilechildren[c]inserts an empty entry (a null pointer) when the key is missing. That is why every[]read in this code comes after acountcheck or right after the insertion.- Signed and unsigned.
board.size()is unsigned, androw >= board.size()convertsrowto unsigned. A negativerowwould become a huge number, which is whyrow < 0comes first in the condition. - Reference parameters.
vector<vector<char>>& boardandvector<string>& foundavoid copying the grid and the list on every recursive call. They also letdfschange the one shared board and add to the one shared answer. - The board is changed and restored. While a path is in progress, its cells hold
#. When every call has returned, each cell has its original letter again, so the caller's board is left as it was. - Order of the answer.
findWordsreturns words in the order the search finds them, not alphabetically: on the first example the order isoath, theneat. The problem accepts any order.
Complexity
Let the board have R rows and C columns, the longest word have L letters, and the total number of letters in all words be S.
Time: O(S + R · C · 4^L). Building the trie takes one step per letter, O(S). Then dfs is started from R · C cells. A path can't be longer than the longest word, because the trie has no deeper node, so each search goes at most L cells deep with at most four choices per step. The number of words doesn't multiply this term: all the words share one search, and the trie check cuts every path that doesn't start a word.
Space: O(S) for the trie in the worst case, when no two words share a prefix, plus O(L) for the recursion stack and the answer. The board is reused, not copied.
Edge cases and common mistakes
- A cell used twice. On the board
[["a","b"],["c","d"]], "abdb" would need thebagain, and "ad" needs a diagonal step. Neither is found, and the answer is empty. Marking cells with#is what prevents the reuse. - One word, several paths. On the board
[["s","e","e"],["n","e","t"]], the word "see" can be traced in two ways. Without clearingchild->word, the answer for the words["see","seen","set","net","tee","teen"]would list "see" and "tee" twice:see, seen, see, net, tee, tee. With the clearing it issee, seen, net, tee. - Forgetting to restore the letter. If the last line of
dfsis missing, cells stay as#after the first path that visited them, and later paths and later start cells fail. On the first example the answer would shrink to justoath. - Checking the neighbours before the board edge. Reading
board[row][col]before the bounds test indexes outside the vector, which is undefined behaviour. - Prefix words. A word can be a prefix of another. With
["a","ab","b","ba"]on[["a","b"]], all four are found, because the word is recorded at the node where its path ends and the search continues deeper below it. - A word longer than the board allows. On
[["a","a"]], the word "aaa" would need three cells, and the answer is empty. - Using a flag instead of the word. With a boolean flag on the node, the search would have to rebuild the word from the path. Storing the word itself is simpler.
Testing
Each answer below is shown sorted.
- Board
["oaan","etae","ihkr","iflv"](one string per row),words = ["oath","pea","eat","rain"]gives["eat","oath"]. - Board
["ab","cd"],words = ["abdb","ad"]gives[]. - Board
["see","net"],words = ["see","seen","set","net","tee","teen"]gives["net","see","seen","tee"]. - Board
["a"],words = ["a"]gives["a"]. - Board
["aa"],words = ["aaa"]gives[]. - Board
["ab"],words = ["ab","a","b","ba"]gives["a","ab","b","ba"].
