Tries (prefix trees) explained: autocomplete in Python with tests and timings

A trie finds every word with a given prefix without looking at the others. Build one in Python, test it and see it beat a full scan by a wide margin.

A tree of letters branching from one root, with one word path highlighted

A trie (say "try", from retrieval), also called a prefix tree, stores words letter by letter so that words with the same beginning share the same path. It is the classic structure for autocomplete, spell checkers, dictionary lookups and IP routing tables, and a frequent interview topic ("implement a trie", "word search II", "longest common prefix"). The code, tests and timings below were run with Python 3 before publishing.

The idea

Store car, card, care, careful and cat. A list repeats the letters c-a-r four times. A trie stores them once:

  • the path c → a → r is shared by four words,
  • at the node for r we mark "a word ends here" (because car is a word),
  • below it, d, e → f → u → l continue to the longer words,
  • c → a → t branches off at a.

To find all words starting with car, walk three steps down, then collect everything below that node. You never look at dog or dot at all.

Operation Cost Notes
Insert a word of length L O(L) One step per letter
Exact lookup O(L) Walk, then check the end marker
"Does any word start with this?" O(L) Walk only
Autocomplete O(L + size of the answer) Walk to the prefix, then visit its subtree

The cost depends on the length of the word, not on how many words are stored. That is the whole appeal.

A working example

Save as trie.py:

import random
import string
import time


class Trie:
    """A prefix tree: each node maps a character to a child node."""

    def __init__(self):
        self.root = {}
        self.size = 0

    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.setdefault(ch, {})
        if "$" not in node:  # "$" marks the end of a stored word
            node["$"] = True
            self.size += 1

    def _walk(self, text):
        node = self.root
        for ch in text:
            node = node.get(ch)
            if node is None:
                return None
        return node

    def contains(self, word):
        node = self._walk(word)
        return node is not None and "$" in node

    def starts_with(self, prefix):
        return self._walk(prefix) is not None

    def autocomplete(self, prefix, limit=5):
        """Return up to `limit` stored words that start with prefix, in alphabetical order."""
        node = self._walk(prefix)
        out = []
        if node is None:
            return out

        def visit(n, path):
            if len(out) >= limit:
                return
            if "$" in n:
                out.append(prefix + "".join(path))
            for ch in sorted(k for k in n if k != "$"):
                visit(n[ch], path + [ch])

        visit(node, [])
        return out


def list_prefix_scan(words, prefix, limit=5):
    """The simple way: look at every word."""
    return sorted(w for w in words if w.startswith(prefix))[:limit]


if __name__ == "__main__":
    t = Trie()
    for w in ["car", "card", "care", "careful", "cat", "dog", "do", "dot"]:
        t.insert(w)
    print("contains('car')      :", t.contains("car"))
    print("contains('ca')       :", t.contains("ca"), "(a prefix, not a stored word)")
    print("starts_with('ca')    :", t.starts_with("ca"))
    print("autocomplete('car')  :", t.autocomplete("car"))
    print("autocomplete('do', 2):", t.autocomplete("do", 2))
    print("autocomplete('x')    :", t.autocomplete("x"))

    rnd = random.Random(2)
    words = {"".join(rnd.choice(string.ascii_lowercase) for _ in range(rnd.randint(4, 10))) for _ in range(150_000)}
    big = Trie()
    t0 = time.perf_counter()
    for w in words:
        big.insert(w)
    build = (time.perf_counter() - t0) * 1000
    wl = list(words)
    prefix = "qu"
    n = 200
    t0 = time.perf_counter()
    for _ in range(n):
        a = big.autocomplete(prefix)
    t1 = (time.perf_counter() - t0) * 1000 / n
    t0 = time.perf_counter()
    for _ in range(n):
        b = list_prefix_scan(wl, prefix)
    t2 = (time.perf_counter() - t0) * 1000 / n
    print(f"\n{len(words):,} words, build trie: {build:.0f} ms")
    print(f"same answer for prefix '{prefix}': {a == b}")
    print(f"  trie autocomplete   {t1:8.3f} ms per query")
    print(f"  scan every word     {t2:8.3f} ms per query")

Output of python3 trie.py (the exact times depend on the machine):

