Skip to main content

Word Search II

Description

You are given a two-dimensional grid of characters (called a board) with m rows and n columns, and a list of strings called words. Your task is to find and return all the words from the list that can be formed on the board.

A word is considered "found" on the board if you can trace it by starting at any cell and moving to horizontally or vertically adjacent cells (up, down, left, right), selecting one letter per cell. Each cell in the board may be used at most once per word — you cannot revisit a cell while constructing the same word.

Return the list of all words from the input list that exist on the board. The order of the returned words does not matter.

4x4 character grid showing the board with highlighted paths for words oath and eat
4x4 character grid showing the board with highlighted paths for words oath and eat

Examples

Example 1

Input:

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

Output: ["eat", "oath"]

Explanation:

  • "oath" can be formed by following the path: (0,0)→(1,0)→(1,1)→(2,1), tracing through o→a→t→h. Each move is to an adjacent cell, and no cell is reused.
  • "eat" can be formed by the path: (1,3)→(1,2)→(1,1), tracing through e→a→t. Again, all moves are to adjacent cells.
  • "pea" cannot be formed — while 'p' does not even appear on the board.
  • "rain" cannot be formed — although 'r' is at (2,3), 'a' is at (1,2) or (0,1)/(0,2), but there is no valid adjacent path spelling out the full word.

Example 2

Input:

board = [["a","b"],
         ["c","d"]]
words = ["abcb"]

Output: []

Explanation: The word "abcb" requires the path a→b→c→b. Starting at (0,0)='a', moving right to (0,1)='b', then down to (1,1)='d' — but that gives 'd', not 'c'. The path a(0,0)→b(0,1)→... cannot reach 'c' at (1,0) without going through non-adjacent cells. Even if we could reach 'c', we would need to return to 'b' at (0,1), but that cell was already used. So the word cannot be formed.

Constraints

  • m == board.length
  • n == board[i].length
  • 1 ≤ m, n ≤ 12
  • board[i][j] is a lowercase English letter.
  • 1 ≤ words.length ≤ 3 × 10^4
  • 1 ≤ words[i].length ≤ 10
  • words[i] consists of lowercase English letters.
  • All the strings of words are unique.

Editorial

Brute Force

Intuition

The most straightforward approach is to treat each word independently. For every word in the list, we search the entire board to see if that word can be formed.

For a single word, we try starting from every cell on the board. From each starting cell, if the first character matches, we perform a depth-first search (DFS), exploring all four neighboring directions (up, down, left, right). At each step, we check if the next character matches, mark the current cell as visited (to prevent reuse), and continue recursively. If we reach the end of the word, it is found. If we hit a dead end, we backtrack by unmarking the cell.

This is essentially the classic "Word Search" problem (LeetCode 79), repeated for every word in the list. Think of it like using a magnifying glass to trace each word separately on a letter grid — you start fresh for every word, scanning the entire board each time.

Step-by-Step Explanation

Let's trace the search for the word "eat" on the board:

o a a n
e t a e
i h k r
i f l v

Step 1: We need to find all cells containing 'e' (the first letter). 'e' appears at (1,0) and (1,3).

Step 2: Try starting DFS from (1,0) where board[1][0]='e'. Mark (1,0) as visited.

  • Need next character 'a'. Check neighbors: (0,0)='o' ✗, (2,0)='i' ✗, (1,1)='t' ✗. No neighbor has 'a'. Dead end — backtrack, unmark (1,0).

Step 3: Try starting DFS from (1,3) where board[1][3]='e'. Mark (1,3) as visited.

  • Need next character 'a'. Check neighbors: (0,3)='n' ✗, (2,3)='r' ✗, (1,2)='a' ✓. Move to (1,2).

Step 4: At (1,2), board[1][2]='a'. Mark (1,2) as visited.

  • Need next character 't'. Check neighbors: (0,2)='a' ✗, (2,2)='k' ✗, (1,1)='t' ✓, (1,3) already visited. Move to (1,1).

Step 5: At (1,1), board[1][1]='t'. Mark (1,1) as visited.

  • We have matched all 3 characters of "eat". The word is found!

Step 6: Add "eat" to the result list. Continue searching for remaining words.

We repeat this entire process for each of the W words in the list.

Brute Force DFS — Searching for "eat" on the Board — Watch how DFS explores the grid cell by cell, backtracking when a path fails, until it finds a valid path spelling out the word.

Algorithm

  1. Initialize an empty result list.
  2. For each word in the words list:
    a. For each cell (r, c) in the board:
    • If board[r][c] equals the first character of the word, start a DFS from (r, c).
      b. DFS(r, c, index):
    • If index == len(word), the word is fully matched — return true.
    • If (r, c) is out of bounds, or the cell is already visited, or board[r][c] ≠ word[index], return false.
    • Mark (r, c) as visited (e.g., replace with '#').
    • Recursively call DFS on all 4 neighbors with index + 1.
    • Unmark (r, c) (restore original character).
    • Return true if any recursive call returned true.
      c. If any starting cell yields true, add the word to results.
  3. Return the result list.

