Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Intermediate/I17
29 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣI17 · Part 2 · Intermediate

Tries

Store the string as a path, not a value — and every prefix question becomes a walk instead of a scan.

20 min read1 diagram15 sections9 exercises
29 of 48

In this chapter

  • The shape: the word is the path
  • The structure from scratch
  • Array children, Map children, and the radix trie
  • Cost: pay for the word length, not the dictionary size
  • Autocomplete: collect everything under a prefix

and 10 more

about 20 min
15On this page
  1. The shape: the word is the path
  2. The structure from scratch
  3. Array children, Map children, and the radix trie
  4. Cost: pay for the word length, not the dictionary size
  5. Autocomplete: collect everything under a prefix
  6. Counting inside the trie: prefix counts and Map Sum
  7. Wildcard search — the '.' matches any character
  8. Replace Words: stop at the shortest root
  9. Longest Word in Dictionary: every prefix must be a word
  10. Word Break — the trie kills the substring scanning
  11. Word Search II — the trie prunes the backtracking
  12. Deleting a word: unlink on the way back up
  13. Stream of Characters: a trie of reversed words
  14. Advanced aside: the binary trie for maximum XOR
  15. Recognizing it in an unseen problem

The shape: the word is the path

A hash set of words answers exactly one question well: "is this exact string present?" It is useless for "does anything here start with ca?" — you'd have to scan every key. A trie (prefix tree) fixes that by storing each character as an edge in a tree, so a word is a root-to-node path and every shared prefix is shared storage. Looking up a prefix costs O(length of the prefix), completely independent of how many words the dictionary holds.

Each node holds two things and nothing else: a map from next-character to child node, and a boolean saying "a complete word ends here." That second flag is load-bearing — without it you can't tell the stored word "do" from the mere prefix "do" inside "dog".

root c d a o r t g "car" "cat" "dog" "do" green = isEnd — a stored word finishes at this node
"car" and "cat" share the c–a path entirely. "do" ends at a node that still has a child, which is why isEnd is a flag and not "has no children."

The structure from scratch

A Map for children beats a fixed 26-slot array: it costs nothing for sparse nodes, and it survives inputs that aren't lowercase a–z (digits, unicode, arbitrary keys). The fixed array is faster by a constant factor when the alphabet really is 26 letters, and it's worth mentioning that tradeoff out loud, but reach for the Map by default.

class TrieNode {
  constructor() {
    this.children = new Map(); // char → TrieNode
    this.isEnd = false;        // a complete stored word terminates here
  }
}

class Trie {
  constructor() { this.root = new TrieNode(); }

  // O(L) time where L = word.length, O(L) new nodes worst case
  insert(word) {
    let node = this.root;
    for (const ch of word) {
      if (!node.children.has(ch)) node.children.set(ch, new TrieNode());
      node = node.children.get(ch);
    }
    node.isEnd = true;
  }

  // walk as far as the string goes; returns the node or null
  _walk(str) {
    let node = this.root;
    for (const ch of str) {
      node = node.children.get(ch);
      if (!node) return null;
    }
    return node;
  }

  search(word) {
    const node = this._walk(word);
    return node !== null && node.isEnd; // reaching the node is NOT enough
  }

  startsWith(prefix) {
    return this._walk(prefix) !== null; // here reaching the node IS enough
  }
}

Dry run: inserting "car", "cat", "do", "dog" — the diagram above

CallNodes walkedNew nodes createdResult
insert("car")root→c→a→rc, a, r (all new)isEnd(r) = true
insert("cat")root→c→a→tt only (c, a reused)isEnd(t) = true
insert("do")root→d→od, o (all new)isEnd(o) = true
insert("dog")root→d→o→gg only (d, o reused)isEnd(g) = true
search("do")root→d→o—node found, isEnd(o)=true → true
search("d")root→d—node found, isEnd(d)=false → false
startsWith("do")root→d→o—node found, isEnd not checked → true

