Lab 07 — Trie Applications (Implement Trie + Word Search II)
Goal
Build a trie supporting insert, search, startsWith (LC 208), then use a trie to solve LC 212 — Word Search II — by pruning a DFS over a 2D grid against a trie of dictionary words. After this lab, the trie should be a reflex for any “many strings, prefix-shared, batch-query” problem.
Background Concepts
A trie (prefix tree) is a tree where each edge is labeled with one character and each path from the root spells a string. A node may carry an is_end flag meaning “a word terminates here”. Two strings sharing a prefix share the prefix path in the trie, giving O(L) per insert / search regardless of how many words exist — a fundamental advantage over hashtable + per-word check when prefixes overlap.
Children can be stored as:
- Array of 26 (or 256) — fastest dispatch, fixed alphabet, slightly memory-heavy. Best for grid-DFS hot loops.
- Hash map char → node — flexible alphabet, slower constant. Good for arbitrary Unicode or large alphabets.
- Compressed (radix tree / Patricia) — collapse single-child chains; smaller memory, harder to implement.
For LC 212, the killer move is: instead of running KMP / search per word against the grid (O(W · cells)), build a trie of all words and run one DFS that explores the grid while walking the trie in parallel, terminating any branch whose current grid letter has no trie-child. This converts the cost from “many independent searches” into “one search with multi-end”.
Interview Context
Tries are asked at every FAANG with high frequency: Meta, Google, Amazon all have multiple variants in their pool. The pattern is “many strings share prefixes; query, autocomplete, or grid-search”. Recognizing the trie-prune-DFS reduction for LC 212 is a strong-hire signal — it’s a 5-line code change with a 100x speedup.
Cousins: autocomplete (LC 642), longest word in dictionary (LC 720), word break (LC 139 — sometimes trie-friendly), maximum xor of two numbers (LC 421 — bit-trie).
Problem Statement A (LC 208)
Implement Trie with insert(word), search(word) (exact match), startsWith(prefix) (any word with this prefix).
Problem Statement B (LC 212)
Given an m × n grid of letters and a list words, return all words that can be formed by a sequence of adjacent (4-directionally connected) cells in the grid, where each cell is used at most once per word.
Constraints
- LC 208: ≤ 3 × 10⁴ ops; lowercase ASCII; word length ≤ 2000.
- LC 212: 1 ≤ m, n ≤ 12; 1 ≤ |words| ≤ 3 × 10⁴; each word length ≤ 10; lowercase ASCII.
Clarifying Questions
- Are duplicate words possible in the dictionary? (LC 212: assume no, dedupe defensively.)
- Can the same cell be reused across different words? (Yes — only “once per word”.)
- Is alphabet exactly lowercase ASCII? (Yes — use array of 26.)
Examples
LC 212:
board = [['o','a','a','n'],
['e','t','a','e'],
['i','h','k','r'],
['i','f','l','v']]
words = ["oath", "pea", "eat", "rain"]
→ ["oath", "eat"]
Initial Brute Force
For each word, run a DFS from every starting cell that matches the word’s first letter; backtrack on dead ends.
Per-word DFS: O(m · n · 4^L). Total: O(W · m · n · 4^L). With W=3×10⁴, m·n=144, L=10: ~10¹⁰ ops. TLE.
Brute Force Complexity
O(W · m · n · 4^L). At the limits: 1.6 × 10¹⁰. TLE by 4 orders of magnitude.
Optimization Path
Trie-pruned DFS. Build a trie of all words once: O(total chars) ≈ 3 × 10⁵. Then run a single DFS from each cell, walking the trie in parallel; whenever a trie-child for the current letter is missing, prune. Whenever an is_end is hit, record that word.
Total: O(m · n · 4^L) for the DFS structure, but the effective branching is small because most paths are pruned within 2-3 chars. Practical speedup: ~100×.
Optimization on top: when a word is recorded, mark its trie-end node and prune empty trie branches as you backtrack — keeps the trie shrinking.
Final Expected Approach
- Build trie: each node has
children[26],word: Optional[str](set on insert at the terminal). - For each cell (i, j), DFS:
- Read
c = board[i][j]. - If
node.children[c - 'a']is None, return. - Descend:
node = node.children[...]. - If
node.word: append to results, setnode.word = Noneto dedupe. - Mark
board[i][j] = '#'to prevent reuse. - Recurse 4 directions.
- Restore
board[i][j] = c.
- Read
- Return results.
Data Structures Used
TrieNode { children: List[Optional[TrieNode]], word: Optional[str] }.- 2D grid (mutable for the visited-mark trick).
- Result list.
Correctness Argument
The trie-DFS enumerates exactly the set of (path-in-grid, path-in-trie) pairs where each step matches the current grid letter to a trie child. A word is reported iff the DFS reaches a trie node with word != None along a non-self-intersecting grid path — by construction this is exactly the set of words present in the grid. Setting node.word = None after recording deduplicates without affecting other paths (the children remain reachable for other words sharing this prefix).
board[i][j] = '#' ensures non-reuse: the only way to visit a cell already on the path is if the trie has ‘#’ as a child of the current node, which it doesn’t (alphabet is lowercase).
Complexity
| Operation | Time | Space |
|---|---|---|
| Trie build | O(total characters in words) | O(total characters) |
| DFS (worst) | O(m · n · 4 · 3^(L-1)) per starting cell | O(L) recursion |
| Total | O(m · n · 4 · 3^(L-1)) | O(W · L) trie + O(L) recursion |
The 3^(L-1) (not 4^L) is because after the first step, you can’t immediately go back, so each node has ≤ 3 forward neighbors.
Implementation Requirements
class TrieNode:
__slots__ = ('children', 'word')
def __init__(self):
self.children = [None] * 26
self.word = None
def findWords(board, words):
root = TrieNode()
for w in words:
node = root
for c in w:
i = ord(c) - ord('a')
if node.children[i] is None:
node.children[i] = TrieNode()
node = node.children[i]
node.word = w
m, n = len(board), len(board[0])
res = []
def dfs(i, j, parent):
c = board[i][j]
if c == '#': return
idx = ord(c) - ord('a')
node = parent.children[idx]
if node is None: return
if node.word is not None:
res.append(node.word)
node.word = None
board[i][j] = '#'
for di, dj in ((-1,0),(1,0),(0,-1),(0,1)):
ni, nj = i + di, j + dj
if 0 <= ni < m and 0 <= nj < n:
dfs(ni, nj, node)
board[i][j] = c
# Optional pruning: if node has no children and no word, parent.children[idx] = None.
for i in range(m):
for j in range(n):
dfs(i, j, root)
return res
Tests
- LC 212 sample →
["oath", "eat"]. - Empty grid / empty word list →
[]. - Word equals a single cell:
board=[['a']], words=["a"]→["a"]. - Word longer than grid: returns nothing.
- Duplicate paths to same word: appears once thanks to
node.word = None. - Words sharing prefixes:
words=["oath", "oat"]— both should be found if both are present. - Stress: random grids of size 12×12, random word list of 100 words length 5; verify against per-word DFS brute force.
Follow-up Questions
- “How would you support deletion?” → reference-count children or do recursive cleanup; tricky due to shared prefixes.
- “Autocomplete with frequency.” → store
countper word at the terminal; on prefix lookup, walk the subtree and pick top-k by count (or maintain a per-node max-heap). - “What if the alphabet is Unicode?” → switch from
children[26]to adict. Per-node memory grows but dispatch is O(1) hash. - “Compress the trie.” → radix / Patricia trie collapses single-child chains; helpful when you have million-word dictionaries (e.g., DNS).
- “Bit trie for max-XOR (LC 421).” → trie of binary representations, depth 30 for 32-bit ints; greedy descent picks the opposite bit when possible.
Product Extension
Search-as-you-type / autocomplete: as a user types each character, walk the trie down one node and emit the top-k completions stored at that node. Production search engines (ES, Solr) build inverted indices, but for “small dictionary, fast prefix lookup” use cases (CLI command completion, query suggestion within an admin tool), an in-memory trie is the right call. DNS resolution uses radix tries internally.
Language/Runtime Follow-ups
- Python:
__slots__on TrieNode trims memory by ~40%. For LC 212 uselistof children rather thandict— dict’s per-key overhead dominates at this size. - Java:
TrieNode { TrieNode[] children = new TrieNode[26]; String word; }. JIT inlines the array dispatch. - Go:
type TrieNode struct { children [26]*TrieNode; word string }. Value-typed array of pointers is cache-friendlier than a slice. - C++:
struct TrieNode { array<TrieNode*, 26> children{}; string word; };. Use adeque<TrieNode>arena to avoidnewper node. - JS/TS: plain object
{children: Array(26), word: null}works; for large triesMapwith char keys uses less memory than 26-array per node.
Common Bugs
- Forgetting to mark
board[i][j] = '#'before recursing — same cell reused, wrong matches reported. - Forgetting to restore
board[i][j] = cafter recursion — corrupts the board for subsequent DFS calls. - Recording the word multiple times because the same prefix is reached via different paths. Fix:
node.word = Noneafter recording. - Storing the word only at the terminal — works if you carry the path string in DFS, wasteful otherwise. Correct: store the word at the terminal trie node.
- Initializing
children = [None](length 1) instead of[None] * 26— silent error at first non-‘a’ insert. - Walking the trie before reading the grid letter — off-by-one; you should read the grid letter, look up
parent.children[c], then descend.
Debugging Strategy
Trace the DFS on the LC 212 sample by hand: starting at (0, 0)=‘o’, root has child ‘o’ → ok; recurse to (1, 0)=‘e’ or (0, 1)=‘a’; descend to “oa” → child ‘a’; etc. If your output is missing words, add print(node.word) on the entry to dfs and verify the word terminations are correctly placed in the trie. If your output has spurious words, the visited-mark or the is_end placement is wrong.
Mastery Criteria
-
Wrote
Trieclass withinsert/search/startsWithin <8 minutes. - Solved LC 212 in <30 minutes from cold start.
- Stated the speedup over per-word DFS and the complexity of the combined DFS.
-
Used
node.wordstorage (notis_end + carry-string) for clean dedup. - Picked array-of-26 over hashmap for performance and justified it.
- Solved LC 421 (bit-trie max XOR) using the same structure.