212. Word Search II - Explanation

Problem Link

Description

Given a 2-D grid of characters board and a list of strings words, return all words that are present in the grid.

For a word to be present it must be possible to form the word with a path in the board with horizontally or vertically neighboring cells. The same cell may not be used more than once in a word.

Example 1:

Input:
board = [
  ["a","b","c","d"],
  ["s","a","a","t"],
  ["a","c","k","e"],
  ["a","c","d","n"]
],
words = ["bat","cat","back","backend","stack"]

Output: ["cat","back","backend"]

Example 2:

Input:
board = [
  ["x","o"],
  ["x","o"]
],
words = ["xoxo"]

Output: []

Constraints:

  • 1 <= board.length, board[i].length <= 12
  • board[i] consists only of lowercase English letter.
  • 1 <= words.length <= 30,000
  • 1 <= words[i].length <= 10
  • words[i] consists only of lowercase English letters.
  • All strings within words are distinct.


Topics

Recommended Time & Space Complexity

You should aim for a solution with O(m * n * 4 * (3^(t-1)) + s) time and O(s) space, where m is the number of rows, n is the number of columns, t is the maximum length of any word and s is the sum of the lengths of all the words.


Hint 1

To search for a word in the grid, we can use backtracking by starting at each cell, simultaneously iterating through the word and matching the characters with the cells, recursively visiting the neighboring cells. However, if we are given a list of words and need to search for each one, it becomes inefficient to iterate through each word and run backtracking for each. Can you think of a better way? Perhaps a data structure could help with more efficient word search and insertion.


Hint 2

We can use a Trie to efficiently search for multiple words. After inserting all words into the Trie, we traverse the grid once. For each character in the grid, we check if it exists in the current Trie node. If not, we prune the search. If we encounter an "end of word" flag in the Trie node, we know a valid word has been found. But how can we add that word to the result list? Maybe you should think about what additional information you can store in the Trie node.


Hint 3

When we insert a word into the Trie, we can store the word's index. Why? Because when we want to add the word to the result list after finding a valid word, we can easily add it using the index. After adding that word, we put index = -1 as we shouldn't add the word multiple times to the result list. How can you implement this?


Hint 4

We insert all the words into the Trie with their indices marked. Then, we iterate through each cell in the grid. At each cell, we start at the root of the Trie and explore all possible paths. As we traverse, we match characters in the cell with those in the Trie nodes. If we encounter the end of a word, we take the index at that node and add the corresponding word to the result list. Afterward, we unmark that index and continue exploring further paths.


Company Tags

Please upgrade to NeetCode Pro to view company tags.



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Backtracking - Exploring all possible paths by making choices, recursing, and undoing choices to try alternatives
  • Depth-First Search (DFS) - Traversing a graph or grid by exploring as far as possible along each branch before backtracking
  • Trie (Prefix Tree) - A tree data structure used for efficient prefix matching and word storage
  • 2D Grid Traversal - Moving through a matrix in four directions (up, down, left, right) while handling boundaries

1. Backtracking

Intuition

For each word, we try to trace it on the board by walking through adjacent cells (up/down/left/right).
To avoid using the same cell twice in one word path, we temporarily mark the cell as visited, and then restore it after exploring (classic backtracking).

If we can match all characters of a word in order, that word is found and added to the result.

Algorithm

  1. Let ROWS, COLS be board dimensions.
  2. For each word in words:
    1. Try every cell (r, c) as a possible starting point (only if it matches word[0]).
    2. Run a DFS/backtracking function backtrack(r, c, i) where:
      • i = current index in word we need to match.
      • Base case: if i == len(word), return true (whole word matched).
      • If out of bounds or board cell doesn't match word[i], return false.
      • Mark the cell as visited (e.g., replace with *).
      • Recurse to 4 neighbors with i + 1.
      • Restore the original character (undo the choice).
      • Return whether any neighbor path succeeded.
    3. If any start cell succeeds, add word to the answer list and stop searching this word.
