Tries

A prefix tree turns a dictionary into a character-by-character graph where every path from root to a marked node spells a word. Lookups stay O(L) in word length regardless of dictionary size, and prefix queries come for free — something a hash set can never give you.

Topic guide5 problems
The unlock

A trie is a tree where the path from the root spells a string — so every word that shares a prefix shares the same nodes, and looking up a word is just walking that path letter by letter, costing O(L)no matter how many millions of words you've stored.

MENTAL MODEL A choose-your-own-adventure of letters

Picture a maze where every fork is labelled with a letter. Standing at the root, you spell your word one character at a time: the letter you're holding tells you which door to walk through. There's never a search — your own characters dictate the path. If the door you need isn't there, that prefix simply doesn't exist and you stop immediately.

Each node carries up to 26 children (one per letter) and a single isEndflag. The flag is the only thing that distinguishes “a real word ends here” from “this is just a place partway along a longer word.” The path being walkable proves a prefix; the isEnd flag proves a word.

The reframe →a hash set asks “is this exact string in my bag?” A trie asks “can I still keep walking?” That one shift is why prefixes, autocomplete, and wildcard matching all fall out for free.

SEE IT Shared prefixes, stored once

Here is a trie holding {cat, car, card, dog}. Watch how cat, car, and card all reuse the single ca path — the marks an isEnd node:

        (root)
        /     \
       c       d
       |       |
       a       o
      / \      |
     t   r     g•          "dog"
     •   |\
"cat"    • d•              "car", "card"
         (car) (card)

The "ca" path is laid down once, then branches into t and r.
Storing "card" after "car" adds a single new node (d) — the
"car" prefix is reused, never copied. That sharing IS the trie.

Now a search. You don't scan anything — you follow the path your characters spell, and the isEnd flag at the end decides the answer:

search("car"):                  start at (root)

  'c' → child exists? yes → step to node  c
  'a' → child exists? yes → step to node  ca
  'r' → child exists? yes → step to node  car
  end of word → is this node isEnd?  yes → TRUE

search("ca"):  walk c → a, reach node "ca", but isEnd? NO → FALSE
               (the PATH exists, but no word ends here)

search("cab"): walk c → a, then ask for child 'b' → missing → FALSE
The smell test → if a problem hands you many strings that share leading characters and asks you to query by prefix, draw two of them overlapping like above. If they fuse at the front, a trie is storing that overlap exactly once.

HOW TO THINK The cold-start ladder — run this on any new problem

When a problem smells like a dictionary, climb these rungs in order — the structure picks itself:

  1. Are there many strings sharing prefixes, with repeated prefix or word lookups?If yes → trie. If it's one-off exact membership, stop — a hash set is simpler.
  2. Insert = walk and create. For each character, step into the child if it exists, otherwise create it; after the last character, set isEnd = true (after the loop, never inside it).
  3. Search = walk and fail on a missing child. Step character by character; if any child is missing, return false. At the end, the answer is node.isEnd(for a full word) or just “you arrived” (for startsWith).
  4. Wildcard or prefix exploration = DFS over children. When the next character is a '.' (or you want every word under a prefix), recurse into all children instead of one.
  5. Word Search II = trie of the words, DFS the grid against it. Build the trie once, then walk the board in lock-step with the trie and prunethe instant the current path isn't a trie prefix.
The one move that unlocks every variant →“walking the trie” and “DFS over a tree” are the same act. Forced step for a letter, branch into all children for a wildcard. Everything else is bookkeeping.

SAY IT Path proves the prefix; the flag proves the word

The invariant to say out loud before you code: reaching a node only proves the prefix exists — only isEnd proves a complete word was inserted. Confuse the two and you get the single most common trie bug.

  • Implement Trie:search walks then checks isEnd; startsWith walks and is happy just to arrive.”
  • Add and Search (with '.'):“a dot means I'm no longer being told which door — so I try every door.”
  • Longest Common Prefix:“walk down while there's exactly one child and no word ends — that single chain isthe common prefix.”
  • Replace Words: “walk each word until I hit the first isEnd— that root is the shortest stored prefix.”
Failure mode → if search("ap") returns true when only "apple"was inserted, you checked “does the node exist” instead of “is this node isEnd.”

RECURSION SHAPE Every trie query is this walk in disguise

Strip away the specific problem and a trie query is the same three moves: out of characters → forced step on a letter → branch on a wildcard. Read it as a sentence, not as code:

