Problem overview
findWords receives a grid of letters, board (an array of arrays of Character), and an array of strings, words. It returns every word that can be traced on the board. A word is traced through cells where each next letter sits right, left, above or below the previous one, and no cell is used twice within one word. Each word is reported once, in any order.
In the first example the board is oaan / etae / ihkr / iflv and the words are oath, pea, eat and rain. oath starts at the top-left o, goes right, down and down. 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, so the result holds oath and eat.
Approach: one search for all words, guided by a trie
Searching the board separately for each word repeats work: words that begin with the same letters trace the same paths. A trie fixes that. It is a tree where each node stands for a prefix and has one child per possible next letter. Put all the words in a trie, then run one depth-first search from each cell, following only the trie's paths. A path that leaves the trie spells a prefix of no word, so the search stops there at once.
The trie node in this lesson has two properties: children, a dictionary from Character to child node, and word, a String? that holds the whole word on the node where a word ends and is nil elsewhere. Keeping the word itself, rather than a flag, lets the search append it the moment its path is complete.
The search marks the cell it is on as used, explores the four neighbours, and puts the letter back when it leaves. That undo is what makes it backtracking: another path can use the cell again.
Step by step
- The trie node.
TrieNodegetschildren, an empty[Character: TrieNode], andword, aString?that starts asnil. - The root. In
findWords, create aTrieNodefor the empty prefix. - Insert each word. For every word, start a
var nodeat the root and walk through its letters. Whennode.children[c]isnil, store a newTrieNodethere, then step into that child. - Mark the end. After the last letter, set
node.wordto the word. - Prepare the search. Create the empty list
found, and a copy of the board namedgrid, since the search needs to mark cells and parameters are constants. - Start from every cell. Two nested loops over
rowin0..<grid.countandcolin0..<grid[0].countcalldfswith&grid, the cell, the trie root and&found. Then returnfound. - Off the board. In
dfs, return when the row or column is outside the grid. Neighbour calls don't check their cells first, so this is where those stop. - Read the letter of the cell into
letter. It is needed again at the end. - No such path. If
node.children[letter]isnil, no word continues with this letter: return. This is the check that keeps the search small. - Step into the trie.
childisnode.children[letter]!, the prefix so far plus this cell. - A word ends here. When
child.wordisn'tnil, append it tofoundand set it tonil. Clearing it means a second path to the same word adds nothing. - Mark the cell by writing
"#"into it. No word contains#, so any call that returns to this cell stops at the trie check. - Explore the neighbours: four calls, for
row + 1,row - 1,col + 1andcol - 1, all continuing fromchild. - Restore the letter in the cell.
The solution in Swift
class TrieNode {
var children: [Character: TrieNode] = [:]
var word: String? = nil
}
class Solution {
func findWords(_ board: [[Character]], _ words: [String]) -> [String] {
let root = TrieNode()
for word in words {
var node = root
for c in word {
if node.children[c] == nil {
node.children[c] = TrieNode()
}
node = node.children[c]!
}
node.word = word
}
var found = [String]()
var grid = board
for row in 0..<grid.count {
for col in 0..<grid[0].count {
dfs(&grid, row, col, root, &found)
}
}
return found
}
func dfs(_ board: inout [[Character]], _ row: Int, _ col: Int, _ node: TrieNode, _ found: inout [String]) {
if row < 0 || row >= board.count || col < 0 || col >= board[0].count {
return
}
let letter = board[row][col]
if node.children[letter] == nil {
return
}
let child = node.children[letter]!
if child.word != nil {
found.append(child.word!)
child.word = nil
}
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 Swift details worth a closer look:
- Classes for trie nodes.
TrieNodeis a class, so every variable that holds a node refers to the same object. That is whynode.children[c] = TrieNode()changes the trie, and whychild.word = nilinsidedfsis seen by every later call. A struct would be copied on assignment and the changes would be lost. inoutparameters. Arrays are value types, sodfswould get its own copy of the board and the list. Declaring the parametersinoutand passing&gridand&foundlets the function change the caller's variables. Thevar grid = boardline makes a copy because the parameterboardoffindWordsis a constant.- Optional dictionary lookups.
node.children[letter]is aTrieNode?:nilfor a missing key. The code tests fornilfirst, and only then uses!, so it can't crash. The same goes forchild.word!after the!= niltest. - Character literals.
"#"is aCharacterwhen it is assigned into a[[Character]]. The dictionary keys areCharactertoo, because looping over aStringgives characters. - Columns come from the first row.
grid[0].countis the number of columns because every row has the same length.
Complexity
Time. Building the trie takes time proportional to the total number of letters in words. The search starts at R * C cells, and a path is at most L cells long, where L is the longest word, with at most four directions at each step. The cost is therefore at most O(R * C * 4^L), whatever the number of words, and usually much smaller because most paths leave the trie after a few letters.
Space. The trie holds up to one node per letter of every word. The recursion goes at most L cells deep, and the grid copy adds R * C cells.
Edge cases and common mistakes
- Reusing a cell. On the board
ab / cd, the wordabdbwould need thebtwice, andadwould need a diagonal step. The result is empty. - The same word along several paths. On
see / net,seecan be traced two ways. Settingchild.wordtonilafter the first find keeps it in the result once. - Words that share a path.
seeandseenboth succeed on that board. The search appendsseewhen it passes the node where it ends, and keeps going to reachseen. - Forgetting to restore the cell. Without the last line of
dfs, cells stay marked after the search leaves them, and words that need them are missed. - A one-cell board.
awith the wordafinds it, and withaafinds nothing. - Result order. The words come back in the order the search finds them, not alphabetically: the first example returns
oathand theneat. Sort the result if you need alphabetical order. - Long words on a small board. On a 3 by 4 board of
as, the wordsaa,aaaandaaaaaaaaaaare all found, since the board has enough cells for a path of ten letters.
Testing
- Board
oaan / etae / ihkr / iflv, wordsoath, pea, eat, raingives["oath", "eat"]. - Board
ab / cd, wordsabdb, adgives[]. - Board
see / net, wordssee, seen, set, net, tee, teengives["see", "seen", "net", "tee"]. - Board
a, wordsagives["a"]; with wordsaait gives[]. - Board
aaaa / aaaa / aaaa, wordsaaaaaaaaaa, aaa, aagives all three words (["aa", "aaa", "aaaaaaaaaa"]once sorted).