"car" and "cat" only ever create one new node (t) because insert's children.has(ch) check reuses the existing c→a path — that sharing is the whole point of a trie. search("d") and startsWith("do") both walk to a real node; the only reason they'd disagree on the same input is the isEnd check, which is exactly the line search has and startsWith doesn't.

⚠ isEnd and "has children" are independent facts Neither one tells you the other. In the diagram, the o node has a child (g) but is a word ("do") — so testing children.size === 0 misses it. And if you only ever insert "dog", the o node still has a child but is not a word, so having children can't tell you a word ends there either. Only the flag can. search() and startsWith() differing by exactly the isEnd check is the entire point of the flag; if your two methods have identical bodies, you have a bug.

Array children, Map children, and the radix trie

The Map in each node is a default, not a law. When the alphabet is fixed and small (lowercase letters, digits, DNA bases) a node can hold a plain array with one slot per letter, indexed by word.charCodeAt(i) - 97. A child lookup becomes an array read with no hashing, and a node has a fixed shape, which helps the engine.

class ArrayTrieNode {
  constructor() {
    this.children = new Array(26).fill(null); // slot 0 is "a", slot 25 is "z"
    this.isEnd = false;
  }
}

class ArrayTrie {
  constructor() { this.root = new ArrayTrieNode(); }

  insert(word) {
    let node = this.root;
    for (let i = 0; i < word.length; i++) {
      const slot = word.charCodeAt(i) - 97;
      if (node.children[slot] === null) node.children[slot] = new ArrayTrieNode();
      node = node.children[slot];
    }
    node.isEnd = true;
  }

  search(word) {
    let node = this.root;
    for (let i = 0; i < word.length; i++) {
      node = node.children[word.charCodeAt(i) - 97];
      if (node === null) return false;
    }
    return node.isEnd;
  }
}
Array of 26 slotsMap per node
Child lookupindex arithmetic, O(1)hash lookup, O(1) on average
Memory per node26 slots whether or not usedgrows with the real number of children
Alphabetmust be known and small; a stray capital letter or digit indexes outside 0..25anything: Unicode, digits, whole words as "characters"
Iterating children in orderalready in alphabetical orderneeds a sort of the keys
Best whendense nodes near the root, lowercase inputsparse, deep nodes or an unknown alphabet

With a Map, a node that has one child costs one entry. With the array, the same node still pays for 26, which dominates memory for long, unshared words. A mistyped input such as "A" gives slot -32, and children[-32] is undefined, not null, so the === null test lets it through into a crash. Say the tradeoff, then pick the Map unless you were told the alphabet.

Long unshared words are exactly where a plain trie wastes nodes: a word stored alone becomes a chain of single-child nodes. A radix trie (compressed trie, Patricia trie) collapses every such chain into one edge that carries a string label. Every non-root node then either ends a word or has at least two children, which bounds the whole tree at about 2n nodes for n words, whatever the word lengths.

function commonPrefixLength(a, b) {
  let i = 0;
  while (i < a.length && i < b.length && a[i] === b[i]) i++;
  return i;
}

class RadixNode {
  constructor() {
    this.edges = new Map(); // first character of the label → { label, node }
    this.isEnd = false;
  }
}

class RadixTrie {
  constructor() { this.root = new RadixNode(); }

  insert(word) {
    let node = this.root, rest = word;
    while (rest.length > 0) {
      const edge = node.edges.get(rest[0]);
      if (!edge) {                                     // no edge starts this way: one new edge holds the whole rest
        const leaf = new RadixNode();
        leaf.isEnd = true;
        node.edges.set(rest[0], { label: rest, node: leaf });
        return;
      }
      const k = commonPrefixLength(edge.label, rest);
      if (k < edge.label.length) {                     // the label only partly matches: split it at k
        const middle = new RadixNode();
        middle.edges.set(edge.label[k], { label: edge.label.slice(k), node: edge.node });
        edge.label = edge.label.slice(0, k);
        edge.node = middle;
      }
      node = edge.node;
      rest = rest.slice(k);
    }
    node.isEnd = true;
  }

