Skip to content

Trie Patterns

A hash set answers “is this exact word present?” in O(L)O(L). It cannot answer “is any word present that starts like this?” without scanning every key. A trie (prefix tree) stores words character by character along shared paths, so prefix questions cost only the length of the prefix — no matter how many words are stored.

The Tries page covers the data structure itself. This page is about recognising when a problem wants one, and the three shapes those problems take.

  • The nested-dict trie — 6 lines, and why it beats a TrieNode class in an interview.
  • Why an end-of-word marker is mandatory, not decorative.
  • Wildcard search: how one . turns lookup into a DFS with branching.
  • Trie-pruned search: the idea that makes LC 212 tractable, and the single most important thing a trie buys you.
  • Three real LeetCode problems solved in the browser: 208, 648, 211.

You do not need a class. A nested dictionary is faster to write, harder to get wrong, and just as fast in practice.

trie_nested_dict.py
def build_trie(words):
    root = {}
    for word in words:
        node = root
        for ch in word:
            node = node.setdefault(ch, {})   # descend, creating as needed
        node["$"] = True                     # end-of-word marker
    return root
 
 
trie = build_trie(["app", "apple"])
print(trie)
# {'a': {'p': {'p': {'$': True, 'l': {'e': {'$': True}}}}}}

setdefault(ch, {}) is the whole insert: it returns the existing child if there is one, otherwise creates and returns a new empty dict. Searching is the same walk:

trie_search.py
def walk(root, s):
    """Return the node reached by s, or None if the path breaks."""
    node = root
    for ch in s:
        if ch not in node:
            return None
        node = node[ch]
    return node
 
 
def search(root, word):                 # exact word present?
    node = walk(root, word)
    return node is not None and "$" in node
 
 
def starts_with(root, prefix):          # any word with this prefix?
    return walk(root, prefix) is not None

For n words of length up to L, over an alphabet of size |Σ|:

OperationTrieHash set
InsertO(L)O(L)O(L)O(L)
Exact searchO(L)O(L)O(L)O(L) average
Prefix searchO(L)O(L)O(nL)O(n \cdot L) — scan everything
All words with a prefixO(L+matches)O(L + \text{matches})O(nL)O(n \cdot L)
SpaceO(nLΣ)O(n \cdot L \cdot \|\Sigma\|) worst caseO(nL)O(n \cdot L)

Exact lookup is not the reason to use a trie — a set matches it and uses less memory. The wins are the two prefix rows, and one more thing that does not fit in a table:

Characters live on edges, and every shared prefix is stored exactly once — which is the property every problem in this family exploits.

triePrefix sharing, and why the end-of-word flag is separateprefix tree
setupA trie stores characters on **edges**, not in nodes, and shares every common prefix. That sharing is the whole point: "car" and "card" occupy one path, so prefix lookup costs O(length) regardless of how many words are stored.
1/22

Note that 'car' terminates inside the path to 'card'. Without an explicit end-of-word flag a trie cannot tell a stored word from a mere prefix, and 'is car a word' becomes unanswerable.

VariantWhat the trie doesCanonical problem
Basic prefix treeInsert / search / startsWith208 Implement Trie
Shortest matching prefixWalk the word, stop at the first $648 Replace Words
Wildcard searchOn ., DFS into every child211 Design Add and Search Words
Prune a grid DFSCarry a trie node with the search position212 Word Search II
Autocomplete / top-kWalk to the prefix, then collect from the subtree1268 Search Suggestions
Store a value at each wordKeep a payload beside the $ marker677 Map Sum Pairs
Bitwise trieInsert 32-bit numbers as paths; greedily take the opposite bit421 Maximum XOR

LC 208 — Implement Trie (Prefix Tree) · Medium

Section titled “LC 208 — Implement Trie (Prefix Tree) · Medium”

Problem. Implement a Trie class with three methods: insert(word), search(word) (is this exact word stored?), and startsWith(prefix) (is any stored word prefixed by this?).

Constraints. 1 <= len(word) <= 2000, lowercase English letters, up to 3 * 10^4 calls total.

