Loading learning content...
Imagine you're looking at a crossword puzzle, but instead of reading clues, you need to verify whether a given word exists somewhere in the grid. The word might wind its way through the grid in any direction—horizontally, vertically, or diagonally—turning at each cell to find its path. This is the Word Search problem, a quintessential backtracking challenge that appears across software engineering domains from game development to text processing to bioinformatics.
The Word Search problem is deceptively simple to state but profoundly instructive in its solution. It exemplifies how backtracking transforms what could be an intractable brute-force exploration into a structured, efficient search through a solution space. By mastering this problem, you gain intuition that transfers directly to countless grid-based exploration challenges.
By the end of this page, you will understand the Word Search problem completely, including its formal definition, why backtracking is the natural solution, how to implement it with optimal pruning, and how to analyze its complexity. You'll see the standard template in action and understand how to adapt it for variations like finding all occurrences or searching for multiple words.
Let us establish the problem with mathematical precision before exploring solutions.
The Word Search Problem:
Given:
m × n grid of characters board[m][n]word of length kDetermine whether word exists in the grid, where the word can be constructed from letters of sequentially adjacent cells, and the same cell may not be used more than once.
Adjacency Definition:
Two cells are "adjacent" if they share an edge. This means each cell (except those on the boundary) has exactly 4 neighbors: up, down, left, and right. Importantly, diagonal cells are not adjacent in the standard formulation.
Constraints:
1 ≤ m, n ≤ 6 (for the LeetCode variant)1 ≤ word.length ≤ 15board and word consist of only lowercase and uppercase English lettersThe constraint sizes hint at the exponential nature of the problem—the solution space is too large for polynomial algorithms but small enough that pruned backtracking works efficiently.
board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"trueThe path A(0,0) → B(0,1) → C(0,2) → C(1,2) → E(2,2) → D(2,1) spells "ABCCED". Notice how the path turns downward at C, then moves down again to E, then left to D. The path must be contiguous with each step moving to an adjacent cell.
A critical constraint is that each cell can only be used once per path. This prevents trivial solutions like bouncing between two adjacent cells to form words like "ABAB". This constraint is what makes the problem a true backtracking challenge—we must track our path and undo our choices when backtracking.
Before implementing a solution, let's understand why backtracking is the right paradigm for this problem.
The Solution Space Structure:
Imagine trying to find the word "HELLO" in a grid. Your solution space looks like a tree:
This forms a search tree where each path from root to leaf represents a sequence of choices. We're looking for a path of length k (the word length) where each node's character matches the corresponding character in the word.
Why Not Brute Force?
Theoretically, we could:
k starting from any cellBut the number of such paths is astronomical. From each cell, we have up to 4 choices. For a path of length k, that's potentially O(4^k) paths from each of m × n cells. For a 6×6 grid and a 15-character word, that's 36 × 4^15 ≈ 38 billion potential paths—clearly infeasible.
In practice, the backtracking solution is much faster than the theoretical worst case suggests. For random grids and words, most paths are pruned within the first few characters because the next character simply doesn't match. The solution's efficiency comes not from being polynomial, but from aggressive pruning that makes the average case far better than the worst case.
Recall the universal backtracking template from earlier in this chapter:
function backtrack(state):
if isComplete(state):
processResult()
return
for choice in getChoices(state):
if isValid(choice, state):
make(choice) // Choose
backtrack(newState) // Explore
undo(choice) // Unchoose
Let's map this template to the Word Search problem:
State: Our current position (row, col) in the grid and the current index i in the word we're trying to match.
isComplete: We've matched all characters, i.e., i == word.length.
getChoices: The four adjacent cells: (row-1, col), (row+1, col), (row, col-1), (row, col+1).
isValid: The cell is within bounds, hasn't been visited in the current path, and its character matches word[i].
make(choice): Mark the cell as visited.
undo(choice): Unmark the cell as visited (the critical backtracking step).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
def exist(board: list[list[str]], word: str) -> bool: """ Determine if word exists in the board using backtracking. Time Complexity: O(m * n * 4^L) where L is the word length Space Complexity: O(L) for the recursion stack """ if not board or not board[0] or not word: return False rows, cols = len(board), len(board[0]) def backtrack(row: int, col: int, index: int) -> bool: # Base case: all characters matched if index == len(word): return True # Boundary and validity check if (row < 0 or row >= rows or col < 0 or col >= cols or board[row][col] != word[index]): return False # CHOOSE: Mark current cell as visited # We use a special character to mark visited cells temp = board[row][col] board[row][col] = '#' # Mark as visited # EXPLORE: Try all four directions directions = [(0, 1), (0, -1), (1, 0), (-1, 0)] for dr, dc in directions: if backtrack(row + dr, col + dc, index + 1): return True # Found the word! # UNCHOOSE: Restore the cell (backtrack) board[row][col] = temp return False # Try starting from every cell in the grid for i in range(rows): for j in range(cols): if board[i][j] == word[0]: # Optimization: only start from matching cells if backtrack(i, j, 0): return True return False # Example usageboard = [ ["A", "B", "C", "E"], ["S", "F", "C", "S"], ["A", "D", "E", "E"]]print(exist(board, "ABCCED")) # Trueprint(exist(board, "SEE")) # Trueprint(exist(board, "ABCB")) # False (would require revisiting B)Let's dissect each component of our solution to understand the nuances that make it work correctly and efficiently.
The Visited-Cell Strategy:
Notice that we don't use a separate visited set or matrix. Instead, we temporarily modify the board itself by replacing the current cell's character with a sentinel value ('#'). This is a common optimization:
board[row][col] = '#' is O(1)This technique is safe because we never look at the value of a '#' cell—we only check if the character matches the word, which '#' never will (assuming the word contains only letters).
This optimization only works if the sentinel character cannot appear in the word. If the word could contain '#', you'd need a separate visited structure. In competitive programming, always verify the input character set before using this trick.
The Order of Checks:
Our validity check combines multiple conditions:
if (row < 0 or row >= rows or
col < 0 or col >= cols or
board[row][col] != word[index]):
return False
The order matters for short-circuit evaluation:
board[row][col] with invalid indices would crashThe character match implicitly handles the visited check—a visited cell contains '#', which won't match any letter in the word.
The Direction Array Pattern:
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)] # right, left, down, up
This is a classic pattern for grid traversal. Each tuple represents (row_delta, col_delta). Using an array instead of four separate recursive calls:
True without exploring further paths. This is critical for efficiency since we only need one valid path.word[0]. This simple check can eliminate most cells from consideration.index parameter tracks how many characters we've matched. When index == len(word), we've matched all characters.Understanding the complexity of backtracking algorithms is crucial but often tricky. Let's analyze our Word Search solution carefully.
Time Complexity: O(m × n × 4^L)
Where:
m = number of rowsn = number of columnsL = length of the wordDerivation:
m × n cellsL levels (the word length)At first glance, this suggests O(m × n × 4^L). But wait—we said 4 directions, yet after the first step, we can't go back to where we came from (it's marked visited). So the actual branching factor is closer to 3 after the first step.
However, we traditionally express this as O(4^L) because:
Space Complexity: O(L)
The space complexity is determined by:
L (the word length)If we used a separate visited matrix, space would be O(m × n), but our in-place marking avoids this.
| Component | Complexity | Explanation |
|---|---|---|
| Outer loops (starting points) | O(m × n) | Try each cell as potential start |
| Backtracking tree depth | O(L) | Maximum word length determines depth |
| Branching factor | O(4) per level | 4 directions from each cell |
| Work per node | O(1) | Constant-time checks |
| Total time (worst case) | O(m × n × 4^L) | All components multiplied |
| Space (recursion) | O(L) | Stack frames for word length |
In practice, the algorithm runs much faster than the worst-case bound suggests. Early pruning—especially when the first character doesn't match—eliminates vast portions of the search space. For random grids with uniform character distribution, the expected time is much closer to O(m × n) for most words that don't exist in the grid.
While our basic solution is correct and reasonably efficient, several optimizations can dramatically improve performance for challenging inputs.
Optimization 1: Character Frequency Check (Pre-pruning)
Before starting the search, verify that the grid contains at least as many of each character as the word requires:
from collections import Counter
def exist_optimized(board, word):
# Count characters in board
board_count = Counter(char for row in board for char in row)
word_count = Counter(word)
# Early termination if word can't possibly exist
for char, count in word_count.items():
if board_count[char] < count:
return False
# ... proceed with backtracking
This O(m × n + L) preprocessing step can immediately return False for impossible cases.
Optimization 2: Reverse Word Search
If the last character of the word is rarer in the grid than the first character, search for the reversed word instead:
if board_count[word[0]] > board_count[word[-1]]:
word = word[::-1] # Search backwards
This doesn't change the worst case but significantly improves average case by reducing the number of starting points.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
from collections import Counter def exist_optimized(board: list[list[str]], word: str) -> bool: """ Optimized word search with pre-pruning and reverse search. """ if not board or not board[0] or not word: return False rows, cols = len(board), len(board[0]) # Optimization 1: Character frequency pre-check board_count = Counter(char for row in board for char in row) word_count = Counter(word) for char, count in word_count.items(): if board_count.get(char, 0) < count: return False # Word can't possibly exist # Optimization 2: Start from the rarer end if board_count[word[0]] > board_count[word[-1]]: word = word[::-1] def backtrack(row: int, col: int, index: int) -> bool: if index == len(word): return True if (row < 0 or row >= rows or col < 0 or col >= cols or board[row][col] != word[index]): return False temp = board[row][col] board[row][col] = '#' # Optimization 3: Order directions by likelihood (can be problem-specific) found = (backtrack(row + 1, col, index + 1) or backtrack(row - 1, col, index + 1) or backtrack(row, col + 1, index + 1) or backtrack(row, col - 1, index + 1)) board[row][col] = temp return found # Only try starting positions that match first character for i in range(rows): for j in range(cols): if board[i][j] == word[0]: if backtrack(i, j, 0): return True return FalseThe Word Search problem has many variations that test different aspects of backtracking mastery. Understanding these prepares you for interview variations and real-world applications.
Variation 1: Word Search II (Find All Words)
Given a list of words instead of a single word, find all words that exist in the grid. The naive approach would run Word Search for each word, but a Trie-based approach is much more efficient:
This reduces time from O(W × m × n × 4^L) to O(m × n × 4^L') where W is the number of words and L' is the maximum word length.
Variation 2: Counting All Occurrences
Instead of just determining if a word exists, count how many distinct paths spell the word:
def count_occurrences(board, word):
count = 0
def backtrack(row, col, index):
nonlocal count
if index == len(word):
count += 1
return # Don't return True—continue searching
# ... same logic but don't return early on success
# ... search from all cells
return count
The key difference: we don't return immediately when we find a match; we increment a counter and continue to find all matches.
The Word Search problem is essentially a simplified version of the game Boggle, where players find words on a 4×4 grid of letter dice. The main differences: Boggle typically allows diagonal moves and has a time limit. Understanding Word Search prepares you to build a Boggle solver—a fun weekend project!
The Word Search problem is a foundational example of how backtracking elegantly solves grid exploration problems. Let's consolidate the key insights:
What's next:
With grid-based backtracking mastered, we'll explore the Combination Sum family of problems—a different flavor of backtracking where we build numeric combinations that sum to a target. These problems introduce new concerns like handling duplicates and optimizing the choice generation process.
You now have a deep understanding of grid-based backtracking through the Word Search problem. This pattern—exploring a 2D space with constraint checking and path tracking—appears in countless problems. Practice implementing variations to solidify your understanding before moving on.