Tries

Imagine building the autocomplete feature for a search engine. It must suggest completions for millions of queries in microseconds. A hash table can tell you whether an exact string exists. But it cannot efficiently answer "what are all the strings starting with `pre`?" You would need to enumerate and hash every candidate. This is precisely the gap that the trie (prefix tree) was built to fill. It is a tree structure organized not by comparison, as in a binary search tree. Instead it is organized by the characters of the keys themselves. A trie stores a set of strings by decomposing each one into a path from the root. Each edge consumes one symbol of the alphabet. Keys that share a prefix share the corresponding path. So common prefixes are stored exactly once. This single structural idea gives tries a special property. No comparison-based tree or hash table can offer it directly. The cost of inserting, deleting, or searching for a key of length mm is O(m)O(m). This is completely independent of how many other keys nn are stored in the structure. Doubling the dictionary from a million words to two million does not slow down a single lookup. Tries matter in real systems far beyond spell-checkers. IP routers use a variant (the radix trie / patricia trie). It performs longest-prefix-match routing on every packet at line rate. Compilers and interpreters use tries to implement symbol tables and keyword dispatch. Predictive text, DNA sequence indexing, and autocomplete-as-you-type interfaces all lean on tries. They naturally support prefix queries — "all keys starting with X." Hash tables cannot answer such queries without a linear scan. This article builds the trie from first principles. It covers its node layout, and the three core operations: insert, search, delete. It also covers the precise complexity analysis that explains the O(m)O(m) guarantee. Finally, it covers the space-time tradeoffs that motivate compressed variants like the radix trie. We verify every claim about complexity by tracing through the recursive structure directly, rather than taking it on faith.

Key takeaways
  • A trie stores strings as root-to-node paths, one edge per character, sharing common prefixes.
  • Search, insert, and delete all run in O(m)O(m) time, where mm is the key length — independent of nn, the number of stored keys.
  • Each node holds up to ∣Σ∣|\Sigma| child pointers (one per alphabet symbol) plus a flag marking "a key ends here."
  • Prefix queries (autocomplete, "all words starting with...") are native to tries but awkward for hash tables.
  • Space is the main cost: naive tries can use O(n⋅m⋅∣Σ∣)O(n \cdot m \cdot |\Sigma|) pointers in the worst case, motivating compressed and hashed-child variants.
  • Real systems — IP routers, autocomplete engines, spell-checkers, symbol tables — rely on trie variants for prefix-aware, alphabet-independent lookups.
The core insight

A trie trades per-key comparison cost for alphabet-driven branching, so that lookup time depends only on key length, never on how many keys are stored.

The idea

A trie (from retrieval, pronounced "try" or "tree") is a tree in which each edge is labeled with a single symbol from a fixed alphabet Σ\Sigma, and each root-to-node path spells out a string — the concatenation of the edge labels along that path. A node is marked as terminal (or "is a word") if the string spelled out from the root to that node belongs to the stored set of keys. Crucially, a node can be both an internal branching point and terminal: the paths for `"car"` and `"card"` overlap entirely up through `car`, so the node reached by `c → a → r` is simultaneously the end of the key `"car"` and an ancestor of the node for `"card"`. This design enforces a powerful invariant: any two keys sharing a length-kk prefix follow identical paths for their first kk edges. The trie never stores that shared prefix twice. Contrast this with a hash table, where `"car"` and `"card"` hash to unrelated buckets with no structural relationship, or with a binary search tree, where the relationship between keys is by lexicographic comparison of whole strings, not by shared prefix structure. In a trie, the relationship is explicit and physical: it is literally a shared subpath. Because branching is driven by the alphabet rather than by comparisons, a node in a trie over an alphabet of size ∣Σ∣|\Sigma| has up to ∣Σ∣|\Sigma| children, one per possible next symbol. This is the key structural departure from binary trees (which always have at most two children regardless of the domain). The tradeoff is immediate: wider branching means shallower trees (depth equals key length mm, not log⁡n\log n), but each node potentially needs ∣Σ∣|\Sigma| pointers, most of which may be null. This is the central space–time tradeoff that every trie implementation must confront, and it motivates variants discussed below (hashed children, compressed/radix tries, ternary search tries).

How it works

Node layout. Each trie node stores:

  • an array (or map) `children[c]` for `c` in the alphabet, giving a pointer to the child reached by symbol `c`, or null/absent if no key uses that continuation;
  • a boolean flag `isEnd`, true if the path from the root to this node spells a complete stored key.

