Ch 18 / 30 Tries and Prefix Trees 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 18

Part 4 · Trees · Chapter 18

Tries and Prefix Trees

The structure behind autocomplete and spellcheck. The cost of a lookup stops depending on how many words you stored and starts depending only on how long the word is.

Reading
Sedgewick Ch. 5.2
Focus
Edges carry the data
Cost
O(key length)
Powers
Autocomplete · routing

By the end of this chapter you can

  1. Explain how a trie differs from every other tree in this course
  2. Implement insert, search and starts-with, and give the cost of each
  3. Collect every completion of a prefix, and say why it is the subtree below it
  4. Weigh a trie's space cost honestly against a hash table's
  5. Recognise the problems where a trie is the right answer, and the ones where it is not

1The letters live on the edges

Every other tree in this course stores data in the nodes. A trie stores it on the edges, and that single change is what makes it work.

The filing cabinet

A filing cabinet with a drawer per first letter. Inside drawer C, dividers for the second letter. Inside Ca, dividers for the third.

To file “cat” you follow C, then a, then t. To find every word starting with “ca”, you open drawer C, go to divider a, and everything behind it qualifies — no reading required.

Note that no drawer is labelled “cat”. The word is the path, not any single label.

In a trie (from retrieval, and usually pronounced “try” to distinguish it from “tree”), a node's identity is the path taken to reach it. The node itself stores almost nothing: a map from character to child, and one boolean saying “a stored word ends here.”

trie_node.py
class TrieNode:
    def __init__(self):
        self.children = {}      # character -> TrieNode
        self.is_word = False    # does a stored word END here?

class Trie:
    def __init__(self):
        self.root = TrieNode()
Why the is_word flag is not optional

Store do and dog. Walking do lands you on a node that is both a complete word and a prefix of another.

Without the flag there is no way to tell “a word ends here” from “this is only a waypoint.” Searching for ca would wrongly succeed, because the path exists on the way to cat.

2Insert, search, starts-with

All three are the same walk down the tree. They differ only in what they do when the path runs out.

trie_ops.py
def insert(self, word):
    node = self.root
    for ch in word:
        if ch not in node.children:
            node.children[ch] = TrieNode()    # create only if missing
        node = node.children[ch]
    node.is_word = True                       # mark the end


def search(self, word):
    node = self._walk(word)
    return node is not None and node.is_word  # the FLAG decides


def starts_with(self, prefix):
    return self._walk(prefix) is not None     # merely arriving is enough


def _walk(self, s):
    """Follow s from the root. Returns the node, or None if the path breaks."""
    node = self.root
    for ch in s:
        if ch not in node.children:
            return None
        node = node.children[ch]
    return node

The difference between search and starts_with is one clause, and it is exactly the distinction the is_word flag exists to make.

Insert
O(m)
Search
O(m)
starts_with
O(m)
Delete
O(m)
Space
O(total chars)
Sorted iteration
O(n · m)

m = the length of the key.

The number that is missing from that table

n does not appear in the time column.

Looking up a five-letter word costs five steps whether the trie holds ten words or ten million. A hash table is also O(1)-ish, but it must hash the whole key and then compare the full key against whatever is in the bucket. A trie compares one character at a time and stops the instant the path breaks.

That early exit is why a trie can reject a misspelling after two characters, and it is what makes tries good at failing fast.

3Autocomplete

The operation tries exist for. Once you have walked the prefix, the answer is simply everything below you.

Every descendant of the node at the end of a prefix shares that prefix — that is what “the path is the key” means. So autocomplete is two steps: walk the prefix, then collect the subtree.

completions.py
def completions(self, prefix, limit=None):
    node = self._walk(prefix)
    if node is None:
        return []                       # no word has this prefix at all

    out = []
    def collect(n, path):
        if limit is not None and len(out) >= limit:
            return
        if n.is_word:
            out.append(prefix + path)
        for ch in sorted(n.children):   # sorted → results in alphabetical order
            collect(n.children[ch], path + ch)

    collect(node, "")
    return out

# Cost: O(m) to walk the prefix, plus O(size of the subtree) to collect.
# Note what is NOT in that cost: the number of words in the trie.

A real autocomplete wants the best completions, not the alphabetically first. The standard extension: store a frequency or score on each word-ending node, and either collect everything and take the top k with a heap (Chapter 17), or cache the best few completions at every node so the answer is available immediately.

ranked.py
class RankedTrieNode:
    def __init__(self):
        self.children = {}
        self.is_word = False
        self.score = 0
        self.top = []      # cached best completions of this node's prefix

# Google's search box does the second thing. The cache costs memory and
# must be updated on insert, and in exchange the query is O(m) with no
# subtree walk at all — which matters when the subtree is a million words.
Deletion, and the tidying-up problem

Deleting a word is easy: walk to it and clear is_word. The word is gone.

