1. From Uncompressed Tries to Compressed Radix Trees & PATRICIA
Let $\mathcal{S} = \{s_1, s_2, \dots, s_N\}$ be a set of $N$ distinct strings of total length $M = \sum |s_i|$. Compare the three structural representations:
- Uncompressed Trie: Up to $M + 1$ nodes. Every character transition is an explicit node. Long unique keys create deep chains of degree-1 nodes that cause $L$ sequential L3/DRAM cache misses.
- Compressed Radix Tree (Compact Prefix Tree): Every internal node must have branching degree $\ge 2$ (unless it marks the end of a key). Any unary chain is collapsed into a single edge storing a string slice $(k, \text{offset}, \text{len})$. Because every internal node branches at least two ways, a Radix Tree over $N$ keys has at most $2N$ nodes ($N$ leaves and $\le N - 1$ branching nodes)!
- PATRICIA Tree (Practical Algorithm To Retrieve Information Coded In Alphanumeric): Stores keys only at the leaves; each internal node stores only the critical bit/byte position $d$ where its subtrees diverge. During
search(q), the traversal skips intermediary bytes completely and only inspectsq[d]at each internal node, performing a single full key comparison at the reached leaf to verify whether the skipped prefix segments actually match!
$ sentinel), every internal node has at least $2$ children. By induction on full trees, the total number of internal nodes is at most $N - 1$ and total edges at most $2N - 2$—completely independent of string length $L$!
Node4(1–4 children): 4 byte keys + 4 child pointers (fits in a single 64-byte L1 cache line).Node16(5–16 children): 16 byte keys packed into a single 128-bit SSE register;findChild(byte)broadcastsbytevia_mm_set1_epi8and finds the matching lane in 1 clock cycle via_mm_cmpeq_epi8+_mm_movemask_epi8+ctz!Node48(17–48 children): A 256-byte direct byte-to-slot index table pointing into a 48-element pointer array (saving 80% of the pointer space ofNode256while keeping $O(1)$ lookup).Node256(49–256 children): Direct 256-pointer array for dense nodes near the root.
Walk a Bitwise 0/1 Binary Trie greedily taking the opposite bit (1 − b) from MSB to LSB to maximize x ⊕ y, inspect Path-Compressed Radix/PATRICIA edge splits ("romane" / "romanus" / "rubens"), and examine UTF-8 / 24-byte SSO memory layouts.
Invariant & State
2. Production Implementation: Compressed Radix Tree (Insert, Exact Lookup & Prefix Enumeration)
class RadixNode {
constructor(edgeLabel = "", value = undefined) {
this.edgeLabel = edgeLabel;
this.value = value; // !== undefined if a key terminates at this node
this.children = new Map(); // firstChar -> RadixNode
}
}
/**
* Compressed Radix Tree (Compact Prefix Tree)
* Guarantees at most 2N nodes regardless of string length L.
*/
class RadixTree {
constructor() {
this.root = new RadixNode("");
this.size = 0;
}
_lcpLength(a, b) {
const len = Math.min(a.length, b.length);
let i = 0;
while (i < len && a.charCodeAt(i) === b.charCodeAt(i)) i++;
return i;
}
insert(key, value = true) {
let curr = this.root;
let i = 0;
while (i < key.length) {
const ch = key[i];
const child = curr.children.get(ch);
if (!child) {
curr.children.set(ch, new RadixNode(key.slice(i), value));
this.size++;
return;
}
const rem = key.slice(i);
const lcp = this._lcpLength(rem, child.edgeLabel);
if (lcp === child.edgeLabel.length) {
// Consumed entire child edge; continue descending
curr = child;
i += lcp;
} else {
// Split child edge at index lcp
const splitNode = new RadixNode(child.edgeLabel.slice(0, lcp));
curr.children.set(ch, splitNode);
child.edgeLabel = child.edgeLabel.slice(lcp);
splitNode.children.set(child.edgeLabel[0], child);
if (lcp === rem.length) {
splitNode.value = value;
} else {
const newLeaf = new RadixNode(rem.slice(lcp), value);
splitNode.children.set(newLeaf.edgeLabel[0], newLeaf);
}
this.size++;
return;
}
}
if (curr.value === undefined) this.size++;
curr.value = value;
}
get(key) {
let curr = this.root;
let i = 0;
while (i < key.length) {
const child = curr.children.get(key[i]);
if (!child) return undefined;
const label = child.edgeLabel;
if (key.length - i < label.length) return undefined;
for (let j = 0; j < label.length; j++) {
if (key.charCodeAt(i + j) !== label.charCodeAt(j)) return undefined;
}
curr = child;
i += label.length;
}
return curr.value;
}
keysWithPrefix(prefix) {
let curr = this.root;
let i = 0;
let accumulated = "";
while (i < prefix.length) {
const child = curr.children.get(prefix[i]);
if (!child) return [];
const rem = prefix.slice(i);
const lcp = this._lcpLength(rem, child.edgeLabel);
if (lcp < rem.length && lcp < child.edgeLabel.length) return [];
accumulated += child.edgeLabel;
curr = child;
i += lcp;
}
const results = [];
const dfs = (node, path) => {
if (node.value !== undefined) results.push(path);
for (const child of node.children.values()) {
dfs(child, path + child.edgeLabel);
}
};
dfs(curr, accumulated);
return results;
}
}
3. Complexity Matrix
| Structure | Worst-Case Node Count ($N$ keys, length $L$) | Exact Lookup Time | Prefix Search (startsWith(P)) |
Space Overhead per Node |
|---|---|---|---|---|
| Uncompressed Array Trie ($|\Sigma| = 256$) | $O(N \cdot L)$ nodes | $\Theta(L)$ | $\Theta(|P| + K)$ | $256$ pointers ($2\text{ KB}$) |
| Compressed Radix Tree / PATRICIA | $\le 2N$ nodes (exact $N-1$ internal) | $O(L)$ | $O(|P| + K)$ | Edge slice + compact map |
| Adaptive Radix Tree (ART) | $O(N)$ adaptive internal nodes | $O(L)$ (SIMD accelerated) | $O(|P| + K)$ | $~8\text{–}16$ bytes per child |
Balanced BST (std::map<string>) |
$N$ nodes | $O(L \log N)$ | $O(L \log N + K)$ | $3$ pointers + color |
prefixLen is stored instead of the full substring), internal nodes skip non-branching bytes and only branch on critical differing bytes. If you return true upon reaching a leaf without comparing the full search key q against the leaf's stored key, any query string that matches at the critical branch bytes but differs at the skipped positions will produce a false positive match! Always perform one full key comparison at the reached leaf.