A learning sheet β a tree whose edges are characters. It answers prefix questions in O(L), no matter how many words you've stored.
A trie stores strings by their shared prefixes. Each node owns a map of child characters; walking a word's letters from the root traces (or builds) a path, and a boolean isEnd flags where real words finish. Every operation β insert, search, prefix-check β is one walk down the word: O(L), completely independent of how many words are stored.
X a word?" in O(L) β but "is X a prefix of some word?" forces a scan of every entry: O(NΒ·L).search(w) = walk w; it's a word iff the path exists and ends on isEnd. startsWith(p) = walk p; true iff the path exists at all.O(L) per op regardless of N, and prefixes are stored once.| Ask | If yes β | Pattern |
|---|---|---|
| Implement insert / search / startsWith? | node = {children, isEnd}; walk the word | Mechanics (Β§4.1) |
| Autocomplete / all words with a prefix / replace words? | walk to the prefix, DFS to collect | Prefix queries (Β§4.2) |
Search allowing a wildcard (. matches any)? | branch the DFS over all children at . | Wildcard (Β§4.3) |
| Find all dictionary words inside a grid / break a string? | trie as a pruner over a DFS | Trie + DFS (Β§4.4) |
| Maximize/compare XOR of numbers? | a binary trie of bits, greedy MSB-first | Bit trie (Β§4.5) |
Map for sparse alphabets, an array for dense ones β and say which trade-off you chose.| Words in the prompt | Reach for |
|---|---|
| "implement a trie / prefix tree", "insert / search / startsWith" | Mechanics Β§4.1 |
| "autocomplete", "search suggestions", "replace words", "longest common prefix", "map sum" | Prefix Β§4.2 |
"add and search word", ". matches any character", "wildcard" | Wildcard Β§4.3 |
| "word search II", "find all words in a board", "concatenated words", "word break II" | Trie + DFS Β§4.4 |
| "maximum XOR of two numbers", "binary trie", "XOR pairs" | Bit trie Β§4.5 |
Node throughout: { children: Map<char, Node>, isEnd: boolean }. Each pattern: a one-liner, a π invariant, a template, a π€ interview lens.
βWalk the word; create missing children on insert, follow them on lookup.β
isEnd = true. Why that's enough: a lookup follows the same characters, so it lands on that node iff the word was inserted β and isEnd is what separates a stored word from a path that's merely a prefix of one.class TrieNode { children = new Map<string, TrieNode>(); isEnd = false; }
class Trie {
root = new TrieNode();
insert(word: string): void {
let node = this.root;
for (const c of word) {
if (!node.children.has(c)) node.children.set(c, new TrieNode());
node = node.children.get(c)!;
}
node.isEnd = true;
}
walk(s: string): TrieNode | null { // shared helper: follow a string; null if the path breaks
let node = this.root;
for (const c of s) {
const next = node.children.get(c);
if (!next) return null;
node = next;
}
return node;
}
search(word: string): boolean {
const node = this.walk(word);
return node !== null && node.isEnd;
}
startsWith(prefix: string): boolean {
return this.walk(prefix) !== null;
}
}
The escalation interviewers reach for: swap the isEnd boolean for two counts per node β and erase plus "how many words/prefixes?" become one-line decrements (LC 1804, Implement Trie II).
// Trie II β counts make delete + "how many?" trivial
class CTrieNode { children = new Map<string, CTrieNode>(); ends = 0; passing = 0; }
class CountingTrie {
root = new CTrieNode();
insert(word: string): void {
let node = this.root;
for (const c of word) {
if (!node.children.has(c)) node.children.set(c, new CTrieNode());
node = node.children.get(c)!;
node.passing++; // +1 word runs through this node
}
node.ends++; // +1 word ends here
}
private walk(s: string): CTrieNode | null {
let node = this.root;
for (const c of s) {
const next = node.children.get(c);
if (!next) return null;
node = next;
}
return node;
}
countWordsEqualTo(word: string): number { return this.walk(word)?.ends ?? 0; }
countWordsStartingWith(pre: string): number { return this.walk(pre)?.passing ?? 0; }
erase(word: string): void { // assumes the word was inserted
let node = this.root;
for (const c of word) {
node = node.children.get(c)!;
node.passing--;
}
node.ends--; // (a real impl may also prune dead nodes)
}
}
Map for sparse alphabets vs a 26-slot array for dense β and say which you chose), explain why every op is O(L) no matter how many words are stored, and keep search vs startsWith straight (isEnd). Nail it and they escalate: implement β add erase β counts per node (the passing/ends split above, LC 1804).βWalk to the prefix node, then DFS everything beneath it.β
Setup: the subtree rooted at the prefix node contains exactly the words sharing that prefix β collect them with a DFS.
isEnd you meet is a completion, and no completion can live outside that subtree.// All stored words that start with `prefix` (uses Trie.walk from Β§4.1)
function wordsWithPrefix(trie: Trie, prefix: string): string[] {
const start = trie.walk(prefix); // the node where the prefix ends
if (!start) return []; // prefix not present β no completions
const out: string[] = [];
function dfs(node: TrieNode, suffix: string): void {
if (node.isEnd) out.push(prefix + suffix);
for (const [c, child] of node.children) {
dfs(child, suffix + c);
}
}
dfs(start, '');
return out;
}
The FAANG version of this pattern (a top Amazon question): as the user types each letter, return the 3 lexicographically-smallest completions. Trick: sort once, then have every node remember the first β€3 words inserted through it β queries become a plain walk.
// Search Suggestions System (1268) β top-3 completions per typed prefix
class SNode {
children = new Map<string, SNode>();
suggestions: string[] = []; // the β€3 lexicographically-smallest words through here
}
function suggestedProducts(products: string[], searchWord: string): string[][] {
products.sort(); // sort once β insertion order = lexicographic
const root = new SNode();
for (const word of products) {
let node = root;
for (const ch of word) {
if (!node.children.has(ch)) node.children.set(ch, new SNode());
node = node.children.get(ch)!;
if (node.suggestions.length < 3) node.suggestions.push(word); // first 3 = smallest 3
}
}
const out: string[][] = [];
let node: SNode | null = root;
for (const ch of searchWord) { // type the word letter by letter
node = node ? (node.children.get(ch) ?? null) : null; // dead prefix stays dead
out.push(node ? node.suggestions : []);
}
return out;
}
. matches any)βOn a literal, follow one child; on a dot, try them all.β
Setup: a normal walk can't handle . because it could match several children β so the walk becomes a branching DFS that recurses into every child whenever it sees a dot.
dfs(node, i) returns true iff the word's suffix from index i can be matched starting at node. Why that's enough: a literal advances one definite child; a . succeeds iff any child can match the rest β together they exhaust the only possibilities, so no match is ever missed.// Add and Search Word (211) β '.' matches any single character
class WordDictionary {
root = new TrieNode();
addWord(word: string): void {
let node = this.root;
for (const c of word) {
if (!node.children.has(c)) node.children.set(c, new TrieNode());
node = node.children.get(c)!;
}
node.isEnd = true;
}
search(word: string): boolean {
function dfs(node: TrieNode, i: number): boolean {
if (i === word.length) return node.isEnd;
const c = word[i];
if (c === '.') { // wildcard β try every child
for (const child of node.children.values()) {
if (dfs(child, i + 1)) return true;
}
return false;
}
const next = node.children.get(c); // literal β one definite child
if (!next) return false;
return dfs(next, i + 1);
}
return dfs(this.root, 0);
}
}
. (a literal follows one child, never a scan), and that you can state the worst case when the dots come early β each one fans out over every child. Nail it and they escalate: a single . β many wildcards β regex-style *.βDrive one DFS by the trie β abandon any path that isn't a live prefix.β
Setup: instead of searching the grid once per word, build a trie of all words and run a single DFS; at each cell, only continue if the trie has that character as a child. The trie collapses many searches into one and prunes dead ends instantly.
z), no dictionary word continues β the whole branch is abandoned.word field firing means a complete dictionary word ends on this very path.// Word Search II (212) β all dictionary words found in the board
class WNode { children = new Map<string, WNode>(); word: string | null = null; }
function findWords(board: string[][], words: string[]): string[] {
const root = new WNode();
for (const w of words) { // build the trie; tag each end with its full word
let node = root;
for (const c of w) {
if (!node.children.has(c)) node.children.set(c, new WNode());
node = node.children.get(c)!;
}
node.word = w;
}
const out: string[] = [];
const R = board.length, C = board[0].length;
function dfs(r: number, c: number, node: WNode): void {
const ch = board[r][c];
const child = node.children.get(ch);
if (!child) return; // PRUNE: not a live prefix
if (child.word) { // a complete dictionary word ends here
out.push(child.word);
child.word = null; // dedup: collect each word once
}
board[r][c] = '#'; // mark visited
for (const [dr, dc] of [[0, 1], [0, -1], [1, 0], [-1, 0]]) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < R && nc >= 0 && nc < C && board[nr][nc] !== '#') {
dfs(nr, nc, child);
}
}
board[r][c] = ch; // restore (backtrack)
}
for (let r = 0; r < R; r++) {
for (let c = 0; c < C; c++) {
dfs(r, c, root);
}
}
return out;
}
word on collect). Nail it and they escalate: Word Search II β Word Squares (425 β full code in backtracking.html Β§8.7) β Word Break II (140 β trie + memo, code in backtracking.html Β§12).βStore numbers as bit-paths; to maximize XOR, walk choosing the opposite bit.β
Setup: insert each number's bits MSBβLSB into a binary trie. To maximize a XOR b, for each a walk the trie always preferring the opposite bit (which sets that XOR bit to 1) when it exists.
// Maximum XOR of Two Numbers in an array (421) β 31-bit ints, bits 30β¦0
type BitNode = { [b in 0 | 1]?: BitNode };
function findMaximumXOR(nums: number[]): number {
const root: BitNode = {};
for (const num of nums) { // insert bits MSB β LSB
let node = root;
for (let b = 30; b >= 0; b--) {
const bit = ((num >> b) & 1) as 0 | 1;
node = node[bit] ??= {};
}
}
let best = 0;
for (const num of nums) {
let node = root;
let cur = 0;
for (let b = 30; b >= 0; b--) {
const bit = ((num >> b) & 1) as 0 | 1;
const want = (bit ^ 1) as 0 | 1; // greedily seek the OPPOSITE bit
if (node[want]) {
cur |= (1 << b); // opposite exists β this XOR bit is 1
node = node[want]!;
} else {
node = node[bit]!; // same-bit branch must exist (paths are full depth)
}
}
best = Math.max(best, cur);
}
return best;
}
O(n Β· 31). Nail it and they escalate: max XOR β Count Pairs With XOR in a Range β Maximum XOR With an Element From Array (1707, offline + sort).| Operation | Time | Space | Note |
|---|---|---|---|
| insert / search / startsWith | O(L) | O(total chars) | L = word length; independent of word count |
| Collect words with a prefix | O(L + S) | O(S) | S = total length of the matches |
| Wildcard search | O(26kΒ·L) worst | O(L) | k = number of dots (branching) |
| Bit trie (max XOR) | O(n Β· 31) | O(n Β· 31) | one path of 31 bits (bits 30β¦0) per number |
Map keeps them lean.| Bug | The input that exposes it |
|---|---|
Forgetting isEnd β treating any path as a word. | Insert "cat", search("ca") β must be false, but a no-flag trie says true. |
Confusing search (needs isEnd) with startsWith (doesn't). | After "cat": startsWith("ca") true, search("ca") false. |
Wildcard . not branching over all children. | Words ["ab","cb"], search ".b" β must try both a and c. |
| Grid DFS: not restoring the cell / not marking visited. | A word that would reuse a neighboring cell β false positive or revisits. |
| Grid DFS: not deduping found words. | The same word reachable two ways β appears twice; null-out the word on collect. |
| Bit trie: wrong bit order or too few bits. | Numbers needing the top bit β low-bit-first insertion gives wrong XOR. |
// 642 Design Search Autocomplete System β every node keeps sentence β count for everything passing through it
type TNode = { kids: Map<string, TNode>; counts: Map<string, number> };
class AutocompleteSystem {
private root: TNode = { kids: new Map(), counts: new Map() };
private cur: TNode | null; private typed = '';
constructor(sentences: string[], times: number[]) { this.cur = this.root; sentences.forEach((s, i) => this.add(s, times[i])); }
private add(s: string, t: number) {
let n = this.root;
for (const ch of s) {
if (!n.kids.has(ch)) n.kids.set(ch, { kids: new Map(), counts: new Map() });
n = n.kids.get(ch)!; n.counts.set(s, (n.counts.get(s) ?? 0) + t);
}
}
input(c: string): string[] {
if (c === '#') { this.add(this.typed, 1); this.typed = ''; this.cur = this.root; return []; }
this.typed += c;
this.cur = this.cur ? this.cur.kids.get(c) ?? null : null; // once off the trie, stay off until '#'
if (!this.cur) return [];
return [...this.cur.counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 3).map(e => e[0]);
}
}
// 336 Palindrome Pairs β split every word; a palindromic half needs the reverse of the other half to exist
function palindromePairs(words: string[]): number[][] {
const idx = new Map(words.map((w, i) => [w, i] as [string, number]));
const isPal = (s: string) => { let l = 0, r = s.length - 1; while (l < r) if (s[l++] !== s[r--]) return false; return true; };
const rev = (s: string) => s.split('').reverse().join('');
const res: number[][] = [];
words.forEach((w, i) => {
for (let k = 0; k <= w.length; k++) {
const left = w.slice(0, k), right = w.slice(k);
if (isPal(left)) { const j = idx.get(rev(right)); if (j !== undefined && j !== i) res.push([j, i]); } // rev(right) + w
if (right.length > 0 && isPal(right)) { const j = idx.get(rev(left)); if (j !== undefined && j !== i) res.push([i, j]); } // w + rev(left) (right.length > 0 avoids double counting)
}
});
return res;
}
// 1032 Stream of Characters β REVERSED-word trie, walk newest β oldest
class StreamChecker {
private root: any = {}; private buf: string[] = []; private maxLen = 0;
constructor(words: string[]) {
for (const w of words) {
this.maxLen = Math.max(this.maxLen, w.length);
let n = this.root;
for (let i = w.length - 1; i >= 0; i--) n = n[w[i]] ??= {};
n.end = true;
}
}
query(c: string): boolean {
this.buf.push(c); if (this.buf.length > this.maxLen) this.buf.shift();
let n = this.root;
for (let i = this.buf.length - 1; i >= 0; i--) {
n = n[this.buf[i]];
if (!n) return false;
if (n.end) return true; // ANY suffix that is a word counts
}
return false;
}
}
#. 336: guard right.length > 0 in the second branch and skip j === i. 1032: check end at every step of the walk, and cap the buffer at the longest word.Own each pattern and these are in range. Bold = Hard tier.
| Pattern | Representative LeetCode | Tier |
|---|---|---|
| Mechanics | 208 Implement Trie Β· 1804 Implement Trie II | Med |
| Prefix queries | 1268 Search Suggestions Β· 648 Replace Words Β· 677 Map Sum Pairs Β· 720 Longest Word Β· 14 Longest Common Prefix Β· 642 Search Autocomplete Β· 336 Palindrome Pairs Β· 1032 Stream of Characters | Med |
| Wildcard | 211 Add & Search Word (the . branch) | Med |
| Grid + trie | 212 Word Search II Β· 425 Word Squares | Hard |
| XOR / bit trie | 421 Maximum XOR of Two Numbers Β· 1707 Max XOR With an Element | Med β Hard |
Cover the right column (blurred β hover/tap to reveal). Say your answer out loud first.
| Prompt | Answer |
|---|---|
| What's a trie op's cost, and what is it independent of? | O(L) in the word's length β independent of how many words are stored. |
| What distinguishes a stored word from a mere prefix? | The isEnd flag. search requires it; startsWith doesn't. |
| When does a trie beat a hash set, and when not? | Beats it for prefix queries and as a search pruner; loses for exact-membership-only (a set is simpler/lighter). |
How does a wildcard . change the walk? | It branches: the DFS recurses into every child and succeeds if any branch matches the rest. |
| How does a trie prune Word Search II? | The DFS carries the trie node for the current path; if the next letter isn't a child, no word continues β prune the branch. |
| Why is the bit-trie XOR walk greedy-optimal? | A higher bit outweighs all lower bits, so taking the opposite branch at the MSB whenever possible is never beaten later. |
| A trie's main weakness? | Memory β many sparse nodes; a Map per node keeps it lean vs a 26-slot array. |
| Search Suggestions (1268): how do you serve top-3 per keystroke in O(1)? | Sort the products once, then during insertion each node keeps the first β€3 words that pass through it (sorted insertion = lexicographically smallest). A query is just a walk β each node already holds its answer. |
| Stream of Characters β why insert the words reversed? | A match is any suffix of the stream that is a word; reading the stream newest-first through a reversed-word trie turns "suffix match" into a normal prefix walk (check end at every step). |
| Palindrome Pairs β the two cases per split? | left palindrome and rev(right) is a word β rev(right)+w; right palindrome (non-empty) and rev(left) is a word β w+rev(left). |
Trie (insert / search / startsWith)wordsWithPrefix (walk + DFS) Β· suggestedProducts (1268)WordDictionary (addWord + .-search DFS)findWords (trie pruning)findMaximumXORFor each: write "what does isEnd/the trie node mean here?" as a comment before the code.