  search(word) {
    let node = this.root, rest = word;
    while (rest.length > 0) {
      const edge = node.edges.get(rest[0]);
      if (!edge || !rest.startsWith(edge.label)) return false;
      node = edge.node;
      rest = rest.slice(edge.label.length);
    }
    return node.isEnd;
  }
}

Dry run: insert "car", then "cat", then "do"

CallWhat happensEdges afterwards
insert("car")root has no edge starting with c, so one edge carries the whole wordc: "car"
insert("cat")the edge "car" shares only "ca" with "cat" (k = 2), so it splits: "ca" leads to a middle node with edges "r" and "t"c: "ca" → { r: "r", t: "t" }
insert("do")no edge starts with d: a new edge "do"c: "ca" → { r, t }, d: "do"

The splitting step is the whole difficulty; searching is the same walk as before, comparing a whole label with startsWith instead of one character. Costs stay O(L) character comparisons per operation. A slice that copies would make the string version O(L²) in the worst case, and although V8 shares the memory of long slices, walking by index is O(L) by construction. In an interview you are almost never asked to write this; knowing it exists, and why it saves memory, is the point.

Cost: pay for the word length, not the dictionary size

OperationTrieHash set of wordsSorted array + binary search
insert word of length LO(L)O(L) hashO(n) shift
exact searchO(L)O(L)O(L log n)
"any word with prefix P?"O(P)O(n · L) — full scanO(L log n)
list all words with prefix PO(P + output)O(n · L)O(L log n + output)
spaceO(total chars), shared prefixes stored onceO(total chars) + hash overheadO(total chars)

The sorted-array column is the honest competitor people forget: sorting the dictionary puts every prefix group in a contiguous block, so binary search handles prefix queries too. The trie wins when the dictionary changes (insertions are O(L), not O(n)) and when you need to walk character-by-character while doing something else — which is exactly the Word Search II case below, and the real reason tries show up in interviews.

The question each structure answers A hash set answers "is this exact string here?" A trie answers "is anything here that starts like this?" — and it can answer it incrementally, one character at a time, without restarting. Any problem where you're extending a candidate string one character at a time and want to bail early is a trie problem.

Autocomplete: collect everything under a prefix

Walk to the prefix node in O(P), then DFS its subtree collecting every isEnd. The cost is O(P + size of the subtree), which is proportional to the answer rather than the dictionary — that's what makes it viable at search-box latency.

function autocomplete(trie, prefix, limit = 10) {
  const start = trie._walk(prefix);
  if (!start) return [];

  const out = [];
  (function dfs(node, suffix) {
    if (out.length >= limit) return; // stop the moment we have enough
    if (node.isEnd) out.push(prefix + suffix);

    // sort keys for lexicographic order; skip the sort if insertion order is fine
    for (const ch of [...node.children.keys()].sort()) {
      dfs(node.children.get(ch), suffix + ch);
      if (out.length >= limit) return;
    }
  })(start, "");

  return out;
}

Real autocomplete wants top-k by popularity, not lexicographic order, and that changes the design: store a frequency on each terminal node, and at insert time also push the word into a small "best few in this subtree" list on every node along the path. Then a prefix query is O(P) with no DFS at all — you read the cached list off the prefix node. That precompute-on-write trade is exactly the answer expected in a "design a search suggestion service" system-design follow-up.

Counting inside the trie: prefix counts and Map Sum

A node can carry any number that is defined by "all words below me". Store at each node the total value of the words that pass through it, and the answer to "what is the total of everything with this prefix" is the number on the prefix node: O(prefix length), no subtree walk. The design question Map Sum asks for exactly that: insert(key, val) sets a key to a value (replacing an old one), and sum(prefix) returns the total of all values whose key starts with the prefix.

The replacement rule is the trap. Re-inserting a key must not add its new value on top of the old; it must add the difference. Keep a side map of each key's current value, compute delta = val - old, and add the delta at every node on the key's path.

class MapSum {
  constructor() {
    this.root = { sum: 0, children: new Map() };
    this.values = new Map(); // key → its current value, so a replacement can be turned into a delta
  }

  insert(key, val) {
    const delta = val - (this.values.get(key) ?? 0);
    this.values.set(key, val);
    let node = this.root;
    node.sum += delta;
    for (const ch of key) {
      if (!node.children.has(ch)) node.children.set(ch, { sum: 0, children: new Map() });
      node = node.children.get(ch);
      node.sum += delta;
    }
  }

  sum(prefix) {
    let node = this.root;
    for (const ch of prefix) {
      node = node.children.get(ch);
      if (!node) return 0;
    }
    return node.sum;
  }
}

Dry run: insert("apple", 3), sum("ap"), insert("app", 2), sum("ap"), insert("apple", 5), sum("ap")

CalldeltaSums on the path a, p, p, l, eReturns
insert("apple", 3)3 - 0 = 33, 3, 3, 3, 3—
sum("ap")—node for "ap" holds 33
insert("app", 2)2 - 0 = 2a: 5, p: 5, p: 5 (the "l" and "e" nodes stay 3)—
sum("ap")—node for "ap" holds 55
insert("apple", 5)5 - 3 = 2a: 7, p: 7, p: 7, l: 5, e: 5—
sum("ap")—node for "ap" holds 77

Insert is O(L), sum is O(P), and memory is O(total characters). The same idea, with 1 instead of a value, gives "how many words start with this prefix" (a prefix count), which also lets you detect that one word is a prefix of another without a flag walk. The brute-force alternative, a map from key to value plus a scan of every key on each sum, costs O(n · L) per query.

Wildcard search — the '.' matches any character

The "Design Add and Search Words" variant adds a dot that matches any single character. Deterministic lookup becomes a small DFS: a concrete character follows one child, a dot branches to all of them.

function searchPattern(node, word, i = 0) {
  if (i === word.length) return node.isEnd;

  const ch = word[i];
  if (ch !== ".") {
    const next = node.children.get(ch);
    return next ? searchPattern(next, word, i + 1) : false; // single deterministic step
  }

  for (const child of node.children.values()) {
    if (searchPattern(child, word, i + 1)) return true; // dot = branch over every child
  }
  return false;
}

Worst case (a query of all dots) this degenerates to visiting the whole trie, O(26L) branching bounded by the number of nodes — but note the branching factor is the number of existing children, not 26, so on a real dictionary it collapses fast. Say that bound out loud rather than claiming O(L).

Replace Words: stop at the shortest root

A dictionary holds "roots" ("cat", "bat"), and every word in a sentence that starts with a root is replaced by the shortest root that begins it. Build a trie of the roots, then walk each word through it: the first node on the path that is marked as a word end is the shortest root, so return as soon as you meet it. If the walk falls off the trie, or the word ends first, no root applies and the word stays as it is.

function replaceWords(dictionary, sentence) {
  const trie = {};
  for (const root of dictionary) {
    let node = trie;
    for (const ch of root) {
      if (!node[ch]) node[ch] = {};
      node = node[ch];
    }
    node.end = true;
  }

  function shortestRoot(word) {
    let node = trie;
    for (let i = 0; i < word.length; i++) {
      node = node[word[i]];
      if (!node) return word;                  // no root starts this way
      if (node.end) return word.slice(0, i + 1); // first end seen is the shortest root
    }
    return word;
  }

  return sentence.split(" ").map(shortestRoot).join(" ");
}