Example. Insert "apple"; search("apple") is True, search("app") is False, startsWith("app") is True; then insert "app" and search("app") becomes True.

Editorial — approach, complexity, follow-ups

A trie is a tree whose edges are labelled with characters, so a path from the root spells a prefix. The nested dict represents it directly: each dict maps a character to its child dict.

Time O(L)O(L) per operation. Space O(total characters inserted)O(\text{total characters inserted}).

search("appl") returning False while startsWith("app") returns True is the pair that proves the marker is doing its job. search("") is False here because no empty word was inserted — had you called insert(""), the root itself would carry "$" and it would be True. Getting that right falls out of the design rather than needing a special case.

Follow-ups you should expect:

  • “Use a class instead of dicts.” A TrieNode with children: dict and is_word: bool is more readable in production and the natural place to hang extra fields. In an interview, dicts are fewer lines and fewer bugs; say you know both and why you picked one.
  • “Implement delete.” The interesting part: remove the marker, then prune upward while a node has no children and no marker. Recursion handles it cleanly. Worth practising — it is the follow-up that separates people who understand the structure from people who memorised insert.
  • “Fixed 26-slot array instead of a dict?” Faster constant factor and predictable layout, but 26×26 \times the memory for sparse tries. Fine for lowercase-only; a dict generalises to any alphabet.
  • “Count words with a given prefix?” Store a counter at each node, incremented on the way down during insert.

Problem. Given a dictionary of root words and a sentence, replace every word in the sentence with the shortest root in the dictionary that is a prefix of it. If no root matches, leave the word unchanged. Return the resulting sentence.

Constraints. 1 <= len(dictionary) <= 1000, 1 <= len(sentence) <= 10^6, lowercase letters and single spaces.

Examples. dictionary = ["cat","bat","rat"], sentence = "the cattle was rattled by the battery" gives "the cat was rat by the bat"

Editorial — approach, complexity, follow-ups

Build one trie from the roots, then walk each sentence word through it. Because a trie walk visits prefixes in increasing length order, the first end-of-word marker you meet is necessarily the shortest matching root — so you return immediately and never compare candidate lengths.

Time O(total dictionary characters+total sentence characters)O(\text{total dictionary characters} + \text{total sentence characters}). Space O(dictionary characters)O(\text{dictionary characters}).

The third test case is the one that matters: ["catt","cat",...] contains both "catt" and "cat", and the answer must use "cat". A solution that walks to the end of the word and keeps the last marker found, or that sorts the dictionary by length and checks prefixes with startswith, gets this wrong or does far more work.

The brute-force alternative — for each word, test every root with word.startswith(root) — is O(words×roots×L)O(\text{words} \times \text{roots} \times L). With 1000 roots and a 10610^6-character sentence that is too slow, which is exactly why the trie is wanted.

Follow-ups you should expect: “Longest matching root instead?” — keep walking and remember the last marker seen. “What if roots can be added dynamically?” — inserting into the trie is O(L)O(L), so nothing changes. “Preserve multiple spaces / punctuation?” — split() collapses whitespace, so switch to a manual scan or re.split and say so.

LC 211 — Design Add and Search Words Data Structure · Medium

Section titled “LC 211 — Design Add and Search Words Data Structure · Medium”

Problem. Implement WordDictionary with addWord(word) and search(word), where the searched word may contain . as a wildcard matching any single character.

Constraints. 1 <= len(word) <= 25, lowercase letters for addWord; search words may contain up to 2 dots (and up to 3 in the harder tests). At most 10^4 calls.

Example. Add "bad", "dad", "mad". Then search("pad") is False, search("bad") is True, search(".ad") is True, search("b..") is True.

Editorial — approach, complexity, follow-ups

addWord is the standard insert. search can no longer be a simple loop, because a . has no single child to descend into — it must try all of them. That makes it a DFS with backtracking over (node, index) pairs.

Time for addWord: O(L)O(L). For search: O(L)O(L) when there are no wildcards; worst case O(ΣdL)O(|\Sigma|^{d} \cdot L) where d is the number of dots, since each dot multiplies the branching. With d <= 3 and |Σ| = 26 that is bounded and fine. Space O(L)O(L) for the recursion.

