Word Search II
Put every word in a trie and search the board once, so a path stops the moment no word starts that way. Solved two ways, one grid search per word and one shared search with a trie in O(m × n × 4 × 3^(L−1)), with a step-through, code in five languages and the Big O worked out from the code.
The problem
Section titled “The problem”You are given a grid of letters board with m rows and n columns, and a list of different words.
Return every word from the list that can be spelled on the board.
A word is spelled by a path of cells. Each next cell must touch the previous one on a side: up, down, left or right, not diagonally. One path may not use the same cell twice. The answer can be in any order.
Example 1
Input: board = [["c","a","t"], ["o","r","e"]], words = ["cat", "core", "toe"]
Output: ["cat", "core"]
“cat” runs along the top row. “core” goes down from c, then right through r and e. “toe” fails: no “o” touches the t.
| 0 | 1 | 2 | |
|---|---|---|---|
| 0 | c | a | t |
| 1 | o | r | e |
Example 2
Input: board = [["a","b"], ["c","d"]], words = ["abd", "acb"]
Output: ["abd"]
a, b, d is a path. In “acb”, the c and the b only touch diagonally.
Example 3
Input: board = [["x"]], words = ["x", "xx"]
Output: ["x"]
“xx” would need the single cell twice.
Constraints
Section titled “Constraints”- 1 ≤ m, n ≤ 12
- 1 ≤ words.length ≤ 30,000 (call this W)
- 1 ≤ words[i].length ≤ 10 (call the longest L)
- Only lowercase letters. All words are different.
Clarifying questions
Section titled “Clarifying questions”- Do diagonal cells count as touching?
- No. Only up, down, left and right.
- Can a path reuse a cell?
- Not within one word. Different words may use the same cells.
- Can a word be found twice?
- It may be spelled in two places, but it must appear in the answer only once.
- Does the order of the answer matter?
- No. Any order is fine.
- Why does the number of words matter?
- There can be 30,000 of them. Searching the board once per word is what makes the simple way too slow.
Spot the pattern
Section titled “Spot the pattern”Spelling one word on a grid is backtracking: step to a neighbor, go on, and undo the step when the path fails. With many words, most of them share starts, like “cat” and “core”. A trie of all words lets one search serve them all, and it tells at each step whether any word still starts this way.
Solutions
Section titled “Solutions”This problem has two approaches. There is no useful step between them.
Brute force O(W × m × n × 3ᴸ)
The idea
Section titled “The idea”Solve the one-word problem, Word Search, once for every word. For a word, try every cell as the start. From a cell that holds the right letter, mark it as used, try the four neighbors for the next letter, and unmark it when done. If some start spells the whole word, add it to the answer.
It is correct, but the work is repeated. Every word searches the whole board again from zero, even when many words share the same first letters.
Walkthrough
Section titled “Walkthrough”| 0 | 1 | 2 | |
|---|---|---|---|
| 0 | c | a | t |
| 1 | o | r | e |
- for word in words:
- if any(exists(row, column, word, 0) for every cell): found.append(word)
- def exists(row, column, word, position):
- if position == len(word): return True
- if outside or board[row][column] != word[position]: return False
- board[row][column] = "#"
- found_rest = any(exists(next_row, next_column, word, position + 1) for the 4 neighbors)
- board[row][column] = word[position]
- return found_rest
The brute force handles one word at a time. For each word, it tries to spell it starting from every cell, moving up, down, left or right.
class Solution { private char[][] board;
// Brute force: run a separate grid search for every word, from every // cell. W words, m * n starts each, and up to 4 * 3^(L-1) steps per start. public List<String> findWords(char[][] board, String[] words) { this.board = board; List<String> found = new ArrayList<>(); for (String word : words) { search: for (int row = 0; row < board.length; row++) for (int column = 0; column < board[0].length; column++) if (exists(row, column, word, 0)) { found.add(word); break search; } } return found; }
private boolean exists(int row, int column, String word, int position) { if (position == word.length()) return true; if (row < 0 || row >= board.length || column < 0 || column >= board[0].length || board[row][column] != word.charAt(position)) return false; // Block this cell so the same path cannot use it twice. board[row][column] = '#'; boolean foundRest = exists(row + 1, column, word, position + 1) || exists(row - 1, column, word, position + 1) || exists(row, column + 1, word, position + 1) || exists(row, column - 1, word, position + 1); // Undo, so the next search sees the board as it was. board[row][column] = word.charAt(position); return foundRest; }}# Brute force: run a separate grid search for every word, from every# cell. W words, m * n starts each, and up to 4 * 3^(L-1) steps per start.def find_words(board: list[list[str]], words: list[str]) -> list[str]: rows, columns = len(board), len(board[0])
def exists(row: int, column: int, word: str, position: int) -> bool: if position == len(word): return True if (row < 0 or row >= rows or column < 0 or column >= columns or board[row][column] != word[position]): return False # Block this cell so the same path cannot use it twice. board[row][column] = "#" found_rest = any( exists(row + row_step, column + column_step, word, position + 1) for row_step, column_step in ((1, 0), (-1, 0), (0, 1), (0, -1)) ) # Undo, so the next search sees the board as it was. board[row][column] = word[position] return found_rest
found = [] for word in words: if any(exists(row, column, word, 0) for row in range(rows) for column in range(columns)): found.append(word) return foundpublic class Solution{ private char[][] board;
// Brute force: run a separate grid search for every word, from every // cell. W words, m * n starts each, and up to 4 * 3^(L-1) steps per start. public IList<string> FindWords(char[][] board, string[] words) { this.board = board; var found = new List<string>(); foreach (string word in words) { bool hit = false; for (int row = 0; row < board.Length && !hit; row++) for (int column = 0; column < board[0].Length && !hit; column++) hit = Exists(row, column, word, 0); if (hit) found.Add(word); } return found; }
private bool Exists(int row, int column, string word, int position) { if (position == word.Length) return true; if (row < 0 || row >= board.Length || column < 0 || column >= board[0].Length || board[row][column] != word[position]) return false; // Block this cell so the same path cannot use it twice. board[row][column] = '#'; bool foundRest = Exists(row + 1, column, word, position + 1) || Exists(row - 1, column, word, position + 1) || Exists(row, column + 1, word, position + 1) || Exists(row, column - 1, word, position + 1); // Undo, so the next search sees the board as it was. board[row][column] = word[position]; return foundRest; }}// Brute force: run a separate grid search for every word, from every// cell. W words, m * n starts each, and up to 4 * 3^(L-1) steps per start.func findWords(board [][]byte, words []string) []string { var exists func(row, column int, word string, position int) bool exists = func(row, column int, word string, position int) bool { if position == len(word) { return true } if row < 0 || row >= len(board) || column < 0 || column >= len(board[0]) || board[row][column] != word[position] { return false } // Block this cell so the same path cannot use it twice. board[row][column] = '#' foundRest := exists(row+1, column, word, position+1) || exists(row-1, column, word, position+1) || exists(row, column+1, word, position+1) || exists(row, column-1, word, position+1) // Undo, so the next search sees the board as it was. board[row][column] = word[position] return foundRest } found := []string{} for _, word := range words { onBoard := false for row := 0; row < len(board) && !onBoard; row++ { for column := 0; column < len(board[0]) && !onBoard; column++ { onBoard = exists(row, column, word, 0) } } if onBoard { found = append(found, word) } } return found}// Brute force: run a separate grid search for every word, from every// cell. W words, m * n starts each, and up to 4 * 3^(L-1) steps per start.function findWords(board: string[][], words: string[]): string[] { const rows = board.length; const columns = board[0].length; const exists = (row: number, column: number, word: string, position: number): boolean => { if (position === word.length) return true; if (row < 0 || row >= rows || column < 0 || column >= columns) return false; if (board[row][column] !== word[position]) return false; // Block this cell so the same path cannot use it twice. board[row][column] = "#"; const foundRest = exists(row + 1, column, word, position + 1) || exists(row - 1, column, word, position + 1) || exists(row, column + 1, word, position + 1) || exists(row, column - 1, word, position + 1); // Undo, so the next search sees the board as it was. board[row][column] = word[position]; return foundRest; }; const found: string[] = []; for (const word of words) { let onBoard = false; for (let row = 0; row < rows && !onBoard; row++) { for (let column = 0; column < columns && !onBoard; column++) { onBoard = exists(row, column, word, 0); } } if (onBoard) found.push(word); } return found;}1 Find the work that repeats
The work repeats through recursion, so we count the calls of
exists. Each call checks one cell against one letter and starts up to 4 more calls. That is a fixed job, so each call counts as 1 step.- for word in words:
- if any(exists(row, column, word, 0) for every cell): found.append(word)
- def exists(row, column, word, position):
- if position == len(word): return True
- if outside or board[row][column] != word[position]: return False
- board[row][column] = "#"
- found_rest = any(exists(next_row, next_column, word, position + 1) for the 4 neighbors)
- board[row][column] = word[position]
- return found_rest
2 Count the runs on Example 1
The table counts the calls of
existsper word, including the calls that stop at once on a wrong letter or the edge of the board. Each search stops as soon as a start spells the word.8 + 9 + 10 = 27 calls.
word "cat" "core" "toe" exists() calls 8 9 10 total8 + 9 + 10 = 27 calls3 Write the count for any n
For one start, the first letter can go 4 ways. Every later step can go at most 3 ways, because the cell it came from is blocked. A word of length L makes at most 4 × 3^(L−1) calls from one start. There are m × n starts and W words:
W × m × n × 4 × 3^(L−1) callsCheck it with Example 1: the bound for “core” alone is 6 × 4 × 27 = 648. The real search used 9, because wrong letters stop most paths at once. Big O counts the worst case, such as a board full of “a” and words like “aaaaaaaaab”.
4 Keep only the part that grows fastest
Drop the 4 and the 1 in the power. 4 × 3^(L−1) is the same as (4/3) × 3ᴸ, a constant times 3ᴸ. What is left is W × m × n × 3ᴸ. With W up to 30,000, this is far too slow.
So the time is O(W × m × n × 3ᴸ).
S Space: count the extra memory the same way
Only one path is open at a time, so the call stack is at most L + 1 deep. The board is marked in place, so no copy is needed. The space is O(L), plus the answer list.
call stack (L frames)found
Trie O(m × n × 3ᴸ)
The idea
Section titled “The idea”Put all the words in a trie first: a tree where each edge is one letter and a node remembers the word that ends there.
Then search the board once. From every cell, walk the grid and the trie together. Stepping onto a cell means following that letter’s edge in the trie. If the edge does not exist, no word starts with the letters on this path, so stop at once. That check is what makes this fast: a path dies as soon as it spells something that no word begins with.
When the trie node holds a word, add it to the answer and clear it, so it is not added twice. Then keep going, because a longer word may continue from the same path.
Why it never misses the answer: every word is one path in the trie. The search tries every board path that is also a trie path, from every start cell, and it only stops paths that no word could follow.
Walkthrough
Section titled “Walkthrough”| 0 | 1 | 2 | |
|---|---|---|---|
| 0 | c | a | t |
| 1 | o | r | e |
- root = trie(words)
- for row, column in every cell:
- explore(row, column, root)
- def explore(row, column, node):
- letter = board[row][column]
- if letter not in node.children: return
- node = node.children[letter]
- if node.word: found.append(node.word); node.word = None
- board[row][column] = "#"
- for (next_row, next_column) in the neighbors inside the board:
- explore(next_row, next_column, node)
- board[row][column] = letter
Put all words in one trie first. Then search the board once: from every cell, walk the grid and the trie together.
class Solution { private static class Node { Node[] children = new Node[26]; // The word that ends here, or null. String word; }
private char[][] board; private final List<String> found = new ArrayList<>();
// Trie of all words, then one grid search from every cell. The trie says // at once when a path can no longer spell any word, so all words share one search. public List<String> findWords(char[][] board, String[] words) { this.board = board; Node root = new Node(); for (String word : words) { Node node = root; for (char letter : word.toCharArray()) { if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node(); node = node.children[letter - 'a']; } node.word = word; } for (int row = 0; row < board.length; row++) for (int column = 0; column < board[0].length; column++) explore(row, column, root); return found; }
private void explore(int row, int column, Node node) { char letter = board[row][column]; // No word continues with this letter, so stop here. if (letter == '#' || node.children[letter - 'a'] == null) return; node = node.children[letter - 'a']; if (node.word != null) { found.add(node.word); // Remove it, so the same word is not reported twice. node.word = null; } // Block this cell so the same path cannot use it twice. board[row][column] = '#'; if (row + 1 < board.length) explore(row + 1, column, node); if (row > 0) explore(row - 1, column, node); if (column + 1 < board[0].length) explore(row, column + 1, node); if (column > 0) explore(row, column - 1, node); board[row][column] = letter; }}# Trie of all words, then one grid search from every cell. The trie says# at once when a path can no longer spell any word, so all words share one search.def find_words(board: list[list[str]], words: list[str]) -> list[str]: # Each trie node maps a letter to a child. "$" holds the word ending here. root = {} for word in words: node = root for letter in word: node = node.setdefault(letter, {}) node["$"] = word rows, columns = len(board), len(board[0]) found = []
def explore(row: int, column: int, node: dict) -> None: letter = board[row][column] # No word continues with this letter, so stop here. if letter not in node: return node = node[letter] if "$" in node: found.append(node["$"]) # Remove it, so the same word is not reported twice. del node["$"] # Block this cell so the same path cannot use it twice. board[row][column] = "#" for next_row, next_column in ( (row + 1, column), (row - 1, column), (row, column + 1), (row, column - 1) ): if 0 <= next_row < rows and 0 <= next_column < columns: explore(next_row, next_column, node) board[row][column] = letter
for row in range(rows): for column in range(columns): explore(row, column, root) return foundpublic class Solution{ private class Node { public Node[] children = new Node[26]; // The word that ends here, or null. public string word; }
private char[][] board; private readonly List<string> found = new List<string>();
// Trie of all words, then one grid search from every cell. The trie says // at once when a path can no longer spell any word, so all words share one search. public IList<string> FindWords(char[][] board, string[] words) { this.board = board; var root = new Node(); foreach (string word in words) { Node node = root; foreach (char letter in word) { if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node(); node = node.children[letter - 'a']; } node.word = word; } for (int row = 0; row < board.Length; row++) for (int column = 0; column < board[0].Length; column++) Explore(row, column, root); return found; }
private void Explore(int row, int column, Node node) { char letter = board[row][column]; // No word continues with this letter, so stop here. if (letter == '#' || node.children[letter - 'a'] == null) return; node = node.children[letter - 'a']; if (node.word != null) { found.Add(node.word); // Remove it, so the same word is not reported twice. node.word = null; } // Block this cell so the same path cannot use it twice. board[row][column] = '#'; if (row + 1 < board.Length) Explore(row + 1, column, node); if (row > 0) Explore(row - 1, column, node); if (column + 1 < board[0].Length) Explore(row, column + 1, node); if (column > 0) Explore(row, column - 1, node); board[row][column] = letter; }}type trieNode struct { children [26]*trieNode // The word that ends here, or "". word string}
// Trie of all words, then one grid search from every cell. The trie says// at once when a path can no longer spell any word, so all words share one search.func findWords(board [][]byte, words []string) []string { root := &trieNode{} for _, word := range words { node := root for i := 0; i < len(word); i++ { if node.children[word[i]-'a'] == nil { node.children[word[i]-'a'] = &trieNode{} } node = node.children[word[i]-'a'] } node.word = word } found := []string{} var explore func(row, column int, node *trieNode) explore = func(row, column int, node *trieNode) { letter := board[row][column] // No word continues with this letter, so stop here. if letter == '#' || node.children[letter-'a'] == nil { return } node = node.children[letter-'a'] if node.word != "" { found = append(found, node.word) // Remove it, so the same word is not reported twice. node.word = "" } // Block this cell so the same path cannot use it twice. board[row][column] = '#' if row+1 < len(board) { explore(row+1, column, node) } if row > 0 { explore(row-1, column, node) } if column+1 < len(board[0]) { explore(row, column+1, node) } if column > 0 { explore(row, column-1, node) } board[row][column] = letter } for row := range board { for column := range board[0] { explore(row, column, root) } } return found}interface TrieNode { children: Map<string, TrieNode>; // The word that ends here, or null. word: string | null;}
// Trie of all words, then one grid search from every cell. The trie says// at once when a path can no longer spell any word, so all words share one search.function findWords(board: string[][], words: string[]): string[] { const root: TrieNode = { children: new Map(), word: null }; for (const word of words) { let node = root; for (const letter of word) { if (!node.children.has(letter)) { node.children.set(letter, { children: new Map(), word: null }); } node = node.children.get(letter)!; } node.word = word; } const rows = board.length; const columns = board[0].length; const found: string[] = []; const explore = (row: number, column: number, parent: TrieNode): void => { const letter = board[row][column]; // No word continues with this letter, so stop here. const node = parent.children.get(letter); if (!node) return; if (node.word !== null) { found.push(node.word); // Remove it, so the same word is not reported twice. node.word = null; } // Block this cell so the same path cannot use it twice. board[row][column] = "#"; if (row + 1 < rows) explore(row + 1, column, node); if (row > 0) explore(row - 1, column, node); if (column + 1 < columns) explore(row, column + 1, node); if (column > 0) explore(row, column - 1, node); board[row][column] = letter; }; for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) explore(row, column, root); } return found;}1 Find the work that repeats
There are two parts. Building the trie takes one step per letter of each word. The search repeats through recursion: each call of
explorechecks one cell against one trie edge and starts up to 4 more calls. Each letter and each call counts as 1 step.- root = trie(words)
- for row, column in every cell:
- explore(row, column, root)
- def explore(row, column, node):
- letter = board[row][column]
- if letter not in node.children: return
- node = node.children[letter]
- if node.word: found.append(node.word); node.word = None
- board[row][column] = "#"
- for (next_row, next_column) in the neighbors inside the board:
- explore(next_row, next_column, node)
- board[row][column] = letter
2 Count the runs on Example 1
Building the trie walks the letters of “cat”, “core” and “toe”: 3 + 4 + 3 = 10. The search makes 22 calls of
explore: 6 start cells, plus every step to a neighbor inside the board. The cells a, o, r and e stop at once, because no word starts with them.10 + 22 = 32 steps.
trie letters explore() calls steps 10 22 total10 + 22 = 32 steps3 Write the count for any n
Building the trie costs at most W × L steps. From each of the m × n cells, one search makes at most 4 × 3^(L−1) calls, for the same reason as before. But now it serves every word at once:
W × L + m × n × 4 × 3^(L−1) stepsCheck it with Example 1: the bound is 3 × 4 + 6 × 4 × 27 = 660. The real run used 32, because the trie stopped almost every path after one or two letters.
4 Keep only the part that grows fastest
The factor W is gone from the search: one search now does the work of all W. The trie part, W × L, is there too, but L is at most 10, so it grows only like the input itself. The search term usually dominates, so the time is written O(m × n × 3ᴸ), with the trie build of O(W × L) on top.
So the time is O(m × n × 3ᴸ).
S Space: count the extra memory the same way
The trie holds at most one node per letter of all words, so W × L nodes. The call stack is at most L + 1 deep. So the space is O(W × L).
trie (one node per letter)call stack (L frames)found
Tested at the limits
Section titled “Tested at the limits”2,000 random boards of up to 4 x 4 letters from a, b and c, with up to 8 words of 1 to 6 letters. The brute force and the trie found the same set of words every time.
| Largest inputs | Result |
|---|---|
| A 12 x 12 board and 30,000 words (the most allowed), every cell a and every word starting with many a letters. Trie. | 1.2 s, and about 6 MB of extra memory. The trie never rules a path out early, so every cell starts a search 10 letters deep. |
| A 12 x 12 board and 30,000 words of random letters. Trie. | 0.08 s, and about 12 MB of extra memory: the trie holds the letters of all the words, which is the O(W × L) space. |
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.