212. Word Search II - Explanation
Description
Given a 2-D grid of characters board and a list of strings words, return all words that are present in the grid.
For a word to be present it must be possible to form the word with a path in the board with horizontally or vertically neighboring cells. The same cell may not be used more than once in a word.
Example 1:
Input:
board = [
["a","b","c","d"],
["s","a","a","t"],
["a","c","k","e"],
["a","c","d","n"]
],
words = ["bat","cat","back","backend","stack"]
Output: ["cat","back","backend"]Example 2:
Input:
board = [
["x","o"],
["x","o"]
],
words = ["xoxo"]
Output: []Constraints:
1 <= board.length, board[i].length <= 12board[i]consists only of lowercase English letter.1 <= words.length <= 30,0001 <= words[i].length <= 10words[i]consists only of lowercase English letters.- All strings within
wordsare distinct.
Topics
Recommended Time & Space Complexity
You should aim for a solution with O(m * n * 4 * (3^(t-1)) + s) time and O(s) space, where m is the number of rows, n is the number of columns, t is the maximum length of any word and s is the sum of the lengths of all the words.
Hint 1
To search for a word in the grid, we can use backtracking by starting at each cell, simultaneously iterating through the word and matching the characters with the cells, recursively visiting the neighboring cells. However, if we are given a list of words and need to search for each one, it becomes inefficient to iterate through each word and run backtracking for each. Can you think of a better way? Perhaps a data structure could help with more efficient word search and insertion.
Hint 2
We can use a Trie to efficiently search for multiple words. After inserting all words into the Trie, we traverse the grid once. For each character in the grid, we check if it exists in the current Trie node. If not, we prune the search. If we encounter an "end of word" flag in the Trie node, we know a valid word has been found. But how can we add that word to the result list? Maybe you should think about what additional information you can store in the Trie node.
Hint 3
When we insert a word into the Trie, we can store the word's index. Why? Because when we want to add the word to the result list after finding a valid word, we can easily add it using the index. After adding that word, we put index = -1 as we shouldn't add the word multiple times to the result list. How can you implement this?
Hint 4
We insert all the words into the Trie with their indices marked. Then, we iterate through each cell in the grid. At each cell, we start at the root of the Trie and explore all possible paths. As we traverse, we match characters in the cell with those in the Trie nodes. If we encounter the end of a word, we take the index at that node and add the corresponding word to the result list. Afterward, we unmark that index and continue exploring further paths.
Prerequisites
Before attempting this problem, you should be comfortable with:
- Backtracking - Exploring all possible paths by making choices, recursing, and undoing choices to try alternatives
- Depth-First Search (DFS) - Traversing a graph or grid by exploring as far as possible along each branch before backtracking
- Trie (Prefix Tree) - A tree data structure used for efficient prefix matching and word storage
- 2D Grid Traversal - Moving through a matrix in four directions (up, down, left, right) while handling boundaries
1. Backtracking
Intuition
For each word, we try to trace it on the board by walking through adjacent cells (up/down/left/right).
To avoid using the same cell twice in one word path, we temporarily mark the cell as visited, and then restore it after exploring (classic backtracking).
If we can match all characters of a word in order, that word is found and added to the result.
Algorithm
- Let
ROWS,COLSbe board dimensions. - For each
wordinwords:- Try every cell
(r, c)as a possible starting point (only if it matchesword[0]). - Run a
DFS/backtracking functionbacktrack(r, c, i)where:i= current index inwordwe need to match.- Base case: if
i == len(word), returntrue(whole word matched). - If out of bounds or board cell doesn't match
word[i], returnfalse. - Mark the cell as visited (e.g., replace with
*). - Recurse to 4 neighbors with
i + 1. - Restore the original character (undo the choice).
- Return whether any neighbor path succeeded.
- If any start cell succeeds, add
wordto the answer list and stop searching this word.
- Try every cell
class Solution:
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
ROWS, COLS = len(board), len(board[0])
res = []
def backtrack(r, c, i):
if i == len(word):
return True
if (r < 0 or c < 0 or r >= ROWS or
c >= COLS or board[r][c] != word[i]
):
return False
board[r][c] = '*'
ret = (backtrack(r + 1, c, i + 1) or
backtrack(r - 1, c, i + 1) or
backtrack(r, c + 1, i + 1) or
backtrack(r, c - 1, i + 1))
board[r][c] = word[i]
return ret
for word in words:
flag = False
for r in range(ROWS):
if flag:
break
for c in range(COLS):
if board[r][c] != word[0]:
continue
if backtrack(r, c, 0):
res.append(word)
flag = True
break
return resTime & Space Complexity
- Time complexity:
- Space complexity:
Where is the number of words, is the number of rows, is the number of columns and is the maximum length of any word in the array .
2. Backtracking (Trie + Hash Set)
Intuition
Searching each word separately repeats the same work many times.
A Trie (prefix tree) lets us share work: while walking on the board, we only continue paths that match some prefix of the given words.
So the board DFS explores "possible prefixes", and whenever the Trie node says this prefix is a complete word, we record it.
We also need to avoid reusing the same cell in a single path, so we keep a visited set during the current DFS path (and backtrack/remove when returning).
Algorithm
- Build a Trie from all
words.- Each Trie node stores
children(next letters) andisWord(trueif a word ends here).
- Each Trie node stores
- Initialize:
resas a set (to avoid duplicates).visitas a set for the currentDFSpath.
- Define
DFSdfs(r, c, node, wordSoFar):- If
(r,c)is out of bounds, already visited, orboard[r][c]is not innode.children, stop. - Mark
(r,c)visited. - Move Trie pointer:
node = node.children[board[r][c]] - Append current char to
wordSoFar. - If
node.isWord == true, addwordSoFartores. - Recurse to 4 neighbors (up/down/left/right) using the updated
nodeandwordSoFar. - Backtrack: remove
(r,c)fromvisit.
- If
- Run
DFSstarting from every cell(r, c)with the Trie root. - Return all collected words from
res.
class TrieNode:
def __init__(self):
self.children = {}
self.isWord = False
def addWord(self, word):
cur = self
for c in word:
if c not in cur.children:
cur.children[c] = TrieNode()
cur = cur.children[c]
cur.isWord = True
class Solution:
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
root = TrieNode()
for w in words:
root.addWord(w)
ROWS, COLS = len(board), len(board[0])
res, visit = set(), set()
def dfs(r, c, node, word):
if (r < 0 or c < 0 or r >= ROWS or
c >= COLS or (r, c) in visit or
board[r][c] not in node.children
):
return
visit.add((r, c))
node = node.children[board[r][c]]
word += board[r][c]
if node.isWord:
res.add(word)
dfs(r + 1, c, node, word)
dfs(r - 1, c, node, word)
dfs(r, c + 1, node, word)
dfs(r, c - 1, node, word)
visit.remove((r, c))
for r in range(ROWS):
for c in range(COLS):
dfs(r, c, root, "")
return list(res)Time & Space Complexity
- Time complexity:
- Space complexity:
Where is the number of rows, is the number of columns, is the maximum length of any word in the array and is the sum of the lengths of all the words.
3. Backtracking (Trie)
Intuition
We still do DFS on the board, but we guide the DFS using a Trie so we only walk paths that match prefixes of the given words.
This version is faster because it adds aggressive pruning:
- Each Trie node keeps
refs= "how many words in the dictionary still pass through this node". - When we successfully find a word, we remove it from the Trie (by setting
idx = -1and decreasingrefs). - If after removal a node's
refsbecomes0, that branch is dead (no remaining words use it), so we physically cut the pointer from its parent (prev.children[...] = null).
That prevents futureDFScalls from exploring useless prefixes.
Also, instead of using a visited set, we mark the board in-place:
- Temporarily set
board[r][c] = '*'while exploring that path. - Restore it when backtracking.
What the Trie stores
Each node has:
children[26]: next letters (array, faster than hashmap)idx: index of a word inwordsif a word ends here, else-1refs: number of “still-alive” words that go through this node (including end words)
Algorithm
Build the Trie:
- Insert every word
words[i]. - While inserting, increment
refson every node along the path. - At the end node, store
idx = i.
- Insert every word
DFSfrom every board cell:dfs(r, c, node)tries to extend the current Trie path usingboard[r][c].
DFSrules:- Stop if:
- out of bounds, or
- cell is already used in current path (
'*'), or - Trie has no child for
board[r][c]
- Otherwise:
- Take letter
ch = board[r][c], move tochild = node.children[ch] - Mark cell as visited:
board[r][c] = '*' - If
child.idx != -1, we found a word:- add
words[child.idx]to result - set
child.idx = -1(avoid duplicates) - decrease
child.refs(this word is removed from remaining dictionary) - If
child.refs == 0, cut this branch from parent:node.children[ch] = null- restore board cell and return early (nothing more to explore here)
- add
- Recurse into 4 neighbors with
child - Restore board cell (backtrack)
- Take letter
- Stop if:
Return collected results.
class TrieNode:
def __init__(self):
self.children = [None] * 26
self.idx = -1
self.refs = 0
def addWord(self, word, i):
cur = self
cur.refs += 1
for c in word:
index = ord(c) - ord('a')
if not cur.children[index]:
cur.children[index] = TrieNode()
cur = cur.children[index]
cur.refs += 1
cur.idx = i
class Solution:
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
root = TrieNode()
for i in range(len(words)):
root.addWord(words[i], i)
ROWS, COLS = len(board), len(board[0])
res = []
def getIndex(c):
index = ord(c) - ord('a')
return index
def dfs(r, c, node):
if (r < 0 or c < 0 or r >= ROWS or
c >= COLS or board[r][c] == '*' or
not node.children[getIndex(board[r][c])]):
return
tmp = board[r][c]
board[r][c] = '*'
prev = node
node = node.children[getIndex(tmp)]
if node.idx != -1:
res.append(words[node.idx])
node.idx = -1
node.refs -= 1
if not node.refs:
prev.children[getIndex(tmp)] = None
node = None
board[r][c] = tmp
return
dfs(r + 1, c, node)
dfs(r - 1, c, node)
dfs(r, c + 1, node)
dfs(r, c - 1, node)
board[r][c] = tmp
for r in range(ROWS):
for c in range(COLS):
dfs(r, c, root)
return resTime & Space Complexity
- Time complexity:
- Space complexity:
Where is the number of rows, is the number of columns, is the maximum length of any word in the array and is the sum of the lengths of all the words.
Common Pitfalls
Forgetting to Remove Duplicate Words from Results
When multiple paths on the board can form the same word, adding the word to the result list without checking for duplicates leads to duplicate entries. Using a set for results or marking words as found in the Trie (by setting idx = -1 or isWord = false) prevents this issue.
Not Restoring the Board After Backtracking
When marking cells as visited by modifying the board directly (e.g., setting board[r][c] = '*'), forgetting to restore the original character after the recursive calls will corrupt the board state. This prevents other starting positions or words from finding valid paths.
Inefficient Trie Without Pruning
Building a Trie but not pruning it after finding words leads to repeated exploration of dead branches. Without decrementing refs counts and removing nodes when refs reaches zero, the algorithm continues searching paths that cannot yield any new words, significantly degrading performance.
Incorrect Trie Traversal During DFS
Advancing the Trie pointer before checking if the child node exists can cause null pointer exceptions. Always verify that node.children[char] exists before moving to the next node. Similarly, checking isWord before updating the node pointer will miss the current word.
Not Handling the Visited Set Correctly with Trie
When using a separate visited set alongside the Trie, forgetting to clear or properly backtrack the visited set for each new starting cell can block valid paths. Each DFS starting point should begin with a fresh or properly reset visited state.
Sign in to join the discussion