The length check matters too. search("b.") is False because every stored word has length 3 — reaching the end of the search string must coincide with a stored word ending, which is what i == len(word) plus the marker test enforces. Likewise search("....") is False: four wildcards cannot match a three-letter word. A solution that returns True as soon as it runs out of pattern gets both wrong.

Follow-ups you should expect: “Support * (zero or more characters)?” — much harder; the DFS must also try consuming nothing and staying at the same node, and you need memoisation on (node_id, index) to avoid blow-up. “Many dots?” — group stored words by length first, so a search only explores words of the matching length. “Delete a word?” — clear the marker and prune childless nodes upward.

Generated from the problem database, so each entry carries its sheet membership and reported companies. Tick them off as you go — progress is saved in this browser, and the Export button writes it to a file you can keep.

8 problems
0 easy7 medium1 hard

Work down the ladder. Tick each problem off as you go — progress is saved in this browser, and the Export button in the filter bar writes it to a file you can keep.

  • 208Implement Trie (Prefix Tree)mediumThe base structure; the end-of-word marker is the whole pointNeetCode 150Blind 75LeetCode Top Interview 150googleamazonbloomberg
  • 211Design Add and Search Words Data StructuremediumWildcards turn the walk into a DFS; skip the marker keyNeetCode 150Blind 75LeetCode Top Interview 150
  • 421Maximum XOR of Two Numbers in an ArraymediumA **bitwise** trie -- insert 32-bit paths, then greedily take the opposite bit
  • 648Replace WordsmediumStop at the first marker to get the shortest root free
  • 677Map Sum PairsmediumStore a value per word; sum the whole subtree under a prefix
  • 720Longest Word in DictionarymediumA word qualifies only if **every** prefix is also a word -- check markers all the way down
  • 1268Search Suggestions SystemmediumWalk to each prefix, then collect the 3 lexicographically smallest below it
  • 212Word Search IIhardOne grid DFS carrying a trie node -- prunes against the whole dictionary at onceNeetCode 150Blind 75LeetCode Top Interview 150googleamazonbytedance

LC 212 Word Search II is the problem this pattern exists for. Grid:

text
o a a n
e t a e
i h k r
i f l v

with words = ["oath", "pea", "eat", "rain"].

The naive approach runs a separate DFS per word: 4 words × 16 start cells × up to 4 directions per step. The trie approach walks the grid once, carrying a trie node alongside the position.

Start celltrie node afteroutcome
(0,0) oroot → oo exists (prefix of “oath”) — continue
(0,0)→(1,0) eo has no child eprune immediately, whole branch dead
(0,0)→(0,1) aoacontinue
… → (1,1) toatcontinue
… → (2,1) hoath, is_word setrecord “oath”
(0,1) aroot has no child aactually it does not — prune at depth 1
(1,0) eroot → e (prefix of “eat”)continue
(1,0)→(1,1) tetcontinue
(1,1)→(1,2) aet has no child aprune

The point is the second row. A per-word DFS would have explored that entire subtree four separate times before failing. With a trie, one lookup that returns None kills the branch for every word simultaneously — and that is the difference between passing and a time-limit exceeded.

They askWhat they’re checkingThe answer
“Why a trie and not a hash set?”Whether you know the actual winA set matches exact lookup; a trie wins on prefix queries and on pruning a search against a whole dictionary
“Dict or a node class?”JudgementDicts are shorter and interview-friendly; a class is more readable and avoids the marker-in-items() trap
“Why the end-of-word marker?”Understanding vs. memorisationIt distinguishes “a word ends here” from “a word passes through here” — without it search("app") is wrong after inserting "apple"
“Space complexity?”PrecisionO(nLΣ)O(n \cdot L \cdot \|\Sigma\|) worst case with array children; a dict makes it O(total characters)O(\text{total characters}) in practice
“Implement delete”DepthClear the marker, then prune upward while a node has no children and no marker
“How does a trie help on LC 212?”The key insightOne DFS carries a trie node; when the path leaves the trie, no word can match, so the branch dies immediately
“Compress it?”BreadthA radix tree / Patricia trie merges single-child chains; suffix automata and Aho-Corasick are the next tier for multi-pattern matching
  • Word that is a prefix of another — insert "apple" then check "app"; the reason the marker exists.
  • Insert both "app" and "apple" — both markers must survive on the same path.
  • Empty stringinsert("") marks the root; decide whether that is legal and be consistent.
  • Searching a longer word than anything storedsearch("appl") and search("....") must be False.
  • Searching a shorter patternsearch("b.") against 3-letter words is False; the end-of-pattern check must coincide with a marker.
  • The marker key during wildcard iteration (LC 211) — skip it, or you will recurse into a boolean.
  • Duplicate inserts — idempotent with this design; no special case needed.
  • Characters outside the assumed alphabet — a dict handles them; a 26-slot array raises IndexError.
pch.quizTag Trie patterns — self-check
  1. Why is `is_word` a separate flag rather than just 'this node has no children'?

    pch.quizShowAnswer

    B — Because a word can terminate inside the path to a longer word — 'car' inside 'card' — Leaf-ness and word-ness are different properties. Conflating them makes 'is car a word' unanswerable once 'card' is inserted, and it is the most common trie bug.

  2. What does a trie buy over a hash set of words?

    pch.quizShowAnswer

    B — Prefix queries — 'does any word start with this?' — which a hash set cannot answer without checking every word — For exact lookup a hash set is as fast and simpler. The trie earns its place the moment prefixes matter — autocomplete, or pruning a search — and Word Search II is entirely about that pruning.

  3. In Word Search II, why is one grid DFS carrying a trie node better than one DFS per word?

    pch.quizShowAnswer

    B — A single failed child lookup prunes the branch for every word at once, instead of once per word — This is the whole reason the problem is a trie problem. Per-word DFS re-explores the same dead-end subtrees once per word; the trie collapses all of those failures into one lookup returning None.

  4. After finding a word in LC 212, what is the standard optimisation?

    pch.quizShowAnswer

    B — Remove the word from the trie, so a grid containing it many times does not re-explore it — Unset the flag and prune now-childless nodes. Without it, a grid with the same word in twenty places pays the full search cost twenty times. This is the follow-up interviewers ask.

  5. What is the space complexity of a trie holding n words of average length L?

    pch.quizShowAnswer

    B — O(n · L) worst case, but far less when prefixes are shared — The worst case is no shared prefixes at all, giving one node per character. Real dictionaries share heavily, which is the whole point — but quote the worst case and then note the sharing.

  • Cue — many words plus a prefix question: autocomplete, wildcard search, or a grid/string search that should prune dead branches early.
  • Structurechildren: dict[str, Node] plus an is_word flag. Characters are on edges; nodes are positions in the shared prefix tree.
  • Why not a hash set — a set answers exact membership just as fast. Only a trie answers “does any word start with this”, which is what makes pruning possible.
  • Complexity — insert and search are O(L)O(L), independent of how many words are stored. Space is O(nL)O(n \cdot L) worst case, much less with shared prefixes.
  • Rememberis_word is not the same as “no children”; delete matched words in LC 212; carry the trie node alongside the DFS position rather than re-searching per word.
  • Variants — wildcard . (branch to every child at that step, LC 211); binary trie over bits for maximum-XOR (LC 421); count-per-node for prefix counts.
  • A trie stores words along shared character paths, making prefix queries O(L)O(L) regardless of how many words are stored.
  • node = node.setdefault(ch, {}) plus a "$" marker is a complete trie in six lines — no node class required.
  • The end-of-word marker is what separates search from startsWith. Omitting it is the number-one trie bug.
  • A wildcard turns the walk into a DFS that branches into every child — and you must skip the marker key while iterating.
  • The biggest win is pruning: carrying a trie node through another search (a grid DFS, a recursion) kills branches that no dictionary word can extend, which is what makes LC 212 feasible.
  • “Prefix” need not mean characters — a bitwise trie solves maximum-XOR problems in O(32n)O(32n).
  • If you only need exact lookup, use a set. A trie earns its memory only when prefixes matter.

Next: the Search and Selection patterns — binary search on rotated arrays, quickselect, and heaps.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading