Problem overview
The function findWords receives a grid of lowercase letters, board, as a [][]byte, and a slice of strings, words. It returns every word that can be traced on the board. A word is traced by a path of cells: its first letter is in some cell, and each next letter is in a cell directly above, below, left or right of the previous one. Diagonal neighbours don't count, and a cell can be used only once within the same word. Each word is returned once, and the order of the result is not important to the algorithm.
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 words oath and eat can be traced, so the answer is ["eat", "oath"] once sorted. In the second example, the board is ["ab", "cd"] and words = ["abdb", "ad"]. The word abdb would need to use the b twice, and a and d touch only at a corner, so the answer is empty.
Approach: one search for all words, guided by a trie
The direct method runs a search on the board for each word. With up to 30,000 words, that repeats the same work for words that begin the same way: oath and oat would both walk o, a, t from scratch.
A trie stores all the words in a tree where each edge is a letter, so words with a common beginning share a path. The plan is:
- Insert every word into a trie.
- Start a depth-first search from every cell of the board, walking the trie at the same time.
- Follow a neighbour only if the trie has an edge for its letter. A path that doesn't begin any word is abandoned at once.
- Whenever the walk reaches a node where a word ends, record that word.
One search from a cell looks for all the words at once, because the trie's root contains all of them. Words are at most L = 10 letters long, so a search goes at most L cells deep. Each step has at most four directions, which gives the statement's bound of O(R * C * 4^L) for the search, plus the time to build the trie, whatever the number of words. In practice the trie prunes most paths long before they reach that depth.
Two details make it correct. First, a cell must not be used twice in one path, so the search marks the cell it is on and restores it when it leaves, which is backtracking. Second, a word can often be traced along several paths, so a word is cleared from the trie once it is found; later paths that spell it find nothing to add.
Step by step
Building the trie.
- Give
TrieNodeachildrenfield of typemap[rune]*TrieNode. It maps the next letter to a pointer to the child node. The keys are runes becauserangeover a string yields runes, and the values are pointers so every walk works on the shared nodes. - Give it a
wordfield of typestring. On the node where a word ends it holds that word; the empty string means no word ends there. Every word has at least one letter, so""is never a real word. - Write
NewTrieNode. It returns a pointer to a newTrieNodewhosechildrenis an empty map made withmake(map[rune]*TrieNode). A map has to be made before anything can be stored in it. - Create the root in
findWords:root := NewTrieNode(). It stands for the empty prefix. - Loop over the words with
range, skipping the index with_. - Start at the root for each word:
node := root. - Loop over the letters
cof the word. Each letter moves one level down. - Create a missing edge. If
node.children[c]isnil, storeNewTrieNode()there. Reading a key that is not in a Go map gives the zero value of the element type, here anilpointer. - Move down:
node = node.children[c]. - Mark the end. After the letters, set
node.word = word. Keeping the whole word on the node, rather than a flag, lets the search report it the moment a path reaches the node.
Searching the board.
- Create the result slice
found := []string{}. - Loop over the rows with
rowfrom 0 whilerow < len(board). - Loop over the columns with
colup tolen(board[0]). Every row has the same length, so the first row gives it. - Call
dfswithboard,row,col,rootand&found. The pointer to the slice lets the helper store a longer slice back afterappend. - Return
foundafter both loops.
The helper dfs.
- Stop off the board. If
rowis below 0 or at leastlen(board), orcolis below 0 or at leastlen(board[0]), return. The four calls at the end step to the neighbours without checking them, so this check handles the edges. - Read the cell's letter into
letter. The board holds bytes, soletteris abyte. It is needed again at the end to put it back. - Stop if no word continues this way. If
node.children[rune(letter)]isnil, return. This check keeps the search small: only paths that begin some word are followed. - Move down the trie:
child := node.children[rune(letter)]. The conversionrune(letter)turns the byte into the rune that the map uses as a key. - Check for a word. If
child.word != "", the path spells that word. - Record it:
*found = append(*found, child.word). - Clear it:
child.word = "", so another path to the same word adds nothing. - Mark the cell as used by setting
board[row][col] = '#'. No word contains#, so a call that comes back to this cell stops at the check onnode.children. - Search below and above with
row + 1androw - 1, passingchildas the node, because the path now includes this cell. - Search right and left with
col + 1andcol - 1. - Put the letter back:
board[row][col] = letter. The cell is free again for other paths. This undo is what makes it backtracking.
The solution in Go
type TrieNode struct {
children map[rune]*TrieNode
word string
}
func NewTrieNode() *TrieNode {
return &TrieNode{children: make(map[rune]*TrieNode)}
}
func findWords(board [][]byte, words []string) []string {
root := NewTrieNode()
for _, word := range words {
node := root
for _, c := range word {
if node.children[c] == nil {
node.children[c] = NewTrieNode()
}
node = node.children[c]
}
node.word = word
}
found := []string{}
for row := 0; row < len(board); row++ {
for col := 0; col < len(board[0]); col++ {
dfs(board, row, col, root, &found)
}
}
return found
}
func dfs(board [][]byte, row int, col int, node *TrieNode, found *[]string) {
if row < 0 || row >= len(board) || col < 0 || col >= len(board[0]) {
return
}
letter := board[row][col]
if node.children[rune(letter)] == nil {
return
}
child := node.children[rune(letter)]
if child.word != "" {
*found = append(*found, 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 Go details worth knowing:
- Runes and bytes.
for _, c := range wordgivescas arune(anint32), whileboard[row][col]is abyte(auint8). The two are different types, and Go does not convert between them silently, so the map lookup indfsneedsrune(letter). For lowercase English letters the byte and rune values are the same number. - A missing map key is not an error.
node.children[c]returnsnilfor a letter with no child, so the code can test== nilwithout thev, ok :=form. It works because the values are pointers, whose zero value isnil. Reading a missing key does not insert it. - Why
foundis a pointer to a slice. A slice passed to a function is a copy of its header (pointer, length, capacity).appendcan return a header for a new array, and the caller would never see it. With*[]string, the helper stores the result in the caller's variable:*found = append(*found, ...). - The board is changed and restored. A
[][]byteshares its rows with the caller, so writing'#'changes the caller's board while the search runs. Every'#'is replaced by the saved letter before the call returns, so the board looks untouched afterwards. The character literal'#'in single quotes is a rune constant that fits into abyte. - Empty result.
foundstarts as[]string{}, so when nothing is found the function returns an empty, non-nilslice. - The order of the result. Words come out in the order the search finds them, which is not alphabetical. For the first example, the function returns
["oath" "eat"]. Sort a copy withsort.Stringsif you need an alphabetical list. - The trie is used up. Clearing
child.wordchanges the trie, which the function builds for itself on every call, so nothing is lost.
Complexity
Let R and C be the board's dimensions, L the maximum word length and W the number of words.
Time: O(W * L + R * C * 4^L). Building the trie costs one step per letter, at most W * L. Each search from a cell follows only paths that start some word, goes at most L cells deep, and branches into at most four directions, but it never goes back to the cell it came from, so the real branching is smaller. The bound does not depend on the number of words, except through the trie's size.
Space: O(W * L). The trie has at most one node per letter of every word. The recursion depth is at most L, and the result holds at most W words.
Edge cases and common mistakes
- Reusing a cell. On the board
["ab", "cd"], the wordabdbis not found. Without the'#'mark, the path would return to theb. - Diagonal neighbours. On the same board,
adis not found. Only the four straight directions are searched. - Not restoring the cell. If the letter is not put back, later searches see
'#'in cells that should hold letters, and words go missing. - The same word on several paths. On the board
["see", "net"], the wordseecan be traced in two ways, but it is returned once because the first path clears it from the trie. - A word that is a prefix of another. With
words = ["a", "ab", "abc"]on the board["abc"], all three are returned. A word is recorded on the way down, and the search continues past it. - One cell, one letter. On the board
["a"], the wordais found, and a word likeaawould not be. - Mixing up bytes and runes. Indexing the map with
letterdirectly does not compile, because the key type isrune. - Searching once per word. It gives the same answers, but it repeats the work for shared beginnings and is much slower with many words.
Testing
Results are shown sorted.
- Board
["oaan", "etae", "ihkr", "iflv"], 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", "aa"], words["aaaa", "aaaaa", "aaa"]gives["aaa", "aaaa"]: four cells allow words up to four letters. - Board
["abc"], words["a", "ab", "abc"]gives["a", "ab", "abc"]. - Board
["aba"], words["aba", "abab"]gives["aba"].