The root represents the empty string and is never itself marked `isEnd` unless the empty string is a stored key.

Search(key). Walk from the root, consuming one character of `key` per step, following `children[key[i]]`. If at any point the required child is missing, the key is absent — return false immediately. If all characters are consumed successfully, the key is present if and only if the final node's `isEnd` flag is true (a path existing is not sufficient — `"car"` existing as a path does not mean `"car"` was inserted, if only `"card"` was ever inserted).

Insert(key). Walk from the root as in search, but whenever a required child is missing, create it. After consuming all characters, mark the final node's `isEnd` flag true.

Delete(key). Walk down to the node for `key` as in search; if the key is not present, do nothing. Otherwise unmark `isEnd`. Then, to reclaim space, walk back up from that node toward the root, deleting any node that has become both non-terminal and childless — but stop as soon as a node is found that is terminal or has another child, since removing further would destroy other stored keys.

Prefix query / autocomplete. Walk to the node for the prefix string (as in search, but without requiring `isEnd`); if that path exists, every key with this prefix corresponds to a terminal node reachable via DFS from that point. This is the operation that hash tables cannot do efficiently, since hashing destroys the ordering/prefix relationship between strings. ``` structure TrieNode: children: map from symbol to TrieNode // sparse; empty by default isEnd: boolean := false structure Trie: root := new TrieNode() function SEARCH(trie, key): // returns true iff key is stored node := trie.root for i := 0 to length(key) - 1: c := key[i] if c not in node.children: return false node := node.children[c] return node.isEnd function INSERT(trie, key): node := trie.root for i := 0 to length(key) - 1: c := key[i] if c not in node.children: node.children[c] := new TrieNode() node := node.children[c] node.isEnd := true function DELETE(trie, key): path := [trie.root] // record nodes visited, for cleanup node := trie.root for i := 0 to length(key) - 1: c := key[i] if c not in node.children: return // key not present; nothing to do node := node.children[c] path.append(node) if not node.isEnd: return // key not present as a full word node.isEnd := false // prune dead nodes bottom-up for i := length(path) - 1 downto 1: current := path[i] parent := path[i - 1] if current.isEnd or SIZE(current.children) > 0: break // still needed by some other key remove current from parent.children // safe to delete // (root is never deleted) function STARTS_WITH_PREFIX(trie, prefix): // does any stored key have this prefix? node := trie.root for i := 0 to length(prefix) - 1: c := prefix[i] if c not in node.children: return false node := node.children[c] return true // reachable, regardless of isEnd function COLLECT_ALL_WITH_PREFIX(trie, prefix): node := trie.root for i := 0 to length(prefix) - 1: c := prefix[i] if c not in node.children: return [] // no key extends this prefix node := node.children[c] results := [] DFS_COLLECT(node, prefix, results) return results function DFS_COLLECT(node, built, results): if node.isEnd: results.append(built) for (c, child) in node.children: DFS_COLLECT(child, built + c, results) ```

Figure
A trie storing {car, cart, card, care, dog, do}
ε c c a a r r* "car" ends here t t* d d* e e* d d o o* "do" ends here g g* "dog" ends here Green double-ring nodes = isEnd=true. "car" is both a full key and an ancestor of "card"/"cart"/"care".

Complexity & correctness

Time complexity. Let mm be the length of the key involved in an operation, ∣Σ∣|\Sigma| the alphabet size, and nn the number of stored keys.

  • `SEARCH`, `INSERT`, `DELETE` each perform exactly one pass over the mm characters of the key, doing O(1)O(1) work per character (one array/map lookup, or one node creation) if children are stored in a direct-indexed array of size ∣Σ∣|\Sigma|. This gives O(m)O(m) time, with no dependence on nn — the number of other keys stored does not appear anywhere in the loop bound.
  • If children are stored in a hash map (common when ∣Σ∣|\Sigma| is large or sparse, e.g. Unicode), each step costs O(1)O(1) expected time, giving O(m)O(m) expected time overall, or O(mlog⁡∣Σ∣)O(m \log |\Sigma|) worst case if a balanced tree of children is used instead to avoid hash-collision attacks.
  • `COLLECT_ALL_WITH_PREFIX` costs O(p+z)O(p + z), where pp is the prefix length and zz is the total size (in characters) of all matching keys returned — you cannot avoid touching output you must produce.

