Data structures & algorithms · Strings
Tries: the prefix tree
CAT, CAR and CARD are ten characters, and one trie stores them in five nodes below a single shared root. Build it here a character at a time and see exactly where the sharing stops and the branch begins.
Build the trie one character at a time →01 The idea
One node per distinct prefix
A search box suggests words before you finish typing them. It cannot afford to look at every word in its dictionary on every keystroke. It needs a structure where the letters you have typed so far are not a string to compare against, but a place you can stand on.
A trie is that structure. Start at an empty root. To store a word, walk down one character at a time, following the child for that character if it exists and creating that child if it does not. The word CAT becomes a path of three nodes, and each node you land on along the way spells one prefix of it: C, then CA, then CAT. The name is short for retrieval, and most people say it like "try".
02 Hand trace
CAT, CAR and CARD into an empty trie
Insert CAT, CAR, CARD in that order. Each box is one node, labelled with the character on the edge that reaches it. A rose box is a node that had to be created; a plain box is a node that was already there and got walked onto for free. Watch when the plain boxes start appearing.
Count what happened. Ten characters went in and five nodes came out — six objects in memory if you count the root, which spells the empty prefix and was there before any word arrived — because CAR reused C and CA, and CARD reused C, CA and CAR. That saving is not a trick you apply afterwards; it is the definition of the structure. A node is a prefix, and those three words have only five distinct prefixes between them: C, CA, CAR, CARD, CAT. The node for CA is where the trie branches — it gained a second child the moment R arrived, and everything above it stays shared by all three words. The last row also tells you what a trie cannot do: nowhere is the string "CAR" stored. A word exists only as a path.
03 Code
Insert, and the one line that separates search from startsWith
The trace is the algorithm. Insert is a loop over the characters with a single decision inside it, and both query methods are that same loop with the create step deleted.
insert(word): node = root for ch in word: if ch not in node.children: node.children[ch] = new Node() node = node.children[ch] node.isEnd = true
Line 4 is the entire space argument. The check runs once per character and only succeeds for characters that are genuinely new. Inserting CAR after CAT, it fails for C and for A, so line 5 never runs and no memory is allocated for them. That is how ten characters became five nodes below the root.
Line 7 is not optional. Marking the last node is what makes CAR a stored word rather than a path you happen to be able to walk. Leave it out and the trie still has exactly the right shape and answers every question wrong.
Naive · scan every word
words = ["CAT", "CAR", "CARD"] startsWith(p): for w in words: # all N, every call if w[0 : len(p)] == p: return true return false # O(N x L)
Trie · walk the prefix once
walk(s): # shared by both queries node = root for ch in s: node = node.children.get(ch) if node == null: return null return node search(w): n = walk(w); return n != null and n.isEndstartsWith(p): return walk(p) != null
A hash set has nothing to walk. It stores each word as one opaque key, so there is no way to ask it about CA except by testing every key it holds — O(N × L) per call, where N is the size of your dictionary. The trie turns the same question into O(L), and here L is 2. You pay for that in space rather than time: the trie holds one node per distinct prefix, and every one of those nodes carries its own child map, up to Σ slots each, where Σ is the alphabet size — 26 here, and much larger the moment the keys are Unicode.
search and startsWith differ by one clause. Both call the same walk. startsWith is happy the moment the walk survives: the node exists, so something in the trie starts with that string. search goes one step further and reads isEnd on that node. On this trie walk("CA") returns a real node, so startsWith("CA") is true — but that node's isEnd is false, because nobody inserted CA, so search("CA") is false.
Counting is the same walk plus a number. Keep a counter on every node and increment it on each node you step onto during an insert. Then countWordsWithPrefix("CA") is one two-step walk and one read: 3, because CAT, CAR and CARD all passed through. The counter at CAR reads 2.
05 Cheat sheet
The numbers you will be asked for
L is the length of the string you passed in. N is the number of words already stored. Notice which one shows up in the trie rows and which one does not.
| Operation | Cost | Why |
|---|---|---|
| insert(word) | O(L) | A constant number of map lookups per character — one to test, one to step; a new node only where the path is new. |
| search(word) | O(L) | The same walk, then one read of isEnd at the last node. |
| startsWith(prefix) | O(L) | The same walk again; isEnd is never read. |
| countWordsWithPrefix(prefix) | O(L) | Walk to the node, read a counter maintained during insert — the one extra line the insert above does not have yet. |
| List every word under a prefix | O(L + output) | Walk to the node, then DFS its subtree; output is the total length of the answers. |
| Any prefix question on a hash set of N words | O(N × L) | Keys are opaque blobs, so every single one has to be tested. |
| Space | O(Σ × total characters) | At most 1 + sum of word lengths nodes, and each node carries a child map. A fixed array costs Σ slots per node (Σ = alphabet size, 26 for a–z); a hash map per node drops it to O(total characters) and adds a hash to every step. Shared prefixes are the discount on the node count. |
06 Where & why
When a trie beats a hash set
A trie is not a faster hash set. Exact lookup is O(L) either way, and the trie spends more memory getting there. What you buy is that a prefix stops being a string you compare against and becomes a node you can stand on.
Reach for it when…
Use something else when…
| Situation | Better choice | Why |
|---|---|---|
| You only ever ask "is this exact key stored?" | Hash set | Both are O(L), but the set holds one entry per word instead of one node per distinct prefix, each carrying a child map of up to Σ slots. |
| The keys share almost nothing — UUIDs, hashes, random tokens | Hash set | Nearly every character forks a new node, so you pay the full space cost and get none of the sharing back. |
| Memory is tight, or the alphabet is Unicode-wide | Radix tree, or a ternary search tree | A radix tree collapses every single-child chain into one edge; a ternary search tree replaces the per-node map with three pointers. |
| You need numeric ordering or range queries | Balanced BST or a sorted array | A trie orders keys as text, so "10" sorts before "9" and a range scan gives you nonsense. |
07 Interview questions
Say these out loud
The CA question — startsWith true, search false — is the one that separates people who have read about tries from people who have built one.
What is a trie?
What does a single trie node actually store?
Why do you need an end-of-word flag? Is a leaf not enough?
What are the time complexities of insert, search and startsWith?
A hash set already gives O(L) lookup. Why build a trie?
What is the difference between search and startsWith?
How much memory does a trie use?
How would you count how many words start with a given prefix?
How do you delete a word from a trie?
How would you build an autocomplete on top of this?
What is a bit trie, and how does it find the maximum XOR of a pair?
What is a compressed trie or radix tree?
08 Practice problems
Six problems, one structure
Five of these are the same walk from the root, and what changes is the question you ask at the node you land on. The sixth changes which strings you hand to insert in the first place.