class Solution:
    def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
        ROWS, COLS = len(board), len(board[0])
        res = []

        def backtrack(r, c, i):
            if i == len(word):
                return True
            if (r < 0 or c < 0 or r >= ROWS or
                c >= COLS or board[r][c] != word[i]
            ):
                return False

            board[r][c] = '*'
            ret = (backtrack(r + 1, c, i + 1) or
                   backtrack(r - 1, c, i + 1) or
                   backtrack(r, c + 1, i + 1) or
                   backtrack(r, c - 1, i + 1))
            board[r][c] = word[i]
            return ret

        for word in words:
            flag = False
            for r in range(ROWS):
                if flag:
                    break
                for c in range(COLS):
                    if board[r][c] != word[0]:
                        continue
                    if backtrack(r, c, 0):
                        res.append(word)
                        flag = True
                        break
        return res

Time & Space Complexity

  • Time complexity: O(wmn43t1)O(w * m * n * 4 * 3 ^ t - 1)
  • Space complexity: O(t)O(t)

Where ww is the number of words, mm is the number of rows, nn is the number of columns and tt is the maximum length of any word in the array wordswords.


2. Backtracking (Trie + Hash Set)

Intuition

Searching each word separately repeats the same work many times.
A Trie (prefix tree) lets us share work: while walking on the board, we only continue paths that match some prefix of the given words.
So the board DFS explores "possible prefixes", and whenever the Trie node says this prefix is a complete word, we record it.

We also need to avoid reusing the same cell in a single path, so we keep a visited set during the current DFS path (and backtrack/remove when returning).

Algorithm

  1. Build a Trie from all words.
    • Each Trie node stores children (next letters) and isWord (true if a word ends here).
  2. Initialize:
    • res as a set (to avoid duplicates).
    • visit as a set for the current DFS path.
  3. Define DFS dfs(r, c, node, wordSoFar):
    1. If (r,c) is out of bounds, already visited, or board[r][c] is not in node.children, stop.
    2. Mark (r,c) visited.
    3. Move Trie pointer: node = node.children[board[r][c]]
    4. Append current char to wordSoFar.
    5. If node.isWord == true, add wordSoFar to res.
    6. Recurse to 4 neighbors (up/down/left/right) using the updated node and wordSoFar.
    7. Backtrack: remove (r,c) from visit.
  4. Run DFS starting from every cell (r, c) with the Trie root.
  5. Return all collected words from res.
class TrieNode:
    def __init__(self):
        self.children = {}
        self.isWord = False

    def addWord(self, word):
        cur = self
        for c in word:
            if c not in cur.children:
                cur.children[c] = TrieNode()
            cur = cur.children[c]
        cur.isWord = True

class Solution:
    def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
        root = TrieNode()
        for w in words:
            root.addWord(w)

        ROWS, COLS = len(board), len(board[0])
        res, visit = set(), set()

        def dfs(r, c, node, word):
            if (r < 0 or c < 0 or r >= ROWS or
                c >= COLS or (r, c) in visit or
                board[r][c] not in node.children
            ):
                return

            visit.add((r, c))
            node = node.children[board[r][c]]
            word += board[r][c]
            if node.isWord:
                res.add(word)

            dfs(r + 1, c, node, word)
            dfs(r - 1, c, node, word)
            dfs(r, c + 1, node, word)
            dfs(r, c - 1, node, word)
            visit.remove((r, c))

        for r in range(ROWS):
            for c in range(COLS):
                dfs(r, c, root, "")

        return list(res)

Time & Space Complexity

  • Time complexity: O(mn43t1+s)O(m * n * 4 * 3 ^ {t - 1} + s)
  • Space complexity: O(s)O(s)

Where mm is the number of rows, nn is the number of columns, tt is the maximum length of any word in the array wordswords and ss is the sum of the lengths of all the words.


3. Backtracking (Trie)

Intuition

We still do DFS on the board, but we guide the DFS using a Trie so we only walk paths that match prefixes of the given words.

This version is faster because it adds aggressive pruning:

  • Each Trie node keeps refs = "how many words in the dictionary still pass through this node".
  • When we successfully find a word, we remove it from the Trie (by setting idx = -1 and decreasing refs).
  • If after removal a node's refs becomes 0, that branch is dead (no remaining words use it), so we physically cut the pointer from its parent (prev.children[...] = null).
    That prevents future DFS calls from exploring useless prefixes.

