πŸ”‘ Trie

A learning sheet β€” a tree whose edges are characters. It answers prefix questions in O(L), no matter how many words you've stored.

🧠 1 · How to think (and invent it)

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.

Watch it get invented β€” a prefix-searchable dictionary

  1. Start with a hash set. It answers "is X a word?" in O(L) β€” but "is X a prefix of some word?" forces a scan of every entry: O(NΒ·L).
  2. The leap. Words that share a prefix should share storage. Lay them in a tree keyed by character; common prefixes become a single shared path that branches only where the words diverge.
  3. The two queries fall out. 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.
  4. Payoff. O(L) per op regardless of N, and prefixes are stored once.
root c a t r d shared "ca" = "cat" (isEnd) = "car" (isEnd) = "card"
"cat", "car", "card" share one "ca" path; green nodes are word-ends. A prefix is just "does this path exist?"
When a trie beats a hash set Use a trie for prefix work (autocomplete, "starts with", longest common prefix) and as a pruner for searches (only explore paths that are valid prefixes). If you only need exact membership, a hash set is simpler, as fast, and lighter.

🎯 2 · Attack plan

AskIf yes β†’Pattern
Implement insert / search / startsWith?node = {children, isEnd}; walk the wordMechanics (Β§4.1)
Autocomplete / all words with a prefix / replace words?walk to the prefix, DFS to collectPrefix 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 DFSTrie + DFS (Β§4.4)
Maximize/compare XOR of numbers?a binary trie of bits, greedy MSB-firstBit trie (Β§4.5)
When a trie is the WRONG / overkill tool Β· Exact membership only, no prefixes β†’ a hash set is simpler and lighter.
Β· Memory: a 26-pointer-array node is fast but heavy; use a Map for sparse alphabets, an array for dense ones β€” and say which trade-off you chose.
Β· The data isn't character/bit-sequenced and has no prefix structure β†’ a trie buys nothing.

πŸ”Ž 3 Β· Recognition map

Words in the promptReach 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

🧩 4 · The five core patterns

Node throughout: { children: Map<char, Node>, isEnd: boolean }. Each pattern: a one-liner, a πŸ”‘ invariant, a template, a 🎀 interview lens.

4.1 Β· Mechanics β€” insert / search / startsWith

β€œWalk the word; create missing children on insert, follow them on lookup.”

πŸ”‘ Invariant β€” why it's safe The invariant: after inserting a word, exactly one root-to-node path spells it, and that final node has 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)
  }
}
🎀 Interview lens What they're really testing: can you justify the node shape (a 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).

4.2 Β· Prefix queries β€” autocomplete

β€œ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.

πŸ”‘ Invariant β€” why it's safe The invariant: every word in the subtree below the prefix node starts with exactly that prefix β€” insertion could not have placed it there otherwise. Why that's enough: reach the prefix node and DFS; every 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;
}
🎀 Interview lens What they're really testing: the walk-then-DFS split, and whether your suggestions come out in lexicographic order β€” either iterate children in sorted order, or precompute per node like 1268 above (sort once, store ≀3 per node, O(1) per keystroke). Nail it and they escalate: words-with-prefix β†’ Search Suggestions (1268) β†’ Replace Words (648, shortest root wins) β†’ Map Sum Pairs (677).

4.3 Β· Wildcard search (. 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.

. a b c a '.' tries every child; succeed if any branch matches
A literal character is one step; a dot fans out into a DFS over all children.
πŸ”‘ Invariant β€” why it's safe The invariant: 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);
  }
}
🎀 Interview lens What they're really testing: that you branch only on . (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 *.

4.4 Β· Trie as a pruner (grid / word break)

β€œ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.

GRID c a r z TRIE c a r βœ— no 'z' child β†’ PRUNE
The trie node moves cell-for-cell with the grid path. The instant the next letter isn't a child (here z), no dictionary word continues β€” the whole branch is abandoned.
πŸ”‘ Invariant β€” why it's safe The invariant: the trie node the DFS carries always spells exactly the current grid path. Why that's enough: if the next cell's letter isn't a child of that node, no dictionary word continues this way β€” the whole branch is safely pruned. And a node's 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;
}
🎀 Interview lens What they're really testing: why one trie-driven DFS beats searching the grid once per word, plus the backtracking hygiene β€” mark the cell, restore it, dedup found words (null the 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).

4.5 Β· Bit trie β€” XOR greedily

β€œ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.

0 1 my bit = 1 β†’ prefer the 0 branch (opposite bits β‡’ XOR bit = 1) fall back to the same bit if absent
Greedy from the most significant bit: every "opposite" turn you can take adds a higher-value 1 to the XOR.
πŸ”‘ Invariant β€” why greedy is optimal The invariant: XOR is decided bit by bit from the MSB, and a 1 in a higher bit outweighs all lower bits combined. Why that's enough: at each level, taking the opposite-bit branch whenever it exists is strictly best β€” no later choice can win back a higher bit you passed up.
// 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;
}
🎀 Interview lens What they're really testing: MSB-first insertion (low-bit-first breaks the greedy argument), the opposite-bit walk, and stating 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).

⏱️ 5 · Complexity

OperationTimeSpaceNote
insert / search / startsWithO(L)O(total chars)L = word length; independent of word count
Collect words with a prefixO(L + S)O(S)S = total length of the matches
Wildcard searchO(26kΒ·L) worstO(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
The size-independence insight A trie's per-word cost depends on the word's length, never on the dictionary size β€” that's its superpower over scanning. Its weakness is memory: many sparse nodes; a Map keeps them lean.

⚠️ 6 Β· Pitfalls β€” and the test that catches each

BugThe 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.
πŸ”— This pattern, elsewhere A trie is a tree β€” every walk/collect is the tree DFS from trees.html. Trie-pruned grid search is backtracking with a smarter "is this still viable?" test; "Word Break" pairs it with DP. The bit trie is the structural side of bit manipulation. vs a hash set: tries win on prefixes and pruning, lose on memory for exact-membership-only.
βž• More trie shapes β€” Autocomplete (642) Β· Palindrome Pairs (336) Β· Stream of Characters (1032)
642: trie nodes that store counts β†’ ranked suggestions. 336: palindromes via prefix/suffix splits (a hash map or a reversed trie). 1032: insert words reversed, read the stream newest-first. (14 Longest Common Prefix: vertical scan; the trie only pays off across many queries.)
// 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;
  }
}
⚠️ Traps 642: the committed sentence must update every node on its path; after a dead branch stay dead until #. 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.

πŸ“Š Coverage β€” pattern β†’ LeetCode (medium & hard)

Own each pattern and these are in range. Bold = Hard tier.

PatternRepresentative LeetCodeTier
Mechanics208 Implement Trie Β· 1804 Implement Trie IIMed
Prefix queries1268 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 CharactersMed
Wildcard211 Add & Search Word (the . branch)Med
Grid + trie212 Word Search II Β· 425 Word SquaresHard
XOR / bit trie421 Maximum XOR of Two Numbers Β· 1707 Max XOR With an ElementMed β†’ Hard

βœ… 7 Β· Self-check

Cover the right column (blurred β€” hover/tap to reveal). Say your answer out loud first.

PromptAnswer
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).

Write these cold (no peeking)

For each: write "what does isEnd/the trie node mean here?" as a comment before the code.