Problem overview
You get a grid of lowercase letters, board, and an array of words. findWords returns every word that can be traced on the board. A word is traced by a path of cells: the first letter is in some cell, and each next letter is in a cell directly above, below, left or right of the previous one. A cell can be used only once within one word. Each word is reported once, and the order of the returned list doesn't matter.
In Kotlin the signature is fun findWords(board: Array<CharArray>, words: Array<String>): List<String>. Each row of the board is a CharArray.
Take the board oaan / etae / ihkr / iflv and the words oath, pea, eat, rain. "oath" runs from the top-left o right, then down twice. "eat" starts at the e at the end of the second row and goes left twice. There is no p on the board, and no a touches the r. The result is [oath, eat].
Approach
The obvious plan is to search the board once for each word with a depth-first search. That repeats work: the words "oath" and "oats" both start with "oat", yet each search would walk the same cells again. With thousands of words, the repetition is expensive.
The better plan is to search for all words at the same time. Put the words in a trie, a tree where each node has one child per next letter and the path from the root spells a prefix. Then run a single depth-first search from each cell, and let the trie steer it. At each step the search asks: does the trie have a child for this cell's letter? If not, no word continues along this path, and the search stops at once. If so, it moves down the trie and the board together.
Two more ideas finish the solution:
- Store the word at its last node. A node that ends a word holds the word itself. When the search reaches it, the word can go straight into the answer.
- Backtracking. To stop a path from reusing a cell, the search overwrites the cell with
'#'before going deeper and writes the letter back when it leaves. The trie holds only letters, so'#'never matches.
Step by step
The trie node
children. InsideTrieNode, aval childrenset to a newHashMap<Char, TrieNode>()maps each next letter to its child node.word. Avar word: String? = nullholds the word that ends at this node. Most nodes end no word, so the type is nullable.
Building the trie
- In
findWords, createval root = TrieNode(), the empty prefix. - For each
word, startvar nodeatrootand go through its letters. If the letter isn't innode.children, store a newTrieNode()for it. Then move down:node = node.children[c]!!. - After the letters,
nodeis the end of the word: setnode.word = word.
Starting the searches
- Create
val found = mutableListOf<String>()to collect the answers. - Loop over every
rowin0 until board.sizeand everycolin0 until board[0].size, callingdfs(board, row, col, root, found). A word can start in any cell. Then returnfound.
The search, dfs
- Leave the board. If the cell is outside the grid, return. The four calls below step to neighbors without checking them first, so this check handles the edges.
- Read the letter into
val letter. It is needed again at the end. - Prune. If
letter !in node.children, no word continues along this path, so return. This also catches the'#'marker, which makes a cell that is already in use a dead end. - Move down the trie:
val child = node.children[letter]!!. - Collect a word. If
child.wordisn'tnull, the path spells that word. Add it tofound, then setchild.word = null. Clearing it means a second path that spells the same word finds nothing to add, so each word appears once. - Mark and explore. Set
board[row][col] = '#', calldfsfor the cells below, above, right and left, all withchildas the node, then restore the cell withboard[row][col] = letter.
The solution in Kotlin
class TrieNode {
val children = HashMap<Char, TrieNode>()
var word: String? = null
}
class Solution {
fun findWords(board: Array<CharArray>, words: Array<String>): List<String> {
val root = TrieNode()
for (word in words) {
var node = root
for (c in word) {
if (c !in node.children) {
node.children[c] = TrieNode()
}
node = node.children[c]!!
}
node.word = word
}
val found = mutableListOf<String>()
for (row in 0 until board.size) {
for (col in 0 until board[0].size) {
dfs(board, row, col, root, found)
}
}
return found
}
fun dfs(board: Array<CharArray>, row: Int, col: Int, node: TrieNode, found: MutableList<String>) {
if (row < 0 || row >= board.size || col < 0 || col >= board[0].size) {
return
}
val letter = board[row][col]
if (letter !in node.children) {
return
}
val child = node.children[letter]!!
if (child.word != null) {
found.add(child.word!!)
child.word = null
}
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
}
}
Kotlin details worth knowing
!!after a map lookup.node.children[c]has the typeTrieNode?, because a map givesnullfor a missing key. The code has just made sure the key exists, so!!is safe.!in.c !in node.childrenis!node.children.containsKey(c)written as an operator: for a map,intests the keys.- Why
child.word!!.foundholdsString, butwordis aString?. Kotlin won't smart-cast it after the!= nullcheck, because it is a mutable property. Writingfound.add(child.word)gives:smart cast to 'String' is impossible, because 'word' is a mutable property that could be mutated concurrently.The!!fixes it, and it is safe here because of the check just above. - Characters. A
Charis written in single quotes, aStringin double quotes.for (c in word)goes over theChars of a string.'#'is the marker character. - The board changes in place.
Array<CharArray>is mutable and passed by reference, sodfswrites into the caller's board. Every'#'is replaced by its letter before the call returns, so the board is the same afterwards. - The result type. The function returns
found, aMutableList<String>, as aList<String>. It lists the words in the order they were found, not in alphabetical order.
Complexity
Let R and C be the board's dimensions and L the length of the longest word, at most 10.
- Building the trie: O(total number of letters in all words), one step per letter.
- Searching: a search from one cell goes at most
Lcells deep, since the trie has no path longer than the longest word. From the first cell it can go in 4 directions and after that in at most 3, because it can't step back onto the cell it came from. That gives at most 4 · 3^(L-1) paths per starting cell, so the time is O(R · C · 4 · 3^(L-1)) at worst. In practice the trie cuts most paths after a letter or two. The number of words appears only in the trie-building part, not in the search. - Space: O(total letters) for the trie, and O(L) for the recursion.
Edge cases and common mistakes
- Reusing a cell. On the board
ab / cd, the word "abdb" would return tob. The marker stops this, so the result is empty. "ad" fails too: the two letters touch only at a corner. - Forgetting to restore the cell. If
board[row][col] = letteris missing, cells stay blocked for every later path, and words get missed. - A word found by several paths. "see" on
see / nethas two paths. Clearingchild.wordkeeps it in the result once. - Words that are prefixes of others. "see" and "seen" share a path. The word is collected when the search passes its node, and the search keeps going to look for longer words.
- A word longer than the available path. On the board
aa, the word "aaa" can't be traced, because it would need three different cells. The result is empty. - One-cell boards. A board of a single letter with the word of that letter works: the search from that cell finds it.
- Diagonal steps. Only four directions are allowed; there are four calls to
dfs, none diagonal.
Testing
These results come from running the solution above. The boards are written as rows separated by slashes:
| Board | Words | Returned |
|---|---|---|
oaan/etae/ihkr/iflv | oath, pea, eat, rain | [oath, eat] |
ab/cd | abdb, ad | [] |
see/net | see, seen, set, net, tee, teen | [see, seen, net, tee] |
a | a | [a] |
aa | aaa | [] |
ab | ab, a, b, ba | [a, ab, b, ba] |