search(node, word, i):              # i = how far into word we are

    if i == word.length:            # 1. consumed every character
        return node.isEnd           #    a word ends here? only then TRUE

    ch = word[i]

    if ch is a real letter:         # 2. one forced step
        next = node.children[ch]
        if next is missing: return False     # dead prefix — stop
        return search(next, word, i + 1)

    if ch == '.':                   # 3. wildcard — try EVERY child
        for child in node.children:
            if search(child, word, i + 1):
                return True
        return False

The exact-search version is just rung 1 + rung 2 — drop the wildcard branch. The grid word-search version replaces “word[i]” with “the four neighbouring cells” and the trie node travels alongside the cursor. Same skeleton, different driver.

WHY IT WINS Cost is the word length, not the dictionary size

The payoff that makes a trie worth the nodes: every operation walks at most L nodes (the length of the query) and never looks at the rest of the trie. Add a million more words and a lookup costs exactly the same.

Hash set + scan
O(N·L)
To answer “any word starts with 'ap'?” you scan all N words.
Trie
O(L)
Walk 'a'→'p' — two nodes — done, regardless of N.
The second payoff → because the trie is a tree, a DFS over it explores every word under a prefix in one sweep — and lets you prune dead branches early. That pruning is the entire reason a trie crushes brute force on grid word-search.

MNEMONIC One node per letter on the path.

One node per letter on the path.A trie stores words as paths from the root; shared prefixes share nodes, so lookup is O(word length) no matter how many words you've stored. The Visualize tab builds the tree letter by letter, reusing shared prefixes.

PATTERN The trie as a shared-prefix graph

Each node represents one character position along a word. A node holds two things: a children map (or fixed-size array of 26 for lowercase-only input) pointing to the next character, and an isEnd flag that marks a complete word without ending the path (longer words can share the same prefix).

Words that share a prefix share nodes. Inserting "apple" and "app" lays down five nodes; "app" just sets isEnd at depth 3 instead of creating new nodes. Every operation — insert, search, startsWith — walks at most L nodes and never touches the rest of the trie.

KEY IDEA O(L) prefix queries — what a hash set cannot do

The prefix advantage → a hash set gives O(L)exact membership checks but has no structural knowledge of prefixes. To ask "does any stored word start with 'ap'?" you must scan every word. A trie answers in O(2) — just walk two nodes.
Hash set
O(L) exact
No prefix support. startsWith scans all words.
Trie
O(L) prefix too
Shared nodes, autocomplete, wildcard DFS.

COST Memory layout and when it matters

A trie of n words of average length L uses at most O(n × L) nodes in the worst case (no shared prefixes). In practice, shared prefixes make it far smaller. Choose your node structure based on the alphabet:

  • Fixed array of 26 (children: TrieNode[26]) — fastest index, but wastes memory on sparse branching and breaks for non-lowercase or Unicode input.
  • Map (children: Map<string, TrieNode>) — handles any character set, allocates only used children. Slightly higher constant per lookup, but correct for interview problems with mixed characters.

For grid word-search, the trie doubles as a pruning structure: once a prefix no longer exists in the trie, you abort the DFS immediately — the classic reason tries beat brute force on Word Search II.

RUN IT One node per letter on the path

step 0 / 17
STARTInsert cat, car, can, dog into a trie. Walk down letter by letter, reusing nodes where prefixes match. One node per letter on the path.
reused / created
nodes: 1
new: 0
current letterend of a wordstored node
slowfast

TRIGGERS When you see ___ → reach for ___

Reach for a trie when the problem is fundamentally about shared prefixes or repeated prefix lookups over a fixed dictionary. If the problem only needs exact membership for isolated words, a hash set is simpler.

autocomplete / prefix search / startsWithtrie insert + startsWith walk
many words sharing prefixestrie to compress shared paths
search words with '.' wildcardstrie + DFS branching on wildcard nodes
find all dictionary words in a gridbuild trie of words, DFS the grid pruning by trie
implement a dictionary with prefix queriestrie with isEnd flag
stream of words + repeated prefix lookupsinsert once, query O(L) repeatedly