replaceWords(["cat", "bat", "rat"], "the cattle was rattled by the battery");
// "the cat was rat by the bat"

Building costs O(total root characters). Each word costs at most the length of the longest root, since the walk dies there, so the replacement phase is O(sentence length) rather than O(words · roots). The rejected alternative is to test every root with startsWith for every word. In the code the end marker is stored under the key "end", which cannot collide with a child because child keys are single characters.

Longest Word in Dictionary: every prefix must be a word

Find the longest word that can be built one letter at a time, each prefix also being a word in the list; break ties with the lexicographically smallest. In the trie, a word qualifies exactly when every node on its path is a word end, so search only through children that are word ends. Visiting children in alphabetical order, and replacing the best answer only when a path is strictly longer, makes the first word found at each length the smallest.

function longestWord(words) {
  const root = { end: true, kids: new Map() }; // the empty prefix counts as buildable
  for (const word of words) {
    let node = root;
    for (const ch of word) {
      if (!node.kids.has(ch)) node.kids.set(ch, { end: false, kids: new Map() });
      node = node.kids.get(ch);
    }
    node.end = true;
  }

  let best = "";
  function dfs(node, path) {
    if (path.length > best.length) best = path; // strict: an equal-length later word is lexicographically larger
    for (const ch of [...node.kids.keys()].sort()) {
      const child = node.kids.get(ch);
      if (child.end) dfs(child, path + ch); // a gap in the chain of words ends this branch
    }
  }
  dfs(root, "");
  return best;
}

Dry run: ["w", "wo", "wor", "worl", "world", "banana", "ban", "ba"]

Branch from the rootPath visitedWhy it stops
"b""b" is not a word, so the branch is never enteredthe first letter is missing
"w"w → wo → wor → worl → worldevery prefix is a word, so best becomes "world"

"banana" has the prefixes "ba" and "ban" in the list but no "b", so it cannot be built. The answer is "world". Time O(total characters) for the build plus the walk over the nodes (and the sorts, bounded by the alphabet size at each node). The alternative without a trie sorts the words and keeps a Set of buildable ones: also fine, and worth mentioning.

Word Break — the trie kills the substring scanning

The DP is the familiar one from the 1D DP chapter: ok[i] means "s[0..i) is fully segmentable." The naive inner loop slices s.substring(i, j) and hashes it, costing O(n² · L). Walking a trie instead reuses the previous character's work and — crucially — breaks the instant no dictionary word continues down this path.

function wordBreak(s, wordDict) {
  const root = {};
  for (const w of wordDict) { // plain objects are fine and fast when keys are chars
    let node = root;
    for (const ch of w) {
      if (!node[ch]) node[ch] = {};
      node = node[ch];
    }
    node.end = true;
  }

  const n = s.length;
  const ok = new Array(n + 1).fill(false);
  ok[0] = true; // the empty prefix is trivially segmentable

  for (let i = 0; i < n; i++) {
    if (!ok[i]) continue; // unreachable start — nothing to extend

    let node = root;
    for (let j = i; j < n; j++) {
      node = node[s[j]];
      if (!node) break; // THE win: no dictionary word starts s[i..j], abandon this start
      if (node.end) ok[j + 1] = true;
    }
  }
  return ok[n];
}

Worst case is still O(n²), but the break means the inner loop runs only as far as the longest dictionary word that actually matches — in practice a handful of characters, not n. No substrings are allocated either, which matters more than it looks on long inputs.

Word Search II — the trie prunes the backtracking

This is the problem tries exist for in interviews. Find every dictionary word hidden in a grid. Running the single-word Word Search backtracking from the backtracking chapter once per word is O(W · R · C · 4L) and times out. The fix is to invert the loop: build one trie of all words and DFS the grid once, carrying a trie node alongside the position. The moment the path spells something no word starts with, the branch dies.

