~/data-structures/trie

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.

what

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.

use when

You ask "does any word start with this?", build autocomplete, or match many words against text at once.

time

O(L) per operation, L = word length

space

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.

  1. Start with a single empty root. It stands for the empty prefix, which every word starts with.
  2. Insert a word by walking from the root with node. For each letter ch, if node has no child for ch, create one. Then move node down to that child.
  3. 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”.
  4. Reuse shared prefixes. Inserting “cat” finds c and a already there and only adds t. A word only costs new nodes for the part nobody has stored yet.
  5. Search walks the same way but never creates anything. “cart” is spelled all the way to a node with is_end set, so it’s a stored word.
  6. “ca” is spelled all the way too, but that node’s is_end is false. It’s a prefix of stored words, not a word itself, so search says false.
  7. starts_with asks the weaker question. The walk for “ca” succeeded, so some stored word starts with it: true.
  8. A missing letter ends the walk early. There’s no o under c, so no stored word starts with “co”, and search("cow") is false after two looks.
loading trie…

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. 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?

  2. 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 = root
    for ch in w:
    node = node.setdefault(ch, {})
    node["$"] = True
    for w in ["app", "apple", "ape"]:
    insert(w)
    node = root["a"]["p"]
    print(len(root), len(node), "$" in node["p"])
  3. 3

    Only apple is stored. What does this print?

    root = {}
    node = root
    for ch in "apple":
    node = node.setdefault(ch, {})
    node["$"] = True
    def walk(s):
    node = root
    for ch in s:
    if ch not in node:
    return None
    node = node[ch]
    return node
    n = walk("app")
    print(n is not None and "$" in n, n is not None, walk("") is root)
  4. 4

    This insert has a bug. What does the program print?

    class Node:
    def __init__(self):
    self.children = {}
    self.is_end = False
    root = Node()
    def insert(word):
    node = root
    for ch in word:
    node.children[ch] = Node() # bug
    node = node.children[ch]
    node.is_end = True
    def search(word):
    node = root
    for ch in word:
    if ch not in node.children:
    return False
    node = node.children[ch]
    return node.is_end
    insert("car")
    insert("cat")
    print(search("car"), search("cat"))
  5. 5

    A trie holds n words. How long does search take for a word of length L, with a hash map of children at each node?

Practice problems

Further reading

esc