59The trie — a tree indexed by letters
In Chapter 58 we packed a whole tree into a flat array and let arithmetic stand in for the pointers. Here we go the other way, back to scattered nodes, but with a twist that sounds almost like a riddle. A trie stores each word not in a node but in the path to it. A word isn't kept in a box. It's spelled by the walk from the root, one letter per edge. Any two words that begin the same way share the very same first steps. Here's the plan. We'll build the prefix tree from its scattered heap nodes up, then weigh what a single node really costs. After that we'll watch its lookup stay dead flat while the dictionary grows a thousandfold. And the whole way through we keep asking the one thing that matters: if a hash map already finds any key in one shot, why would anyone walk a word letter by letter at all? By the end you'll read a word as a route. You'll know exactly why finding cat takes three steps whether the trie holds ten words or ten million. And you'll be able to do the one thing a hash map simply cannot: ask for every key that starts with cu.
01A word is a path, not a payload
Let's start with the one idea everything else hangs on, and name each piece the moment it lands. A trie is a tree whose edges are labelled with symbols, and here those symbols are letters. The name comes from retrieval, though most people say it "try" to keep it distinct from "tree." Now watch where the letter goes, because it's the whole trick. A node holds two things, and remarkably not the letter. It holds a children map that sends a next-symbol to the child it leads to. And it holds a boolean end-of-word flag that says "a stored word finishes exactly here." The letter lives on the link, not in the node. So a key is nothing but the sequence of edge-labels you cross from the root down to an end-flagged node. The root spells the empty string. Walk c, then a, then t, and land on a node whose flag is true. Now you have read the word cat straight off the edges.
One honest note before we go further. The end-of-word flag as described gives you a set. It answers "is this word stored?" and nothing more. To get a real map, the kind where cat maps to 42, you make one small change. Instead of a boolean flag, the end node carries a value slot, empty for "not a word" and filled for "a word ends here, and here's its payload." Everything else in the chapter is identical. We'll keep using the boolean flag because it keeps the pictures clean. But keep in mind the flag is just the simplest possible value: present or not.
The magic is what happens when two words agree at the start. cat and car share c-a, so they share those two nodes and only split at the third step. This is the defining move of the structure: a shared prefix is a shared path. Store a thousand words beginning auto… and the a-u-t-o spine exists exactly once, with a thousand routes fanning out past it.
t, and the little bar above the keys offers to, the, that. You add an h, and it changes before your thumb has landed: the, that, they. You have done this ten thousand times without ever wondering how a phone searches a hundred thousand words between two keystrokes. It doesn't. It never searched anything. Typing t walked it one step down a tree of letters, and h walked it one more — and everything hanging below where it now stands is every word you could still be typing. The suggestions were never found. They were already there, and you told the phone where to stand.t-h spine is paid for one time, and every word past it hangs off that same nodeq link is missing, so the walk stops at letter three — a miss can be cheaper than a hitis_endSo how does a word get in? Building the trie is the same walk, with one difference. When a link you need is missing, you create it instead of stopping. Insert cat into an empty trie and you carve three new nodes, one per letter, then set the end-flag on the last. Now insert car. You walk c, then a, and both links already exist, so you reuse them for free. Only at the third letter do the paths part. The t was there, the r is new, so you add a single node and flag it. Two words, and yet the c-a spine was paid for exactly once, four nodes in total. Step the build above and watch a shared node light up only when a new word truly needs a new letter.
Now the memory picture, this volume's obsession. Nowhere in RAM is there a tidy lattice of letters — on the heap a trie is a scatter of tiny node objects, each one a record of just two fields. The first is a reference to its children map, itself a hash table, the very same one we built back in the hash-map chapter. The second is a one-byte-ish flag, and that is the entire node. The root is nothing more than a name holding a single 8-byte reference (Volume 1), and every edge is a key in some node's children dict that points at another scattered node. Follow those keys down from the root and you are the trie. Notice that the shape exists only as pointers, exactly as it did for the binary tree. Only now each node branches by label, not by fixed left/right.
c in it, the heap figure a little further down draws a box labelled node "ca", and every trie picture you will ever meet does the same. So the model that quietly forms is the one the pictures suggest — a node is a small record holding its letter plus some children, and looking up cat means landing on a node, reading the character stored there, and comparing it against the next character of the key until a child matches. Nothing propping that up is silly. It is how you would draw it on paper, it is exactly how a linked list of characters would work, and the pictures are not lying to you — the circle is simply the only place with room to print the letter. So the belief is that each node stores the letter that leads to it. Two probes settle it, and they have to be two, because the belief was stated about a record with fields and the quickest trie to type has no fields at all. First build the smallest trie that can tell: two words with different first letters and identical tails, cat and kat. If a node held its letter, the node under c and the node under k would have to differ — watch whether they do, and whether the trie bothered to merge those identical tails. Then put the question to the record version, the TrieNode this chapter builds and weighs, by asking it to list every field it owns.trie = {}
for w in ("cat", "kat"): # same tail, different first letter
node = trie
for ch in w:
node = node.setdefault(ch, {})
print(trie)
print(trie["c"] == trie["k"], trie["c"] is trie["k"]) # same contents? same object?
class TrieNode: # the chapter's node, every field of it
__slots__ = ("children", "is_end")
print(TrieNode.__slots__, list(trie)) # all a node holds; where letters actually live{'c': {'a': {'t': {}}}, 'k': {'a': {'t': {}}}}
True False
('children', 'is_end') ['c', 'k']TrieNode has room for exactly two things and neither of them is a character, and why the only place a letter is ever written down is the level above the node it names.To make that concrete, look at what a single children dict actually holds. In the trie for {cat, car, card, care}, the shared node after c-a has a children dict of exactly {'t': …, 'r': …} — two entries, each a single-character key pointing at the next scattered node. The node after car holds {'d': …, 'e': …}, splitting toward card and care. So the keys of every children dict are single characters, never whole words. And the letters you read while walking are those dict keys, not anything stored inside the nodes. The node itself is almost empty. The relationships between nodes are the whole structure.
So how heavy is one node? The honest answer comes the only way we trust in this book. Measure, don't guess. We give the class __slots__ (Volume 1) so its two fields pack straight into the object rather than sprawling into a per-instance dict.
import sys
class TrieNode:
__slots__ = ('children', 'is_end') # pack the 2 fields INTO the object
def __init__(self):
self.children = {} # a dict: letter -> child TrieNode
self.is_end = False # does a stored word end here?
n = TrieNode()
print(sys.getsizeof(n)) # 48 → node record itself
print(sys.getsizeof(n.children)) # 64 → its (empty) children dictLine 4 is the shape of the whole structure: a node is a map plus a flag. Ran on CPython 3.12.7, sys.getsizeof(n) returns 48 bytes for the node record itself. The empty children dict it points at is another 64, so a bare node costs about 112 bytes before it holds a single child. That children dict is the trie's whole personality, because it is how one node branches into many. Hold that number in your head, because the next two sections are a fight over it.
Let's put a real number on that. Take our little trie for {cat, car, card, care} and count the nodes it needs: a root, then c, then the shared ca, then one node each for the tails t, r, d, and e. That is 7 nodes in all, and every one carries its own children dict, so the skeleton alone costs about 7 × 112 = 784 bytes. Now count the actual data those four words hold: cat, car, card, care is just 14 characters. Fourteen bytes of letters, wrapped in the better part of a kilobyte of structure. That gap between the data and the scaffolding is the whole story of this chapter.
Start with the thing it does faster in a way you can feel: its lookup cost doesn't depend on how much you've stored. Let's derive that from the layout. →
02Why lookup costs the word's length, not the dictionary's size
Reading a word out of a trie is a walk, and the walk practically writes itself. You stand on the root, and for each character of the key you follow the child link labelled with that character. If the link you need is missing, then the word isn't stored — stop right there. And if you run out of characters, you check the end-flag on the node you're standing on. That is the whole algorithm.
def insert(root, word):
cur = root
for ch in word: # one step per character
if ch not in cur.children:
cur.children[ch] = TrieNode() # carve a new path node when needed
cur = cur.children[ch] # descend the link labelled ch
cur.is_end = True # plant the flag where the word ends
def contains(root, word):
cur = root
for ch in word:
cur = cur.children.get(ch) # follow the link, or None if absent
if cur is None:
return False # fell off the tree → not stored
return cur.is_end # arrived → is a WORD only if flaggedRead contains as the cost model. The loop runs once per character of the key, and each pass does exactly one dict lookup and one pointer hop. So a key of length L costs L steps — the operation is O(L). Look at what's not in that count: the number of words already stored, N, appears nowhere. Whether the trie holds ten words or ten million, cat is three links from the root and no more. The last line earns its keep: arriving at a node isn't enough — ca is a real node on the way to cat, but it's a word only if its flag is set. The end-flag is what separates a prefix from a word.
Let's walk cat through that loop once, by hand, so the count stops being abstract. Step one: we stand on the root and ask its children dict for c, find it, and hop to the c node. Step two: we ask that node for a, find it, and hop again to ca. Step three: we ask for t, find it, and land on the cat node. The key is now spent, so we read that node's end-flag, see it set, and answer yes. Three characters, three dict probes, three hops — L equals 3, and the size of the trie never once entered the arithmetic.
Notice a bonus the cost model hands you for free: a miss can be cheaper than a hit. Look up dog in our {cat, car, card, care} trie. Step one asks whether the root's children dict holds a d. It doesn't. The link is missing, so we stop after a single character, one dict probe, and we already know dog isn't stored. We never touched the other two letters. A hash map can't quit early like this. It must grind all three characters of dog into a bucket before it can even look. The trie fails fast the instant the path runs out.
To watch that claim survive contact with a real machine, I built three tries of 1,000, then 100,000, then 1,000,000 random 8-letter words. At each size I timed 200,000 lookups of the same fixed word cat. The exact nanoseconds are machine-dependent, so treat them loosely, because the shape of the numbers is the real point:
N= 1000: nodes= 6538 lookup('cat') ~ 163 ns
N= 100000: nodes= 507662 lookup('cat') ~ 160 ns
N= 1000000: nodes= 4381570 lookup('cat') ~ 161 ns # 1000x the words, same timeThe trie grew by a factor of a thousand, from 6,538 nodes to 4.4 million, and the lookup didn't move: ~161 ns, three hops, flat. That flatness is O(L) with no hidden N. Now the honest comparison the scope demands. A hash map, the one we dissected in the hash-map chapter, is also O(L) in the key. Its hash function must read every one of the L characters to compute a bucket, and a match re-checks all L on the hit. So both structures touch all L characters. The "trie is O(L), dict is O(1)" line you'll hear is a half-truth. The real difference is how they touch them. The trie consumes the key one character at a time along a path, so it can quit at the first missing link. And crucially, every prefix it passes through is a real, addressable place. The hash map grinds the whole key into a single number and jumps to one bucket. So it has no idea that car lies on the road to card.
cat is three hops no matter how many other words are stored — measured flat at ~161 ns across N = 1K, 100K, and 1M. The rest of the trie is dead weight the lookup never touches.ca in the widget: you land on a genuine node — but it's a rest stop on the way to cat, car, card, care, not a stored word. The only thing that makes car a word and ca not is the boolean flag. Drop it and a trie can tell you a prefix exists but never whether an exact key was inserted — a favourite off-by-one bug.That flat, N-independent cost sounds like a free lunch. It isn't — you paid for it up front, in bytes. Time to open the children map and count. →
03The space bill: prefixes shared, alphabets paid
Here is where most people's intuition about tries is upside down. "It shares prefixes, so it must save memory," the story goes. Let's measure it instead of believing it. I stored 1,600 words that overlap heavily (roots like auto, inter, under crossed with common stems and suffixes) and compared a dict-node trie against a plain Python set of the same strings.
1600 words · 15,600 characters if written out end to end
trie nodes ............ 3,523 # prefix sharing folded 12,078 char-slots away
trie total bytes ...... 676,224 # ≈ 423 bytes / word
set-of-str bytes ...... 212,488 # ≈ 133 bytes / word
Both halves of that result are true, and both matter. Prefix sharing is real: 15,600 characters collapsed into just 3,523 nodes, so twelve thousand repeated character-positions simply vanished onto shared paths. And yet the trie still weighs three times as much as the set. Why? Because every one of those 3,523 nodes drags a children dict behind it, that 64-plus-byte map from the last section, plus 48 more for the node object. You saved on character-slots and then paid it all back, with interest, in per-node overhead. The sharing bought you fewer boxes, but each box that remains is expensive.
Push those two numbers together and the tax gets a size. Those 3,523 nodes at roughly 112 bytes each come to about 3,523 × 112 ≈ 385 KB of structure for 1,600 stored words. Divide it out and each word is hauling around about 245 bytes of trie, even though the word itself is only a handful of bytes of actual text. The per-node overhead, not the letters, is what you are really paying for.
So if a trie costs three times the memory of a set and touches the same L characters on a lookup, a fair question is: why does anyone use one? Here's the honest answer, and it reframes the whole chapter. You don't reach for a trie to save space or to beat a hash map at exact lookup. On those two scores the hash map wins. You reach for it for the one question the hash map physically cannot answer, every key beneath a prefix, and you pay the per-node overhead as the price of keeping that structure alive. The next section is that payoff, and it's the reason the trie exists at all.
And it gets worse in the textbook version. The classic trie doesn't hang a dict off every node; instead it uses a fixed array with one slot per possible symbol, which is 26 for lowercase letters or 256 for bytes. That array is the same size whether the node has one child or twenty-five.
None. This is why the classic array-node trie detonates on a big symbol set.None just to keep one child (measured: 312 B total). A dict node stores only what exists — trading the wasted slots for hash-table overhead. Either way, the branching apparatus, not the data, dominates.set of the same words. Prefix sharing genuinely removes repeated characters, but it replaces each of them with a node that carries an entire branching structure — an array of alphabet slots, or a hash table. You don't reach for a trie to save space. You reach for it because it makes prefixes into places — and that's a power a set or a hash map can't sell you at any price. Weigh it as a speed-and-capability trade, never as a memory win.None. This is the classic way a "just use a trie" decision blows a memory budget; it's why serious tries either use dict/hash children, or compress single-child chains away (next section), or move to a double-array or succinct representation.So the trie is heavier and its lookup is no faster in Big-O than a hash. If that were the whole story, no one would build one. But there's a query it answers that a hash map cannot even attempt — and it's the reason you meet tries every single day. →
04The superpower: every word beneath a point
Ask a hash map "give me every key that starts with cu." It can't. A hash function deliberately scatters keys, so cup, cur, and cute land in unrelated buckets with nothing between them. That is the point: the whole design goal is to destroy any relationship between similar keys. To answer the prefix question it would have to scan all N keys and test each one: O(N).
Make that O(N) concrete for a second, because the word “scan” hides how brutal it is. Suppose the hash map holds a million keys and you want the ones that start with cu. There is no shortcut here: you must pull out all 1,000,000 keys, test each one's first two characters, and throw away the millions that don't match. The map spread the keys on purpose, so being neighbours in the alphabet buys you nothing. The work scales with everything you stored, not with the handful you actually want.
A trie answers it structurally, because it never destroyed the relationship in the first place. Every word starting with cu lives in the subtree hanging below the cu node. So the query is two moves: walk to the prefix node (O(length of the prefix)), then collect every end-flag in the subtree below it. You visit only the words that match — nothing else in the trie is touched.
def starts_with(root, prefix):
cur = root
for ch in prefix: # 1) walk to the prefix node — O(len prefix)
cur = cur.children.get(ch)
if cur is None:
return [] # nothing in the trie starts this way
out = []
def collect(node, path): # 2) gather every end-flag below it
if node.is_end:
out.append(path)
for ch, child in node.children.items():
collect(child, path + ch)
collect(cur, prefix)
return outThe first loop is the ordinary descent. It reaches the node that spells the prefix, or bails if the path dies. The nested collect is a plain tree walk, the same descent we wrote for the binary tree, rooted at that node. Whenever it meets an end-flag it records the word built up so far, and it only ever descends links that exist. Run on our small trie, starts_with(root, "ca") returned ['cat', 'car', 'card', 'care'] and starts_with(root, "do") returned ['do', 'dog']. Those are exactly the words on those paths, gathered without looking at a single unrelated key. On the 1,600-word trie, starts_with(root, "auto") found all 160 matches by touching only the auto subtree.
cup, cur, cute into unrelated buckets — a prefix query must scan everything. The trie keeps them under one cu node, so the query is a subtree walk that touches only the matches.Myth
"A hash map does everything a trie does, and it's lighter — so a trie is just a curiosity."Reality
A hash map cannot answer "all keys starting with X" without scanning everything, because hashing is built to destroy prefix relationships. The trie keeps them as structure, turning autocomplete, longest-prefix routing, and ordered range-by-prefix into a cheap subtree walk. Different question, different tool.You've now seen the trie's soul: a prefix is a place. But its Big-O advantage hides a cost the machine charges at runtime that the exponent never shows — the same one every pointer structure pays. →
05The pointer tax, one hop per letter
Big-O counts steps, but the machine bills by the cache line (Volume 3's memory hierarchy). And a trie is one of the most pointer-heavy structures in this book. Every single character of a lookup is a dict probe followed by a jump to a fresh, unrelated heap address, the scattered 0x7f… nodes from the first diagram. The CPU's prefetcher makes a contiguous array fly by guessing the next address, but here it is blind. It cannot predict where the a-link of this node lands. So each letter risks a cache miss and a stall on RAM. Reading a 10-letter word can mean ten dependent misses in a row, each jump's address only known after the previous load returns. The O(L) is honest, but the constant hidden inside it is large and set entirely by that scatter.
This is why serious trie implementations rarely look like the teaching version. The first fix is path compression. If a run of nodes each has exactly one child, they carry no branching decision. So merge them into one edge labelled with the whole substring. That's a radix tree (or Patricia trie), the same structure the Linux kernel uses for IP routing and that databases use for index keys. The second fix is layout. Pack the nodes into one contiguous array, a double-array trie, so that following a link is arithmetic into a block the prefetcher loves, not a jump to the unknown. Both attack the constant, not the O, because on this structure the constant is where the pain lives.
Here's how much that first fix buys you. Insert the single word automobile into a plain trie and you get a 10-node chain, one node per letter, each dragging its own 112-byte children dict for a lone child it never shares. A radix tree looks at that chain, sees no branching decision anywhere along it, and collapses the whole run into one edge labelled automobile. Ten nodes become one, and nine dependent pointer-hops become zero. The letters that never fork were never worth a node.
That IP-routing mention deserves a concrete look, because it's the trie's other superpower: longest-prefix match. Say a router knows two routes, 10.0.0.0/8 ("anything starting 10") and 10.1.0.0/16 ("anything starting 10.1"), and a packet arrives for 10.1.9.4. The router walks the address down the trie and passes both end-flags on the way. The rule is simple: the deepest flag it reaches wins, because the deeper node matched more of the address. So 10.1.9.4 takes the /16 route, not the /8. No hash map can do this, since it would need an exact key. But the trie answers it in one descent, because every route that could match sits on the single path the address spells out.
And notice why that has to work, because it isn't luck. Every route that could possibly match an address is, by definition, a prefix of that address — 10 and 10.1 are both prefixes of 10.1.9.4. And every prefix of the address lies on the one downward path the address spells out, in strict order of length. So a single descent is guaranteed to pass every candidate route, shortest first, and the last flag it meets is the longest match. The structure can't miss a route, because a matching route has nowhere else to live.
setdefault carves the path; one reserved key says “a word ends here”ch, creating an empty dict there first if it is missing. It is chapter 54's read-or-create doing the exact job the chapter's if ch not in cur.children does — one hash-and-jump instead of three.'$' is the convention. Use a value rather than True and the set becomes a map — the honest upgrade this chapter names in its second paragraph.None, not an exception: a missing link is the normal answer, not a bug. That is the same “is absence expected?” choice as chapter 54's .get, and here absence is the entire point.if ch != '$' guard, or your autocomplete will try to descend into True.Trie = lambda: defaultdict(Trie) is a dict whose factory is itself, so node[ch] conjures the child. Charming, one line, and it grows when you merely look — the last tripwire measures exactly that.INPUTfrom collections import defaultdict
END = "$" # "a word ends here" - a key, not a flag
def add(trie, word):
node = trie
for ch in word:
node = node.setdefault(ch, {}) # read-or-create, in ONE lookup
node[END] = True
def walk(trie, prefix):
node = trie
for ch in prefix:
node = node.get(ch)
if node is None:
return None # the road ran out - fail fast
return node
def completions(node, prefix=""):
if node is None: return []
out = [prefix] if END in node else []
for ch, kid in node.items():
if ch != END:
out += completions(kid, prefix + ch)
return out
trie = {}
for w in ["the", "this", "that", "there", "then", "top"]:
add(trie, w)
print("the whole structure:")
print(" ", trie)
print("\nchildren of the 'th' node:", list(walk(trie, "th")))
print("completions('th') :", completions(walk(trie, "th"), "th"))
print("completions('the'):", completions(walk(trie, "the"), "the"))
print("completions('z') :", completions(walk(trie, "z"), "z"))
print("'the' a word? ", END in walk(trie, "the"))
print("'th' a word? ", END in walk(trie, "th"), " <- a real node, not a word")
Trie = lambda: defaultdict(Trie) # the one-liner: a dict that conjures dicts
t2 = Trie()
for w in ["the", "this", "that"]:
node = t2
for ch in w: node = node[ch]
node[END] = True
print("\ndefaultdict trie :", completions(t2, ""))OUTPUTthe whole structure:
{'t': {'h': {'e': {'$': True, 'r': {'e': {'$': True}}, 'n': {'$': True}}, 'i': {'s': {'$': True}}, 'a': {'t': {'$': True}}}, 'o': {'p': {'$': True}}}}
children of the 'th' node: ['e', 'i', 'a']
completions('th') : ['the', 'there', 'then', 'this', 'that']
completions('the'): ['the', 'there', 'then']
completions('z') : []
'the' a word? True
'th' a word? False <- a real node, not a word
defaultdict trie : ['the', 'this', 'that']- N words is nowhere near N dicts. Counted, not guessed: the six words above (23 letters) needed 13 dicts. Scaled up, 100,000 random eight-letter words needed 507,644 dicts — 5.1 per word, one per surviving character position. Prefix sharing is real and it is not free: 250 heavily overlapping words (2,475 letters, roots like
auto/inter/under) folded down to 572 dicts. Every one of those dicts is the ~64-byte branching apparatus this chapter's space section weighs. The letters were never the bill. - Drop the end marker and every prefix answers yes. On a trie holding only
{there, top}, a flaglesscontainsreturnedTruefor'there'— and also for'the','th'and't', none of which were ever stored. With'$'in place the same four probes returnedTrue, False, False, False. Arriving at a node proves you walked a road somebody built; only the marker proves the road ended there. - The
defaultdicttrie writes when you only read. Starting from a trie holding justcat, top-level keys were['c']; after the single lookupdd['z']['z']['z']they were['c', 'z'], and'z' in ddansweredTrue— a query had written three nodes. The plain-dict version's.get('z')returnedNoneand changed nothing. Same trap as chapter 54'sdefaultdict: build with it, query with.get.
The deeper cut — radix trees, DAWGs, and giving up nodes entirely
Compression can go further than merging chains. A DAWG (directed acyclic word graph) notices that tries share prefixes but waste identical suffixes. The -ing ending of a thousand words is a thousand separate paths. A DAWG merges those identical sub-tries too, turning the tree into a graph (the very next chapter) and shrinking an English word list dramatically. Scrabble engines live on this. At the far end sit succinct tries and double-array tries (the marisa-trie family). They drop the node objects altogether and encode the whole structure in a tight bit-array plus offset tables. That's megabytes where the naive version wanted gigabytes, at the price of being read-only. The throughline is this. The logical trie, prefix as path, is fixed. But the physical layout is a dial you turn from "easy to code" toward "fits in cache," exactly as the heap chapter turned a pointer tree into a heap-in-an-array.
dict per node (or collections.defaultdict) is the right first cut — it dodges the 256-slot array blowup and is trivial to write. When the key set is large, static, and hot (a shipped dictionary, a routing table, an autocomplete index), that's the moment to switch to a radix/compressed trie or a prebuilt succinct one. Write the simple version to think; ship the compressed version to scale.You now have the whole trie: a path per word, O(L) reads, a heavy per-node bill, and a prefix superpower no hash can match. The only skill left is the one that matters most — knowing, on sight, when this is the shape to reach for. →
06When to reach for a trie
Strip it to the trigger. A trie earns its keep when your keys are sequences — strings, IP addresses, DNA, sequences of moves — and you query them by prefix or by best-match, not just by exact equality. That second clause is the whole decision. If all you ever do is "is this exact key present?" and "give me its value," then a hash map is lighter, simpler, and just as fast — use it. But the moment the question becomes "what completes this?", or "what's the longest stored key that's a prefix of this?", or "iterate everything under this branch in order," the hash map goes quiet. Now the trie is the answer, because it alone kept prefixes as structure.
That is the 1% move again, and by now it's a refrain across this whole volume: match the access pattern to the layout. The trie's access pattern is unmistakable once you can name it: keys that share heads, questioned by their heads. A router asks "which of my routes is the longest prefix of this address." A search box asks "what have people typed that starts like this." A spell-checker asks "what real words are one edit from this path." They are all asking the same question, and the trie is the same answer: descend by symbol, and let the shared paths do the work. Knowing that on sight, before you write a line, is most of what "good at data structures" means. The code, as always, is the easy part that follows.
["flower", "flow", "flight"] the answer is "fl". Two labels before we start, so nothing below reads as more than it is. First, the strings are already in a list — nothing is being inserted, nothing deleted, the corpus sits still. Second, and it will turn out to be the only fact that decides anything, the question is asked once, when the job finishes. Now rewind to just before you read section 06 and watch somebody arrive, because here the route matters more than the destination: the first turn is a genuinely good one, and it is still the wrong call.'$'. Section 05's toolbelt reserves that character to mean “a word ends here”, and '$' is a perfectly legal character inside an object-store key — on logs/a$b/x and logs/a$b/y a '$'-marked trie answers 'logs/a' where the truth is 'logs/a$b/', because the spine walk mistakes a real character for the marker and stops. So the marker below is a sentinel object no character can ever equal. None of this is a beginner's mistake. This is what having understood the chapter looks like.setdefault and allocated 507,657 dicts — in order to hand back 13 characters. That dict count belongs to this corpus, a shared thirteen-character stem plus eight random letters; section 02's 507,662 and the toolbelt's 507,644 are the same census taken of two different word lists, which is why the three land within twenty of each other without being one number. Every dict deeper than 13 was built in full, paid for in full, and never looked at once. And the plain comparison is not even losing asymptotically to make up for it: both routes are O(total characters) in the worst case, but the scan's inner step is a != in a tight loop where the trie's is a hash, sometimes an allocation, and a jump to a fresh address. So here is the uncomfortable part, and this chapter would be lying to you if it stepped around it: the simple way is not the naive way here — it is the correct answer, and the trie is an elegant, provably right, entirely unnecessary detour. There is no clever fix coming. The instinct is not underpowered; it is overbuilt.Hold that next to the sentence this section opened with. It called the second clause — and you query them by prefix or by best-match — the whole decision, and for the question that sentence is asking, it is: that clause is what decides which structure can answer you at all, and a hash map cannot. Whether to build one is a second question, and it turns on something the trigger never names. Turn the same corpus over, ask it the trie's own kind of question repeatedly, and the arithmetic inverts in front of you. On those identical 100,000 keys, "which keys start with
logs/2026/08/ab?" costs 6.140 ms by scanning every key and 0.155 ms by walking fifteen links and collecting the subtree — 39× — while the build costs 181.7 ms, once. Divide one by the other and the trie is behind for the first 30 asks and ahead from the 31st onward: by a thousand asks it has handed back 5.8 seconds. That division is the whole of the second decision, and you can do it on paper before writing either version — build cost divided by saving per ask. The decision figure above asks whether your keys are sequences and whether you query them by prefix; here both answers were yes, and the trie was still wrong. How often is the axis neither the figure nor the trigger draws.Three honest edges, because 30 is not a law. First, it is not even a stable number on one machine: five runs of the program below landed between 30 and 33, because the scan timing it divides by wandered from 5.7 to 6.8 ms across those same runs. Read the arithmetic, not the integer. Second, break-even is a property of this corpus: lengthen the prefix so fewer keys match and the trie's collect gets cheaper while the scan barely moves, so break-even falls; lengthen the keys and the build gets dearer, so it rises. The formula travels; the number does not — measure yours. Third, if inserts and deletes keep arriving there is no break-even to compute at all, because the structure has to exist anyway; the build is already sunk cost and the prefix query rides on it for nothing. That is the ordinary case in production, and it is exactly why the phone keyboard at the top of this chapter can afford a trie: it did not build one to answer your keystroke — it had one already, and had had one for years.
import random, time, tracemalloc
random.seed(59)
ALPHA = "abcdefghijklmnopqrstuvwxyz"
END = object() # a sentinel no character can equal -- the
# chapter's '$' would be a BUG on these keys
keys = ["logs/2026/08/" + "".join(random.choice(ALPHA) for _ in range(8))
for _ in range(100_000)]
def build(words): # the trie, as a plain dict of dicts
root = {}
for w in words:
node = root
for ch in w:
node = node.setdefault(ch, {})
node[END] = True
return root
def census(root): # what the build actually allocated
dicts, stack = 1, [root] # the root dict, plus one per link below it
while stack:
for ch, kid in stack.pop().items():
if ch is not END:
dicts += 1
stack.append(kid)
return dicts
# --- FIRST INSTINCT: index everything, then read the single-child spine ---
def lcp_trie(words):
node, out = build(words), []
while len(node) == 1 and END not in node:
(ch, kid), = node.items() # one way down, and no word ends here
out.append(ch)
node = kid
return "".join(out)
# --- the direct answer: compare every key to the first, column by column ---
def lcp_scan(words):
if not words: return "" # the trie version gets this for free
first = words[0]
for i, ch in enumerate(first):
for w in words:
if i >= len(w) or w[i] != ch:
return first[:i]
return first
t0 = time.perf_counter(); a = lcp_trie(keys); t1 = time.perf_counter()
t2 = time.perf_counter(); b = lcp_scan(keys); t3 = time.perf_counter()
tracemalloc.start(); tmp = build(keys)
mem = tracemalloc.get_traced_memory()[1]; nodes = census(tmp)
tracemalloc.stop(); del tmp
print("ONE ask -- longest common prefix of 100,000 object-store keys")
print(" build a trie, then walk its single-child spine : %5.0f ms peak +%.0f MB" % ((t1-t0)*1e3, mem/2**20))
print(" compare every key to the first, char by char : %5.0f ms peak +0 MB" % ((t3-t2)*1e3))
print(" same answer %r : %s" % (a, a == b))
print(" the build pushed %s characters through setdefault and allocated %s dicts"
% (format(sum(map(len, keys)), ","), format(nodes, ",")))
print(" ...to hand back %d characters" % len(a))
# --- now ask a PREFIX question, over and over, of the same corpus ---
t0 = time.perf_counter(); trie = build(keys); build_ms = (time.perf_counter()-t0)*1e3
def by_scan(p):
return [k for k in keys if k.startswith(p)]
def by_trie(p):
node = trie
for ch in p: # walk: O(len prefix)
node = node.get(ch)
if node is None: return []
out = []
def go(n, pre): # collect: O(matches)
if END in n: out.append(pre)
for ch, kid in n.items():
if ch is not END: go(kid, pre + ch)
go(node, p)
return out
P = "logs/2026/08/ab"
assert sorted(by_trie(P)) == sorted(by_scan(P))
t0 = time.perf_counter()
for _ in range(40): by_scan(P)
scan_ms = (time.perf_counter()-t0)*1e3/40
t0 = time.perf_counter()
for _ in range(2000): by_trie(P)
trie_ms = (time.perf_counter()-t0)*1e3/2000
print()
print("ASKED AGAIN AND AGAIN -- keys starting with %r (%d of them)" % (P, len(by_scan(P))))
print(" scan every key .startswith(p) : %6.3f ms per ask" % scan_ms)
print(" trie walk 15, collect below : %6.3f ms per ask (%.0fx faster)" % (trie_ms, scan_ms/trie_ms))
print(" build the trie : %6.1f ms paid once" % build_ms)
print(" break-even : %6.0f asks" % (build_ms/(scan_ms-trie_ms)))ONE ask -- longest common prefix of 100,000 object-store keys build a trie, then walk its single-child spine : 234 ms peak +94 MB compare every key to the first, char by char : 53 ms peak +0 MB same answer 'logs/2026/08/' : True the build pushed 2,100,000 characters through setdefault and allocated 507,657 dicts ...to hand back 13 characters ASKED AGAIN AND AGAIN -- keys starting with 'logs/2026/08/ab' (170 of them) scan every key .startswith(p) : 6.140 ms per ask trie walk 15, collect below : 0.155 ms per ask (39x faster) build the trie : 181.7 ms paid once break-even : 30 asks
True. Then write suggest(trie, prefix, k=3): walk to the prefix node, collect every completion beneath it, and return the k most-typed.Three parts, and the middle one is the recursion. The walk is the loop you already know, returning
None the moment a link is missing. The collect is a depth-first sweep rooted at that node, carrying the path built so far: record (count, path) wherever the marker sits, then recurse into every child — every child except the marker itself. The ranking is chapter 58's tool, one line: heapq.nlargest(k, hits), a size-k heap over the matches you actually found instead of a sort of everything.The check. With to typed most and the next,
suggest('t') should hand back ['to', 'the', 'that'] and suggest('th') should hand back ['the', 'that', 'they'] — the bar changing under your thumb, exactly as the anchor described. Then check the three edges that matter: suggest('z') on a dead link must return [] without touching the tree, suggest('') must rank the whole vocabulary (the empty prefix is the root), and 'th' itself must not appear as a suggestion — it is a node, not a word.show the solution
import heapq
from collections import Counter
END = "$" # the end node's payload: how many times you have typed this word
def add(trie, word, count):
node = trie
for ch in word:
node = node.setdefault(ch, {})
node[END] = node.get(END, 0) + count # a VALUE, not a bare flag
def build(counts):
trie = {}
for word, c in counts.items():
add(trie, word, c)
return trie
def walk(trie, prefix):
node = trie
for ch in prefix:
node = node.get(ch)
if node is None:
return None # no stored word starts this way
return node
def collect(node, prefix, out):
"""Every (count, word) at or below this node."""
if END in node:
out.append((node[END], prefix))
for ch, kid in node.items():
if ch != END:
collect(kid, prefix + ch, out)
return out
def suggest(trie, prefix, k=3):
node = walk(trie, prefix) # 1) O(len prefix) walk
if node is None:
return []
hits = collect(node, prefix, []) # 2) sweep only that subtree
return [w for _, w in heapq.nlargest(k, hits)] # 3) chapter 58's size-k heap
typed = Counter({"the": 900, "this": 240, "that": 610, "there": 180, "then": 150,
"they": 300, "top": 40, "to": 1200, "tomorrow": 55})
trie = build(typed)
for p in ("t", "th", "the", "to", "z", ""):
print(f" suggest({p!r:5}) -> {suggest(trie, p)}")
print("\nevery completion under 'th', with its count:")
for c, w in sorted(collect(walk(trie, "th"), "th", []), reverse=True):
print(f" {w:9} {c}")
print("\n words stored :", len(typed))
print(" completions under 'th' :", len(collect(walk(trie, 'th'), 'th', [])))
print(" is 'th' itself a word? :", END in walk(trie, "th"))
print(" suggest('zz') :", suggest(trie, "zz"), "- one dead link, walk over")
# ---------------- OUTPUT, verbatim (CPython 3.12.7) ----------------
# suggest('t' ) -> ['to', 'the', 'that']
# suggest('th' ) -> ['the', 'that', 'they']
# suggest('the') -> ['the', 'they', 'there']
# suggest('to' ) -> ['to', 'tomorrow', 'top']
# suggest('z' ) -> []
# suggest('' ) -> ['to', 'the', 'that']
#
# every completion under 'th', with its count:
# the 900
# that 610
# they 300
# this 240
# there 180
# then 150
#
# words stored : 9
# completions under 'th' : 6
# is 'th' itself a word? : False
# suggest('zz') : [] - one dead link, walk overNext (chapter 60): a trie is still a tree — one path to every node, no cycles, strictly top-down. Tear off that last rule. Let any node point to any node, let paths loop back on themselves, and the tree opens into the most general shape in this book: the graph, where a friendship, a road map, a web link, and a git history are all the same two sets — nodes and edges — and the only real question left is how you store the tangle. →
- A trie does not store a word in a node, it stores it in the path to that node — the letter rides the link, which really means it is a key in the parent's children map, which is why the node under
cand the node underkcame back indistinguishable when we looked — and the node itself carries only that children map plus an end-flag, so a shared prefix is a shared path and thea-u-t-ospine beneath a thousandauto…words is carved exactly once. - A lookup costs O(L), the length of the key, and N is nowhere in that count — measured flat at ~161 ns across tries of 1,000, 100,000 and 1,000,000 words, three hops for
catevery time — but "trie O(L), dict O(1)" is a half-truth, because a hash map has to read all L characters too; the real difference is that a trie eats the key one character at a time along a path, so it can quit at the first missing link, and every prefix it crosses on the way is a real, addressable place. - The end-flag is the only thing separating a prefix from a word, and every trie question you will ever meet lands on one of three outcomes: fall off a missing link and the key was never stored at all, arrive at a node whose flag is off and you are merely on the way to something (that is
ca, and that iscarin a trie holding onlycart), arrive at a flagged node and you have genuinely arrived. - A trie is not a memory win — measured, 1,600 heavily overlapping words folded 15,600 character-slots down to 3,523 nodes and the result still weighed 676,224 bytes against a plain
set's 212,488, about 3× — because prefix sharing deletes character-slots and hands every survivor an entire branching apparatus instead: a 64-plus-byte dict on top of a 48-byte node, or in the textbook version a fixed slot array that spends 200 bytes onNoneto hold one child, and roughly 2 KB apiece once the alphabet widens to 256. - You reach for it for the one question a hash map physically cannot attempt — every key beneath a prefix — because hashing exists precisely to destroy that relationship, while a trie never had to build it: walk to the prefix node, then collect the subtree, touching only the matches and nothing else, which is autocomplete, longest-prefix IP routing, spell-check and Scrabble all running the same descent under different names.
None. Does the key set hold still? If it is large, static and hot — a shipped word list, a routing table, an autocomplete index — that is the moment to give up plain nodes for a radix tree, a DAWG or a succinct/double-array build, which buy back the pointer tax at the price of being read-only. And one Python-shaped caveat that decides what you actually type: the standard library ships no trie, so "use a trie here" means a dict of dicts — setdefault to carve, .get to walk, one reserved key as the end marker (reserved meaning it must be something your keys cannot contain), and the recursive defaultdict one-liner kept only for building, since it grows the structure when you merely look at it.setdefault and .get are the two calls the whole chapter is written in. Chapter 56 gave you the tree of scattered heap nodes joined by pointers, and the collect that powers autocomplete is that same recursive descent, simply started partway down instead of at the root. Chapter 58 gave you the heap, and it closes the loop in the drill: once the subtree walk has found the matches, heapq.nlargest(k, hits) ranks them with a size-k heap instead of sorting everything you found. Volume 1 supplied the ground — a name is an 8-byte reference, which is all the root ever is; __slots__ is why a node record measures 48 bytes and why listing its fields turns up no letter anywhere; and per-object overhead is why its empty children dict costs another 64 before it holds a thing. And Volume 3 explains the number Big-O refuses to show you: one dependent, unpredictable jump per letter is a prefetcher's blind spot, and that is where the constant hiding inside O(L) actually lives — which is the whole reason radix trees and double-array layouts exist.A trie turns a word into a walk and a prefix into a place — so let's build one from a bare dict-and-a-flag node and watch letters become paths, prefixes become autocomplete, and shared spines quietly fold themselves away.