RED FLAGSWhen it's NOT this pattern

  • Only exact membership needed, no prefix queries. A hash set gives O(L) lookup with far less code — a trie adds complexity for zero benefit.
  • Only one or two words, one or two lookups. Building the trie is overkill; just compare strings directly.
  • The alphabet is huge or unbounded (Unicode, arbitrary strings). A Map-based trie still works, but memory per node climbs fast. Verify the input constraints before assuming lowercase letters only.
  • It's really a string DP or suffix problem. If the question is about substrings, suffixes, or edit distance, a trie is the wrong spine — look at suffix arrays, KMP, or DP instead.

TEMPLATE TrieNode + insert

When → Every trie problem starts here. Use a Map for children unless the problem guarantees lowercase letters only and you want the micro-opt of a fixed array.

trienode-insert.ts
class TrieNode {
  children: Map<string, TrieNode> = new Map();
  isEnd = false;                        // marks a complete word boundary
}

class Trie {
  private root = new TrieNode();

  /** O(L) — one node per character, shared with all prior inserts */
  insert(word: string): void {
    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;                  // mark end AFTER the last char
  }
}
Set isEnd after the loop, not inside it → setting it inside would mark every intermediate prefix as a complete word, silently breaking search().

TEMPLATE search / startsWith

When → The core read operations. Both share a private _walk helper; the only difference is whether you check isEnd at the destination node.

search-startswith.ts
class Trie {
  private root = new TrieNode();        // assume insert() already defined

  /** O(L) — returns true only if the COMPLETE word was inserted */
  search(word: string): boolean {
    const node = this._walk(word);
    return node !== null && node.isEnd; // must reach end AND be a full word
  }

  /** O(L) — returns true if any inserted word begins with prefix */
  startsWith(prefix: string): boolean {
    return this._walk(prefix) !== null; // reaching the node is enough
  }

  private _walk(s: string): TrieNode | null {
    let node: TrieNode = this.root;
    for (const ch of s) {
      if (!node.children.has(ch)) return null;
      node = node.children.get(ch)!;
    }
    return node;
  }
}
The isEnd check is the whole game → search('app') must return false when only 'apple' was inserted, even though the path exists. Walking to a node proves the prefix; only isEnd proves the word.

TEMPLATE Wildcard search (DFS on '.')

When → Any character in the pattern can be a '.' that matches exactly one character. DFS branches on every child when it sees a dot. The same DFS skeleton also powers grid word-search when you walk the trie in lock-step with the board.

wildcard-search-dfs-on-.ts
class WordDictionary {
  private root = new TrieNode();        // assume TrieNode with Map children

  addWord(word: string): void {
    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;
  }

  /** '.' matches any single character — DFS branches on every child */
  search(word: string): boolean {
    return this._dfs(this.root, word, 0);
  }

  private _dfs(node: TrieNode, word: string, i: number): boolean {
    if (i === word.length) return node.isEnd;
    const ch = word[i];
    if (ch !== '.') {
      const next = node.children.get(ch);
      return next !== undefined && this._dfs(next, word, i + 1);
    }
    // wildcard: try every child branch
    for (const child of node.children.values()) {
      if (this._dfs(child, word, i + 1)) return true;
    }
    return false;
  }
}
Branching cost → a single '.' fans out to all children at that depth. In the worst case (all dots) this is O(26^L), but real dictionaries prune the tree heavily. For Word Search II, extend this DFS to walk the grid simultaneously and delete matched words from the trie to avoid emitting duplicates.

PITFALL Forgetting the isEnd flag

This is the #1 trie bug. If you only check whether a node exists, search('ap') returns true when only "apple" is stored. Always set isEnd = true on the final node during insert, and always check it in search().

PITFALL Using a fixed array(26) for non-lowercase input

A new Array(26) indexed by ch.charCodeAt(0) - 97 explodes with negative indices or out-of-bounds access the moment the input contains uppercase letters, digits, or spaces. Default to a Map unless the problem explicitly guarantees lowercase English letters only.

PITFALL Re-finding duplicate words in Word Search II

The grid DFS can reach the same trie node via multiple paths and emit the same word multiple times. The fix: after recording a found word, set node.isEnd = false (or delete the word from the trie) so subsequent DFS visits skip it. Collecting results into a Set is a band-aid — mutating the trie is the canonical solution.

PITFALL Memory blow-up from dense nodes

Tries can hold a large number of nodes. With a fixed array of 26 per node and many short words with little prefix overlap, memory usage scales with the total characters inserted rather than unique prefixes. Profile constraints before committing to a trie over a hash set for exact-match-only problems.