This is a genuinely different complexity regime from a balanced binary search tree, which costs O(mlog⁡n)O(m \log n) per operation (log⁡n\log n comparisons, each comparison costing up to O(m)O(m) to compare two strings character by character in the worst case — so a naive BST on strings can cost O(mlog⁡n)O(m \log n), and even a hash table costs O(m)O(m) expected time just to hash the key, plus collision resolution). The trie's O(m)O(m) bound holds deterministically (no probabilistic assumption about hash distribution) and is completely insensitive to nn: a trie with a billion keys answers a query on a 5-character string exactly as fast as one with ten keys, provided the alphabet-indexed array lookup is O(1)O(1).

Correctness — invariant argument. Define the path invariant: for every node vv reachable from the root by consuming symbols s1s2…sks_1 s_2 \ldots s_k, v.isEndv.isEnd is true if and only if the string s1s2…sks_1 s_2 \ldots s_k is currently a member of the stored key set.

  • `INSERT(key)` maintains the invariant by construction: it creates exactly the missing nodes along the path for `key`'s characters (leaving all other paths, hence all other keys' terminal flags, untouched), then sets `isEnd := true` only at the node for the full string `key`. No other node's flag is altered.
  • `DELETE(key)` maintains the invariant symmetrically: it sets `isEnd := false` only at the node for `key` (only if it was previously true — a no-op is correct if `key` was absent), and the subsequent bottom-up pruning removes a node only if it is both non-terminal (`isEnd = false`, so no key ends exactly there) and childless (no key extends further through it), meaning it is not on the path to any remaining stored string. The loop halts at the first ancestor that is terminal or has another child, so it never deletes structure still needed by another key.
  • `SEARCH(key)` reads the invariant directly: it returns exactly `isEnd` of the node reached by following `key`'s characters (or `false` if that path doesn't fully exist, which correctly signals `key` is absent since no complete path means no chance of it being a terminal node for `key`).

By induction over the sequence of insert/delete operations, the invariant holds after every operation, so `SEARCH` is always correct.

Space complexity. In the worst case, a trie with nn keys of average length mm over alphabet size ∣Σ∣|\Sigma| uses O(n⋅m)O(n \cdot m) nodes (no two keys can force more than n⋅mn \cdot m distinct root-to-leaf path segments), and each node costs O(∣Σ∣)O(|\Sigma|) words if children are stored as a direct array. This gives a worst-case space bound of O(n⋅m⋅∣Σ∣)O(n \cdot m \cdot |\Sigma|) words — the dominant practical cost, especially for large alphabets like Unicode (where ∣Σ∣|\Sigma| can be tens of thousands). This is the central tradeoff: the trie buys alphabet-driven, length-only lookup time at the price of per-node overhead proportional to alphabet size. Real implementations mitigate this via:

  • Hashed children (map instead of array): reduces per-node space to O(actual branching factor)O(\text{actual branching factor}) at each node, at the cost of hash-table constant factors and losing the guarantee of worst-case O(1)O(1) per step (becomes expected O(1)O(1)).
  • Radix tries / Patricia tries: compress chains of single-child nodes into one edge labeled with a substring, collapsing long unbranching paths (e.g., a trie holding only `"international"` and `"internationally"` need not allocate 13 separate one-child nodes). This reduces node count to O(n)O(n) regardless of key length, at the cost of more complex edge-splitting logic on insert.
  • Ternary search tries: each node has only 3 children (less-than, equal, greater-than a pivot character), trading array density for a binary-search-tree-like layout per character — giving O(log⁡∣Σ∣)O(\log |\Sigma|) per character instead of O(1)O(1), but with much better space for sparse alphabets.

Worked example

Insert the keys `at`, `as`, `ate`, `cat` (in that order) into an empty trie, alphabet {a,c,e,s,t}\{a, c, e, s, t\}, then search for `at`, `a`, and `care`, then delete `ate`.

StepOperationTrie state after operation (paths shown, `*` marks isEnd)
1`INSERT("at")`root→a→t* (only path `a-t`, terminal at `t`)
2`INSERT("as")`root→a→{t, s} (node `a` now has two children, `t` and `s`)
3`INSERT("ate")`root→a→t→e (existing node `t` was already terminal for "at"; now also has a child `e` which is terminal for "ate")
4`INSERT("cat")`adds new branch root→c→a→t* (completely separate from the `a...` branch, since first character differs)

