Tries & Prefix Trees
A trie stores a set of strings in a tree keyed by characters, so that insert, search, delete, and prefix-test all run in time — the length of the key, independent of how many keys are stored. Shared prefixes are stored once, which makes tries the natural structure for autocomplete, wildcard dictionaries, board word-search, and — over the alphabet — the maximum-XOR-pair problem.
╌╌╌╌
A balanced search tree gives us lookups by comparing whole keys to
each other. But when the keys are strings, a single comparison is not :
deciding whether "international" precedes "internet" already costs five
character comparisons, so a BST of strings of length really spends
per operation. Worse, the BST throws away an obvious source of
structure, namely that "intern", "internet", and "international" all share a
prefix, and re-examines those shared characters on every descent.
A trie (the middle syllable of retrieval, usually pronounced
try
) exploits that
structure. Instead of comparing keys against each other, it routes each key
one character at a time down a tree whose edges are labeled by characters. A
root-to-node path spells a prefix; following the characters of a key leads to
the unique node that key reaches. Lookup costs , where the work depends only on
the key being searched for, never on , the number of stored keys.1
The structure
The root represents the empty prefix. The bookkeeping is two-level: a node can
exist purely as an interior waypoint (a prefix of some longer key) yet not be a
key itself. In the word set the node
at path t exists but is not a stored word, so its is false; the nodes at
to, tea, and ted have . The flag is what lets a stored
word be a prefix of another stored word: with both in and inn present, the
node at in is simultaneously a terminal (its own flag is true) and an interior
waypoint on the path to inn. Without the flag, the structure could not tell
apart from in is a word
.in is merely a prefix of inn
Each node needs a way to find a child by character. Two standard choices:
- Array children. A fixed array of pointers per node (e.g. 26 for
lowercase letters, or
children[2]for a binary trie). Child lookup is a single index, at the cost of slots per node whether used or not. - Hash-map children. A
char → nodemap per node, so a node stores only the children it actually has. Smaller for sparse, large alphabets (Unicode), with a small constant-factor hashing overhead.
Operations: all
Insertion walks down from the root, creating a child node whenever the needed edge is missing, and sets on the final node. Search walks the same path but never creates; it fails the moment a needed edge is absent.
- 1
- 2for to do
- 3
- 4if then
- 5
- 6
- 7
- 1
- 2for to do
- 3
- 4if then
- 5return false
- 6
- 7return
Each loop runs times and does work per character (one indexed slot or
one hash probe), so insert, search, and the prefix test all run in .
startsWith(p) is identical to except it returns true as soon as
the path for exists, ignoring , because any node on a valid path
witnesses that is a prefix of some stored key.
Correctness rests on one property that every operation preserves:
preserves the invariant because it only ever creates the node its own path requires and flags only its final node; it cannot disturb any other key's path, which is why a trie needs no rebalancing — the shape is determined by the key set alone, not by insertion order.
The blue nodes are the six stored words; the white interior nodes (t, te,
i) are prefixes shared among them and stored exactly once. Looking up ten
visits three edges regardless of whether the trie holds six words or six million.
A build trace: counting created nodes
To see the sharing, build that trie from scratch, one insert at a time. Each insert walks its word and creates a node only where the path runs out:
- Insert
teainto the empty trie. No edges exist, so all three characters miss: create nodes fort,te,tea(3 new nodes); flagtea. - Insert
ted. The walk reusestandte(two existing edges), thendmisses: 1 new node. - Insert
to. Reusest;omisses: 1 new node. - Insert
in. Nothing underiexists: 2 new nodes. - Insert
inn. Reusesiandin; the secondnmisses: 1 new node. - Insert
ten. Reusestandte;nmisses: 1 new node.
The six words contain characters, but the finished trie has
only 9 nodes besides the root, because the 7 characters that ride shared
prefixes cost nothing. The more the key set overlaps, the wider that gap grows;
inserting tent next would cost exactly one node.
Search versus prefix test: the word-inside-a-word case
The distinction between and startsWith comes down to the
flag, and the word set exercises every case:
search("te")walkst,esuccessfully but reads at thetenode: false. The path exists only as scaffolding for longer words.startsWith("te")walks the same two edges and returns true immediately — the node's existence is the witness.search("in")returns true even thoughinhas a child: a terminal node may still be interior.search("int")fails at the third character — theinnode has notedge — and this is the only way search fails: a missing edge, or a false flag at the end. There is no third failure mode.
Collected into one reference Trie, the interface is a direct transcription of
the walks above: insert creates missing links as it descends,
__contains__ and starts_with share a single _node_at walk that differ only
in whether they read , and keys_with_prefix runs the prefix-node walk
then DFSes the subtree. Hash-map children keep each node to the edges it actually
uses.
Deleting a key: prune on the way back
Deletion requires care because of the shared structure. Clearing at the
word's node is always correct (searches for the deleted word now return false),
but it can leave behind a chain of flagless, childless nodes that no surviving
key uses. Removing ten from our running trie by flag alone leaves
the n node dangling under te forever.
To address this, use a recursive delete that prunes on the way back up. Recurse to the end of the word, clear the flag, then, as the recursion unwinds, delete any node that is now both flagless and childless; stop pruning at the first node that still serves a purpose.
- 1if then
- 2reached the word's node
- 3else
- 4
- 5if then return falsewas never stored
- 6if then
- 7unlink the pruned child
- 8return and has no children and
The return value is the pruning decision: a node survives if its flag is set (it terminates another word) or it still has a child (it lies on another word's path). The word set again covers the cases:
- Delete
inn. Clear the flag on the deepnnode; it has no children, so it is pruned and unlinked. Unwinding reaches theinnode, whose flag is still true — pruning stops. One node removed. - Delete
in(from the original set). Clear the flag on theinnode; it still has thenchild leading toinn, so nothing is pruned. Zero nodes removed — the structure is untouched, only the flag flips. - Delete
teafrom alone: all three nodes fail the survival test in turn and the whole chain unwinds away.
Each case costs one descent and one unwind, so delete is
like everything else. An equivalent bookkeeping scheme stores a reference count
in each node — the number of stored words whose path passes through it —
incremented on insert, decremented on delete; a node is pruned when its count
hits zero. Same effect, and it also answers how many words start with ?
in
.
Space and trade-offs
With array children the worst case is pointers: keys, up to nodes each, slots per node. That bound is pessimistic, and tries are far better than it suggests precisely when prefixes are shared: every common prefix collapses to a single path, so a dictionary of English words (densely overlapping) stores far fewer than nodes. Hash-map children replace the factor with the actual child count, trading a constant for the array's indexing.
For example, with 64-bit pointers and , an array node carries bytes of child slots plus the flag — call it 216 bytes — whether it has 26 children or one. Deep in a trie most nodes have exactly one child (long unshared word tails), so almost all of those slots hold nil. A 100{,}000-word dictionary that compresses to roughly 250{,}000 nodes then occupies about MB of node storage, some fifty times the ~1 MB of raw text it encodes. Hash-map children shrink a one-child node to one map entry, but each entry drags its own overhead (hashing, buckets, per-entry headers — tens of bytes), and child lookup gains a constant factor over a direct index. The binary trie sits at the other extreme and is why the XOR trick below is cheap: means just two pointers, 16 bytes per node.
The regimes, then: array children when the alphabet is small and speed matters (26 lowercase letters, 2 bits); hash-map children when the alphabet is large or sparse (Unicode); a radix tree (end of this lesson) when memory dominates and the one-child chains must go.
Applications
Autocomplete and prefix search. To offer completions for what a user has typed, walk to the node for the typed prefix in , then DFS the subtree beneath it to enumerate every stored key with that prefix, since the trie has already grouped them. The enumeration costs where is the size of the emitted subtree — proportional to the answer, not to the dictionary — and if the DFS visits children in alphabet order, the completions come out already sorted.
Wildcard dictionary (the .
problem). Design Add and Search Words asks for a
dictionary where a query may contain . matching any single character. Plain
search no longer follows one path: at a . we must branch into all children
and recurse. Concrete characters keep the search ; each . multiplies the
branching, but the trie still prunes any path that cannot match.
- 1if then
- 2return
- 3if then
- 4for each child of do
- 5if then return true
- 6return false
- 7else
- 8if then return false
- 9return
In the worst case a query of dots over alphabet can visit
paths, so the bound degrades to — but every
branch dies the instant its next concrete character has no edge, and in a real
dictionary almost all of them die immediately. The figure's query t.n fans to
two children at the dot and kills the o branch one character later.
Word search on a board. Word Search II hunts for many dictionary words in a grid simultaneously. Building a trie of all target words lets one DFS over the board carry a trie pointer alongside the grid position: the instant the current board path spells a string that is not a prefix of any target, the missing trie edge prunes the entire branch. One traversal finds all words, and the shared prefixes mean overlapping targets share work.
The binary trie: maximum XOR pair
A non-string application treats a fixed-width integer as a string of bits over . Maximum XOR of Two Numbers asks for . Brute force is ; a binary trie solves it in for -bit numbers.
Insert every number bit-by-bit from the high bit down, so each root-to-leaf path of length is one number. To maximize the XOR of a query against the stored set, walk down from the root and at each bit greedily steer toward the opposite bit of : a differing bit contributes a at that (high) position. If the opposite child exists, take it; otherwise follow the only child available. The path traced spells the stored number that maximizes .
The greedy choice is safe because of the geometric-series gap: winning bit position is worth , while every lower position combined is worth at most
So any candidate that differs from at bit beats every candidate that agrees there, no matter how the lower bits fall — the usual exchange argument collapses to one inequality. The greedy walk never needs to backtrack, and one subtlety makes it total: the trie stores complete -bit paths (leading zeros included), so whenever the preferred child is missing, the other child must exist, and the walk always reaches depth . Building the trie costs ; querying each of the numbers costs ; total versus for brute force. For 32-bit values and that is steps instead of on the order of .
The trie holds . For the query the greedy walk wants the opposite bit at each level: . The high bit of is , so it takes the -child; the remaining two bits of are , so at each step the wanted opposite bit is available and the walk follows it. It lands on the stored number , giving , the maximum.
Multi-pattern and compressed variants
A trie of patterns augmented with failure links, pointers that, on a mismatch, jump to the longest proper suffix of the current match that is also a prefix in the trie, is the Aho–Corasick automaton: it scans a text once and reports every occurrence of every pattern in linear time, the multi-string generalization of KMP (which is single-pattern failure-link matching).2
For storage, a compressed trie (a radix tree or Patricia trie) attacks the one-child chains directly: contract every maximal chain of single-child, unflagged nodes into one edge labeled by the whole substring.3 Every interior node then has at least two children (or is a terminal), which caps the node count at for keys — independent of key length — because a tree with leaves and no unary interior nodes has at most interior nodes. The stored strings shrink to one pointer-plus-length pair per edge.
In exchange, insertion is more intricate: a new key may match an edge label only
partway, forcing an edge split. Take a radix tree holding
: one edge labeled te leaves the root, then
edges a and n branch to the two terminals. Inserting to walks the root
edge and mismatches at its second character (), so
the edge splits at the common prefix t: a new interior node takes over, with
the remainder e of the old label on one side (keeping its a/n subtree
intact) and a fresh o edge on the other. One split per insert suffices, and
the operation stays .
Suffix trees and suffix arrays push the compression idea further, indexing all suffixes of a text for fast substring search — the subject of the next lesson.
Where tries go in the real world
Tries are the standard structure for IP routing. A router must match a destination address against a table of prefixes and forward on the longest matching prefix — exactly a prefix search in a binary trie over the address bits. Naive bit-at-a-time tries are too slow for line-rate forwarding, so production routers use compressed and multi-bit variants: the Patricia trie (Morrison, PATRICIA, JACM 1968) collapses one-child chains just as this lesson's radix tree does, and the LC-trie / multibit-trie families (Nilsson & Karlsson, IP-Address Lookup Using LC-Tries, IEEE JSAC 1999) consume several bits per node to bound the depth. The longest-prefix-match problem is the reason tries, rather than hash tables, sit in the data plane of the internet.
The compression idea also scales to enormous static dictionaries through the DAWG (directed acyclic word graph): merge not only shared prefixes, as a trie does, but also shared suffixes, turning the trie into a minimal deterministic automaton for the word set. This is the classic representation for spell-checkers and Scrabble engines, storing hundreds of thousands of words in a few hundred kilobytes. Pushed to indexing every substring of a text rather than a fixed word list, the same automaton idea becomes the suffix automaton, a cousin of the suffix arrays and Aho–Corasick automaton of the next lesson.
For storing a large set of strings where you only need membership tests and can
tolerate a small false-positive rate, tries compete with succinct
alternatives. A Bloom filter answers have I seen this key?
in constant space
per element with no per-key pointers; a trie answers the same question exactly
and additionally supports prefix and ordered queries, at the cost of the
per-node pointer storage a Bloom filter avoids.
Takeaways
- A trie is a rooted tree whose edges are labeled by characters; a
root-to-node path spells a prefix, and an flag marks nodes that
complete a stored key — the flag is what distinguishes
instored as a word frominexisting only as a prefix ofinn. Children are an array of size or a per-node map. - Insert, search, delete, and
startsWithall run in , the key length, and are independent of , beating a BST's . Delete must prune on the way back: unlink nodes left flagless and childless, stopping at the first node another word still needs. - Space is up to with array children ( bytes of slots per node, used or not), but shared prefixes are stored once, so prefix-heavy sets compress well; tries beat hash sets by giving ordered traversal, prefix queries, and no collisions, while hash sets win pure membership tests on cache behavior.
- Tries power autocomplete (, proportional to the output), wildcard
.matching, and board word-search pruning; over a binary trie solves maximum-XOR pair in by greedily walking toward the opposite bit — safe because , the worth of all lower bits combined. - Aho–Corasick = trie + failure links = multi-pattern KMP; Patricia / radix trees contract single-child chains into substring-labeled edges, splitting an edge when an insert matches its label only partway, which caps the node count at ; suffix trees / arrays index all suffixes of a text.
Footnotes
- Skiena, § — String Data Structures: tries route keys character-by-character, giving search independent of the number of stored strings. ↩
- Erickson, Ch. — Data Structures: tries as a string dictionary; failure links extend a trie into the Aho–Corasick multi-pattern matcher. ↩
- CLRS, Problem 12-2 — Radix trees: the trie over bit strings, sorted output by preorder traversal, and the compressed form. ↩
╌╌ END ╌╌