Tries and Prefix Trees
How a trie stores strings by shared prefixes, its operations and costs, the memory trade-off of fixed child arrays, and applications from autocomplete to maximum-XOR queries.
What you will learn
How a trie stores strings by shared prefixes, its operations and costs, the memory trade-off of fixed child arrays, and applications from autocomplete to maximum-XOR queries.
Prerequisites: Modules 6 and 11.
Concepts
Trie nodes, edges, end-of-word marker, insert, exact search, prefix search, deletion, lowercase alphabet versus map-based child storage, memory trade-offs.
Example
Explain that this compact node assumes lowercase English letters and requires input validation; Unicode/general alphabets need another representation.
Output (g++ 11.2, -std=c++17, warnings as errors, AddressSanitizer + UndefinedBehaviorSanitizer; Ubuntu 22.04 on WSL2):
Insert, search and prefix count are O(L) for a word of length L, independent of how many words are stored. Collecting all completions is depth-first, so they come out in lexicographic order. The 26-pointer node costs over 200 bytes even when it has one child; a hash map or sorted vector of children (or a compressed trie) trades speed for memory.
struct TrieNode {
TrieNode* child[26]{};
bool terminal = false;
};#include <bits/stdc++.h>
using namespace std;
struct TrieNode {
TrieNode* child[26]{};
bool terminal = false;
int pass = 0; // words passing through: prefix counts
};
struct Trie {
TrieNode root;
static bool valid(const string& w) { return all_of(w.begin(), w.end(), [](char c) { return c >= 'a' && c <= 'z'; }); }
void insert(const string& w) {
if (!valid(w)) throw invalid_argument("only a-z supported: " + w);
TrieNode* n = &root;
for (char c : w) {
int i = c - 'a';
if (!n->child[i]) n->child[i] = new TrieNode();
n = n->child[i];
++n->pass;
}
n->terminal = true;
}
const TrieNode* walk(const string& p) const {
const TrieNode* n = &root;
for (char c : p) { if (c < 'a' || c > 'z' || !(n = n->child[c - 'a'])) return nullptr; }
return n;
}
bool contains(const string& w) const { auto n = walk(w); return n && n->terminal; }
int countPrefix(const string& p) const { auto n = walk(p); return n ? n->pass : 0; }
void collect(const TrieNode* n, string& cur, vector<string>& out) const {
if (n->ter
…contains car: true, contains ca: false
words with prefix "car": 4
autocomplete "car": car card care cart
node size: 216 bytes (26 pointers + flags)
rejected: only a-z supported: CaféAdvanced topics
Compressed/radix tries, ternary search tries, bitwise tries for XOR queries, autocomplete ranking, prefix counting, dictionary matching, and memory optimization.
Output (g++ 11.2, -std=c++17, warnings as errors, AddressSanitizer + UndefinedBehaviorSanitizer; Ubuntu 22.04 on WSL2):
Choosing the opposite bit at the highest possible position is optimal because a 1 in bit b outweighs all lower bits combined. O(32) per query instead of O(n).
#include <bits/stdc++.h>
using namespace std;
// Maximum XOR of any two numbers: insert bits from high to low, greedily take the opposite bit
struct BitTrie {
vector<array<int, 2>> next{{-1, -1}};
void insert(unsigned x) {
int n = 0;
for (int b = 31; b >= 0; --b) {
int bit = x >> b & 1;
if (next[n][bit] == -1) { next[n][bit] = (int)next.size(); next.push_back({-1, -1}); }
n = next[n][bit];
}
}
unsigned bestXor(unsigned x) const {
int n = 0; unsigned r = 0;
for (int b = 31; b >= 0; --b) {
int want = !(x >> b & 1);
if (next[n][want] != -1) { r |= 1u << b; n = next[n][want]; } else n = next[n][!want];
}
return r;
}
};
int main() {
vector<unsigned> a = {3, 10, 5, 25, 2, 8};
BitTrie t; unsigned best = 0;
for (unsigned x : a) { t.insert(x); best = max(best, t.bestXor(x)); }
unsigned brute = 0;
for (unsigned x : a) for (unsigned y : a) brute = max(brute, x ^ y);
cout << "max XOR = " << best << " (brute force " << brute << ")\n";
}max XOR = 28 (brute force 28)Practice
Easy: implement startsWith.
Medium: word search II — find all dictionary words in a letter grid (DFS over the grid, walking the trie).
Hard: add deletion that frees nodes no longer on any word's path.
Quiz
? Search for a word of length L in a trie holding n words costs…
+ O(L) :: One step per character, independent of n.
- O(log n) :: That is a balanced BST lookup (times comparison cost).
- O(n) :: No.
- O(n·L) :: That is a linear scan of the dictionary.
? `contains("ca")` returned false although "car" is stored. Why?
+ The node for "ca" exists but is not marked terminal. :: The end-of-word flag distinguishes words from prefixes.
- "ca" is not a valid word in English. :: The trie has no dictionary knowledge.
- Tries cannot store two-letter words. :: They can.
- A bug. :: Correct behaviour.
? When should you avoid the 26-pointer node? (select all that apply)
+ Large or Unicode alphabets :: Fixed arrays cannot represent them.
+ Memory-constrained sets of long, sparse words :: Most pointers stay null.
- Lowercase-only dictionaries where speed matters :: That is its best case.
+ When input is not validated :: An out-of-range index is undefined behaviour.