DSA Strings & Trees
Trie (Prefix Tree)
Autocomplete stalls when every keystroke rescans a million-word dictionary. Exact membership in a hash set is fine; “all words starting with typ” is not.
A trie stores strings so share nodes. The plus a separate “path exists” from “word exists.”
Algorithm journey
Insert
Follow or create one edge per symbol; mark terminal.
Search
Path plus terminal means membership.
Prefix query
Reachability of the prefix node is enough.
Delete carefully
Clear terminal; prune only unused nodes.
What actually happens
Naive idea: store every word in a list or hash set; for each prefix, scan all keys. Correct, and costly per keystroke at dictionary scale.
Better idea: index by characters. Walk the prefix once in O(P), then enumerate the subtree. Shared structure pays for common stems.
The improved design still fails with wrong units, aggressive delete pruning that removes shared nodes, unbounded memory, or using a trie when only exact membership was needed.
Invariant
The symbols on every root-to-node path spell exactly one stored prefix. A node is terminal iff that prefix is an inserted word. Deletion clears the terminal and prunes upward only while a node is nonterminal and childless.
How to choose a trie
1. Prefix queries or autocomplete? Trie (or sorted array + lower bound for static data).
2. Exact membership only? Prefer a hash set.
3. Memory tight on long chains? Consider or an FST for immutable dictionaries.
Quantitative check
Prefix walk is O(P). Returning k suggestions also costs output traversal: never call the whole feature O(P). One million words with sharing still allocate millions of nodes; measure edges and payloads.
Key characteristics
Root is Empty
The root node doesn't store any character; it's the starting point for all words.
Shared Prefixes
Words with common prefixes share the same nodes up to the point where they differ.
EndOfWord Marker
Nodes often have a boolean flag to indicate the completion of a valid word.
🎮 Interactive trie
Insert lowercase words and watch prefixes merge. The root is labeled ∅ (empty). A green dot marks the end of a complete word. Use the prefix field to highlight the path you would follow for startsWith.
💻 Implementation
Python
class TrieNode:
def __init__(self):
self.children = {}
self.is_end_of_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end_of_word = True
def search(self, word):
node = self.root
for char in word:
if char not in node.children:
return False
node = node.children[char]
return node.is_end_of_word
def starts_with(self, prefix):
node = self.root
for char in prefix:
if char not in node.children:
return False
node = node.children[char]
return True
def delete(self, word):
def erase(node, i):
if i == len(word):
if not node.is_end_of_word:
return False, False
node.is_end_of_word = False
return True, not node.children
child = node.children.get(word[i])
if child is None:
return False, False
deleted, prune = erase(child, i + 1)
if prune:
del node.children[word[i]]
return deleted, (not node.is_end_of_word and not node.children)
deleted, _ = erase(self.root, 0)
return deleted
trie = Trie()
for value in ("app", "apple", "café"): trie.insert(value)
assert trie.search("app") and trie.starts_with("ap")
assert trie.delete("app") and not trie.search("app") and trie.search("apple")Java
import java.util.HashMap;
import java.util.Map;
import java.util.PrimitiveIterator;
class Trie {
private static final class Node {
final Map<Integer, Node> children = new HashMap<>();
boolean terminal;
}
private final Node root = new Node();
public void insert(String word) {
Node node = root;
PrimitiveIterator.OfInt points = word.codePoints().iterator();
while (points.hasNext()) {
int codePoint = points.nextInt();
node = node.children.computeIfAbsent(codePoint, ignored -> new Node());
}
node.terminal = true;
}
public boolean search(String word) {
Node node = find(word);
return node != null && node.terminal;
}
public boolean startsWith(String prefix) { return find(prefix) != null; }
public boolean delete(String word) {
if (!search(word)) return false;
erase(root, word.codePoints().toArray(), 0);
return true;
}
private boolean erase(Node node, int[] word, int index) {
if (index == word.length) node.terminal = false;
else {
Node child = node.children.get(word[index]);
if (erase(child, word, index + 1)) node.children.remove(word[index]);
}
return !node.terminal && node.children.isEmpty();
}
private Node find(String text) {
Node node = root;
PrimitiveIterator.OfInt points = text.codePoints().iterator();
while (points.hasNext()) {
int codePoint = points.nextInt();
node = node.children.get(codePoint);
if (node == null) return null;
}
return node;
}
public static void main(String[] args) {
Trie trie = new Trie();
trie.insert("app"); trie.insert("apple"); trie.insert("café");
if (!trie.search("app") || !trie.startsWith("ap")) throw new AssertionError();
if (!trie.delete("app") || trie.search("app") || !trie.search("apple")) throw new AssertionError();
}
}Lifecycle invariant: paths are prefixes
Insert
Follow/create one edge per symbol; mark terminal only at the full word.
Search
The path may exist while the word does not: terminal distinguishes “app” from prefix of “apple”.
Prefix
Reachability of the prefix node is sufficient; enumerate its subtree for suggestions.
Delete
Clear terminal, then prune upward only while a node is nonterminal and childless.
Invariant: the symbols on every root-to-node path spell exactly one stored prefix. Deleting a word must never prune a node shared by another word or a node that is itself terminal.
Memory, alphabets, and Unicode
| Child representation | Lookup | Memory profile | Best fit |
|---|---|---|---|
| Fixed array of σ slots | O(1) | Potentially huge at sparse nodes | Small fixed alphabet such as lowercase ASCII |
| Hash map | Expected O(1) | Allocation/hash overhead per populated edge | Sparse or large alphabets |
| Sorted vector/map | O(log degree) | Compact, cache-friendly choices possible | Read-heavy immutable tries |
Define the unit: bytes, Unicode code points, or user-perceived grapheme clusters. Java char is a UTF-16 code unit, so the reference code iterates code points. Normalize equivalent text (for example NFC) and decide case-folding/locale policy before indexing.
Compressed tries and alternatives
Radix / Patricia compression
Collapse every nonterminal one-child chain into one edge labeled by a substring. Height becomes branching decisions rather than characters, saving nodes but making splits/merges more complex.
Choose by query
- • Hash set: exact membership, simpler and usually smaller.
- • Sorted array + lower bound: static prefix ranges with compact storage.
- • Ternary search tree: three pointers per symbol node.
- • Minimal DFA/FST: highly compressed immutable dictionaries.
- • Suffix array/automaton: substring rather than prefix queries.
Autocomplete and production checklist
- • Prefix traversal is O(P); returning k suggestions also costs output traversal, never call it just O(P).
- • Store frequency/recency or cached top-k candidates when ranking matters.
- • Enforce limits for word length, node count, and result count against memory abuse.
- • Immutable snapshots simplify concurrent reads; updates need synchronization/copy-on-write.
- • Test empty string policy, prefix-as-word, overlapping words, duplicates, and missing deletes.
- • Test emoji/supplementary code points, normalization equivalents, and locale case behavior.
- • After deletion, verify other words and shared prefixes still exist.
- • Measure nodes/edge fan-out and payload memory, not only O(total characters).
📊 Complexity Analysis
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| Insertion | O(L) | O(L) new nodes worst case |
| Search | O(L) | O(1) |
| Prefix Search | O(L) | O(1) |
* L = length of the word, N = number of words
Exercises
- Insert “app” then “apple”. Delete “app”. Which nodes must remain to preserve the ?
- Dictionary of 10⁶ words, prefix length 3, return top 10. Compare trie + subtree walk vs scanning a hash set each keystroke.
- When is a sorted array + lower_bound better than a mutable trie for prefix ranges?
Primary sources
- Fredkin, E. (1960): Trie memory.
- Knuth, The Art of Computer Programming, Vol. 3: digital search trees / tries.
- Morrison, D. (1968): PATRICIA (compressed path tries).
Takeaway
Tries win on prefix work because of the prefix invariant and shared paths. For exact membership alone, a hash set is usually simpler.