Lesson 204 min read

Tries (Prefix Trees)

November 05, 2026

// TL;DR

A TRIE (prefix tree) stores strings as a tree keyed by characters, so words sharing a prefix share a path. Insert and search a word in O(word length) — independent of how many words are stored — which a hash set cannot match for PREFIX queries. Build it in Python as a dict of dicts, marking word-ends with a sentinel key. It solves autocomplete/typeahead, 'does any word start with this prefix', word dictionaries with wildcards, and — storing numbers bit by bit — maximum-XOR problems. Signals: 'prefix', 'starts with', 'autocomplete', 'dictionary of words', 'search with .' wildcards.

A hash set can tell you whether an exact word exists, but it is useless the moment a question mentions prefixes — “does any word start with app?”, “autocomplete this”, “find the longest word buildable one letter at a time”. That is the trie’s home turf, and it is a small, elegant structure once you see it.

The idea

A trie storing the words cat, car, and dog. From the root, one path goes c, a, then branches to t and r (the ends of cat and car), and another path goes d, o, g (the end of dog). Words that share a prefix share the same path, and each word-end node is marked. Looking up or inserting a word means walking its characters from the root, which costs O(word length) regardless of how many words the trie holds.

A trie (pronounced “try”, from re-trie-val) is a tree where each edge is labelled with a character. A word is a path from the root; a boolean marker on the final node says “a word ends here”. Because cat and car share the prefix ca, they share that path and only diverge at the last letter. Insert and search both cost O(length of the word) — crucially, this does not depend on how many words are already stored, which is what makes prefix queries fast.

In Python the cleanest implementation is a dict of dicts, using a sentinel key (here '$') to mark the end of a word — no node class required.

Worked example: implement a trie

The three core operations — insert, search (exact), and startsWith (prefix) — are almost the same walk down the tree:

class Trie:
    def __init__(self):
        self.root = {}
    def insert(self, word):
        node = self.root
        for c in word:
            node = node.setdefault(c, {})   # create the child if missing
        node['$'] = True                    # mark end of a word
    def search(self, word):
        node = self.root
        for c in word:
            if c not in node: return False
            node = node[c]
        return '$' in node                  # exact match only if a word ends here
    def startsWith(self, prefix):
        node = self.root
        for c in prefix:
            if c not in node: return False
            node = node[c]
        return True                         # the path exists → some word has this prefix
# Trie().insert("apple"); search("apple") -> True; search("app") -> False; startsWith("app") -> True

The only difference between search and startsWith is the final line: an exact search demands a word-end marker, a prefix search just needs the path to exist.

Supporting wildcards

Add a single branch and the same structure handles . wildcards (the classic “Add and Search Word” problem): at a ., recurse into every child instead of one.

def search(self, word):
    def dfs(node, i):
        if i == len(word): return '$' in node
        c = word[i]
        if c == '.':
            return any(dfs(node[k], i + 1) for k in node if k != '$')
        return c in node and dfs(node[c], i + 1)
    return dfs(self.root, 0)

The bit-trie trick

Tries are not only for letters. Store each number as a 32-bit path (most significant bit first) and you get a bit trie, which solves maximum XOR problems: to maximise a ^ b, walk the trie greedily always taking the opposite bit when it exists, because a differing bit contributes a 1 to the XOR at that position.

def findMaximumXOR(nums):
    root = {}
    for num in nums:                        # insert every number as a 32-bit path
        node = root
        for i in range(31, -1, -1):
            b = (num >> i) & 1; node = node.setdefault(b, {})
    best = 0
    for num in nums:                        # for each number, greedily pick opposite bits
        node = root; cur = 0
        for i in range(31, -1, -1):
            b = (num >> i) & 1; want = 1 - b
            if want in node: cur |= (1 << i); node = node[want]
            else:            node = node[b]
        best = max(best, cur)
    return best
# findMaximumXOR([3,10,5,25,2,8]) -> 28

Step-by-step walkthrough

Trace a trie as we insert car, cat, cart and then query it. Shared prefixes reuse existing nodes, and an end-of-word marker ($) distinguishes a stored word from a mere prefix.

operation what happens result
insert “car” new nodes: c, a, r mark end
insert “cat” new nodes: t mark end
insert “cart” new nodes: t mark end
search “car” walk c → a → r True
search “ca” walk c → a False
startsWith “ca” walk c → a True
search “cab” walk c → a → b False

Note search("ca") is False (no word ends there) but startsWith("ca") is True — the end marker is the only difference. Every operation costs O(length of the word), regardless of how many words are stored.

When to reach for it

Signals: “prefix”, “starts with”, “autocomplete / typeahead / suggestions”, “dictionary of words”, “search with . wildcards”, or “maximum XOR pair”. More generally, reach for a trie whenever you need to query many strings by shared prefix rather than by exact value — a hash set can do exact membership, but only a trie makes prefixes cheap. It is also the backbone of the Typeahead system-design problem.

Pitfalls

  • Forgetting the end marker. Without a per-node “word ends here” flag, you cannot tell a stored word from a mere prefix — search("app") would wrongly return true just because apple exists.
  • Memory. A trie can use a lot of pointers for sparse, long strings. Mention that an array-of-26-children node is faster but heavier than a dict; a dict is usually fine in an interview.
  • Using a trie when a set suffices. If the problem only ever asks for exact membership and never prefixes, a hash set is simpler and faster. The trie earns its keep on prefix work.

Recap

A trie stores strings as a character-keyed tree so shared prefixes share a path, giving O(word length) insert and search independent of the number of words. Build it as a dict of dicts with a sentinel end-marker; search needs the marker, startsWith just needs the path. A .-wildcard is one DFS branch away, and storing numbers bit-by-bit turns the same structure into a bit trie for maximum-XOR problems. Signal: prefix, starts-with, autocomplete, word dictionary.

Practice: 10 solved problems on tries → — cover each one, solve it yourself, then check the tested code.

Next: Lesson 21 — Bit Manipulation.

exercisePractice this patternTries — Practice Problemsarrow_forward