HARD Lab · Tries · 75

212. Word Search II

Find every word from a dictionary that can be spelled along adjacent cells of a grid. Instead of searching the board once per word, build a trie of all words and run a single backtracking DFS that walks board letters and trie edges in lockstep, pruning the instant a prefix dies.

HardTrieBacktrackingMatrix DFSTypeScript

PROBLEM What we're solving

Given an m×n grid of letters and a list of words, return all words that can be formed by a path of horizontally or vertically adjacent cells, where no cell is reused within a single word. For board = [oaan; etae; ihkr; iflv] and words = [oath, pea, eat, rain], the answer is [oath, eat]oath snakes o→a→t→h and eat is e→a→t, while pea and raincan't be traced.

KEY IDEA One trie-guided DFS, not one DFS per word

Insight → if you search the board separately for each word, the same partial paths get re-explored over and over. Put all the words into a trie and DFS the board once, descending the trie as you step. The trie collapses shared prefixes (eat and eats share eat), and the moment the current board letter has no matching trie child, that whole branch is dead — prune immediately.

RECIPE Build the trie, then DFS + backtrack

  • 1 · Build a trie of every word. Store the full word string at its terminal node so a hit is self-describing — why: lets you collect without tracking the path.
  • 2 · DFS from each cell, carrying the current trie node. Look up node.children.get(board[r][c]). If it's missing, returnwhy: no dictionary word continues with this letter, so the branch is hopeless.
  • 3 · On a terminal node, record the word and null out its marker — why: de-dupes repeats without a separate Set.
  • 4 · Mark the cell visited (overwrite with #), recurse into the 4 neighbors, then restore itwhy: backtracking frees the cell for other paths.
Classic confusion → the start cell doesmatter, but you don't need a special case: just call dfs(r, c, root) for every cell. The very first children.get against rootrejects any cell whose letter isn't the first letter of some word, so non-starts cost almost nothing.

COST Complexity & alternatives

DFS once per word
O(W · m·n · 4ᴸ)
Re-walks shared prefixes for every word W.
Trie + single DFS
O(m·n · 4·3ᴸ⁻¹)
One walk; trie prunes dead branches early.

Why the trie wins

With the trie, board traversal is bounded by the grid and the longest word length L (after the first step only 3 directions are new), independent of how many words share a prefix. Trie space is O(total letters in words). A common extra optimization: prune leaf trie nodes after their word is found so the trie shrinks as you go.

Pattern transfer →"match many strings at once over a structure" is the trie-DFS signature: it powers Word Search (single word, no trie needed), Replace Words (shortest root), Concatenated Words, and Aho–Corasick multi-pattern matching.

RUN IT DFS the board along trie edges

step 0 / 45
STARTBuild a trie from oath, pea, eat, rain, then DFS the board following only letters that exist as trie edges.
path·empty
State
cell (r,c)
prefix
{ }
found
current cellpath tip / prefixpath so far / found
slowfast

TYPESCRIPT The solution, annotated

findWords.ts
interface TrieNode {
  children: Map<string, TrieNode>;
  word: string | null;   // non-null only at the end of a word
}

function findWords(board: string[][], words: string[]): string[] {
  // 1. Build a trie of all target words.
  const root: TrieNode = { children: new Map(), word: null };
  for (const w of words) {
    let node = root;
    for (const ch of w) {
      if (!node.children.has(ch)) {
        node.children.set(ch, { children: new Map(), word: null });
      }
      node = node.children.get(ch)!;
    }
    node.word = w;             // mark terminal
  }

  const rows = board.length;
  const cols = board[0].length;
  const found: string[] = [];

  function dfs(r: number, c: number, node: TrieNode): void {
    const ch = board[r][c];
    const next = node.children.get(ch);
    if (!next) return;         // prune: no word continues here

    if (next.word !== null) {  // reached a complete word
      found.push(next.word);
      next.word = null;        // de-dupe without a Set
    }

    board[r][c] = '#';         // mark visited
    for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
      const nr = r + dr, nc = c + dc;
      if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && board[nr][nc] !== '#') {
        dfs(nr, nc, next);
      }
    }
    board[r][c] = ch;          // restore (backtrack)
  }

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      dfs(r, c, root);         // start only matters if board[r][c] is a root edge
    }
  }
  return found;
}

Reading it block by block

Build the trie. Each word is inserted character by character; the terminal node stores the whole word in word. Sharing prefixes is automatic — two words with the same start reuse the same early nodes.
dfs entry — the prune. We immediately look up node.children.get(ch). If there is no such child, no word continues with this letter from here, so we return. This single check is the whole speedup.
Collect on a terminal. If next.wordis non-null we've spelled a full word; push it and set next.word = null to avoid reporting duplicates, no Set required.
Visit, recurse, restore. Overwrite the cell with #so the same cell isn't reused on this path, recurse into the four neighbors (in-bounds and not #), then write the original letter back — classic backtracking.
Launch from every cell. Calling dfs(r, c, root)for all cells; the root lookup cheaply rejects cells that don't begin any word.
Complexity → Building the trie is O(total characters in words). The search is O(m·n · 4·3L−1) where L is the longest word length — after the first move only 3 directions are new. Space is O(total characters) for the trie plus O(L) recursion depth.

INTERVIEWFollow-ups they'll ask

  • "How do you avoid duplicate results?" Null the terminal word when first collected; optionally prune now-empty trie leaves.
  • "Why a trie instead of a hash set of words?" The trie lets you prune on prefixes mid-DFS; a hash set only tells you about complete words.
  • "In-place visited marker vs a visited matrix?" Overwriting with # is O(1) extra space; a boolean matrix is clearer but costs O(m·n).
  • "What if a word can reuse cells?"Drop the visited marker — but then paths can be infinite, so you'd need a different bound (e.g. word length).
  • "Thousands of words?" Prune the trie aggressively after finds; the search cost stays tied to the grid and longest word, not the word count.

MNEMONIC The one-liner

"Trie the words, walk the grid; child missing → quit; word here → grab it; mark, roam, restore."

TRIGGERS When you see ___ → reach for ___

find MANY words in a gridtrie of words + one DFS
prefix can dead-end earlyprune when child is absent
no cell reuse in a pathmark "#" then restore
avoid duplicate hitsstore word at node, null it on find

SKELETON The reusable shape

skeleton.ts
// trie: node = { children: Map, word: string|null }
function dfs(r, c, node) {
  const ch = board[r][c];
  const next = node.children.get(ch);
  if (!next) return;                 // prune
  if (next.word) { found.push(next.word); next.word = null; }  // collect + de-dupe
  board[r][c] = '#';                 // visit
  for (const [nr, nc] of neighbors)  // 4-dir, in-bounds, not '#'
    dfs(nr, nc, next);
  board[r][c] = ch;                  // backtrack
}
for each cell (r,c) dfs(r, c, root);

FLASHCARDS Tap to flip

Core data structure for Word Search II?
A trieof all the words, DFS'd in lockstep with the board so one traversal matches every word.
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 7
QUESTION 01
Why use a trie instead of running DFS once per word?
QUESTION 02
During DFS, you prune (return early) exactly when:
QUESTION 03
How do you signal that a trie node ends a word, and avoid duplicate results?
QUESTION 04
How is "no cell reused within one word" enforced?
QUESTION 05
For board=[oaan;etae;ihkr;iflv], words=[oath,pea,eat,rain], the result is:
QUESTION 06
Search-time complexity (L = longest word length)?
QUESTION 07
A common further optimization once a word is found is to: