◈ python mapVol 4 · Ch 59/62
Volume 4 Python, from the metal up · chapter 59

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.

iolinked · chapter 59 — the checkpoints6 steps
$ sections covered in The trie — a tree indexed by letters
01A word is a path, not a payload
02Why lookup costs the word's length, not the dictionary's size
03The space bill: prefixes shared, alphabets paid
04The superpower: every word beneath a point
05The pointer tax, one hop per letter
06When to reach for a trie

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.

★ YOU ALREADY RUN THIS · your-phone-keyboardtwo letters, three suggestions — and it never looked at a word list
You are thumbing a message on the bus. You type 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.
typing t, then h, moves you two steps and no moretwo hops down the trie — the cost is the length of what you typed, never the size of the dictionary: O(L), with no N in it anywhere
the three words on the bar are whatever hangs below where you standwalk to the prefix node, then collect its subtree — the one question a hash map cannot even attempt, because hashing scattered those words on purpose
the, this, that, there, then all begin with those same two lettersa shared prefix is a shared path: the t-h spine is paid for one time, and every word past it hangs off that same node
type a q after them and the bar empties on the spotthe q link is missing, so the walk stops at letter three — a miss can be cheaper than a hit
“th” is offered as a road, but never offered as a wordthe end-of-word flag. Standing on a node proves you are on the way to something, not that you have arrived — that is the whole job of is_end
pin it: your keyboard never searches a word list — it stands on the letters you have already typed and reads off everything still hanging below.

So 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.

⚠ MOST BEGINNERS THINK…a node keeps its own letter, so a walk compares characters
The stepper above already admitted this in passing — one of its notes says outright that the letter lives on the link and that printing it inside the circle is a drawing convenience. Being told that is not the same as believing it, because everything your eyes have been fed says otherwise: the stepper draws a circle with 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.
TYPE THIS — 10 SECONDS
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']
A node stores no letter at all — the letter is a key in its parent's children map — which is why the two nodes come back identical in content yet stay two separate objects (a trie shares heads, never tails), why 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.

each node = a heap object: [ end-flag · children map {letter → node} ] — the letters ride the arrows, not the boxes root a name → ref 0x7fa0 ROOT end ✗ c →● 0x7f3c node "c" end ✗ a →● 0x7fc8 node "ca" ← shared end ✗ t →● r →● 0x7f14 node "cat" end ✓ { } no kids 0x7f9e node "car" end + kids end ✓ d →● (card) e →● (care)
Fig — the trie for {cat, car, card, care} as it truly sits in memory: scattered node objects joined by amber keyed links. The letters label the arrows; nodes carry only an end-flag and a children map. Node "ca" is shared by every word that starts that way — the prefix is the shared path.
The human insight: don't store the key — spell it
Edward Fredkin named the trie in 1960 (from retrieval). The leap was refusing to keep whole words in nodes at all. If the key is a sequence, he saw, you can let the sequence be the address: each symbol picks the next turn, and words that start alike automatically fold onto one path. The structure stops being a bag of strings and becomes a map of every prefix that has ever passed through it.

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.

memory.pypython
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 dict

Line 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.

Wait —
if a hash map already finds any key in "O(1)", and it's cheaper per entry, why would anyone build this letter-by-letter tree at all? What can it possibly do that a dict can't?

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.

olength.pypython
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 flagged

Read 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:

olength.pypython
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 time

The 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.

reading "cat" = follow one link per letter, then check the end-flag root c a t ✓ hop 1: "c" hop 2: "a" hop 3: "t" = cat the rest of the trie — every other word on every other path N = 6 or N = 1,000,000 never visited · never compared · not in the cost
Fig — why lookup is O(L), not O(N). The walk to 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.
InteractiveType a word — watch the path descend, letter by letter
cd ao tr de g c d a o t r d e g ◯ = end-flag (a word)
3 hops
Stored: cat · car · card · care · do · dog. The walk costs one hop per letter — never anything about the other words.
Without the end-flag, you can't tell a word from a passer-by
Try typing 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.

space.pypython
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.

InteractiveDial the alphabet — watch one node's None-pointers detonate
one array node · exactly ONE real child · every other slot is None a● 255 empty ∅ slots bytes this ONE node burns on None: 2.0 KB bytes (Latin-1) · 256 symbols a million such nodes ≈ 1.9 GB of nothing the words fit in kilobytes — the empty slots eat the rest node footprint · log scale (each mark = ×32) 1 KB32 KB1 MB a node pays rent on every letter it could branch to
256 symbols
Drag from DNA (4 symbols) to full Unicode (1,114,112). The child never changes — one link — yet the fixed array grows with the alphabet, and almost all of it is None. This is why the classic array-node trie detonates on a big symbol set.
ARRAY node — 26 fixed slots, one per letter, allocated whether used or not a … 17 more ∅ … z 25 unused slots × 8 bytes = 200 bytes of None-pointers paid for nothing, per one-child node DICT node — stores only the children that actually exist { 'a' → ● } one entry, ~64–184 bytes — no per-letter waste, but still a whole hash table riding on every node measured: array-node object 48 B + its 26-slot list 264 B = 312 B; sparse-friendly dict, but ≥64 B even when nearly empty the trie's real cost is never the letters — it's the branching machinery bolted to every node
Fig — the alphabet tax. A fixed 26-slot array node spends 200 bytes on 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.
↺ The thing people get backwards
A trie is not "the compact way to store a dictionary." Measured, a naive Python trie ran ~3× heavier than a plain 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.
The array-node trie can detonate on a big alphabet
Switch from 26 letters to 256 bytes (to store arbitrary strings) and every node becomes a 256-pointer array — roughly 2 KB apiece. A few million sparse nodes and you're spending gigabytes on 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.

prefix.pypython
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 out

The 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.

HASH MAP — hashing scatters keys on purpose 0 cur 2 cute 4 5 cup prefix "cu"? the buckets have no order — you must scan all N keys · O(N) TRIE — the "cu" prefix is one place c u cu cup cur cute prefix "cu" = walk 2, collect the subtree · O(matches)
Fig — the same three keys, two arrangements. The hash map flings 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.
InteractivePick a prefix — watch the autocomplete subtree light up
cd ao tr de g c d a o t r d e g prefix "ca" → car, card, care, cat
Walk to the prefix node (cyan), then every word in the subtree beneath it lights green. This is literally what your search box does on each keystroke.
🌍 Where you meet this — dozens of times a day, unknowingly
Autocomplete is this exact move: every keystroke in a search box walks to the prefix node and collects the words beneath, ranked. Phone keyboards and old T9 predictive text are tries over your language. Spell-checkers walk a trie to find near-matches. Most invisibly of all: the router that forwarded the packets carrying this page did a longest-prefix match on the destination IP address through a trie of routes — the core loop of the entire internet, run billions of times a second. Add Scrabble/Boggle solvers and genome prefix search and you've used a trie before finishing breakfast.

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.

plain trie: read "card" = 4 dependent jumps to scattered addresses (each may stall on RAM) root c ca car card radix (compressed) trie: no branching until "card" splits — one edge holds the whole run root "card" label = "card" 1 hop, not 4 — fewer cache misses
Fig — the runtime penalty and its fix. A plain trie makes one dependent, scattered jump per letter. A radix trie collapses every non-branching run of nodes into a single edge — fewer nodes, fewer hops, friendlier to the cache.

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.

THE STDLIB TOOLBELT · there is no trie — a dict of dicts is onesetdefault carves the path; one reserved key says “a word ends here”
trie = {} # the root is an ordinary dict # INSERT - carve the path, then plant the marker node = trie for ch in word: node = node.setdefault(ch, {}) # read-or-create, in ONE lookup node['$'] = True # a reserved key: a word ends HERE # WALK - one dict probe and one hop per character node = trie for ch in prefix: node = node.get(ch) if node is None: break # the road ran out - fail fast from collections import defaultdict Trie = lambda: defaultdict(Trie) # the recursive one-liner
node.setdefault(ch, {})The whole insert, in one call: hand back the child under 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.
'$' as the end markerSingle characters are the only real keys, so any non-character key is free to mean something else. '$' 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.
.get(ch) vs [ch]On the walk you want 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.
node.items(), skipping '$'Collecting a subtree means iterating a node's children — and the marker is sitting in there with them. Every recursive collect needs the if ch != '$' guard, or your autocomplete will try to descend into True.
defaultdict(Trie)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']
TRIPWIRES
  • 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 flagless contains returned True for 'there' — and also for 'the', 'th' and 't', none of which were ever stored. With '$' in place the same four probes returned True, False, False, False. Arriving at a node proves you walked a road somebody built; only the marker proves the road ended there.
  • The defaultdict trie writes when you only read. Starting from a trie holding just cat, top-level keys were ['c']; after the single lookup dd['z']['z']['z'] they were ['c', 'z'], and 'z' in dd answered True — a query had written three nodes. The plain-dict version's .get('z') returned None and changed nothing. Same trap as chapter 54's defaultdict: 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.

Default to a dict-of-children; reach for compression when it's read-mostly
For everyday code, a 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.

key is a SEQUENCE? string · IP · DNA · path no → query by EXACT key only? "is it in?" · "get its value" HASH MAP (ch 6) — lighter query by PREFIX / best-match? autocomplete · longest-prefix · range TRIE — the only tool
Fig — the whole decision on one screen. Sequence keys + prefix questions → trie. Exact-equality lookups → the lighter hash map. Reading the question, not the data, is the skill.

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.

WATCH AN EXPERT THINKthe longest string a hundred thousand keys all begin with
A bucket in an object store holds 100,000 keys written by one job, and you want the longest string every single one of them starts with — the deepest shared root, so you can strip it off every log line, or point a listing at it instead of at the whole bucket. Stated bare, it is the classic: given a list of strings, return their longest common prefix; for ["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.
FIRST INSTINCT
This chapter has spent five sections teaching that a prefix is a place, and a longest common prefix is a prefix. So build a trie of all 100,000 keys and stand on the root. Walk down while the node you are on has exactly one child and no word ends there — a second child means the keys have begun to disagree, and an end-marker means one key has run out and cannot be extended further. The letters you cross on the way down are the answer. What makes this lovely is that nothing is computed: the longest common prefix is the single-child spine at the top of the trie, so you are not calculating it, you are reading it off the structure. Seven lines — fifteen counting the builder it cannot run without, against eight for the direct version below — correct on the first attempt, and correct on every input including the empty list, which it handles for free while the direct version needs a guard line for it. It earns that last claim only because of one line the program spends deliberately: the end marker here cannot be '$'. 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.
WHY IT STALLS
Measured on those 100,000 keys, the trie route took 234 ms and a peak of +94 MB. Comparing every key to the first, character column by character column, took 53 ms and allocated nothing. (Machine-dependent, as always in this book — the ratio is the point, not the milliseconds.) Then read the line the program prints about its own build, because that is where the waste is legible: it pushed 2,100,000 characters through 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.
THE TELL
Ask what those 234 milliseconds and 94 megabytes actually purchased, because it was not the answer — 53 ms and zero bytes bought the answer. They purchased an index. And an index is not an answer, it is capital: money spent up front against questions you have not asked yet. Which means the fact that decided this was never in the data at all. It was sitting in the sentence "you want the longest common prefix", in the word the — singular, once. A data structure is an investment, and an investment returns nothing on a single transaction.

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
trigger: build the trie when the prefix question will be asked again — answer it directly when it is asked once, and settle which one you are in with a division you can do before writing a line of either: build cost divided by saving per ask. Here that is 181.7 / (6.140 − 0.155) = 30.4, so from the 31st ask on, the structure has paid for itself.
NOW WRITE THE THREE WORDS ABOVE THE KEYBOARDwalk, collect, rank — autocomplete is one loop, one recursion and one heap
The drill. Write the three words above the keyboard. Build a dict-of-dicts trie from a small vocabulary with counts — how often you have typed each word — storing the count at the end node instead of a bare 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 over

Next (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. →

SAY IT BACKthe chapter in five breaths
  1. 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 c and the node under k came 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 the a-u-t-o spine beneath a thousand auto… words is carved exactly once.
  2. 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 cat every 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.
  3. 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 is car in a trie holding only cart), arrive at a flagged node and you have genuinely arrived.
  4. 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 — 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 on None to hold one child, and roughly 2 KB apiece once the alphabet widens to 256.
  5. 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.
reach for this when… your keys are sequences and you question them by their heads — repeatedly. All three clauses are load-bearing, and they are not all deciding the same thing: the first two settle which structure can answer you at all, the third settles whether it is worth building one. Each also has a neighbour that wants something else. If every question is "is this exact key present, and what is its value?", the hash map of chapter 54 is lighter, simpler and just as fast on the key, and choosing a trie there is paying about 3× the memory for a capability you never use. If the prefix question is real but gets asked once, do not build anything: the longest common prefix of 100,000 keys cost 234 ms and 94 MB through a freshly built trie and 53 ms and zero bytes by plain comparison, and asking that same corpus a repeated prefix question only put the trie ahead from the 31st ask onward — so do the division, build cost over saving per ask, before you write either version. If inserts and deletes keep arriving, that division is moot and the trie wins by default, because the structure has to exist anyway and the prefix query rides on it for free; that is the case your phone keyboard is in. Then two shape questions. How wide is the alphabet? Dict children by default — fixed slot arrays are only sane for small closed alphabets, and at 256 symbols they turn a million sparse nodes into gigabytes of 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.
You already owned the pieces: chapter 54 gave you the hash map, and it shows up here twice over — once as the children map bolted to every single node, which is where the entire space bill comes from, and once as the foil, the structure that wins every exact lookup and cannot answer one prefix question without scanning everything, which is what makes "a prefix is a place" a capability rather than a slogan; its 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.
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 59, in working code

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.

Carving the paths
The whole structure is one tiny node — a children dict keyed by letter and an end-flag — repeated. Build it, insert into it, and watch two words share a spine.
Reading a word back out
Lookup is a walk: follow each letter's link, and if you survive to the end, read the flag. This is where the trie's subtlety lives — a word, a mere prefix, and a dead path all look different.
Prefixes are places
The power a hash map can't sell you: because the trie never scattered similar keys, every word under a prefix lives in one subtree. Walk to the prefix, then harvest below it.
Counting, sharing, forgetting
The trie has no len(): to count words you walk end-flags, to count cost you walk nodes. And deletion is subtler than it looks, because the nodes you'd remove may still belong to someone else.
end of chapter 59 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked