โ† Back to DevBytes

Solving Word Search in Python: Step-by-Step Guide

Introduction to Word Search Problems

The Word Search problem is a classic algorithmic challenge that appears frequently in coding interviews and competitive programming. Given a 2D grid of characters and a target word, your task is to determine whether the word exists in the grid. The word can be constructed from letters of sequentially adjacent cells, where "adjacent" cells are horizontally or vertically neighboring. The same letter cell may not be used more than once in a single word construction.

This problem is an excellent test of your understanding of graph traversal techniques, particularly Depth-First Search (DFS) with backtracking. Mastering it builds a strong foundation for solving more complex grid-based and graph-based problems.

Why Word Search Matters

Understanding how to solve the Word Search problem is valuable for several reasons:

Understanding the Problem Statement

Before writing any code, let's clearly define the problem. You are given:

You must return True if the word exists in the grid, or False otherwise. The word can be formed by sequentially adjacent cells (up, down, left, right), and each cell can only be used once per word.

For example, given the board:

board = [
  ['A','B','C','E'],
  ['S','F','C','S'],
  ['A','D','E','E']
]
word = "ABCCED"

The function should return True because the word can be traced starting from position (0,0) moving right, right, down, down, left, left.

Approach: DFS with Backtracking

The most effective approach combines Depth-First Search with backtracking. Here is the strategy:

This approach ensures we explore every possible valid path while avoiding infinite loops caused by revisiting cells.

Step-by-Step Implementation

Step 1: Define the Class and Method Signature

We will encapsulate our solution in a class, which is the standard format for LeetCode-style problems.

from typing import List

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        if not board or not board[0]:
            return False
        
        rows, cols = len(board), len(board[0])
        # Implementation continues below

Step 2: Define the DFS Helper Function

The DFS function will take the current position and the index of the character we are trying to match. It returns True if the word can be completed from this position.

def dfs(r: int, c: int, index: int) -> bool:
    # Base case: all characters matched
    if index == len(word):
        return True
    
    # Boundary and character checks
    if (r < 0 or r >= rows or 
        c < 0 or c >= cols or 
        board[r][c] != word[index]):
        return False
    
    # Mark the cell as visited by temporarily changing its value
    temp = board[r][c]
    board[r][c] = "#"
    
    # Explore all four directions
    found = (dfs(r + 1, c, index + 1) or
             dfs(r - 1, c, index + 1) or
             dfs(r, c + 1, index + 1) or
             dfs(r, c - 1, index + 1))
    
    # Backtrack: restore the original value
    board[r][c] = temp
    
    return found

Step 3: Trigger DFS from Every Matching Starting Cell

Now we iterate through the grid and start a DFS whenever we find a cell matching the first character of the word.

for r in range(rows):
    for c in range(cols):
        if board[r][c] == word[0]:
            if dfs(r, c, 0):
                return True

return False

Step 4: Complete Solution

Putting it all together, here is the complete implementation:

from typing import List

class Solution:
    def exist(self, board: List[List[str]], word: str) -> bool:
        if not board or not board[0]:
            return False
        
        rows, cols = len(board), len(board[0])
        
        def dfs(r: int, c: int, index: int) -> bool:
            if index == len(word):
                return True
            
            if (r < 0 or r >= rows or 
                c < 0 or c >= cols or 
                board[r][c] != word[index]):
                return False
            
            temp = board[r][c]
            board[r][c] = "#"
            
            found = (dfs(r + 1, c, index + 1) or
                     dfs(r - 1, c, index + 1) or
                     dfs(r, c + 1, index + 1) or
                     dfs(r, c - 1, index + 1))
            
            board[r][c] = temp
            return found
        
        for r in range(rows):
            for c in range(cols):
                if board[r][c] == word[0]:
                    if dfs(r, c, 0):
                        return True
        
        return False

Testing the Solution

Let's verify our solution with a few test cases to ensure correctness.