Also, instead of using a visited set, we mark the board in-place:

  • Temporarily set board[r][c] = '*' while exploring that path.
  • Restore it when backtracking.

What the Trie stores

Each node has:

  • children[26]: next letters (array, faster than hashmap)
  • idx: index of a word in words if a word ends here, else -1
  • refs: number of “still-alive” words that go through this node (including end words)

Algorithm

  1. Build the Trie:

    • Insert every word words[i].
    • While inserting, increment refs on every node along the path.
    • At the end node, store idx = i.
  2. DFS from every board cell:

    • dfs(r, c, node) tries to extend the current Trie path using board[r][c].
  3. DFS rules:

    • Stop if:
      • out of bounds, or
      • cell is already used in current path ('*'), or
      • Trie has no child for board[r][c]
    • Otherwise:
      1. Take letter ch = board[r][c], move to child = node.children[ch]
      2. Mark cell as visited: board[r][c] = '*'
      3. If child.idx != -1, we found a word:
        • add words[child.idx] to result
        • set child.idx = -1 (avoid duplicates)
        • decrease child.refs (this word is removed from remaining dictionary)
        • If child.refs == 0, cut this branch from parent:
          • node.children[ch] = null
          • restore board cell and return early (nothing more to explore here)
      4. Recurse into 4 neighbors with child
      5. Restore board cell (backtrack)
  4. Return collected results.

class TrieNode:
    def __init__(self):
        self.children = [None] * 26
        self.idx = -1
        self.refs = 0

    def addWord(self, word, i):
        cur = self
        cur.refs += 1
        for c in word:
            index = ord(c) - ord('a')
            if not cur.children[index]:
                cur.children[index] = TrieNode()
            cur = cur.children[index]
            cur.refs += 1
        cur.idx = i

class Solution:
    def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
        root = TrieNode()
        for i in range(len(words)):
            root.addWord(words[i], i)

        ROWS, COLS = len(board), len(board[0])
        res = []

        def getIndex(c):
            index = ord(c) - ord('a')
            return index

        def dfs(r, c, node):
            if (r < 0 or c < 0 or r >= ROWS or
                c >= COLS or board[r][c] == '*' or
                not node.children[getIndex(board[r][c])]):
                return

            tmp = board[r][c]
            board[r][c] = '*'
            prev = node
            node = node.children[getIndex(tmp)]
            if node.idx != -1:
                res.append(words[node.idx])
                node.idx = -1
                node.refs -= 1
                if not node.refs:
                    prev.children[getIndex(tmp)] = None
                    node = None
                    board[r][c] = tmp
                    return

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

            board[r][c] = tmp

        for r in range(ROWS):
            for c in range(COLS):
                dfs(r, c, root)

        return res

Time & Space Complexity

  • Time complexity: O(mn43t1+s)O(m * n * 4 * 3 ^ {t - 1} + s)
  • Space complexity: O(s)O(s)

Where mm is the number of rows, nn is the number of columns, tt is the maximum length of any word in the array wordswords and ss is the sum of the lengths of all the words.


Common Pitfalls

Forgetting to Remove Duplicate Words from Results

When multiple paths on the board can form the same word, adding the word to the result list without checking for duplicates leads to duplicate entries. Using a set for results or marking words as found in the Trie (by setting idx = -1 or isWord = false) prevents this issue.

Not Restoring the Board After Backtracking

When marking cells as visited by modifying the board directly (e.g., setting board[r][c] = '*'), forgetting to restore the original character after the recursive calls will corrupt the board state. This prevents other starting positions or words from finding valid paths.

Inefficient Trie Without Pruning

Building a Trie but not pruning it after finding words leads to repeated exploration of dead branches. Without decrementing refs counts and removing nodes when refs reaches zero, the algorithm continues searching paths that cannot yield any new words, significantly degrading performance.

Incorrect Trie Traversal During DFS

Advancing the Trie pointer before checking if the child node exists can cause null pointer exceptions. Always verify that node.children[char] exists before moving to the next node. Similarly, checking isWord before updating the node pointer will miss the current word.

Not Handling the Visited Set Correctly with Trie

When using a separate visited set alongside the Trie, forgetting to clear or properly backtrack the visited set for each new starting cell can block valid paths. Each DFS starting point should begin with a fresh or properly reset visited state.