contains('car')      : True
contains('ca')       : False (a prefix, not a stored word)
starts_with('ca')    : True
autocomplete('car')  : ['car', 'card', 'care', 'careful']
autocomplete('do', 2): ['do', 'dog']
autocomplete('x')    : []

149,469 words, build trie: 716 ms
same answer for prefix 'qu': True
  trie autocomplete      0.026 ms per query
  scan every word       13.679 ms per query

How the code works

  • Each node is a dictionary from a character to the next node. The special key "$" marks the end of a stored word. This is how contains('ca') is False while starts_with('ca') is True: the path exists but no word ends there.
  • insert walks down, creating nodes with setdefault, then sets the end marker. Inserting a duplicate does not change size.
  • autocomplete first walks to the prefix node. Then a depth-first visit goes through the children in sorted letter order, so the results come out alphabetically and stop at the limit.
  • list_prefix_scan is the simple alternative: test startswith on every word. It exists so that we can check the trie against a trusted answer.

Reading the timings

With about 150,000 random words and the prefix qu:

  • Building the trie took 716 ms (one-time cost).
  • A trie autocomplete took 0.026 ms per query.
  • Scanning every word took 13.7 ms per query, roughly 500 times slower.

The scan gets slower in step with the number of words; the trie query does not. If you answer one query, scanning wins because it needs no build step. If you answer many queries on the same words, as a search box does, the trie wins easily.

Tests

Save as test_trie.py:

import random
import string
import unittest

from trie import Trie, list_prefix_scan


class TrieTests(unittest.TestCase):
    def test_contains_vs_prefix(self):
        t = Trie()
        t.insert("car")
        self.assertTrue(t.contains("car"))
        self.assertFalse(t.contains("ca"))
        self.assertTrue(t.starts_with("ca"))
        self.assertFalse(t.starts_with("cb"))

    def test_duplicates_counted_once(self):
        t = Trie()
        for w in ["a", "a", "ab"]:
            t.insert(w)
        self.assertEqual(t.size, 2)

    def test_empty_prefix_lists_everything_sorted(self):
        t = Trie()
        for w in ["b", "a", "c"]:
            t.insert(w)
        self.assertEqual(t.autocomplete("", 10), ["a", "b", "c"])

    def test_matches_simple_scan_on_random_words(self):
        rnd = random.Random(4)
        words = {"".join(rnd.choice("abc") for _ in range(rnd.randint(1, 6))) for _ in range(300)}
        t = Trie()
        for w in words:
            t.insert(w)
        for prefix in ["", "a", "ab", "abc", "cc", "zzz"]:
            self.assertEqual(t.autocomplete(prefix, 7), list_prefix_scan(words, prefix, 7))

    def test_limit_respected(self):
        t = Trie()
        for w in ["aa", "ab", "ac", "ad"]:
            t.insert(w)
        self.assertEqual(len(t.autocomplete("a", 2)), 2)


if __name__ == "__main__":
    unittest.main()
$ python3 -m unittest
Ran 5 tests in 0.004s

OK

The most useful test compares the trie against the simple scan on random words and several prefixes, including an empty prefix and a prefix with no matches.

Trie or something else?

  • Many prefix queries on a fixed or slowly changing set of words: trie.
  • Exact lookups only: a hash set is simpler and faster. A trie is not needed.
  • Sorted list plus binary search: also finds a prefix range in O(L log n) with far less memory. It is a fair alternative if the data rarely changes.
  • Memory: a plain dictionary-per-node trie uses a lot of memory on large word sets. Compressed forms (radix tree, DAWG) and arrays for fixed alphabets reduce it.

Common mistakes

  • Forgetting the end marker, so car looks like it is stored just because card is.
  • Confusing "prefix exists" with "word exists".
  • Building the trie for a single query.
  • Returning results in arbitrary order from autocomplete.
  • Ignoring memory on millions of words, or ignoring case and accents (normalise the text first).

Main idea: a trie shares common beginnings, so prefix searches cost the length of the prefix plus the answer, not the size of the dictionary. Use it for autocomplete and prefix queries, and test it against a simple scan.


Interview prep and DSA series, Part 3 (more algorithms): 3 of 5. Previous: Heaps and priority queues explained: top-k, merging and task scheduling.

Keep reading

Also on Newssite