Code

#include <vector>
#include <string>
using namespace std;

class Solution {
public:
    vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
        vector<string> result;
        int m = board.size(), n = board[0].size();

        for (const string& word : words) {
            bool found = false;
            for (int r = 0; r < m && !found; r++) {
                for (int c = 0; c < n && !found; c++) {
                    if (board[r][c] == word[0]) {
                        if (dfs(board, word, r, c, 0)) {
                            result.push_back(word);
                            found = true;
                        }
                    }
                }
            }
        }
        return result;
    }

private:
    bool dfs(vector<vector<char>>& board, const string& word, int r, int c, int idx) {
        if (idx == word.size()) return true;
        if (r < 0 || r >= board.size() || c < 0 || c >= board[0].size()) return false;
        if (board[r][c] != word[idx]) return false;

        char temp = board[r][c];
        board[r][c] = '#';

        bool found = dfs(board, word, r + 1, c, idx + 1) ||
                     dfs(board, word, r - 1, c, idx + 1) ||
                     dfs(board, word, r, c + 1, idx + 1) ||
                     dfs(board, word, r, c - 1, idx + 1);

        board[r][c] = temp;
        return found;
    }
};
class Solution:
    def findWords(self, board: list[list[str]], words: list[str]) -> list[str]:
        m, n = len(board), len(board[0])
        result = []

        def dfs(r, c, idx, word):
            if idx == len(word):
                return True
            if r < 0 or r >= m or c < 0 or c >= n:
                return False
            if board[r][c] != word[idx]:
                return False

            temp = board[r][c]
            board[r][c] = '#'

            found = (dfs(r + 1, c, idx + 1, word) or
                     dfs(r - 1, c, idx + 1, word) or
                     dfs(r, c + 1, idx + 1, word) or
                     dfs(r, c - 1, idx + 1, word))

            board[r][c] = temp
            return found

        for word in words:
            found = False
            for r in range(m):
                for c in range(n):
                    if board[r][c] == word[0] and dfs(r, c, 0, word):
                        result.append(word)
                        found = True
                        break
                if found:
                    break

        return result
import java.util.ArrayList;
import java.util.List;

class Solution {
    public List<String> findWords(char[][] board, String[] words) {
        List<String> result = new ArrayList<>();
        int m = board.length, n = board[0].length;

        for (String word : words) {
            boolean found = false;
            for (int r = 0; r < m && !found; r++) {
                for (int c = 0; c < n && !found; c++) {
                    if (board[r][c] == word.charAt(0)) {
                        if (dfs(board, word, r, c, 0)) {
                            result.add(word);
                            found = true;
                        }
                    }
                }
            }
        }
        return result;
    }

    private boolean dfs(char[][] board, String word, int r, int c, int idx) {
        if (idx == word.length()) return true;
        if (r < 0 || r >= board.length || c < 0 || c >= board[0].length) return false;
        if (board[r][c] != word.charAt(idx)) return false;

        char temp = board[r][c];
        board[r][c] = '#';

        boolean found = dfs(board, word, r + 1, c, idx + 1) ||
                        dfs(board, word, r - 1, c, idx + 1) ||
                        dfs(board, word, r, c + 1, idx + 1) ||
                        dfs(board, word, r, c - 1, idx + 1);

        board[r][c] = temp;
        return found;
    }
}

Complexity Analysis

Time Complexity: O(W × M × N × 4^L)

For each of the W words, we scan all M×N cells as potential starting points. From each starting cell, the DFS can branch into 4 directions at each level, and the maximum depth is L (the word length). So each word search takes O(M × N × 4^L). With W words, total is O(W × M × N × 4^L).

With W up to 3×10^4, M and N up to 12, and L up to 10, the worst case is enormous: 3×10^4 × 144 × 4^10 ≈ 4.5 × 10^12, which is far too slow.

Space Complexity: O(L)

The recursion stack goes at most L levels deep (the length of the word being searched). We modify the board in-place for visited tracking, so no extra grid is needed.

Why This Approach Is Not Efficient

The brute force searches for each word completely independently. If you have 30,000 words and a 12×12 board, you perform 30,000 separate DFS traversals across the grid. This is wildly redundant because:

  1. Repeated work: Many words share common prefixes (e.g., "oath" and "oat" both start with "oat"). The brute force traces the same prefix path from scratch for each word.

  2. No early termination: When exploring a path like "xyz..." on the board, the brute force has no way to know that no word in the list starts with "xyz". It continues the DFS even when the path is guaranteed to be a dead end for all words.

  3. Per-word overhead: Each of the W words triggers a full M×N scan of starting cells.

