Tries
Think of it like this
Imagine a dictionary organized as a branching path. Instead of one entry per word, you share common prefixes. "cat", "car", "card", and "care" all start with "c-a" — so you only store that path once.
Words: cat, car, card, care, bat, ball
root
/ \
c b
| |
a a
/ \ / \
t r t l
| |
d l
↑ ↑
"card" "ball"Every path from root to a marked node is a word. Shared prefixes cost nothing extra. This makes prefix queries — "show me all words starting with 'ca'" — extremely fast.
Why Not Just Use a Hash Map?
A hash map gives O(1) exact lookup but has no concept of prefix relationships. With a hash map you'd need to scan ALL words to find those starting with "ca". With a trie, you walk the "c" → "a" path and then enumerate everything below — only what's relevant.
| Operation | Hash Map | Trie |
|---|---|---|
| Exact lookup | O(1) | O(L) |
| Prefix search | O(n × L) — scan all | O(L + k) — walk prefix, enumerate k matches |
| Autocomplete | O(n × L) | O(L + k) |
| Longest prefix match | O(n × L) | O(L) |
Node Structure and Implementation
class TrieNode {
constructor() {
this.children = {}; // char → TrieNode
this.isEnd = false; // true if a word ends here
this.count = 0; // optional: how many words pass through this node
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
// Insert a word — O(L)
insert(word) {
let node = this.root;
for (const ch of word) {
if (!node.children[ch]) {
node.children[ch] = new TrieNode();
}
node = node.children[ch];
node.count++; // optional: track words passing through
}
node.isEnd = true;
}
// Exact search — O(L)
search(word) {
let node = this.root;
for (const ch of word) {
if (!node.children[ch]) return false;
node = node.children[ch];
}
return node.isEnd; // must be a complete word, not just a prefix
}
// Prefix check — O(L)
startsWith(prefix) {
let node = this.root;
for (const ch of prefix) {
if (!node.children[ch]) return false;
node = node.children[ch];
}
return true; // prefix exists (may or may not be a complete word)
}
// Delete a word — O(L)
delete(word) {
function _delete(node, word, depth) {
if (!node) return false;
if (depth === word.length) {
if (!node.isEnd) return false;
node.isEnd = false;
return Object.keys(node.children).length === 0; // safe to delete?
}
const ch = word[depth];
if (!node.children[ch]) return false;
const shouldDelete = _delete(node.children[ch], word, depth + 1);
if (shouldDelete) {
delete node.children[ch];
return !node.isEnd && Object.keys(node.children).length === 0;
}
return false;
}
_delete(this.root, word, 0);
}
}Time and Space Complexity
| Operation | Complexity |
|---|---|
| Insert | O(L) where L = word length |
| Search (exact) | O(L) |
| Prefix search | O(L) |
| Autocomplete (get all) | O(L + k) where k = number of matches |
| Delete | O(L) |
Space: O(n × L × Σ) where n = words, L = avg length, Σ = alphabet size (26 for lowercase English)
Key insight: Lookup time is O(L) — independent of how many words are stored. A dictionary with 1 million words has the same lookup speed as one with 100 words.
Autocomplete Implementation
class AutocompleteTrie extends Trie {
// Find all words with given prefix
autocomplete(prefix) {
let node = this.root;
for (const ch of prefix) {
if (!node.children[ch]) return []; // prefix doesn't exist
node = node.children[ch];
}
// DFS from this node to collect all words below
const results = [];
this._dfs(node, prefix, results);
return results;
}
_dfs(node, current, results) {
if (node.isEnd) results.push(current);
for (const [ch, child] of Object.entries(node.children)) {
this._dfs(child, current + ch, results);
}
}
}
const trie = new AutocompleteTrie();
['react', 'redux', 'remix', 'reason', 'recoil', 'resolve'].forEach(w => trie.insert(w));
trie.autocomplete('re'); // → ['react', 'redux', 'remix', 'reason', 'recoil', 'resolve']
trie.autocomplete('red'); // → ['redux']
trie.autocomplete('rem'); // → ['remix']Replace Words with Shortest Root
Given a dictionary of root words, replace any word in a sentence that has a matching root prefix with the shortest root:
function replaceWords(dictionary, sentence) {
const trie = new Trie();
for (const root of dictionary) trie.insert(root);
return sentence.split(' ').map(word => {
// Walk the trie — stop at first complete word (shortest root)
let node = trie.root;
let prefix = '';
for (const ch of word) {
if (!node.children[ch]) break;
prefix += ch;
node = node.children[ch];
if (node.isEnd) return prefix; // found shortest root
}
return word; // no root found, keep original
}).join(' ');
}
// dictionary=["cat","bat","rat"], sentence="the cattle was rattled by the battery"
// → "the cat was rat by the bat"Word Search II (Trie + DFS Backtracking)
Find all words from a dictionary that exist in an m×n character board. Trie prunes impossible branches early.
function findWords(board, words) {
const trie = new Trie();
words.forEach(w => trie.insert(w));
const result = new Set();
const m = board.length, n = board[0].length;
function dfs(node, row, col, path) {
if (row < 0 || row >= m || col < 0 || col >= n) return;
const ch = board[row][col];
if (ch === '#' || !node.children[ch]) return; // visited or not in trie
const nextNode = node.children[ch];
const nextPath = path + ch;
if (nextNode.isEnd) result.add(nextPath);
board[row][col] = '#'; // mark visited
dfs(nextNode, row+1, col, nextPath);
dfs(nextNode, row-1, col, nextPath);
dfs(nextNode, row, col+1, nextPath);
dfs(nextNode, row, col-1, nextPath);
board[row][col] = ch; // restore
}
for (let r = 0; r < m; r++)
for (let c = 0; c < n; c++)
dfs(trie.root, r, c, '');
return [...result];
}Trie (Prefix Tree) Insertion
Start with an empty root node. We want to insert 'CAT'.
Compact Trie (Patricia/Radix Tree)
A standard trie has many single-child nodes (wasted space). A compressed trie merges chains of single-child nodes:
Standard trie for "smart", "smartphone": Compressed trie:
s → m → a → r → t (end) "smart" (end)
↓ ↓
p → h → o → n → e (end) "phone" (end)
Compressed: "smart" node → "phone" node (stores whole strings, not chars)
Used in: ip routing tables, Unix file systems, autocomplete at scaleReal-World Frontend Application
- Browser address bar autocomplete: As you type, the browser searches a trie of your bookmarks and history to suggest completions in O(L) — not O(n × L) linear scan
- Search-as-you-type: The instant suggestions in Google Search, Algolia, Elasticsearch all use trie-based prefix indexing on the server
- Spell checkers: Walk the trie with fuzzy matching (Levenshtein distance ≤ k) to find "did you mean?" suggestions
- IP routing (Longest Prefix Match): Routers store routing table as a binary trie. Each hop queries it to find the longest matching network prefix for the destination IP — O(32) for IPv4
Common Mistakes
| Mistake | What Goes Wrong | Fix |
|---|---|---|
| Returning true for prefix when checking exact word | search("ca") returns true even if "ca" isn't a word | Check node.isEnd, not just that the node exists |
| Forgetting to restore board in Word Search II | Cells stay marked '#' and block valid paths | Always restore board[r][c] = ch after backtracking |
| Building trie one character per level when words are long | Deep call stack / timeout | For very long strings, use iterative insertion |
| Using array of 26 instead of map for children | Works for lowercase English only | Use {} map for flexibility (Unicode, numbers, etc.) |
Key Problems to Solve
| # | Problem | Technique | Difficulty |
|---|---|---|---|
| 1 | Implement Trie | Core insert/search/prefix | Medium |
| 2 | Search Suggestions System | Trie + sort suggestions | Medium |
| 3 | Replace Words | Trie shortest prefix match | Medium |
| 4 | Longest Word in Dictionary | BFS on trie (only expand complete words) | Medium |
| 5 | Map Sum Pairs | Trie storing prefix sums | Medium |
| 6 | Word Search II | Trie + DFS backtracking on board | Hard |
| 7 | Design Search Autocomplete System | Trie + frequency ranking | Hard |
| 8 | Maximum XOR of Two Numbers | Binary trie (bits instead of chars) | Medium |