โ† Back to DevBytes

Solving Word Search II in Go: 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, which makes a naive approach extremely inefficient. The key insight is to use a Trie (prefix tree) data structure to efficiently search for all words simultaneously.

Why It Matters

This problem is a perfect test of several fundamental computer science concepts working together:

A naive solution that runs a separate DFS for each word would result in O(W * M * N * 4^L) time complexity, where W is the number of words, M and N are board dimensions, and L is the maximum word length. By using a Trie, we can reduce this significantly because we share the search across all words and prune branches early when no word in our dictionary shares the current prefix.

Understanding the Trie Data Structure

A Trie is a tree-like data structure where each node represents a character. Words are stored as paths from the root to leaf nodes (or to nodes marked as word endings). The beauty of a Trie for this problem is that when we explore the board, we can check at each step whether the current path forms a valid prefix of any word in our dictionary. If it doesn't, we immediately backtrack.

Trie Node Structure in Go

Let's start by defining our Trie node:

package main

import "fmt"

// TrieNode represents a node in the prefix tree
type TrieNode struct {
    children map[byte]*TrieNode
    word     string // non-empty if this node marks the end of a word
}

// NewTrieNode creates a new TrieNode
func NewTrieNode() *TrieNode {
    return &TrieNode{
        children: make(map[byte]*TrieNode),
        word:     "",
    }
}

Notice that instead of using a boolean flag to mark word endings, we store the actual word string. This is a convenient trick: when we reach a node during our DFS that has a non-empty word field, we know we've found a complete word, and we can add it directly to our results without reconstructing it from the path.

Building the Trie

Next, we insert all the words from our dictionary into the Trie:

// Insert adds a word into the trie
func (t *TrieNode) Insert(word string) {
    node := t
    for i := 0; i < len(word); i++ {
        ch := word[i]
        if _, exists := node.children[ch]; !exists {
            node.children[ch] = NewTrieNode()
        }
        node = node.children[ch]
    }
    node.word = word
}

Each character of the word is traversed character by character, creating new nodes as needed. The final node stores the complete word string.

Implementing the DFS Backtracking Search

Now comes the core of the solution. We iterate over every cell in the board. For each cell, if its character exists as a child of the Trie root, we begin a DFS from that cell.

The FindWords Function

func findWords(board [][]byte, words []string) []string {
    // Build the trie from the word list
    root := NewTrieNode()
    for _, w := range words {
        root.Insert(w)
    }

    result := []string{}
    rows := len(board)
    cols := len(board[0])

    // Directions: up, down, left, right
    directions := [][]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}

    var dfs func(row, col int, node *TrieNode)
    dfs = func(row, col int, node *TrieNode) {
        ch := board[row][col]

        // Check if this character leads anywhere in the trie
        child, exists := node.children[ch]
        if !exists {
            return
        }

        // If we found a complete word, add it to results
        if child.word != "" {
            result = append(result, child.word)
            child.word = "" // Avoid duplicate entries for the same word
        }

        // Mark the cell as visited by temporarily changing its value
        board[row][col] = '#'

        // Explore all four directions
        for _, dir := range directions {
            newRow := row + dir[0]
            newCol := col + dir[1]
            if newRow >= 0 && newRow < rows &&
                newCol >= 0 && newCol < cols &&
                board[newRow][newCol] != '#' {
                dfs(newRow, newCol, child)
            }
        }

        // Restore the cell's original value (backtrack)
        board[row][col] = ch
    }

    // Start DFS from every cell
    for i := 0; i < rows; i++ {
        for j := 0; j < cols; j++ {
            dfs(i, j, root)
        }
    }

    return result
}

How the Backtracking Works

The DFS function does several important things:

Complete Working Example

Let's put everything together with a main function and a test case:

package main

import "fmt"

// TrieNode represents a node in the prefix tree
type TrieNode struct {
    children map[byte]*TrieNode
    word     string
}

func NewTrieNode() *TrieNode {
    return &TrieNode{
        children: make(map[byte]*TrieNode),
        word:     "",
    }
}

func (t *TrieNode) Insert(word string) {
    node := t
    for i := 0; i < len(word); i++ {
        ch := word[i]
        if _, exists := node.children[ch]; !exists {
            node.children[ch] = NewTrieNode()
        }
        node = node.children[ch]
    }
    node.word = word
}