Key insight: instead of searching for one word at a time, we should search for all words simultaneously. If we build a Trie from all words, we can traverse the board once and check at each cell whether the current path is a prefix of any word. If no word has the current prefix, we prune that path immediately — saving enormous amounts of work.

Optimal Approach - Trie + Backtracking

Intuition

Instead of searching for each word individually, we flip the problem: build a Trie containing all the target words, then traverse the board once, using the Trie to guide which paths are worth exploring.

Here is the key idea:

  1. Build a Trie from all words in the input list. Each path from root to a marked node represents a complete word.
  2. Start DFS from every cell on the board. At each cell, check if the current character matches any child in the current Trie node.
  3. Follow the Trie as you explore the board. If the Trie node has a child for the current board character, continue the DFS. If not, prune the path — no word can possibly be formed down this route.
  4. When you reach a Trie node marked as end-of-word, you have found a complete word. Add it to the result.
  5. Optimization — prune found words: Once a word is found, remove its end-of-word marker (and optionally prune leaf nodes) to avoid finding duplicates and reduce future search space.

Think of it like having a cheat sheet (the Trie) while playing a word-search puzzle. Instead of looking for one word at a time, you trace paths on the board and consult your cheat sheet at each step: "Is this prefix leading anywhere useful?" If not, you stop immediately and try a different direction.

Step-by-Step Explanation

Let's trace with board and words = ["oath", "pea", "eat", "rain"].

Phase 1: Build the Trie

Step 1: Insert "oath" → root → o → a → t → h (mark h as end, store word "oath").

Step 2: Insert "pea" → root → p → e → a (mark a as end, store word "pea").

Step 3: Insert "eat" → root → e → a → t (mark t as end, store word "eat").

Step 4: Insert "rain" → root → r → a → i → n (mark n as end, store word "rain").

The trie root now has children: 'o', 'p', 'e', 'r'.

Phase 2: DFS from each cell

Step 5: Start at cell (0,0)='o'. Trie root has child 'o' → enter it. Mark (0,0) visited.

Step 6: At (0,0), Trie node 'o' has child 'a'. Check neighbors: (0,1)='a' ✓. Move to (0,1), enter Trie child 'a'. Mark (0,1) visited.

Step 7: At (0,1), Trie node 'o→a' has child 't'. Check neighbors: (1,1)='t' ✓. Move to (1,1), enter Trie child 't'. Mark (1,1) visited.

Step 8: At (1,1), Trie node 'o→a→t' has child 'h'. Check neighbors: (2,1)='h' ✓. Move to (2,1), enter Trie child 'h'. Mark (2,1) visited.

Step 9: At (2,1), Trie node 'o→a→t→h' is marked as end-of-word with word "oath". Add "oath" to result! Remove end-of-word marker to avoid duplicates. Backtrack.

Step 10: Continue DFS from other cells. Eventually reach (1,3)='e'. Trie root has child 'e' → enter. Follow path e→a→t, find "eat". Add to result.

Step 11: "pea" — 'p' never appears on the board, so the Trie 'p' branch is never entered. "rain" — while 'r' exists at (2,3), the adjacent cells cannot spell out "rain" in sequence. Neither word is found.

Result: ["oath", "eat"]

Trie-Guided DFS — Finding "oath" on the Board — Watch how the DFS follows the Trie structure to efficiently trace the word "oath" through adjacent cells on the board, pruning paths that don't match any word prefix.

Algorithm

Phase 1 — Build the Trie:

  1. Create a Trie node class with a dictionary of children and an optional word field.
  2. For each word, insert it into the Trie character by character.
  3. At the end of each word, store the complete word string in the final Trie node.

Phase 2 — DFS with Trie guidance:

  1. Initialize an empty result list.
  2. For each cell (r, c) on the board:
    • If the Trie root has a child matching board[r][c], start a DFS.
  3. DFS(r, c, trieNode):
    • If (r, c) is out of bounds or visited, return.
    • Let char = board[r][c]. If trieNode does not have char as a child, return (prune).
    • Move to childNode = trieNode.children[char].
    • If childNode.word is not null, add it to results and set childNode.word = null (prevent duplicates).
    • Mark (r, c) as visited.
    • Recurse on all 4 neighbors with childNode.
    • Unmark (r, c).
    • Pruning optimization: If childNode has no remaining children, remove it from trieNode.children to shrink the Trie.
  4. Return the result list.

Code

#include <vector>
#include <string>
#include <unordered_map>
using namespace std;

struct TrieNode {
    unordered_map<char, TrieNode*> children;
    string word = "";
};