function findWords(board, words) {
  const root = {};
  for (const w of words) {
    let node = root;
    for (const ch of w) {
      if (!node[ch]) node[ch] = {};
      node = node[ch];
    }
    node.word = w; // store the word itself — no need to rebuild it from the path
  }

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

  function dfs(r, c, node) {
    const ch = board[r][c];
    const next = node[ch];
    if (!next) return; // PRUNE: no word in the dictionary continues this way

    if (next.word) {
      found.push(next.word);
      delete next.word; // de-dupe: never report the same word twice
    }

    board[r][c] = "#"; // mark visited in place, same trick as Word Search
    if (r > 0)        dfs(r - 1, c, next);
    if (r < rows - 1) dfs(r + 1, c, next);
    if (c > 0)        dfs(r, c - 1, next);
    if (c < cols - 1) dfs(r, c + 1, next);
    board[r][c] = ch; // un-choose

    // leaf pruning: this branch is exhausted, unlink it so future DFS never enters
    if (Object.keys(next).length === 0) delete node[ch];
  }

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) dfs(r, c, root);
  }
  return found;
}
⚠ Three bugs this problem reliably produces Duplicates — the same word can be spelled from several start cells, so you must clear the marker after reporting it (delete next.word, not just pushing). Using a Set to de-dupe instead works but leaves the trie node reporting forever, wasting work. Forgetting the restore board[r][c] = ch silently blocks cells for later, unrelated searches. The leaf-pruning line is the only optional one, and it's what turns a TLE into a fast solution on adversarial inputs like a grid of all 'a' with words "aaaa...a".

delete next.word rather than next.word = null matters for the pruning line below it: an assigned-null key still shows up in Object.keys, so the node would never look empty and the prune would never fire.

Say it like this → "Instead of running the grid search once per word, I'll put all the words in a trie and search the grid once, carrying a trie pointer with the DFS. As soon as the path I've spelled isn't a prefix of any word, I stop — so shared prefixes are explored once rather than once per word, and dead branches are cut at the first character that doesn't match anything."

Deleting a word: unlink on the way back up

Deletion is the operation people skip and interviewers like to ask for. Two separate things might need to happen. The word must stop being a word, which means clearing isEnd on its last node. And nodes that only existed for that word should disappear, otherwise the trie leaks memory and startsWith still answers true for prefixes of a word that is gone. A node is removable when it is not a word end and has no children. The check runs from the deepest node back toward the root and stops at the first node that is still needed.

function removeWord(root, word) {
  const path = []; // [parent, character] pairs from the root down to the last node
  let node = root;
  for (const ch of word) {
    const next = node.children.get(ch);
    if (!next) return false; // the word is not in the trie
    path.push([node, ch]);
    node = next;
  }
  if (!node.isEnd) return false; // the path exists only as a prefix of other words
  node.isEnd = false;

  for (let i = path.length - 1; i >= 0; i--) {
    const [parent, ch] = path[i];
    const child = parent.children.get(ch);
    if (child.isEnd || child.children.size > 0) break; // something else still uses this node
    parent.children.delete(ch);
  }
  return true;
}

Dry run: the trie holds "do" and "dog"

CallWalk and flagCleanup, deepest node firstResult
removeWord("dog")reaches g, g.isEnd becomes falseg has no children and is not an end: delete it. Then o is an end ("do"): stoptrie holds "do" only, with d → o
removeWord("do")reaches o, o.isEnd becomes falseo has no children: delete it. d now has none and is not an end: delete ittrie is empty
removeWord("d")the root has no child d (the trie is empty)nonefalse: nothing was removed

Cost O(L). The two traps are deleting a prefix of another word (removing "do" must keep the nodes d, o, g that "dog" needs) and returning true for a word that was only a prefix. An alternative with the same effect is a counter of words passing through each node, decremented along the path, deleting any node whose count reaches zero.

Stream of Characters: a trie of reversed words

