Word Search (Grid DFS)
Check whether a word can be traced through adjacent grid cells without reuse, by DFS from every matching start cell with in-place visited marking.
Overview
Word Search asks whether word appears in a letter grid as a path of horizontally/vertically adjacent cells, each used at most once. The solution is Maze Search (Grid Backtracking) where the "wall" test is board[r][c] != word[k] and the target is k == len(word).
Marking visited cells in place (temporarily overwriting with #) avoids a separate visited matrix and is a standard interview trick. For many words on one board (Word Search II), replace the single word with a Trie so that all words are searched in one DFS pass, and prune trie nodes that have been fully matched.
Intuition
A mental model before the formal terms.
Put a finger on every cell that matches the first letter. From there, spell the word by sliding to a neighbor that matches the next letter, never returning to a cell already under your finger trail. If you get stuck, lift your finger back one letter and try another neighbor.
The trie variant is spelling all words at once: at each cell you only continue if some word in the dictionary has the current path as a prefix.
How it works
- For every cell
(r, c), calldfs(r, c, 0). dfs(r, c, k): ifk == len(word)returntrue. If out of bounds orboard[r][c] != word[k]returnfalse.- Save
board[r][c], overwrite with#, recurse into four neighbors withk + 1, then restore the character. - Return
trueas soon as any neighbor succeeds. Optional pre-check: if the letter counts ofwordexceed those of the board, returnfalseimmediately; if the last letter is rarer than the first, search the reversed word.
Why it works
The # overwrite ensures each cell is used at most once on the current path (a # never equals a letter of the word), and restoring it after the calls means sibling branches see the original board.
Depth-first exploration from every start cell is exhaustive over all simple paths whose letters spell the word; the mismatch check prunes every branch as early as the first wrong letter, so the search never explores paths that cannot become the word.
With a trie, the invariant is "the current path is a prefix of at least one unfound word"; when the invariant fails there is no point continuing, and removing found words from the trie keeps that prune tight.
Recognition
How to tell a problem wants this.
- Letter grid + "does this word exist as a path of adjacent cells".
- Path constraints ("no cell reused") with a string as the target — pruning on a mismatch at every step.
- Many words over one board — that is the Trie + DFS combination.
Interactive visualization
Play, step, change the input. ← → and space work too.
1for each cell (r, c): if dfs(r, c, 0): return true2dfs(r, c, i):3 if out of bounds or used or grid[r][c] != word[i]: return false4 if i == len(word) - 1: return true5 used[r][c] = true6 found = any(dfs(neighbor, i + 1))7 used[r][c] = false; return found // backtrackPseudocode
1for each cell (r, c): if dfs(r, c, 0): return true2dfs(r, c, k):3 if k == len(word): return true4 if out of bounds or board[r][c] != word[k]: return false5 saved = board[r][c]; board[r][c] = "#"6 found = any(dfs(nr, nc, k + 1) for 4 neighbors)7 board[r][c] = saved8 return foundImplementations
1from collections import Counter2 3# Grid DFS: trace a word through orthogonally adjacent cells without reusing4# any cell. The key trick is marking visited IN PLACE and restoring on the way5# out, which needs no separate visited grid and no allocation per branch.6 7 81 · Try every cell as a starting point9def exist(board: list[list[str]], word: str) -> bool:10 if not word:11 return True12 rows = len(board)13 if rows == 0:14 return False15 cols = len(board[0])16 172 · Bounds and character check first, so recursion never runs on a miss18 def dfs(r: int, c: int, k: int) -> bool:19 if not (0 <= r < rows and 0 <= c < cols):20 return False21 if board[r][c] != word[k]:22 return False23 if k == len(word) - 1:24 return True25 263 · Mark in place with a sentinel that cannot appear in the word27 saved = board[r][c]28 board[r][c] = "\u0000"29 30 found = (31 dfs(r + 1, c, k + 1)32 or dfs(r - 1, c, k + 1)33 or dfs(r, c + 1, k + 1)34 or dfs(r, c - 1, k + 1)35 )36 374 · Restore on the way out — this is what makes it a search, not a walk38 board[r][c] = saved39 return found40 41 return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))42 43 445 · A cheap prune: if the board lacks enough of some letter, give up early45def feasible(board: list[list[str]], word: str) -> bool:46 have = Counter(ch for row in board for ch in row)47 need = Counter(word)48 return all(have[ch] >= n for ch, n in need.items())0 <= r < rows and 0 <= c < colsis the chained-comparison bounds check, which reads as the mathematical condition.dfsis a closure, so the recursive calls pass only the three changing coordinates.any(dfs(r, c, 0) for r in range(rows) for c in range(cols))short-circuits on the first success, replacing the nested loop with one expression.- The mark-and-restore is identical to the other languages; Python lists of lists are mutable, so
board[r][c] = ...works directly. Countermakesfeasiblea two-line function, andhave[ch]returns 0 for a missing key rather than raising.
Recursion depth is the word length, so RecursionError is only a risk for words longer than about 1000 characters.
collections.Countersupportshave[ch]on a missing key returning 0, unlike a plaindictwhich raises.Counter(word) - Counter(...)and<=between Counters express subset-of-multiset directly:Counter(word) <= haveis the wholefeasiblecheck in one expression (3.10+).- Chained comparisons (
0 <= r < rows) evaluateronce and are both faster and clearer than twoand-joined tests. - A grid of strings would be immutable and could not be marked in place; a list of lists (or a
bytearrayper row) is required.
- Passing a list of *strings* as the board, which cannot be mutated —
board[r][c] = xraisesTypeError. - Forgetting the restore.
- Using
board[r][c] != word[k]withk == len(word), which raisesIndexErrorrather than silently reading garbage.
- Mutability of the grid decides the representation: Python needs a list of lists because strings are immutable, while C++ can use
std::stringrows and JS/TS can use arrays of single-character strings. - Out-of-range word indexing fails differently: Python raises
IndexError, JS/TS returnundefined(which compares unequal and silently returns false), and C++std::string::operator[]atsize()returns the null terminator. - Letter counting: Python
Counteris a one-liner with a 0 default, JS/TS need aMapplus?? 0, and C++ uses a fixed 256-entry array — the fastest of the three and the least general. - A closure capturing the grid is idiomatic in JS/TS and Python; C++ needs either a lambda with explicit captures or a free function taking the grid by reference, which is why the C++ version has the forward-declaration wrinkle.
Complexity
L = word length. Each of the R·C starts explores at most 3 new directions per step (never back into the previous cell). Word Search II with a trie of total length T is O(R · C · 3^Lmax) with O(T) trie space.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- One or a few words against a small board (
≤ 15×15,L ≤ 15). - Many words against one board — build a Trie and run a single DFS per start cell.
- Words may reuse cells or the path is fixed (straight lines only) — then simple scanning per direction is
O(R · C · 8 · L). - Substring search in a 1D string — use Knuth–Morris–Pratt (KMP) or Rabin–Karp.
Alternatives
Common mistakes
- Not restoring the cell after the DFS, corrupting the board for subsequent start cells.
- Checking
k == len(word)after the bounds check, which fails when the last letter sits on the grid edge. - Using a separate visited array but forgetting to unmark it.
- In Word Search II, collecting a found word repeatedly — mark the trie node as found (set its word to null) after the first hit.
- Skipping the letter-frequency pre-check and timing out on adversarial boards full of the same letter.
Interview patterns
- Word Search I: single word, in-place marking.
- Word Search II: trie + DFS, prune trie leaves after collection, letter-frequency pre-check.
- Reverse the word when its last letter is rarer on the board than its first — fewer start cells.
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Stack versus queueBeginner
- Recursion versus iterationIntermediate
- When space complexity mattersIntermediate
- Course ScheduleIntermediate
- Number of IslandsIntermediate
- Word SearchAdvanced