class Solution {
public:
    vector<string> findWords(vector<vector<char>>& board, vector<string>& words) {
        TrieNode* root = new TrieNode();

        // Build Trie
        for (const string& w : words) {
            TrieNode* node = root;
            for (char c : w) {
                if (node->children.find(c) == node->children.end()) {
                    node->children[c] = new TrieNode();
                }
                node = node->children[c];
            }
            node->word = w;
        }

        vector<string> result;
        int m = board.size(), n = board[0].size();

        for (int r = 0; r < m; r++) {
            for (int c = 0; c < n; c++) {
                dfs(board, r, c, root, result);
            }
        }
        return result;
    }

private:
    void dfs(vector<vector<char>>& board, int r, int c, TrieNode* node, vector<string>& result) {
        if (r < 0 || r >= board.size() || c < 0 || c >= board[0].size()) return;
        char ch = board[r][c];
        if (ch == '#' || node->children.find(ch) == node->children.end()) return;

        TrieNode* child = node->children[ch];

        if (!child->word.empty()) {
            result.push_back(child->word);
            child->word = "";
        }

        board[r][c] = '#';

        dfs(board, r + 1, c, child, result);
        dfs(board, r - 1, c, child, result);
        dfs(board, r, c + 1, child, result);
        dfs(board, r, c - 1, child, result);

        board[r][c] = ch;

        // Pruning: remove leaf nodes
        if (child->children.empty()) {
            node->children.erase(ch);
        }
    }
};
class TrieNode:
    def __init__(self):
        self.children = {}
        self.word = None

class Solution:
    def findWords(self, board: list[list[str]], words: list[str]) -> list[str]:
        # Build Trie
        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

        m, n = len(board), len(board[0])
        result = []

        def dfs(r, c, node):
            if r < 0 or r >= m or c < 0 or c >= n:
                return
            char = board[r][c]
            if char == '#' or char not in node.children:
                return

            child = node.children[char]

            if child.word is not None:
                result.append(child.word)
                child.word = None  # avoid duplicates

            board[r][c] = '#'  # mark visited

            dfs(r + 1, c, child)
            dfs(r - 1, c, child)
            dfs(r, c + 1, child)
            dfs(r, c - 1, child)

            board[r][c] = char  # restore

            # Pruning: remove childless nodes
            if not child.children:
                del node.children[char]

        for r in range(m):
            for c in range(n):
                dfs(r, c, root)

        return result
import java.util.*;

class TrieNode {
    Map<Character, TrieNode> children = new HashMap<>();
    String word = null;
}

class Solution {
    private List<String> result = new ArrayList<>();

    public List<String> findWords(char[][] board, String[] words) {
        // Build Trie
        TrieNode root = new TrieNode();
        for (String w : words) {
            TrieNode node = root;
            for (char c : w.toCharArray()) {
                node.children.putIfAbsent(c, new TrieNode());
                node = node.children.get(c);
            }
            node.word = w;
        }

        int m = board.length, n = board[0].length;
        for (int r = 0; r < m; r++) {
            for (int c = 0; c < n; c++) {
                dfs(board, r, c, root);
            }
        }
        return result;
    }

    private void dfs(char[][] board, int r, int c, TrieNode node) {
        if (r < 0 || r >= board.length || c < 0 || c >= board[0].length) return;
        char ch = board[r][c];
        if (ch == '#' || !node.children.containsKey(ch)) return;

        TrieNode child = node.children.get(ch);

        if (child.word != null) {
            result.add(child.word);
            child.word = null;
        }

        board[r][c] = '#';

        dfs(board, r + 1, c, child);
        dfs(board, r - 1, c, child);
        dfs(board, r, c + 1, child);
        dfs(board, r, c - 1, child);

        board[r][c] = ch;

        // Pruning
        if (child.children.isEmpty()) {
            node.children.remove(ch);
        }
    }
}

Complexity Analysis

Time Complexity: O(M × N × 4 × 3^(L-1))

We start DFS from each of the M×N cells. From each cell, the first step can go in 4 directions, but subsequent steps can only go in 3 directions (we cannot revisit the cell we came from). The DFS depth is at most L (maximum word length). This gives O(M × N × 4 × 3^(L-1)) in the worst case.

Critically, the Trie provides early pruning: if a prefix does not exist in the Trie, the DFS terminates immediately for that branch. In practice, this reduces the explored paths dramatically compared to brute force. The progressive pruning of found words further shrinks the Trie over time, making later searches even faster.

With M, N ≤ 12 and L ≤ 10, the upper bound is 144 × 4 × 3^9 ≈ 11.3 million, which is very manageable.

Space Complexity: O(W × L + L)

The Trie stores all W words of average length L, requiring O(W × L) space in the worst case (no shared prefixes). The recursion stack uses O(L) space. In practice, shared prefixes reduce the Trie's actual memory footprint significantly.