if __name__ == "__main__":
    sol = Solution()
    
    board = [
        ['A','B','C','E'],
        ['S','F','C','S'],
        ['A','D','E','E']
    ]
    
    print(sol.exist(board, "ABCCED"))  # True
    print(sol.exist(board, "SEE"))     # True
    print(sol.exist(board, "ABCB"))    # False
    print(sol.exist(board, "ADEE"))    # True
    print(sol.exist(board, ""))        # True (empty word)

Each test case exercises a different scenario: a long winding path, a short path, an impossible path due to cell reuse, and an edge case with an empty word.

Complexity Analysis

Understanding the time and space complexity is crucial for evaluating whether this solution scales well.

While the time complexity looks exponential, in practice the algorithm terminates early on most paths because character mismatches prune the search tree quickly.

Best Practices and Optimizations

1. Early Pruning with Reverse Word Check

If the frequency of the last character in the word is lower than the first character in the grid, searching the word in reverse can significantly reduce the search space. This is a simple but powerful optimization.

from collections import Counter

def should_reverse(board, word):
    count = Counter(ch for row in board for ch in row)
    if count[word[0]] > count[word[-1]]:
        return True
    return False

# In the exist method:
if should_reverse(board, word):
    word = word[::-1]

2. Avoid Visited Sets for Memory Efficiency

In our implementation, we mark visited cells by temporarily replacing their value with a placeholder like "#". This avoids the overhead of creating and maintaining a separate visited set, which would consume extra memory and slow down lookups.

3. Check Word Length Against Grid Size

If the word is longer than the total number of cells in the grid, we can immediately return False without any searching.

if len(word) > rows * cols:
    return False

4. Character Frequency Pre-check

Before starting the search, verify that the grid contains enough of each character to form the word. If any character in the word appears more times than it does in the grid, return False immediately.

from collections import Counter

board_counter = Counter(ch for row in board for ch in row)
word_counter = Counter(word)

for ch, count in word_counter.items():
    if board_counter[ch] < count:
        return False

5. Use Iterative DFS for Very Large Grids

For extremely large grids, the recursive approach may hit Python's recursion limit. In such cases, you can convert the DFS to an iterative version using an explicit stack. This is more complex but avoids stack overflow errors.

Common Pitfalls to Avoid

Extending to Word Search II

Once you understand the basic Word Search problem, a natural extension is Word Search II, where you must find all words from a given list that exist in the grid. A naive approach would run the single-word search for each word, but this is inefficient.

The optimal solution uses a Trie (prefix tree) data structure to store all words, then performs a single DFS traversal of the grid. At each cell, you check if the current character leads to a valid prefix in the Trie. This reduces redundant work when multiple words share common prefixes.

class TrieNode:
    def __init__(self):
        self.children = {}
        self.word = None

class Solution2:
    def findWords(self, board, words):
        root = TrieNode()
        for word in words:
            node = root
            for ch in word:
                if ch not in node.children:
                    node.children[ch] = TrieNode()
                node = node.children[ch]
            node.word = word
        
        rows, cols = len(board), len(board[0])
        result = []
        
        def dfs(r, c, node):
            ch = board[r][c]
            if ch not in node.children:
                return
            next_node = node.children[ch]
            if next_node.word:
                result.append(next_node.word)
                next_node.word = None  # Avoid duplicates
            
            board[r][c] = "#"
            for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols:
                    dfs(nr, nc, next_node)
            board[r][c] = ch
        
        for r in range(rows):
            for c in range(cols):
                dfs(r, c, root)
        
        return result

Conclusion

The Word Search problem is a fundamental exercise in backtracking and graph traversal that every developer should master. By combining Depth-First Search with careful backtracking, you can efficiently explore all possible paths through a grid while avoiding revisiting cells. The key insights are marking cells in place to save memory, pruning early when characters do not match, and always restoring the grid state after exploring a path. With the optimizations discussed, such as reverse word checking and character frequency pre-checks, you can handle even large grids with confidence. This problem serves as a gateway to more advanced topics like Trie-based multi-word search and constraint satisfaction problems, making it an essential part of any developer's algorithmic toolkit.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles