In a Trie, Lookup Cost Depends on the Word, Not on How Many Words
Most lookup structures answer is this key present? A trie answers a bigger question at the same price: what could this key still become?
It manages that by storing words the way they are spelled โ one node per character, with any words sharing a prefix sharing the nodes for it. The path from the root to a node spells something, and everything below that node is the set of words that begin with it.
Type a word, one letter at a time
The letters offered at each step are exactly the ones that could continue a real word.
A trie stores words by their letters rather than as whole strings: one node per character,
and any words sharing a prefix share the nodes for it. These 9 words โ
cab, car, card, care, cat, do, dog, done, dot โ contain 29 characters in total, but the trie needs
only 13 nodes, because ca is stored once and serves five words.
Walking down from the root spells a prefix, and everything below where you stop is the set of
completions. Stopping at car leaves 3 words reachable
(car, card, care); stopping at do leaves 4 (do, dog, done, dot). Note that do is both a complete word and
the beginning of three longer ones, which is why a trie has to mark word endings separately
rather than assuming they are the leaves.
The cost of a lookup is the length of the key โ 2 choices at the first step, then one node per character โ and that does not change as more words are added.
Click letters to spell a prefix. The letters offered are exactly the ones that could continue a real word, and the highlighted subtree is what is still reachable โ which is precisely what an autocomplete box is doing while you type.
- Steps from the root
- 0
- Words still reachable
- 0
- Nodes in the trie
- 0
- Characters if stored apart
- 0
The same walk, as text
Typed so far
Completions
Possible next letters
The green ring marks a node where a word ends. Notice that 'do' has one even though three longer words continue through it.
That is not an analogy for autocomplete โ it is autocomplete. An autocomplete box holds a trie (or something with the same shape), keeps a pointer to the node for what you have typed, and moves it one step per keystroke. It never searches the word list. The subtree under the pointer is the suggestion list, already assembled.
Two things the picture shows that a definition does not
Word endings have to be marked. do is a complete word and also the first two letters of
dog, done and dot. If a trie assumed that words end at the leaves, do would be unfindable.
That is the single most common bug in a first trie implementation, and it is visible here as a node
with a ring that also has children.
Sharing is not a vague benefit. The nine default words contain 29 characters between them. The
trie needs 13 nodes, because ca is stored once and serves five words. That ratio improves as the
word list grows and the prefixes overlap more, which is why tries are used for dictionaries,
routing tables and IP prefix matching.
The cost claim, measured
Here is the property that makes a trie worth the pointers: finding a word takes one step per character in that word, and the number of stored words never enters into it.
from itertools import product
def build(words):
root = {}
for word in words:
node = root
for ch in word:
node = node.setdefault(ch, {})
node["$"] = True # marks "a word ends here"
return root
def count_nodes(node):
return sum(1 + count_nodes(child) for ch, child in node.items() if ch != "$")
def steps_to_find(root, word):
"""Returns how many nodes were visited, or None if the word is not stored."""
node, steps = root, 0
for ch in word:
if ch not in node:
return None
node = node[ch]
steps += 1
return steps if node.get("$") else None
def words_under(node, prefix=""):
out = [prefix] if node.get("$") else []
for ch, child in sorted((c, n) for c, n in node.items() if c != "$"):
out += words_under(child, prefix + ch)
return out
small = ["cab", "car", "card", "care", "cat", "do", "dog", "done", "dot"]
# Every 4-letter string over a 7-letter alphabet that INCLUDES the letters of "card", so the
# same lookup can be run against both tries and compared.
big = ["".join(p) for p in product("abcdert", repeat=4)]
print(f"{'word list':<12} {'words':>6} {'characters':>11} {'trie nodes':>11} find 'card'")
for name, words in [("small", small), ("7^4 = all", big)]:
root = build(words)
chars = sum(len(w) for w in words)
found = steps_to_find(root, "card")
print(f"{name:<12} {len(words):>6} {chars:>11,} {count_nodes(root):>11,} {found if found else 'not stored'}")
print()
root = build(small)
for prefix in ["c", "ca", "car", "do", "don"]:
node = root
for ch in prefix:
node = node[ch]
print(f"typing {prefix!r:6} leaves {len(words_under(node, prefix))} word(s) reachable: {', '.join(words_under(node, prefix))}")word list words characters trie nodes find 'card' small 9 29 13 4 7^4 = all 2401 9,604 2,800 4 typing 'c' leaves 5 word(s) reachable: cab, car, card, care, cat typing 'ca' leaves 5 word(s) reachable: cab, car, card, care, cat typing 'car' leaves 3 word(s) reachable: car, card, care typing 'do' leaves 4 word(s) reachable: do, dog, done, dot typing 'don' leaves 1 word(s) reachable: done
Both tries find card in 4 steps. One holds 9 words; the other holds 2,401 โ 267 times as many
โ and the lookup is not one step longer, because a trie never compares the key against other keys.
It just spells it out.
The second block is the autocomplete behaviour as data. Typing c and then a narrows nothing,
because every c word here continues with a; typing r cuts five candidates to three. Each
keystroke is one pointer move, and the answer is whatever sits underneath.
Trie or hash table?
A hash table also finds a key in time that does not depend on the number of keys, and uses far less memory doing it. If exact lookup is all you need, use the hash table.
The trie earns its cost when you need the neighbourhood of a key: everything starting with this prefix, the longest stored prefix of this string, all keys in alphabetical order. A hash table scatters keys deliberately, so it can answer none of those without examining everything it holds. A trie keeps related keys adjacent by construction, which is the same trade a bad hash makes in reverse โ structure you can exploit, versus spread you cannot.
What this page simplifies
- One node per character, always. Real tries compress chains of single-child nodes into one edge (a radix tree), which removes most of the memory overhead the plain form has.
- Memory is not measured here. Node counts are counted; the pointer table each node carries is the actual cost, and it is what makes a naive trie expensive for large alphabets.
- The big word list is artificial. Every 4-letter combination shares prefixes far more evenly than real words do. It is used to hold the lookup cost constant while the word count changes, which is the one claim it is there to support.
- No deletion. Removing a word from a trie means unwinding nodes that no other word needs, and getting that wrong leaves a structure that answers correctly and grows forever.