โ† Back to DevBytes

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

Introduction to Word Search II

Word Search II is a classic algorithmic problem frequently encountered in coding interviews and competitive programming. Given a 2D board of characters and a list of words, the task is to return all words from the list that can be found on the board. A 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.

While the simpler Word Search I problem asks you to find a single word on the board, Word Search II scales this up to multiple words, demanding a more efficient approach than simply running a depth-first search (DFS) for each word independently.

Why This Problem Matters

Word Search II is an excellent test of your ability to combine multiple algorithmic concepts into a single, cohesive solution. It exercises your understanding of:

In real-world applications, similar patterns appear in autocomplete systems, spell checkers, dictionary lookups, and any scenario where you need to match strings against a structured grid or sequence of characters.

Understanding the Problem

Let's formalize the problem. You are given:

You must return all words from words that exist on the board. A word exists if you can trace it by moving to adjacent cells (up, down, left, right) without reusing any cell within the same word's path.

Example

Consider the following board and word list:

board = [
  ['o','a','a','n'],
  ['e','t','a','e'],
  ['i','h','k','r'],
  ['i','f','l','v']
]
words = ["oath","pea","eat","rain"]

The expected output is ["eat","oath"] because these two words can be traced on the board, while "pea" and "rain" cannot.

The Naive Approach and Its Limitations

The most straightforward solution is to iterate over each word and run a DFS from every cell on the board to check if that word exists. For each word of length L, the DFS explores up to 4^L paths. With N words, the total time complexity becomes O(N * m * n * 4^L).

This approach performs redundant work. If two words share a common prefix, such as "apple" and "apply", the DFS will explore the same initial paths twice. When the word list is large or contains many overlapping prefixes, this inefficiency becomes a serious bottleneck.

Introducing the Trie

A Trie (pronounced "try") is a tree-like data structure that stores a dynamic set of strings, where each node represents a single character. Tries excel at prefix-based operations. By inserting all words into a Trie before searching, we can explore the board once and simultaneously check all words for matches.

The key insight is this: instead of searching for each word separately, we traverse the board and walk down the Trie at the same time. If the current cell's character does not match any child of the current Trie node, we prune that branch immediately. This dramatically reduces the search space.

Trie Node Structure

Here is a simple Trie node implementation in Python:

class TrieNode:
    def __init__(self):
        self.children = {}
        self.word = None  # Stores the complete word at the terminal node

Using a dictionary for children allows us to handle any character set efficiently. The word attribute is set to the full word string when a node marks the end of a valid word. This avoids reconstructing the word from the path during traversal.

Building the Trie

Before searching the board, we insert every word from the input list into the Trie:

def build_trie(words):
    root = TrieNode()
    for word in words:
        node = root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.word = word
    return root

Each character of each word is inserted as a node. When we reach the end of a word, we store the word itself in the terminal node. This storage strategy lets us collect a found word in constant time during the board search.

Searching the Board with Backtracking

With the Trie built, we now traverse every cell on the board. From each cell, we initiate a DFS that simultaneously walks the Trie. The algorithm proceeds as follows:

Complete Implementation

Here is the full solution combining the Trie and the backtracking search:

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


class Solution:
    def findWords(self, board, words):
        # Step 1: Build the Trie from the word list
        root = TrieNode()
        for word in words:
            node = root
            for char in word:
                if char not in node.children:
                    node.children[char] = TrieNode()
                node = node.children[char]
            node.word = word

        rows = len(board)
        cols = len(board[0])
        result = []

        def dfs(r, c, node):
            char = board[r][c]

            # If the character is not in the current Trie node's children, prune
            if char not in node.children:
                return

            next_node = node.children[char]

            # If we found a complete word, record it
            if next_node.word is not None:
                result.append(next_node.word)
                next_node.word = None  # Avoid duplicate entries

            # Mark the cell as visited
            board[r][c] = '#'

            # Explore all four directions
            directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
            for dr, dc in directions:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols:
                    dfs(nr, nc, next_node)

            # Backtrack: restore the cell
            board[r][c] = char

            # Optimization: remove leaf nodes to prune future searches
            if not next_node.children:
                node.children.pop(char)

        # Step 2: Start DFS from every cell on the board
        for i in range(rows):
            for j in range(cols):
                dfs(i, j, root)

        return result

How the Code Works

The findWords method first builds the Trie. Then, for every cell on the board, it calls dfs with the root of the Trie. Inside dfs, the method checks whether the current cell's character leads anywhere in the Trie. If it does, and if that Trie node marks the end of a word, the word is added to the result list and the word reference is cleared to prevent duplicates.

The visited state is managed by temporarily replacing the cell's character with a sentinel value '#'. After exploring all four directions, the original character is restored. This in-place marking avoids the overhead of maintaining a separate visited matrix.

The final optimization removes leaf nodes from the Trie after all their children have been explored. Once a Trie node has no children and its word has been collected, it is safe to delete it. This prevents the algorithm from re-exploring dead-end paths from other starting cells on the board.

Complexity Analysis

Let m and n be the dimensions of the board, L be the maximum length of any word, and N be the number of words.

In contrast, the naive approach without a Trie has a time complexity of O(N * m * n * 4^L), which is significantly worse when N is large.

Best Practices

1. Prune the Trie Aggressively

Removing leaf nodes after exploration is one of the most impactful optimizations. Once a word is found and its node has no remaining children, deleting it prevents other DFS paths from wastefully descending into a branch that can no longer yield new results.

2. Avoid Duplicate Results

Set next_node.word = None after collecting a word. This ensures that if the same word can be formed through multiple paths on the board, it appears only once in the result. Using a set for results is another option, but clearing the word reference is more memory efficient.

3. Use In-Place Visited Marking

Instead of allocating a separate 2D visited array, temporarily modify the board cell. This saves memory and avoids the overhead of resetting a visited matrix after each DFS. Just remember to restore the original character during backtracking.

4. Handle Edge Cases

Always validate inputs. Check for empty boards, empty word lists, and words longer than the total number of cells on the board. A word longer than m * n can never be found and can be skipped during Trie construction.

5. Choose Directions Wisely

Defining the four directions as a list of tuples keeps the code clean and easy to modify. If diagonal movement were allowed, you would simply add four more tuples to the directions list.

Testing the Solution

Here is a test script that validates the implementation against the example input:

if __name__ == "__main__":
    board = [
        ['o', 'a', 'a', 'n'],
        ['e', 't', 'a', 'e'],
        ['i', 'h', 'k', 'r'],
        ['i', 'f', 'l', 'v']
    ]
    words = ["oath", "pea", "eat", "rain"]

    solution = Solution()
    found_words = solution.findWords(board, words)
    print("Found words:", found_words)
    # Expected output: ['oath', 'eat'] (order may vary)

When you run this script, the output should contain both "oath" and "eat". The order may differ depending on the traversal sequence, but both words must be present and no others.

Common Pitfalls

Conclusion

Solving Word Search II efficiently requires combining a Trie with backtracking DFS. The Trie eliminates redundant prefix exploration, while backtracking with in-place visited marking keeps memory usage low. By pruning the Trie during traversal and clearing found words to avoid duplicates, you can achieve a solution that handles large boards and word lists gracefully. This problem is a powerful demonstration of how choosing the right data structure transforms an intractable brute-force approach into an elegant and efficient algorithm, and mastering it will strengthen your ability to tackle a wide range of string and grid-based challenges in technical interviews and beyond.

๐Ÿ›  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