Tries behind prefix APIs
Autocomplete and dictionary objects built on a character tree.
01The intuition
The problem it solves
You are writing the contacts app on a phone. As the user types "ra", the screen should show Rahul, Rani and Ravi at once. The first version keeps the names in a list and checks every one against the prefix on every keystroke.
class Contacts:
def __init__(self) -> None:
self.names: list[str] = []
def add(self, name: str) -> None:
self.names.append(name)
def suggest(self, prefix: str) -> list[str]:
return [n for n in self.names if n.startswith(prefix)]
With 300 contacts nobody notices. A search box over 10 million product names, with a keystroke every 150 ms, runs 10 million string comparisons per keystroke. The work does not depend on how many answers there are, only on how many names exist. The structure should make the prefix do the narrowing.
Build it one piece at a time
Step one: store names as a tree of characters. Each node has a dictionary from a character to a child node. "ra" is the path root → r → a, and every name starting with "ra" lives in the subtree under that node. Add a flag that says a word ends here.
class Node:
def __init__(self) -> None:
self.children: dict[str, "Node"] = {}
self.is_word = False
class Contacts:
def __init__(self) -> None:
self.root = Node()
def add(self, name: str) -> None:
node = self.root
for ch in name:
node = node.children.setdefault(ch, Node())
node.is_word = True
Step two: suggest walks the prefix once, then collects words in the subtree. The walk costs one dictionary lookup per typed character, and the collection costs only the size of the answer region.
class Contacts: # continued
def suggest(self, prefix: str) -> list[str]:
node = self.root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return []
out: list[str] = []
stack = [(node, prefix)]
while stack:
n, text = stack.pop()
if n.is_word:
out.append(text)
for ch, child in n.children.items():
stack.append((child, text + ch))
return out
Step three: a real autocomplete wants only the top five, not every match. Store, at each node, the best few completions under it, updated when a word is added or its count changes. The walk then returns the stored list without touching the subtree at all.
class Node:
def __init__(self) -> None:
self.children: dict[str, "Node"] = {}
self.is_word = False
self.top: list[tuple[int, str]] = [] # (count, word), best first
def record(self, word: str, count: int, k: int) -> None:
rest = [t for t in self.top if t[1] != word]
rest.append((count, word))
rest.sort(key=lambda t: (-t[0], t[1]))
self.top = rest[:k]
After adding Rahul, Rani and Ravi the tree looks like this; the node for "ra" already knows its three completions.
| Path | is_word | top (k = 3) |
|---|---|---|
| r | no | Rahul, Rani, Ravi |
| r → a | no | Rahul, Rani, Ravi |
| r → a → h → u → l | yes | Rahul |
| r → a → n → i | yes | Rani |
| r → a → v → i | yes | Ravi |
Why it works
The invariant is that every word is exactly one root-to-flag path, and that the subtree under a node contains all words sharing that node's prefix and no others. Lookup cost is therefore the length of the prefix, not the number of words. With per-node top lists, the invariant extends to: each node's list equals the k best words in its subtree, so a query is a walk plus a copy.
Where it breaks: memory. A node per character with a dictionary each is heavy; a million words of average length 8 is up to 8 million nodes. Compressing chains of single children (a radix tree) or storing children in arrays for small alphabets brings it down. The second trap is updates with per-node top lists: changing one word's count touches every node on its path, length-of-word work, which is fine for words but not for very long keys.
list scan: n × L per query. Trie: Lprefix + answer size per query, independent of n
The trade-off you must say out loud
A trie wins only when prefix is the question. For exact lookups a hash map is simpler and faster; for "contains this substring anywhere" a trie does nothing. Say that you chose a trie because the API is prefix-shaped, mention the memory cost, and offer the radix compression if asked. The follow-up that usually comes next is concurrency: readers walking the tree while a writer inserts, which is where a read-write lock or copy-on-write for the small top lists comes in.
Where it shows up in the 100 Google problems
- Phone book with concurrent access: prefix search over contacts is the trie, and the reader-writer question follows.
- HTTP router with path parameters: a trie keyed by path segments instead of characters, with parameter nodes as wildcards.
- In-memory file system: directories form a trie over path components; ls is a one-level walk.
- In-process search index: the dictionary of terms can be a trie so prefix queries like "data*" expand cheaply.