Trie Patterns
A hash set answers “is this exact word present?” in . 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.
What you’ll learn
Section titled “What you’ll learn”- The nested-
dicttrie — 6 lines, and why it beats aTrieNodeclass 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.
The cue
Section titled “The cue”The template — a trie in six lines
Section titled “The template — a trie in six lines”You do not need a class. A nested dictionary is faster to write, harder to get wrong, and just as fast in practice.
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:
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 NoneComplexity, and what a trie actually buys
Section titled “Complexity, and what a trie actually buys”For n words of length up to L, over an alphabet of size |Σ|:
| Operation | Trie | Hash set |
|---|---|---|
| Insert | ||
| Exact search | average | |
| Prefix search | — scan everything | |
| All words with a prefix | ||
| Space | worst case |
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:
Visual intuition
Section titled “Visual intuition”Characters live on edges, and every shared prefix is stored exactly once — which is the property every problem in this family exploits.
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.
The variant map
Section titled “The variant map”| Variant | What the trie does | Canonical problem |
|---|---|---|
| Basic prefix tree | Insert / search / startsWith | 208 Implement Trie |
| Shortest matching prefix | Walk the word, stop at the first $ | 648 Replace Words |
| Wildcard search | On ., DFS into every child | 211 Design Add and Search Words |
| Prune a grid DFS | Carry a trie node with the search position | 212 Word Search II |
| Autocomplete / top-k | Walk to the prefix, then collect from the subtree | 1268 Search Suggestions |
| Store a value at each word | Keep a payload beside the $ marker | 677 Map Sum Pairs |
| Bitwise trie | Insert 32-bit numbers as paths; greedily take the opposite bit | 421 Maximum XOR |
Practice — real LeetCode problems
Section titled “Practice — real LeetCode problems”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 per operation. Space .
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
TrieNodewithchildren: dictandis_word: boolis 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 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.
LC 648 — Replace Words · Medium
Section titled “LC 648 — Replace Words · Medium”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 . Space .
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 .
With 1000 roots and a -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 , 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: . For search: when there are no
wildcards; worst case where d is the number of
dots, since each dot multiplies the branching. With d <= 3 and
|Σ| = 26 that is bounded and fine. Space 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.
LeetCode problem set
Section titled “LeetCode problem set”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.
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 point
- 211Design Add and Search Words Data StructuremediumWildcards turn the walk into a DFS; skip the marker key
- 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 once
Dry run
Section titled “Dry run”LC 212 Word Search II is the problem this pattern exists for. Grid:
o a a n
e t a e
i h k r
i f l vwith 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 cell | trie node after | outcome |
|---|---|---|
(0,0) o | root → o | o exists (prefix of “oath”) — continue |
(0,0)→(1,0) e | o has no child e | prune immediately, whole branch dead |
(0,0)→(0,1) a | o → a | continue |
… → (1,1) t | oa → t | continue |
… → (2,1) h | oat → h, is_word set | record “oath” |
(0,1) a | root has no child a… | actually it does not — prune at depth 1 |
(1,0) e | root → e (prefix of “eat”) | continue |
(1,0)→(1,1) t | e → t | continue |
(1,1)→(1,2) a | et has no child a | prune |
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.
Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
| “Why a trie and not a hash set?” | Whether you know the actual win | A set matches exact lookup; a trie wins on prefix queries and on pruning a search against a whole dictionary |
| “Dict or a node class?” | Judgement | Dicts 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. memorisation | It distinguishes “a word ends here” from “a word passes through here” — without it search("app") is wrong after inserting "apple" |
| “Space complexity?” | Precision | worst case with array children; a dict makes it in practice |
| “Implement delete” | Depth | Clear the marker, then prune upward while a node has no children and no marker |
| “How does a trie help on LC 212?” | The key insight | One DFS carries a trie node; when the path leaves the trie, no word can match, so the branch dies immediately |
| “Compress it?” | Breadth | A radix tree / Patricia trie merges single-child chains; suffix automata and Aho-Corasick are the next tier for multi-pattern matching |
Edge-case checklist
Section titled “Edge-case checklist”- 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 string —
insert("")marks the root; decide whether that is legal and be consistent. - Searching a longer word than anything stored —
search("appl")andsearch("....")must beFalse. - Searching a shorter pattern —
search("b.")against 3-letter words isFalse; 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.
Self-check
Section titled “Self-check”-
Why is `is_word` a separate flag rather than just 'this node has no children'?
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.
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.
-
What does a trie buy over a hash set of words?
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.
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.
-
In Word Search II, why is one grid DFS carrying a trie node better than one DFS 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.
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.
-
After finding a word in LC 212, what is the standard optimisation?
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.
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.
-
What is the space complexity of a trie holding n words of average length L?
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.
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.
Recall card
Section titled “Recall card”- Cue — many words plus a prefix question: autocomplete, wildcard search, or a grid/string search that should prune dead branches early.
- Structure —
children: dict[str, Node]plus anis_wordflag. 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 , independent of how many words are stored. Space is worst case, much less with shared prefixes.
- Remember —
is_wordis 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 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
searchfromstartsWith. 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 .
- 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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading