Word Search II

Hard Tries

Problem

Given a 2D board of characters and a list of words, return all words from the list that can be formed by sequentially adjacent letters on the board, where adjacent letters are horizontally or vertically neighboring and each letter cell may not be used more than once per word.

  • 1 ≤ board.length ≤ 12
  • 1 ≤ board[i].length ≤ 12
  • 1 ≤ words.length ≤ 3 * 10⁴
  • 1 ≤ words[i].length ≤ 10
  • board and words[i] consist of lowercase English letters

Example

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"]

The algorithm first builds a Trie from the list of words to enable efficient prefix checks. Starting from each cell, it performs DFS to explore all possible paths. For example, starting at board[0][0] = 'o', it explores neighbors to form 'oath', which is found in the Trie and added to the result. Paths that do not match any prefix in the Trie are pruned early, avoiding unnecessary exploration. The critical moment is pruning paths that do not lead to any word, which drastically reduces the search space compared to naive backtracking.

Approach

Straightforward Solution

A naive approach tries to find each word independently by DFS on the board, resulting in O(m * n * 4^l) per word (m,n board dimensions, l word length), which is too slow for large word lists.

Core Observation

The problem requires searching for multiple words on a grid with adjacency constraints. A brute-force approach checking each word independently is inefficient because it repeats similar searches. The key is to share prefix information among words to prune searches early.

Path to Optimal

The key recognition signals are 'multiple words', 'prefix matching', and 'adjacent letters'. These indicate using a Trie to represent all words simultaneously, enabling prefix pruning during DFS. By building a Trie, the search can abandon paths that do not match any prefix, drastically reducing the search space. Combining Trie with DFS backtracking on the board yields an efficient solution.

Optimal Approach

Build a Trie from the list of words. For each cell in the board, start a DFS that explores neighbors only if the current path matches a prefix in the Trie. Mark visited cells to avoid reuse. When a Trie node indicates a complete word, add it to the result set. This approach ensures each path is explored only once per prefix, achieving efficient pruning and correctness.

Time

O(M * 4 * 3^(L-1))

M is the number of cells in the board. Each DFS explores up to 4 neighbors, but after the first step, at most 3 neighbors are explored to avoid revisiting the previous cell. L is the maximum length of words. Trie pruning reduces unnecessary paths, making the search efficient.

Space

O(N * L)

N is the number of words and L is the average length. The Trie requires O(N * L) space to store all words. The recursion stack uses O(L) space. The visited set uses O(M) space but is bounded by board size.

Pattern Spotlight

Trie (Prefix Tree) combined with Backtracking (DFS) for Efficient Multi-Word Search

Use a Trie to represent all words so that during DFS, paths that do not match any prefix can be abandoned immediately, preventing redundant exploration and enabling efficient simultaneous search for multiple words.

Solution

1class TrieNode:
2 def __init__(self):
3 self.children = {}
4 self.is_word = False
5
6 def add_word(self, word):
7 curr = self
8 for c in word:
9 if c not in curr.children:
10 curr.children[c] = TrieNode()
11 curr = curr.children[c]
12 curr.is_word = True
13
14class Solution:
15 def findWords(self, board: list[list[str]], words: list[str]) -> list[str]:
16 root = TrieNode()
17 for w in words:
18 root.add_word(w)
19
20 ROWS, COLS = len(board), len(board[0])
21 res, visit = set(), set()
22
23 def dfs(r, c, node, word):
24 if (r < 0 or c < 0 or
25 r == ROWS or c == COLS or
26 (r, c) in visit or board[r][c] not in node.children):
27 return
28
29 visit.add((r, c))
30 node = node.children[board[r][c]]
31 word += board[r][c]
32 if node.is_word:
33 res.add(word)
34
35 dfs(r + 1, c, node, word)
36 dfs(r - 1, c, node, word)
37 dfs(r, c + 1, node, word)
38 dfs(r, c - 1, node, word)
39 visit.remove((r, c))
40
41 for r in range(ROWS):
42 for c in range(COLS):
43 dfs(r, c, root, "")
44
45 return list(res)

Step-by-Step Solution

1

Build a Trie to Represent All Words for Prefix-Based Pruning

2def __init__(self):
3 self.children = {}
4 self.is_word = False
6def add_word(self, word):
7 curr = self
8 for c in word:
9 if c not in curr.children:
10 curr.children[c] = TrieNode()
11 curr = curr.children[c]
12 curr.is_word = True
15def findWords(self, board: list[list[str]], words: list[str]) -> list[str]:
16 root = TrieNode()
17 for w in words:
18 root.add_word(w)

Objective

To construct a prefix tree that encodes all words, enabling efficient prefix checks during DFS.

Key Insight

Building a Trie consolidates all words into a single data structure where common prefixes are shared. This allows the search to quickly determine if the current path can lead to any word by checking if the next character exists in the Trie node's children. This shared prefix structure is essential to prune invalid paths early and avoid redundant searches for each word separately.

Interview Quick-Check

Core Logic

The Trie stores all words such that each node represents a prefix; checking if a character exists in children determines if the path is valid.

Common Pitfalls & Bugs

Forgetting to mark the end of a word with a flag (is_word) causes the algorithm to miss valid words during DFS.

2

Perform DFS with Backtracking to Explore Valid Paths on the Board

20ROWS, COLS = len(board), len(board[0])
21res, visit = set(), set()
23def dfs(r, c, node, word):
24 if (r < 0 or c < 0 or
25 r == ROWS or c == COLS or
26 (r, c) in visit or board[r][c] not in node.children):
27 return
29 visit.add((r, c))
30 node = node.children[board[r][c]]
31 word += board[r][c]
32 if node.is_word:
33 res.add(word)
35 dfs(r + 1, c, node, word)
36 dfs(r - 1, c, node, word)
37 dfs(r, c + 1, node, word)
38 dfs(r, c - 1, node, word)
39 visit.remove((r, c))

Objective

To recursively explore all valid paths on the board that match prefixes in the Trie, collecting complete words found.

Key Insight

DFS explores neighbors in four directions, but only continues if the next character exists in the current Trie node's children. The visited set prevents revisiting cells in the current path, ensuring correctness. When a Trie node indicates a complete word, it is added to the result set. Backtracking by removing the cell from visited after exploration restores state for other paths. This combination ensures exhaustive yet efficient search.

Interview Quick-Check

Core Logic

DFS prunes paths early by checking Trie children, uses a visited set to avoid reuse of cells, and adds words to results when a complete word node is reached.

State & Boundaries

Boundary checks ensure indices are valid and cells are not revisited in the current path.

Common Pitfalls & Bugs

Incorrectly managing visited cells (e.g., not removing after recursion) leads to missed paths or infinite loops.

3

Initiate DFS from Every Cell to Find All Words

41for r in range(ROWS):
42 for c in range(COLS):
43 dfs(r, c, root, "")
45return list(res)

Objective

To start the backtracking search from each cell on the board, ensuring all possible starting points are considered.

Key Insight

Since words can start from any cell, the algorithm must initiate DFS from every position. This exhaustive start combined with Trie pruning ensures all valid words are found without redundant searches. Converting the result set to a list returns the final answer.

Interview Quick-Check

Core Logic

Starting DFS from every cell guarantees coverage of all possible word paths.

Complexity

Although starting from every cell increases calls, Trie pruning keeps the overall search efficient.

Line Analysis

This solution has 7 Critical lines interviewers watch for.

Line 39 Critical
visit.remove((r, c))

Remove the current cell from visited to backtrack.

This state restoration is the single most critical line in the backtracking algorithm; it ensures that the visited set correctly reflects the current path, allowing other DFS branches to explore the cell without false blockage.

Line 12 Critical
curr.is_word = True

Mark the final trie node as a complete word.

After all characters are inserted, this flag marks the path as a complete word that can be reported during DFS.

Line 23 Critical
def dfs(r, c, node, word):

Define the DFS function to explore board paths matching Trie prefixes.

DFS recursively explores neighbors, pruning paths that do not match any prefix in the Trie.

Line 24 Critical
if (r < 0 or c < 0 or

Check if current position is out of bounds or invalid for exploration.

Boundary and pruning checks prevent invalid or redundant exploration, ensuring correctness and efficiency.

Line 26 Critical
(r, c) in visit or board[r][c] not in node.children):

Check if column index exceeds board boundaries or cell is visited or character not in Trie children.

Ensures only valid, unvisited cells with matching Trie prefixes are explored, enabling pruning.

Line 30 Critical
node = node.children[board[r][c]]

Advance the Trie node to the child corresponding to the current board character.

Moving down the Trie aligns the search path with the current prefix, enabling prefix validation.

Line 32 Critical
if node.is_word:

Check if the current Trie node marks a complete word.

Detecting complete words during DFS allows adding valid words to the result set immediately.

Line 3 Core
self.children = {}

Initialize the children dictionary.

The children dictionary stores outgoing character transitions for prefix traversal.

Line 4 Core
self.is_word = False

Initialize the word-ending flag.

The is_word flag distinguishes a complete stored word from a prefix that merely continues to other nodes.

Line 9 Core
if c not in curr.children:

Check whether the character child is missing.

A missing child means the trie does not yet contain this prefix path and must create it.

Line 10 Core
curr.children[c] = TrieNode()

Create the missing trie child.

Creating a new TrieNode extends the trie to represent the new prefix.

Line 18 Core
root.add_word(w)

Insert the current word into the Trie.

Populating the Trie with all words enables prefix-based pruning during DFS.

Line 21 Core
res, visit = set(), set()

Initialize the result set and visited set.

The result set collects unique found words; the visited set tracks cells used in the current DFS path to prevent reuse.

Line 27 Core
return

Return early if any invalid condition is met.

Early return avoids unnecessary recursion and exploration of invalid paths.

Line 29 Core
visit.add((r, c))

Mark the current cell as visited.

Tracking visited cells prevents revisiting the same cell in the current path, maintaining path validity.

Line 33 Core
res.add(word)

Add the current word to the result set.

Storing found words ensures unique results and prevents duplicates.

Line 43 Core
dfs(r, c, root, "")

Invoke DFS from the current cell with the root Trie node and empty word.

This initiates the prefix-based search from each cell, enabling discovery of all valid words.

Line 11 Core
curr = curr.children[c]

Move to the child node for the current character.

Advancing the pointer moves insertion deeper along the word path.

Line 16 Core
root = TrieNode()

Create the root TrieNode for all words.

The root node serves as the entry point for all prefix searches and insertions.

Line 25 Core
r == ROWS or c == COLS or

Check if row index exceeds board boundaries.

Prevents index errors and invalid moves beyond the board.

Line 31 Core
word += board[r][c]

Append the current character to the forming word.

Building the current word string allows adding complete words to the result when found.

Line 35 Core
dfs(r + 1, c, node, word)

Recursively explore the cell below.

DFS explores all four directions to find all valid word paths.

Line 36 Core
dfs(r - 1, c, node, word)

Recursively explore the cell above.

DFS explores all four directions to find all valid word paths.

Line 37 Core
dfs(r, c + 1, node, word)

Recursively explore the cell to the right.

DFS explores all four directions to find all valid word paths.

Line 38 Core
dfs(r, c - 1, node, word)

Recursively explore the cell to the left.

DFS explores all four directions to find all valid word paths.

Line 2 Standard
def __init__(self):

Define the TrieNode initializer.

The initializer defines the per-node state that every trie insertion and lookup depends on.

Line 6 Standard
def add_word(self, word):

Define the trie word-insertion helper.

The add_word helper encapsulates insertion logic so each input word can be loaded into the trie consistently.

Line 7 Standard
curr = self

Initialize the insertion pointer.

Insertion starts at the current trie root and walks one character at a time.

Line 8 Standard
for c in word:

Iterate through the characters of the word.

Each character extends or follows one edge in the trie path for the word.

Line 15 Standard
def findWords(self, board: list[list[str]], words: list[str]) -> list[str]:

Define the findWords method.

This method is the entry point that builds the trie, searches the board, and returns all discovered words.

Line 17 Standard
for w in words:

Iterate over each word in the input list.

Each word must be inserted into the Trie to build the prefix tree for efficient search.

Line 41 Standard
for r in range(ROWS):

Iterate over each row in the board to start DFS.

Starting DFS from every cell ensures all possible word paths are explored.

Line 42 Standard
for c in range(COLS):

Iterate over each column in the board to start DFS.

Starting DFS from every cell ensures all possible word paths are explored.

Line 20 Standard
ROWS, COLS = len(board), len(board[0])

Store the number of rows and columns of the board.

Caching dimensions simplifies boundary checks during DFS.

Line 45 Standard
return list(res)

Return the list of found words.

Converting the result set to a list provides the final output in the expected format.

Test Your Understanding

Why is using a Trie critical for pruning the DFS search space when searching for multiple words on the board?

Because the Trie encodes all prefixes of the words, allowing the DFS to abandon any path that does not match a prefix of any word, thus avoiding exploring paths that cannot lead to a valid word and drastically reducing the search space.

Related Problems

Tries pattern

Don't just read it. Drill it.

Reconstruct Word Search II from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.

Unlock the Word Search II drill

or drill a free problem