Letters arrive one at a time, and after each one you must say whether some suffix of everything received so far is a dictionary word. A normal trie matches from the start of a word, but here the end is fixed (the newest letter) and the start is unknown. So flip the problem: insert every word reversed, and on each arrival walk the stream backwards from the newest letter. The first word end you reach means a word ends here.

class StreamChecker {
  constructor(words) {
    this.root = { children: new Map(), isEnd: false };
    this.maxLength = 0;
    for (const word of words) {
      let node = this.root;
      for (let i = word.length - 1; i >= 0; i--) { // reversed insert
        if (!node.children.has(word[i])) node.children.set(word[i], { children: new Map(), isEnd: false });
        node = node.children.get(word[i]);
      }
      node.isEnd = true;
      this.maxLength = Math.max(this.maxLength, word.length);
    }
    this.recent = []; // only the last maxLength letters can matter
  }

  query(letter) {
    this.recent.push(letter);
    if (this.recent.length > this.maxLength) this.recent.shift();

    let node = this.root;
    for (let i = this.recent.length - 1; i >= 0; i--) { // newest letter first
      node = node.children.get(this.recent[i]);
      if (!node) return false;   // no word ends with these letters
      if (node.isEnd) return true; // a whole reversed word matched: a suffix of the stream is a word
    }
    return false;
  }
}

Dry run: words ["cd", "f", "kl"], stream a, b, c, d, e, f

queryrecent (last 2 letters)Walk, newest firstReturns
"a"aroot has no child "a"false
"b"a, broot has no child "b"false
"c"b, croot has no child "c" ("cd" is stored as d then c)false
"d"c, dd, then c, and that node is a word endtrue
"e"d, eroot has no child "e"false
"f"e, ff is a word end at oncetrue

Each query costs O(longest word), independent of the stream length, because only the last maxLength letters are kept. The shift() there is also O(maxLength), which does not change the per-query bound. The shape to remember is "match against the end of a growing string, so reverse the dictionary". The general tool for matching many words against a stream is an Aho-Corasick automaton (a trie plus failure links), which does a constant amortized amount of work per letter; the reversed trie is the simpler one to write in an interview.

Advanced aside: the binary trie for maximum XOR

A trie doesn't have to be built from letters. Write each number as its 32-bit binary string and insert those — now the "alphabet" is {0, 1}, the tree is exactly 32 deep, and you can answer "which stored number XORs with x to give the largest result?" greedily. XOR gives a 1 bit exactly when the bits differ, and the high bits dominate the value, so at each level you steer toward the opposite bit if such a branch exists.

// maximum XOR of any pair — O(32n) time, O(32n) nodes, vs O(n²) brute force
function findMaximumXOR(nums) {
  const BITS = 31; // stay inside 32-bit signed range: bit 31 down to bit 0
  const root = {};

  for (const num of nums) {
    let node = root;
    for (let b = BITS; b >= 0; b--) {
      const bit = (num >> b) & 1;
      if (!node[bit]) node[bit] = {};
      node = node[bit];
    }
  }

  let best = 0;
  for (const num of nums) {
    let node = root, current = 0;
    for (let b = BITS; b >= 0; b--) {
      const bit = (num >> b) & 1;
      const want = bit ^ 1; // the opposite bit sets this position in the XOR
      if (node[want]) {
        current |= 1 << b;  // greedy: a high bit is worth more than every lower bit combined
        node = node[want];
      } else {
        node = node[bit];   // forced to match — this bit contributes 0
      }
    }
    best = Math.max(best, current);
  }
  return best;
}

The greedy step is safe for the same reason binary place value works: setting bit b contributes 2b, which strictly exceeds the sum of every lower bit (2b − 1). So there is never a reason to give up a high bit hoping to win low ones. The same structure, with counts stored per node, extends to "count pairs with XOR less than k" and to offline queries with a max-value constraint.