func findWords(board [][]byte, words []string) []string {
    root := NewTrieNode()
    for _, w := range words {
        root.Insert(w)
    }

    result := []string{}
    rows := len(board)
    cols := len(board[0])
    directions := [][]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}

    var dfs func(row, col int, node *TrieNode)
    dfs = func(row, col int, node *TrieNode) {
        ch := board[row][col]
        child, exists := node.children[ch]
        if !exists {
            return
        }

        if child.word != "" {
            result = append(result, child.word)
            child.word = ""
        }

        board[row][col] = '#'
        for _, dir := range directions {
            newRow := row + dir[0]
            newCol := col + dir[1]
            if newRow >= 0 && newRow < rows &&
                newCol >= 0 && newCol < cols &&
                board[newRow][newCol] != '#' {
                dfs(newRow, newCol, child)
            }
        }
        board[row][col] = ch
    }

    for i := 0; i < rows; i++ {
        for j := 0; j < cols; j++ {
            dfs(i, j, root)
        }
    }

    return result
}

func main() {
    board := [][]byte{
        {'o', 'a', 'a', 'n'},
        {'e', 't', 'a', 'e'},
        {'i', 'h', 'k', 'r'},
        {'i', 'f', 'l', 'v'},
    }
    words := []string{"oath", "pea", "eat", "rain"}

    found := findWords(board, words)
    fmt.Println("Found words:", found)
}

When you run this program, the output will be:

Found words: [oath eat]

The words "oath" and "eat" can be traced on the board through adjacent cells, while "pea" and "rain" cannot.

Complexity Analysis

Understanding the time and space complexity is crucial for evaluating this solution:

Best Practices and Optimizations

1. Remove Leaf Nodes After Finding Words

One powerful optimization is to remove Trie nodes that no longer have any children after a word is found. This progressively shrinks the Trie during the search, making subsequent searches faster:

func (t *TrieNode) removeChild(ch byte) {
    delete(t.children, ch)
}

// In the DFS, after exploring all directions:
if len(child.children) == 0 {
    delete(node.children, ch)
}

This optimization is particularly effective when the board contains many of the dictionary words, as the Trie gets smaller with each discovery.

2. Use Arrays Instead of Maps for Children

If you know the character set is limited (for example, only lowercase English letters), using a fixed-size array of 26 elements instead of a map can improve performance due to better cache locality and the elimination of hash overhead:

type TrieNode struct {
    children [26]*TrieNode
    word     string
}

func (t *TrieNode) Insert(word string) {
    node := t
    for i := 0; i < len(word); i++ {
        idx := word[i] - 'a'
        if node.children[idx] == nil {
            node.children[idx] = NewTrieNode()
        }
        node = node.children[idx]
    }
    node.word = word
}

3. Early Termination

If the number of found words equals the total number of words in the dictionary, you can terminate the search early. This is a simple but effective optimization:

if len(result) == len(words) {
    return
}

4. Avoid String Concatenation in DFS

By storing the complete word at the terminal Trie node (as we did), we avoid building strings character by character during the DFS. This eliminates O(L) string operations at each step and is a significant performance win.

5. Handle Edge Cases

Always validate your inputs before processing:

func findWords(board [][]byte, words []string) []string {
    if len(board) == 0 || len(board[0]) == 0 || len(words) == 0 {
        return []string{}
    }
    // ... rest of the implementation
}

Common Pitfalls to Avoid

Conclusion

Solving Word Search II in Go elegantly combines the Trie data structure with DFS backtracking to efficiently search for multiple words on a 2D board simultaneously. The Trie enables shared prefix exploration and early pruning, transforming what would be an exponentially expensive naive search into a practical solution. By following the step-by-step approach outlined in this tutorial โ€” building the Trie, implementing the DFS with in-place visited marking, and applying optimizations like node removal and array-based children โ€” you can write a robust and performant solution. This problem serves as an excellent exercise in combining data structures, algorithms, and careful implementation details, and mastering it will strengthen your ability to tackle similar grid-based search problems in both interviews and real-world applications.

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