Trie now stores: `at`, `as`, `ate`, `cat`. Note the node reached by `a→t` is terminal (`"at"` is a key) and has a child `e` (leading to `"ate"`) — exactly the overlapping-terminal situation the invariant is designed to handle.

`SEARCH("at")`: root → `a` (exists) → `t` (exists) → end of string, check `isEnd` at this node → true (it was set in step 1). Returns found.

`SEARCH("a")`: root → `a` (exists) → end of string, check `isEnd` at node `a` → false (node `a` was never marked terminal; it is purely a branching point for `at`, `as`, `ate`). Returns not found — even though `"a"` is a valid prefix, it was never inserted as a key. This distinguishes "is a prefix of something" from "is a stored key."

`SEARCH("care")`: root → `c` (exists, from `"cat"`) → `a` (exists) → `r` — no child `r` exists under this `a` node (its only child is `t`). Return false immediately, without examining the rest of the string. This demonstrates the early-termination benefit: search cost is bounded by the matched prefix length, often less than mm on a failed search.

`DELETE("ate")`: walk the path `root, a, t, e` (recording it). The node `e` is terminal — set `isEnd := false`. Begin pruning upward: node `e` is now non-terminal and childless → delete it from node `t`'s children. Move to node `t`: is it terminal? Yes (`"at"` still ends here) → stop pruning. The trie now stores `at`, `as`, `cat` again, with the `e`-node reclaimed. Node `t` and its `isEnd` flag are untouched, correctly preserving `"at"`. This trace shows all three subtleties at once: a node can be both terminal and branching (`t` under `a`), a node can be a valid path but not a stored key (`a` alone), and deletion must stop pruning as soon as it hits a node still needed by another key.

Common pitfalls & traps

PitfallWhy it happensFix
Confusing "path exists" with "key stored"Forgetting to check `isEnd`, treating any reachable node as a matchAlways test the terminal flag at the end of the walk, not just path existence
Deleting too aggressivelyRemoving a node just because the target key's flag was cleared, ignoring other keys still routed through itPrune bottom-up, stopping at the first ancestor that is terminal or has another child
Ignoring the empty string edge caseRoot's own `isEnd` never checked, so inserting/searching `""` silently failsHandle `""` explicitly: it is a zero-length path that terminates at the root itself
Blowing up memory with array-per-node over large alphabetsAllocating a 65536-entry array per node for Unicode textUse hashed/sparse children, or compress chains (radix trie)
Assuming trie lookup beats hashing for exact-match-only workloadsTries shine for prefix queries, but for pure membership testing a hash table is often faster and smaller in practiceChoose the structure based on whether prefix operations are actually needed

Pioneers & history

The trie was independently introduced twice in the same year. René de la Briandais proposed a version for efficient file searching, and Edward Fredkin, working at MIT and Bolt Beranek and Newman, published the structure and coined the name "trie" (from retrieval), suggesting the word be pronounced "tree" — though common usage has largely settled on "try" to avoid confusion with the general term "tree." Fredkin's paper framed the trie explicitly as a tool for lookup in large symbol tables and dictionaries, anticipating exactly the string-processing and networking applications that would later dominate its use.

Origins · 1 minEdward Fredkin and how this was discovered
EF
Edward Fredkin
1934–2023 · Computer scientist and physicist at MIT, pioneer of digital physics and the trie data structure

Fredkin worked across an unusually wide range: he built early computer systems, directed MIT's Project MAC (a foundational time-sharing and AI research lab), and later pursued the idea that physical reality itself might be computational at its foundation ("digital physics"). His 1960 paper on the trie was a comparatively small, practical contribution amid this broader intellectual arc, but it became one of the most widely used data structures in string processing, from compilers to network routers.

History
1959
— René de la Briandais describes a prefix-tree structure for fast file searching.
1960
— Edward Fredkin publishes "Trie Memory," naming and formalizing the structure.
1968
— Donald R. Morrison introduces PATRICIA (Practical Algorithm To Retrieve Information Coded In Alphanumeric), a compressed binary trie.
1992
— Butler Lampson and coauthors advance trie-based data structures for scalable IP address lookup.
2000
s — Compressed and multi-bit tries become standard in hardware router forwarding tables for line-rate longest-prefix matching.

Where this matters