Recognizing it in an unseen problem

  • The words prefix, autocomplete, dictionary, starts with, or a list of words plus something to search them against
  • You're building a candidate string one character at a time (grid DFS, backtracking, DP over a string) and want to abandon it the moment no target could continue — that incremental "still a valid prefix?" check is the trie's unique ability
  • Brute force is "for each of W words, scan/search the whole input" — the trie inverts it to one pass over the input carrying all W words at once
  • Distinguish from a hash set: if only exact membership is ever asked, a Set is simpler, smaller and faster — don't build a trie to show off
  • Distinguish from suffix structures: "any substring" questions (repeated substrings, longest common substring) want a suffix trie/automaton or hashing, not a plain prefix trie
  • Bitwise pair problems — maximum XOR, XOR under a threshold — are a binary trie over 32-bit strings in disguise
  • Watch the flag: isEnd is separate from "leaf," and search vs startsWith must differ by exactly that check
  • "Replace with the shortest root," "sum of values by prefix," "count of words with this prefix" → store the answer on the node and read it at the end of the prefix walk
  • "Every prefix must also be a word" → only descend through children that are word ends
  • Letters arrive in a stream and you ask about a suffix → insert the words reversed and walk backwards from the newest letter
  • "Remove a word" → clear the flag, then delete nodes upward while they are not word ends and have no children
Before you move on
  • Write insert, search, and startsWith from memory, and say in one sentence exactly what isEnd changes between the last two.
  • Explain why "car" and "cat" share nodes in the trie but "do" still needs isEnd as a flag separate from "has children," using the "dog" example.
  • Explain why Word Search II builds one trie for all words instead of running Word Search once per word, and what the prefix-miss check prunes.
  • Trace the greedy bit-by-bit choice in maximum XOR and explain why taking the opposite bit is never a mistake, even made greedily from the top bit down.
  • Given a new problem, recognize when it needs a trie — incremental prefix checks while a candidate string is still being built — versus a plain hash set, which only answers exact membership.
  • Compare array children with Map children, and say what a radix trie compresses and why it has about 2n nodes.
  • Keep a running total on every node for Map Sum, and say why a replaced key adds only the difference.
  • Delete a word from a trie without breaking the words that share its prefix, and say which nodes are safe to unlink.
  • Answer a stream-of-characters query with a reversed trie, and say why only the last maxLength letters need to be kept.

09Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsMaximum XOR of Two Numbers in an Arrayadvanced
  • 8 testsImplement Trie (Prefix Tree)intermediate
  • 8 testsDesign Add and Search Wordsadvanced
  • 8 testsWord Search IIadvanced
  • 8 testsReplace Wordsintermediate
  • 8 testsLongest Word in Dictionaryintermediate
  • 8 testsMap Sum Pairsintermediate
  • 8 testsStream of Charactersadvanced
  • 9 testsTrie With Removeadvanced
All 318 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

I17

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. I16Previous · I16Matrix problems
  2. I17you are here
  3. Next · I18Monotonic stack & queue in depthThrow away every element that can never win again — what survives is already sorted.26 min read

01On this page

15 sections · 0%

  1. 01The shape: the word is the path
  2. 02The structure from scratch
  3. 03Array children, Map children, and the radix trie
  4. 04Cost: pay for the word length, not the dictionary size
  5. 05Autocomplete: collect everything under a prefix
  6. 06Counting inside the trie: prefix counts and Map Sum
  7. 07Wildcard search — the '.' matches any character
  8. 08Replace Words: stop at the shortest root
  9. 09Longest Word in Dictionary: every prefix must be a word
  10. 10Word Break — the trie kills the substring scanning
  11. 11Word Search II — the trie prunes the backtracking
  12. 12Deleting a word: unlink on the way back up
  13. 13Stream of Characters: a trie of reversed words
  14. 14Advanced aside: the binary trie for maximum XOR
  15. 15Recognizing it in an unseen problem
Mark as read

[] chapters · t top