Word Search II
Problem
Given a 2D board of characters and a list of words, return all words from the list that can be formed by sequentially adjacent letters on the board, where adjacent letters are horizontally or vertically neighboring and each letter cell may not be used more than once per word.
- 1 ≤ board.length ≤ 12
- 1 ≤ board[i].length ≤ 12
- 1 ≤ words.length ≤ 3 * 10⁴
- 1 ≤ words[i].length ≤ 10
- board and words[i] consist of lowercase English letters
Example
board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]], words = ["oath","pea","eat","rain"]["eat","oath"]The algorithm first builds a Trie from the list of words to enable efficient prefix checks. Starting from each cell, it performs DFS to explore all possible paths. For example, starting at board[0][0] = 'o', it explores neighbors to form 'oath', which is found in the Trie and added to the result. Paths that do not match any prefix in the Trie are pruned early, avoiding unnecessary exploration. The critical moment is pruning paths that do not lead to any word, which drastically reduces the search space compared to naive backtracking.
Approach
Straightforward Solution
A naive approach tries to find each word independently by DFS on the board, resulting in O(m * n * 4^l) per word (m,n board dimensions, l word length), which is too slow for large word lists.
Core Observation
The problem requires searching for multiple words on a grid with adjacency constraints. A brute-force approach checking each word independently is inefficient because it repeats similar searches. The key is to share prefix information among words to prune searches early.
Path to Optimal
The key recognition signals are 'multiple words', 'prefix matching', and 'adjacent letters'. These indicate using a Trie to represent all words simultaneously, enabling prefix pruning during DFS. By building a Trie, the search can abandon paths that do not match any prefix, drastically reducing the search space. Combining Trie with DFS backtracking on the board yields an efficient solution.
Optimal Approach
Build a Trie from the list of words. For each cell in the board, start a DFS that explores neighbors only if the current path matches a prefix in the Trie. Mark visited cells to avoid reuse. When a Trie node indicates a complete word, add it to the result set. This approach ensures each path is explored only once per prefix, achieving efficient pruning and correctness.
Time
O(M * 4 * 3^(L-1))
M is the number of cells in the board. Each DFS explores up to 4 neighbors, but after the first step, at most 3 neighbors are explored to avoid revisiting the previous cell. L is the maximum length of words. Trie pruning reduces unnecessary paths, making the search efficient.
Space
O(N * L)
N is the number of words and L is the average length. The Trie requires O(N * L) space to store all words. The recursion stack uses O(L) space. The visited set uses O(M) space but is bounded by board size.
Pattern Spotlight
Trie (Prefix Tree) combined with Backtracking (DFS) for Efficient Multi-Word Search
Use a Trie to represent all words so that during DFS, paths that do not match any prefix can be abandoned immediately, preventing redundant exploration and enabling efficient simultaneous search for multiple words.
Solution
| 1 | class TrieNode: |
| 2 | def __init__(self): |
| 3 | self.children = {} |
| 4 | self.is_word = False |
| 5 | |
| 6 | def add_word(self, word): |
| 7 | curr = self |
| 8 | for c in word: |
| 9 | if c not in curr.children: |
| 10 | curr.children[c] = TrieNode() |
| 11 | curr = curr.children[c] |
| 12 | curr.is_word = True |
| 13 | |
| 14 | class Solution: |
| 15 | def findWords(self, board: list[list[str]], words: list[str]) -> list[str]: |
| 16 | root = TrieNode() |
| 17 | for w in words: |
| 18 | root.add_word(w) |
| 19 | |
| 20 | ROWS, COLS = len(board), len(board[0]) |
| 21 | res, visit = set(), set() |
| 22 | |
| 23 | def dfs(r, c, node, word): |
| 24 | if (r < 0 or c < 0 or |
| 25 | r == ROWS or c == COLS or |
| 26 | (r, c) in visit or board[r][c] not in node.children): |
| 27 | return |
| 28 | |
| 29 | visit.add((r, c)) |
| 30 | node = node.children[board[r][c]] |
| 31 | word += board[r][c] |
| 32 | if node.is_word: |
| 33 | res.add(word) |
| 34 | |
| 35 | dfs(r + 1, c, node, word) |
| 36 | dfs(r - 1, c, node, word) |
| 37 | dfs(r, c + 1, node, word) |
| 38 | dfs(r, c - 1, node, word) |
| 39 | visit.remove((r, c)) |
| 40 | |
| 41 | for r in range(ROWS): |
| 42 | for c in range(COLS): |
| 43 | dfs(r, c, root, "") |
| 44 | |
| 45 | return list(res) |
| 1 | import java.util.*; |
| 2 | |
| 3 | class TrieNode { |
| 4 | Map<Character, TrieNode> children = new HashMap<>(); |
| 5 | boolean isWord = false; |
| 6 | |
| 7 | void addWord(String word) { |
| 8 | TrieNode curr = this; |
| 9 | for (char c : word.toCharArray()) { |
| 10 | curr.children.putIfAbsent(c, new TrieNode()); |
| 11 | curr = curr.children.get(c); |
| 12 | } |
| 13 | curr.isWord = true; |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | public class Solution { |
| 18 | public List<String> findWords(char[][] board, String[] words) { |
| 19 | TrieNode root = new TrieNode(); |
| 20 | for (String w : words) { |
| 21 | root.addWord(w); |
| 22 | } |
| 23 | |
| 24 | int ROWS = board.length, COLS = board[0].length; |
| 25 | Set<String> res = new HashSet<>(); |
| 26 | boolean[][] visit = new boolean[ROWS][COLS]; |
| 27 | |
| 28 | for (int r = 0; r < ROWS; r++) { |
| 29 | for (int c = 0; c < COLS; c++) { |
| 30 | dfs(board, r, c, root, "", visit, res); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | return new ArrayList<>(res); |
| 35 | } |
| 36 | |
| 37 | private void dfs(char[][] board, int r, int c, TrieNode node, String word, boolean[][] visit, Set<String> res) { |
| 38 | int ROWS = board.length, COLS = board[0].length; |
| 39 | if (r < 0 || c < 0 || r == ROWS || c == COLS || visit[r][c] || !node.children.containsKey(board[r][c])) { |
| 40 | return; |
| 41 | } |
| 42 | |
| 43 | visit[r][c] = true; |
| 44 | node = node.children.get(board[r][c]); |
| 45 | word += board[r][c]; |
| 46 | if (node.isWord) { |
| 47 | res.add(word); |
| 48 | } |
| 49 | |
| 50 | dfs(board, r + 1, c, node, word, visit, res); |
| 51 | dfs(board, r - 1, c, node, word, visit, res); |
| 52 | dfs(board, r, c + 1, node, word, visit, res); |
| 53 | dfs(board, r, c - 1, node, word, visit, res); |
| 54 | |
| 55 | visit[r][c] = false; |
| 56 | } |
| 57 | } |
| 1 | #include <vector> |
| 2 | #include <string> |
| 3 | #include <unordered_map> |
| 4 | #include <unordered_set> |
| 5 | |
| 6 | using namespace std; |
| 7 | |
| 8 | class TrieNode { |
| 9 | public: |
| 10 | unordered_map<char, TrieNode*> children; |
| 11 | bool is_word = false; |
| 12 | |
| 13 | void add_word(const string& word) { |
| 14 | TrieNode* curr = this; |
| 15 | for (char c : word) { |
| 16 | if (curr->children.find(c) == curr->children.end()) { |
| 17 | curr->children[c] = new TrieNode(); |
| 18 | } |
| 19 | curr = curr->children[c]; |
| 20 | } |
| 21 | curr->is_word = true; |
| 22 | } |
| 23 | }; |
| 24 | |
| 25 | class Solution { |
| 26 | public: |
| 27 | vector<string> findWords(vector<vector<char>>& board, vector<string>& words) { |
| 28 | TrieNode root; |
| 29 | for (const string& w : words) { |
| 30 | root.add_word(w); |
| 31 | } |
| 32 | |
| 33 | int ROWS = board.size(); |
| 34 | int COLS = board[0].size(); |
| 35 | unordered_set<string> res; |
| 36 | vector<vector<bool>> visit(ROWS, vector<bool>(COLS, false)); |
| 37 | |
| 38 | for (int r = 0; r < ROWS; ++r) { |
| 39 | for (int c = 0; c < COLS; ++c) { |
| 40 | dfs(board, r, c, &root, "", visit, res); |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | return vector<string>(res.begin(), res.end()); |
| 45 | } |
| 46 | |
| 47 | private: |
| 48 | void dfs(vector<vector<char>>& board, int r, int c, TrieNode* node, string word, vector<vector<bool>>& visit, unordered_set<string>& res) { |
| 49 | int ROWS = board.size(); |
| 50 | int COLS = board[0].size(); |
| 51 | if (r < 0 || c < 0 || r == ROWS || c == COLS || visit[r][c] || node->children.find(board[r][c]) == node->children.end()) { |
| 52 | return; |
| 53 | } |
| 54 | |
| 55 | visit[r][c] = true; |
| 56 | node = node->children[board[r][c]]; |
| 57 | word += board[r][c]; |
| 58 | if (node->is_word) { |
| 59 | res.insert(word); |
| 60 | } |
| 61 | |
| 62 | dfs(board, r + 1, c, node, word, visit, res); |
| 63 | dfs(board, r - 1, c, node, word, visit, res); |
| 64 | dfs(board, r, c + 1, node, word, visit, res); |
| 65 | dfs(board, r, c - 1, node, word, visit, res); |
| 66 | |
| 67 | visit[r][c] = false; |
| 68 | } |
| 69 | }; |
| 1 | class TrieNode { |
| 2 | constructor() { |
| 3 | this.children = new Map(); |
| 4 | this.isWord = false; |
| 5 | } |
| 6 | |
| 7 | addWord(word) { |
| 8 | let curr = this; |
| 9 | for (const c of word) { |
| 10 | if (!curr.children.has(c)) { |
| 11 | curr.children.set(c, new TrieNode()); |
| 12 | } |
| 13 | curr = curr.children.get(c); |
| 14 | } |
| 15 | curr.isWord = true; |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | class Solution { |
| 20 | findWords(board, words) { |
| 21 | const root = new TrieNode(); |
| 22 | for (const w of words) { |
| 23 | root.addWord(w); |
| 24 | } |
| 25 | |
| 26 | const ROWS = board.length, COLS = board[0].length; |
| 27 | const res = new Set(); |
| 28 | const visit = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); |
| 29 | |
| 30 | const dfs = (r, c, node, word) => { |
| 31 | if (r < 0 || c < 0 || r === ROWS || c === COLS || visit[r][c] || !node.children.has(board[r][c])) { |
| 32 | return; |
| 33 | } |
| 34 | |
| 35 | visit[r][c] = true; |
| 36 | node = node.children.get(board[r][c]); |
| 37 | word += board[r][c]; |
| 38 | if (node.isWord) { |
| 39 | res.add(word); |
| 40 | } |
| 41 | |
| 42 | dfs(r + 1, c, node, word); |
| 43 | dfs(r - 1, c, node, word); |
| 44 | dfs(r, c + 1, node, word); |
| 45 | dfs(r, c - 1, node, word); |
| 46 | |
| 47 | visit[r][c] = false; |
| 48 | }; |
| 49 | |
| 50 | for (let r = 0; r < ROWS; r++) { |
| 51 | for (let c = 0; c < COLS; c++) { |
| 52 | dfs(r, c, root, ""); |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | return Array.from(res); |
| 57 | } |
| 58 | } |
Step-by-Step Solution
Build a Trie to Represent All Words for Prefix-Based Pruning
| 2 | def __init__(self): |
| 3 | self.children = {} |
| 4 | self.is_word = False |
| 6 | def add_word(self, word): |
| 7 | curr = self |
| 8 | for c in word: |
| 9 | if c not in curr.children: |
| 10 | curr.children[c] = TrieNode() |
| 11 | curr = curr.children[c] |
| 12 | curr.is_word = True |
| 15 | def findWords(self, board: list[list[str]], words: list[str]) -> list[str]: |
| 16 | root = TrieNode() |
| 17 | for w in words: |
| 18 | root.add_word(w) |
Objective
To construct a prefix tree that encodes all words, enabling efficient prefix checks during DFS.
Key Insight
Building a Trie consolidates all words into a single data structure where common prefixes are shared. This allows the search to quickly determine if the current path can lead to any word by checking if the next character exists in the Trie node's children. This shared prefix structure is essential to prune invalid paths early and avoid redundant searches for each word separately.
Interview Quick-Check
Core Logic
The Trie stores all words such that each node represents a prefix; checking if a character exists in children determines if the path is valid.
Common Pitfalls & Bugs
Forgetting to mark the end of a word with a flag (is_word) causes the algorithm to miss valid words during DFS.
Perform DFS with Backtracking to Explore Valid Paths on the Board
| 20 | ROWS, COLS = len(board), len(board[0]) |
| 21 | res, visit = set(), set() |
| 23 | def dfs(r, c, node, word): |
| 24 | if (r < 0 or c < 0 or |
| 25 | r == ROWS or c == COLS or |
| 26 | (r, c) in visit or board[r][c] not in node.children): |
| 27 | return |
| 29 | visit.add((r, c)) |
| 30 | node = node.children[board[r][c]] |
| 31 | word += board[r][c] |
| 32 | if node.is_word: |
| 33 | res.add(word) |
| 35 | dfs(r + 1, c, node, word) |
| 36 | dfs(r - 1, c, node, word) |
| 37 | dfs(r, c + 1, node, word) |
| 38 | dfs(r, c - 1, node, word) |
| 39 | visit.remove((r, c)) |
Objective
To recursively explore all valid paths on the board that match prefixes in the Trie, collecting complete words found.
Key Insight
DFS explores neighbors in four directions, but only continues if the next character exists in the current Trie node's children. The visited set prevents revisiting cells in the current path, ensuring correctness. When a Trie node indicates a complete word, it is added to the result set. Backtracking by removing the cell from visited after exploration restores state for other paths. This combination ensures exhaustive yet efficient search.
Interview Quick-Check
Core Logic
DFS prunes paths early by checking Trie children, uses a visited set to avoid reuse of cells, and adds words to results when a complete word node is reached.
State & Boundaries
Boundary checks ensure indices are valid and cells are not revisited in the current path.
Common Pitfalls & Bugs
Incorrectly managing visited cells (e.g., not removing after recursion) leads to missed paths or infinite loops.
Initiate DFS from Every Cell to Find All Words
| 41 | for r in range(ROWS): |
| 42 | for c in range(COLS): |
| 43 | dfs(r, c, root, "") |
| 45 | return list(res) |
Objective
To start the backtracking search from each cell on the board, ensuring all possible starting points are considered.
Key Insight
Since words can start from any cell, the algorithm must initiate DFS from every position. This exhaustive start combined with Trie pruning ensures all valid words are found without redundant searches. Converting the result set to a list returns the final answer.
Interview Quick-Check
Core Logic
Starting DFS from every cell guarantees coverage of all possible word paths.
Complexity
Although starting from every cell increases calls, Trie pruning keeps the overall search efficient.
Line Analysis
This solution has 7 Critical lines interviewers watch for.
visit.remove((r, c))
Remove the current cell from visited to backtrack.
This state restoration is the single most critical line in the backtracking algorithm; it ensures that the visited set correctly reflects the current path, allowing other DFS branches to explore the cell without false blockage.
curr.is_word = True
Mark the final trie node as a complete word.
After all characters are inserted, this flag marks the path as a complete word that can be reported during DFS.
def dfs(r, c, node, word):
Define the DFS function to explore board paths matching Trie prefixes.
DFS recursively explores neighbors, pruning paths that do not match any prefix in the Trie.
if (r < 0 or c < 0 or
Check if current position is out of bounds or invalid for exploration.
Boundary and pruning checks prevent invalid or redundant exploration, ensuring correctness and efficiency.
(r, c) in visit or board[r][c] not in node.children):
Check if column index exceeds board boundaries or cell is visited or character not in Trie children.
Ensures only valid, unvisited cells with matching Trie prefixes are explored, enabling pruning.
node = node.children[board[r][c]]
Advance the Trie node to the child corresponding to the current board character.
Moving down the Trie aligns the search path with the current prefix, enabling prefix validation.
if node.is_word:
Check if the current Trie node marks a complete word.
Detecting complete words during DFS allows adding valid words to the result set immediately.
self.children = {}
Initialize the children dictionary.
The children dictionary stores outgoing character transitions for prefix traversal.
self.is_word = False
Initialize the word-ending flag.
The is_word flag distinguishes a complete stored word from a prefix that merely continues to other nodes.
if c not in curr.children:
Check whether the character child is missing.
A missing child means the trie does not yet contain this prefix path and must create it.
curr.children[c] = TrieNode()
Create the missing trie child.
Creating a new TrieNode extends the trie to represent the new prefix.
root.add_word(w)
Insert the current word into the Trie.
Populating the Trie with all words enables prefix-based pruning during DFS.
res, visit = set(), set()
Initialize the result set and visited set.
The result set collects unique found words; the visited set tracks cells used in the current DFS path to prevent reuse.
return
Return early if any invalid condition is met.
Early return avoids unnecessary recursion and exploration of invalid paths.
visit.add((r, c))
Mark the current cell as visited.
Tracking visited cells prevents revisiting the same cell in the current path, maintaining path validity.
res.add(word)
Add the current word to the result set.
Storing found words ensures unique results and prevents duplicates.
dfs(r, c, root, "")
Invoke DFS from the current cell with the root Trie node and empty word.
This initiates the prefix-based search from each cell, enabling discovery of all valid words.
curr = curr.children[c]
Move to the child node for the current character.
Advancing the pointer moves insertion deeper along the word path.
root = TrieNode()
Create the root TrieNode for all words.
The root node serves as the entry point for all prefix searches and insertions.
r == ROWS or c == COLS or
Check if row index exceeds board boundaries.
Prevents index errors and invalid moves beyond the board.
word += board[r][c]
Append the current character to the forming word.
Building the current word string allows adding complete words to the result when found.
dfs(r + 1, c, node, word)
Recursively explore the cell below.
DFS explores all four directions to find all valid word paths.
dfs(r - 1, c, node, word)
Recursively explore the cell above.
DFS explores all four directions to find all valid word paths.
dfs(r, c + 1, node, word)
Recursively explore the cell to the right.
DFS explores all four directions to find all valid word paths.
dfs(r, c - 1, node, word)
Recursively explore the cell to the left.
DFS explores all four directions to find all valid word paths.
def __init__(self):
Define the TrieNode initializer.
The initializer defines the per-node state that every trie insertion and lookup depends on.
def add_word(self, word):
Define the trie word-insertion helper.
The add_word helper encapsulates insertion logic so each input word can be loaded into the trie consistently.
curr = self
Initialize the insertion pointer.
Insertion starts at the current trie root and walks one character at a time.
for c in word:
Iterate through the characters of the word.
Each character extends or follows one edge in the trie path for the word.
def findWords(self, board: list[list[str]], words: list[str]) -> list[str]:
Define the findWords method.
This method is the entry point that builds the trie, searches the board, and returns all discovered words.
for w in words:
Iterate over each word in the input list.
Each word must be inserted into the Trie to build the prefix tree for efficient search.
for r in range(ROWS):
Iterate over each row in the board to start DFS.
Starting DFS from every cell ensures all possible word paths are explored.
for c in range(COLS):
Iterate over each column in the board to start DFS.
Starting DFS from every cell ensures all possible word paths are explored.
ROWS, COLS = len(board), len(board[0])
Store the number of rows and columns of the board.
Caching dimensions simplifies boundary checks during DFS.
return list(res)
Return the list of found words.
Converting the result set to a list provides the final output in the expected format.
Test Your Understanding
Why is using a Trie critical for pruning the DFS search space when searching for multiple words on the board?
Because the Trie encodes all prefixes of the words, allowing the DFS to abandon any path that does not match a prefix of any word, thus avoiding exploring paths that cannot lead to a valid word and drastically reducing the search space.
Related Problems
Tries pattern
Don't just read it. Drill it.
Reconstruct Word Search II from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.
Unlock the Word Search II drill