Networking: Internet routers must perform longest-prefix match against forwarding tables containing hundreds of thousands of IP prefixes, on every packet, at multi-gigabit line rates. Compressed binary tries (Patricia tries) and multi-bit trie variants are the workhorse structure for this, because the O(m)O(m) bound (with mm the fixed 32- or 128-bit address length) guarantees predictable per-packet lookup cost regardless of table size.

Autocomplete and predictive text: Search engines, IDE code completion, and mobile keyboard prediction all use trie-like structures (often augmented with frequency counts or compressed as a DAWG — directed acyclic word graph) to serve "complete this prefix" queries instantly as a user types, one keystroke at a time.

Spell-checking and dictionary lookup: Storing an entire dictionary as a trie lets a spell-checker verify a word in time proportional to the word's own length, and lets it efficiently generate nearby valid words (for suggestions) by walking neighboring paths.

Compilers and interpreters: Symbol tables, keyword recognizers, and lexical analyzers frequently use trie-based dispatch to match the longest valid token prefix at each position in source code, since this is exactly the longest-prefix-match problem tries were designed for.

Bioinformatics: Suffix tries and suffix trees (a compressed relative) index genomic sequences to support fast substring and pattern-matching queries over massive strings, leveraging the same prefix-sharing principle at the level of suffixes rather than whole words.

:::faq[Can a trie store keys that are not strings, like sequences of integers or bits]? Yes — a trie only requires that keys be sequences over some fixed, ordered alphabet; the "characters" can be bits (giving a binary trie, useful for IP routing over 32- or 128-bit addresses), bytes, or any discrete symbol set. The structure and complexity analysis are identical: search/insert/delete cost O(m) where m is the sequence length, regardless of what the symbols represent semantically.

Ask the tutor: How does a radix (Patricia) trie modify the insert algorithm to split a compressed edge when a new key diverges partway through it?

Is a trie always faster than a hash table?

Not necessarily. For pure exact-match membership testing with no prefix queries, a well-tuned hash table often wins on both time (O(1) expected, small constant) and space (no per-character node overhead). Tries become clearly superior when you need prefix-based operations — autocomplete, longest-prefix match, "all keys starting with X" — which a hash table cannot answer without scanning every key. The right choice depends on the query workload, not just on raw key-lookup speed.

Why is trie search O(m) instead of O(log n) like a balanced BST?

A trie's depth equals the length of the key, mm, because each level consumes exactly one character — it has nothing to do with how many other keys are stored. A balanced BST's depth is O(log⁡n)O(\log n) because it halves the candidate set at each comparison, but each of those O(log⁡n)O(\log n) comparisons on strings can itself cost up to O(m)O(m) character comparisons, making a naive string BST actually O(mlog⁡n)O(m \log n) in the worst case — slower than a trie's clean O(m)O(m).

What happens if two keys are identical except one is a prefix of the other, like "car" and "card"?

Both are stored correctly as separate keys, because the trie tracks terminal status per node independently of branching. The node at the end of "car" is marked `isEnd = true`, and it also has a child edge labeled `d` leading further to the node for "card," which is separately marked `isEnd = true`. Neither key's flag interferes with the other's; that is the entire point of separating "path exists" from "key stored" in the design.

How do you handle a very large alphabet like Unicode in a trie?

Direct-indexed arrays of size |Σ| per node become impractical when |Σ| is in the tens of thousands (as with Unicode code points), since most entries would be null. The standard fix is to replace the array with a hash map or sorted small array per node, storing only the children that actually exist. This keeps space proportional to actual branching factor rather than alphabet size, at the cost of expected rather than guaranteed O(1) per-character step.

What is a radix trie / Patricia trie, and why does it matter?

A radix trie (or Patricia trie) compresses any chain of nodes that each have exactly one child into a single edge labeled with the whole substring, rather than one edge per character. This collapses long unbranching runs — common with real-world text and IP prefixes — down to O(n) total nodes regardless of key length, dramatically reducing memory versus a naive trie, at the cost of more complex insertion logic that must sometimes split an existing compressed edge.

Does a trie support ordered traversal like an in-order walk of a BST?

Yes, and this is often underappreciated: a depth-first traversal of a trie visiting children in alphabetical order yields all stored keys in sorted lexicographic order automatically, since the trie's branching structure already encodes lexicographic order at every node. This gives tries a built-in sorted-enumeration capability that a hash table fundamentally cannot offer without an explicit sort step.

Before this