Problem overview
find_words receives a grid of letters, board, as a Vec<Vec<char>>, and a vector of words. It returns every word that can be traced on the board. A word is traced along a path of cells: each next letter sits in a cell directly above, below, left or right of the previous one, and no cell is used twice within one word. Each word is returned once, in any order. The examples list them alphabetically, so the order in which your function finds them doesn't matter.
On the board oaan / etae / ihkr / iflv with the words oath, pea, eat and rain, the answer is ["eat", "oath"]. On ab / cd, the word abdb would need the cell of b twice, and ad would need a diagonal step, so nothing is found. On see / net, the word see can be traced in two ways, but it is returned once.
Approach: search the board once, guided by a trie
The direct method runs a depth-first search from every cell for every word. Words that start the same way, such as see and seen, repeat the same work, and with up to 30,000 words that adds up.
A trie fixes that. It stores all the words in one tree where each level is one more letter, so a shared beginning is stored and followed once. Then one depth-first search from each cell looks for all the words at the same time: at each step it only moves to a neighbour whose letter continues some word. If no word continues with that letter, the whole branch is cut off.
The trie node keeps the finished word itself, as a String, instead of a yes/no flag. When the search reaches that node, it already has the word to report. Clearing the string right after reporting means a word reachable along several paths is reported once.
The search is backtracking. Before stepping to the neighbours, the current cell is overwritten with '#', a character no word contains, so the path can't use it again. When the search leaves the cell, the letter is written back, and the cell is free for other paths.
With words of at most L letters, a path goes at most L cells deep and has at most four directions at each step, giving O(R · C · 4^L) time for the search, plus the cost of building the trie. The number of words doesn't appear in the search cost.
Step by step
- Give
TrieNodeachildrenfield, aHashMap<char, TrieNode>, from each next letter to its child. A struct can contain a map of its own type, because the map keeps its values on the heap. - Add a
wordfield, aString. On a node where a word ends, it holds that word. An empty string means no word ends here, which is safe because every word has at least one letter. TrieNode::newreturns a node with an empty map and an empty string.- In
find_words, create the trie root. It stands for the empty prefix. - Insert every word with a loop over
&words. - Start each word at the root with
let mut node = &mut root;, a mutable reference. - Loop over
word.chars(). Each letter goes one level down. - Create the missing child. If
node.childrenhas no keyc, insert a newTrieNodeunder it. - Move down with
get_mut(&c).unwrap(). The child exists by now, sounwrapis safe. - After the letters, store the word in
node.wordas a clone. - Create
found, an emptyVec<String>that every search adds to. - Loop over every row with
0..board.len(), and every column with0..board[0].len(). - Call
Self::dfsfrom each cell with the board, the cell asi32values, the trie root andfound. The coordinates arei32so that stepping above the first row gives -1 instead of breaking ausize. - Return
foundwith no semicolon. - In
dfs, leave when the cell is off the board. The four calls below step to neighbours without checking them, so this check catches them. - Read the cell's letter into
letter. It is needed again at the end, to restore the cell. - Leave when
node.childrenhas no keyletter. No word continues this way. The marker'#'also stops here, which is how a used cell is skipped. - Move down the trie to
child, a mutable reference found withget_mut. - If
child.wordisn't empty, the path spells a word. Push a clone of it ontofoundand clear it, so the word isn't reported again. - Mark the cell by setting it to
'#'. - Search all four neighbours, continuing from
child: below, above, right, left. - Restore the letter. Undoing the mark on the way out is what makes this backtracking.
The solution in Rust
use std::collections::HashMap;
struct TrieNode {
children: HashMap<char, TrieNode>,
word: String,
}
impl TrieNode {
fn new() -> TrieNode {
TrieNode { children: HashMap::new(), word: String::new() }
}
}
impl Solution {
fn find_words(mut board: Vec<Vec<char>>, words: Vec<String>) -> Vec<String> {
let mut root = TrieNode::new();
for word in &words {
let mut node = &mut root;
for c in word.chars() {
if !node.children.contains_key(&c) {
node.children.insert(c, TrieNode::new());
}
node = node.children.get_mut(&c).unwrap();
}
node.word = word.clone();
}
let mut found: Vec<String> = Vec::new();
for row in 0..board.len() {
for col in 0..board[0].len() {
Self::dfs(&mut board, row as i32, col as i32, &mut root, &mut found);
}
}
found
}
fn dfs(board: &mut Vec<Vec<char>>, row: i32, col: i32, node: &mut TrieNode, found: &mut Vec<String>) {
if row < 0 || row >= board.len() as i32 || col < 0 || col >= board[0].len() as i32 {
return;
}
let letter = board[row as usize][col as usize];
if !node.children.contains_key(&letter) {
return;
}
let child = node.children.get_mut(&letter).unwrap();
if !child.word.is_empty() {
found.push(child.word.clone());
child.word.clear();
}
board[row as usize][col as usize] = '#';
Self::dfs(board, row + 1, col, child, found);
Self::dfs(board, row - 1, col, child, found);
Self::dfs(board, row, col + 1, child, found);
Self::dfs(board, row, col - 1, child, found);
board[row as usize][col as usize] = letter;
}
}
Rust details worth a closer look:
- A mutable cursor into the trie.
let mut node = &mut root;is a mutable reference, and the loop reassigns it to point one level lower. The variable ismutbecause it changes which node it refers to, not because the node is replaced. - Insert, then
get_mut. Checkingcontains_key, inserting, and then callingget_mut(&c).unwrap()gives a mutable reference to the child without any ownership trouble. Both calls take&c, a reference to the key. - Passing mutable references on. In
dfs,board,childandfoundare already&mut, so the recursive calls pass them as they are. Rust reborrows them for each call, which lets the four calls follow one another. usizeandi32.rowandcolarei32, sorow - 1can be -1. The check at the top rejects negative or too-large values beforerow as usizeis used to index, where a negative value would otherwise turn into a huge number.charandString. The board holdscharvalues, which are single Unicode characters, so'#'is written with single quotes.child.word.clear()empties the string in place, andis_empty()tests for it.- Cloning the word.
child.word.clone()copies the string intofound, because the trie still owns the original until it is cleared. - The
useline.HashMapneedsuse std::collections::HashMap;at the top.
Complexity
Time: O(R · C · 4^L) for the search, where L is the longest word, plus O(W · L) to build the trie for W words. A search starts from each of the R · C cells, goes at most L cells deep, and has at most four directions per step. In practice the trie cuts most branches early, and a used cell can't be revisited. The search doesn't depend on how many words there are.
Space: O(W · L) for the trie, since every letter of every word can take a node, plus O(L) for the recursion and the answer's size. The board is changed in place and restored.
Edge cases and common mistakes
- Reusing a cell. On
ab / cd,abdbfails because the secondbis the cell already marked'#'. Without the marker, the search would walk a, b, d, b and report it. - Diagonals. Only four directions are tried, so
adonab / cdisn't found. - Duplicates. On
see / net,seehas two paths. With theclear()line removed, the result on that board, sorted, is["net", "see", "see", "seen", "tee", "tee"]; with it, each word appears once. - Forgetting to restore the letter. Leaving a
'#'on the board would hide cells from later searches and from the other starting cells. - A word that is a prefix of another.
seeandseenboth come out: the node forseereports its word and the search continues below it. - One-cell boards. A board
awith the wordafinds it; with the wordaaait doesn't, since the one cell can't be used again. - The order of the result. The function returns words in the order they are found, such as
["oath", "eat"]for the first example, not alphabetically. Sort the result if you need alphabetical order.
Testing
Running the reference solution and sorting the result gives:
- board
oaan / etae / ihkr / iflv, wordsoath, pea, eat, raingives["eat", "oath"]. - board
ab / cd, wordsabdb, adgives[]. - board
see / net, wordssee, seen, set, net, tee, teengives["net", "see", "seen", "tee"]. - board
a, wordsagives["a"]. - board
a / a(two rows), wordsaaagives[]. - board
ab(one row), wordsab, ba, a, bgives["a", "ab", "b", "ba"].