What is fiddly is reclaiming the now-useless nodes. A node can be removed only if it has no children and is not itself a word — and removing it may make its parent removable too. So deletion prunes upward, stopping at the first node that is still needed. In practice many implementations skip this entirely and just clear the flag.

4What a trie costs

Tries are fast and they are expensive. Being honest about the second part is what stops you reaching for one when a hash table would do.

A trie allocates a node per character per distinct prefix. In Python each node is an object with a dictionary, which is roughly 100–250 bytes before you store anything.

Storing 100,000 English wordsApproximate memory
set of strings~8 MB
Python trie, dict children~50–150 MB
Trie with array-of-26 childrenworse — 26 slots per node, mostly empty
Compressed trie (radix tree)~15 MB

The saving grace is prefix sharing. car, card, care and cart share three nodes rather than storing twelve characters. On data with heavy shared prefixes — URLs, file paths, IP prefixes, DNA — the ratio flips and a trie can be smaller than the raw strings.

The compressed trie

Most of the waste is chains of single-child nodes. A radix tree collapses each chain into one edge labelled with the whole substring:

radix.py
# Plain trie for ["romane", "romanus", "romulus"]:
#   r - o - m - a - n - e
#                 \    \- u - s
#                  \- u - l - u - s
#   13 nodes, most with exactly one child.

# Radix tree — collapse every single-child chain:
#   "rom" - "an" - "e"
#      \        \- "us"
#       \- "ulus"
#   5 nodes.

# Same operations, same asymptotic costs, a fraction of the memory.
# This is what IP routing tables and Git's object store actually use.
Do not reach for a trie by default

For plain membership testing, a set is faster and uses a tenth of the memory. A trie earns its keep only when you need the operations a hash table cannot do:

  • “every key starting with X”
  • “the longest stored key that is a prefix of X”
  • “keys within one edit of X”

Hashing deliberately destroys the relationship between similar keys. A trie preserves it. That is the whole difference, and it is the only reason to pay the memory.

5Where tries actually get used

Five real systems, and the one property each of them needs.

1. IP routing

A router must find the longest matching prefix for a destination address: 192.168.1.0/24 should win over 192.168.0.0/16. Walk the address bit by bit and remember the deepest node marked as a route. Hash tables cannot do this at all — you would have to try all 32 prefix lengths separately.

2. Autocomplete and search suggestions

The obvious one. Walk the prefix, read the subtree, ranked by a stored score.

3. Spellcheck and fuzzy matching

Walk the trie while allowing a bounded number of mismatches. Because the whole subtree is pruned the moment the error budget runs out, this is dramatically faster than comparing against every dictionary word.

fuzzy.py
def fuzzy_search(node, word, i, edits, prefix, out):
    """All stored words within `edits` substitutions of `word`."""
    if edits < 0:
        return                                  # ← the pruning IS the algorithm
    if i == len(word):
        if node.is_word:
            out.append(prefix)
        return
    for ch, child in node.children.items():
        cost = 0 if ch == word[i] else 1
        fuzzy_search(child, word, i + 1, edits - cost, prefix + ch, out)

# Every wrong branch dies as soon as the budget is exhausted, so most of
# the dictionary is never visited.

4. Word games and puzzle solvers

A Boggle or Scrabble solver walks the board and the trie simultaneously. The moment the letters so far are not a prefix of any word, the entire branch of the search is abandoned. Without that pruning the search space is impossible.

5. Compilers and text editors

Keyword recognition, syntax highlighting and symbol completion are all prefix problems.

You needReach forWhy
Exact membership onlysetFaster, tenth of the memory
Prefix queriestrieA hash table cannot do them
Longest matching prefixtrieSame reason — routing depends on it
Fuzzy / edit-distance matchingtriePruning kills most of the search space
Keys in sorted orderbalanced tree, or trieBoth work; the tree is smaller
Suffix queriessuffix tree / suffix arrayA trie of all suffixes, done properly
Millions of keys, memory-boundradix treeCollapses the single-child chains
The one sentence to take away

A hash table answers “is this exact key present?” and destroys every other relationship in the process.

A trie answers “what do I know about keys that look like this?” — and that question has no cheap answer without one.

What to carry forward

  • In a trie the edges carry the characters and a node's identity is the path to it. That is why prefixes are free.
  • The is_word flag is essential: it distinguishes 'a word ends here' from 'this is a waypoint to a longer word'.
  • All operations are O(key length). The number of stored keys does not appear in the cost at all.
  • Autocomplete = walk the prefix, then collect the subtree. Every descendant shares the prefix by construction.
  • Tries are memory-hungry — often 10× a set. Prefix sharing and radix compression are what make them affordable.
  • Use a trie only for what a hash table cannot do: prefix queries, longest-prefix matching, fuzzy search. Otherwise use a set.

>_Playground

A full trie with autocomplete and fuzzy search. Compare its memory against a plain set, and watch the pruning at work.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

Chapter 19 — Graphs and How to Represent Them

Friends, roads, web links, dependencies. The most general structure in the course — and choosing the representation is half the battle.

Continue →