Trie (prefix tree)
A tree of letters where words that share a start share nodes. Checking a word or a prefix takes one step per letter.
A tree where each edge is a letter and each path from the root spells a prefix. A flag on a node marks where a whole word ends.
You ask "does any word start with this?", build autocomplete, or match many words against text at once.
O(L) per operation, L = word length
O(total letters inserted)
You’ll recognise it when
- The question is about prefixes: “does any word start with
pre?”, “how many words start with it?”, “suggest words as the user types”. - You have a dictionary of many words and must match all of them against some text or a letter grid at the same time.
- You need the longest dictionary word that is a prefix of a string, as in a tokenizer or a router matching paths.
- The keys are bit strings and you want the number that differs the most from a given one (maximum XOR).
If you only ever ask “is this exact word in the list?”, a hash set is simpler and just as fast. The trie earns its keep when prefixes matter.
The idea
Think of a paper dictionary with thumb tabs. To find “cart” you open the C section, then the CA pages, then CAR, and each step throws away everything that doesn’t start the same way. “car”, “cat” and “cart” all live behind the same C and CA tabs; nobody prints those letters three times.
A trie is those tabs as a tree. Each edge is one letter, so the path from the root to a node spells a prefix. Words with the same start share the same path, and a flag, is_end, marks the nodes where a whole word stops. To look anything up you spell it from the root, one letter per step. How many other words are stored doesn’t matter.
How it works
Each node holds a map from letter to child and an is_end flag. Scroll through the steps and the graphic follows along: it inserts “car”, “cat”, “cart” and “dog”, then runs four queries.
- Start with a single empty root. It stands for the empty prefix, which every word starts with.
- Insert a word by walking from the root with
node. For each letterch, ifnodehas no child forch, create one. Then movenodedown to that child. - Mark the end. After the last letter, set
is_end = True. Without the flag, the trie couldn’t tell the word “car” from the first three letters of “cart”. - Reuse shared prefixes. Inserting “cat” finds
candaalready there and only addst. A word only costs new nodes for the part nobody has stored yet. - Search walks the same way but never creates anything. “cart” is spelled all the way to a node with
is_endset, so it’s a stored word. - “ca” is spelled all the way too, but that node’s
is_endis false. It’s a prefix of stored words, not a word itself, sosearchsays false. starts_withasks the weaker question. The walk for “ca” succeeded, so some stored word starts with it: true.- A missing letter ends the walk early. There’s no
ounderc, so no stored word starts with “co”, andsearch("cow")is false after two looks.
Why it’s correct: for a non-empty s, the node reached by spelling it exists exactly when some inserted word starts with s, because insert creates the node for every prefix of every word and nothing else creates nodes. is_end on that node is true exactly when s itself was inserted. The empty prefix never leaves the root, which always exists, so starts_with("") is true even in an empty trie; most problems expect exactly that.
class TrieNode:
def __init__(self):
self.children = {} # letter -> TrieNode
self.is_end = False # does a whole word end here?
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def _walk(self, s):
"""The node reached by spelling s from the root, or None."""
node = self.root
for ch in s:
if ch not in node.children:
return None
node = node.children[ch]
return node
def search(self, word):
node = self._walk(word)
return node is not None and node.is_end
def starts_with(self, prefix):
return self._walk(prefix) is not None#include <memory>
#include <string>
#include <unordered_map>
using namespace std;
struct TrieNode {
unordered_map<char, unique_ptr<TrieNode>> children; // letter -> child
bool is_end = false; // does a whole word end here?
};
class Trie {
unique_ptr<TrieNode> root = make_unique<TrieNode>();
// The node reached by spelling s from the root, or nullptr.
TrieNode* walk(const string& s) const {
TrieNode* node = root.get();
for (char ch : s) {
auto it = node->children.find(ch);
if (it == node->children.end()) return nullptr;
node = it->second.get();
}
return node;
}
public:
void insert(const string& word) {
TrieNode* node = root.get();
for (char ch : word) {
auto& child = node->children[ch];
if (!child) child = make_unique<TrieNode>();
node = child.get();
}
node->is_end = true;
}
bool search(const string& word) const {
TrieNode* node = walk(word);
return node != nullptr && node->is_end;
}
bool starts_with(const string& prefix) const {
return walk(prefix) != nullptr;
}
};import java.util.HashMap;
import java.util.Map;
class TrieNode {
Map<Character, TrieNode> children = new HashMap<>(); // letter -> child
boolean is_end = false; // does a whole word end here?
}
class Trie {
private final TrieNode root = new TrieNode();
void insert(String word) {
TrieNode node = root;
for (char ch : word.toCharArray()) {
if (!node.children.containsKey(ch)) {
node.children.put(ch, new TrieNode());
}
node = node.children.get(ch);
}
node.is_end = true;
}
// The node reached by spelling s from the root, or null.
private TrieNode walk(String s) {
TrieNode node = root;
for (char ch : s.toCharArray()) {
TrieNode child = node.children.get(ch);
if (child == null) return null;
node = child;
}
return node;
}
boolean search(String word) {
TrieNode node = walk(word);
return node != null && node.is_end;
}
boolean startsWith(String prefix) {
return walk(prefix) != null;
}
}Why it’s O(L)
Every operation does one child lookup per letter and stops. For a word of length L that is L steps, whether the trie holds ten words or ten million. A search that falls off early is even cheaper.
| Operation | Trie | Hash set of words | Sorted list |
|---|---|---|---|
| insert | O(L) | O(L) | O(n) to shift elements |
| search whole word | O(L) | O(L) | O(L log n) |
| any word starts with p? | O(len p) | O(n · len p): check every word | O(len p · log n) |
The price is memory. The trie has at most one node per inserted letter, so O(total letters), and fewer when prefixes are shared. But every node is an object with its own map or array. A node with a 26-slot array spends 26 pointers even if it has one child, so a trie is often several times bigger than the plain strings. Dictionary children only store the letters that are used, at the cost of hashing each step.
Common mistakes
Search that forgets is_end
Reaching the last letter only proves the word is a prefix of something stored. With “cart” stored, “car” reaches a node too.
return node is not None # ✗ true for "car" when only "cart" was inserted
return node is not None and node.is_end # ✓ a whole word ends here
Creating a child that already exists
Assigning a new node without checking throws away the whole branch under it, and every word stored there.
node.children[ch] = TrieNode() # ✗ wipes out "car" when inserting "cat"
if ch not in node.children: # ✓ only create what's missing
node.children[ch] = TrieNode()
Deleting shared nodes
To delete “car” while “cart” is stored, you can’t remove the c, a, r nodes: “cart” runs through them. Clear is_end, then only prune nodes from the bottom up while they have no children and no is_end of their own.
An end marker that can be a letter
The dict-of-dicts shortcut marks word ends with a key like "$". If a word can contain $, that key becomes a real edge and a word appears to end where it doesn’t. Use a separate is_end field, as above, or a key that can’t be a character.
Variations
- Counting words with a prefix. Store a counter on each node and add 1 to every node on the insert path. “How many words start with p?” is then a walk plus reading one number.
- Autocomplete. Walk to the prefix’s node, then DFS below it to list the words there. Store the top few suggestions at each node if you need them instantly.
- Word search on a grid. Put all dictionary words in a trie and run one DFS from each cell, walking the trie in step with the path. Abandon a path the moment its letters leave the trie, which prunes almost everything.
- Dict of children vs a 26-slot array. Arrays are faster to index and simpler in C++ and Java, but every node pays for 26 slots. Dicts store only the letters used, which suits large or sparse alphabets like Unicode.
- XOR trie. Insert numbers as bit strings from the highest bit down, with children 0 and 1. To find the stored number with the largest XOR against
x, walk down choosing the opposite bit whenever that child exists: O(bits) per query.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
Words arrive one at a time, and between inserts you’re asked “how many words so far start with
pre?”. There are up to 10^5 of each. What gives O(length) per operation?Add 1 to a counter on each node along the insert path; a query walks the prefix and reads that node’s counter. Both cost O(length). The sorted list answers queries fast, but
insortshifts elements, so each insert is O(n). The set and the word-count map have to look at every word per query. -
2
This trie is a dict of dicts, with the key
"$"marking the end of a word. What does it print?root = {}def insert(w):node = rootfor ch in w:node = node.setdefault(ch, {})node["$"] = Truefor w in ["app", "apple", "ape"]:insert(w)node = root["a"]["p"]print(len(root), len(node), "$" in node["p"])All three words start with
athenp, so the root has one child. Underapthe children arepande: 2 keys. Theappnode holds both$(the wordappends there) andl. The root has 1 key, not 3, because shared prefixes are stored once. -
3
Only
appleis stored. What does this print?root = {}node = rootfor ch in "apple":node = node.setdefault(ch, {})node["$"] = Truedef walk(s):node = rootfor ch in s:if ch not in node:return Nonenode = node[ch]return noden = walk("app")print(n is not None and "$" in n, n is not None, walk("") is root)A whole-word search for
appneeds the end marker, and only the lastenode ofapplehas it: False. A prefix check only needs the walk to succeed: True. Walking the empty string never leaves the root, so an empty prefix matches every stored word. -
4
This
inserthas a bug. What does the program print?class Node:def __init__(self):self.children = {}self.is_end = Falseroot = Node()def insert(word):node = rootfor ch in word:node.children[ch] = Node() # bugnode = node.children[ch]node.is_end = Truedef search(word):node = rootfor ch in word:if ch not in node.children:return Falsenode = node.children[ch]return node.is_endinsert("car")insert("cat")print(search("car"), search("cat"))Inserting
catreplaces the existingcchild with a brand-new empty node, throwing away the wholecarbranch.catis found,caris gone. Only create a child whench not in node.children; otherwise reuse the one that’s there. -
5
A trie holds n words. How long does
searchtake for a word of length L, with a hash map of children at each node?Each letter is one child lookup, O(1) on average, and there are L letters. The number of stored words never enters into it. O(L log n) is what binary search over a sorted word list costs, and O(n · L) is comparing against every word.
Practice problems
- easy Longest Common Prefix leetcode.com
- medium Implement Trie (Prefix Tree) leetcode.com
- medium Replace Words leetcode.com
- medium Design Add and Search Words Data Structure leetcode.com
- medium Search Suggestions System leetcode.com
- medium Maximum XOR of Two Numbers in an Array leetcode.com
- hard Word Search II leetcode.com