DSA DSA In Depth
0%
lesson trieradix-treepatricia

Prefix Tries, Compressed Radix Trees & PATRICIA Trees

7 min read Module 4.1: Tries, Exact String Matching & Multi-Pattern Automata Updated Oct 23, 2026
📄 Cheat Sheet
Data Structures: Tries & Prefix Tree Operations
HackerRank Data Structures: Tries & Prefix Tree Operations Watch on YouTube ↗

Explains how prefix sharing works in standard Tries and why path-compressed Radix Trees and Adaptive Radix Trees (ART) dominate production databases and routers.

0:00 / --:--

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 inspects q[d] at each internal node, performing a single full key comparison at the reached leaf to verify whether the skipped prefix segments actually match!
Radix / PATRICIA Node-Count Invariant: In a compressed Radix or PATRICIA tree storing $N \ge 2$ prefix-free keys (or keys terminated with a unique $ 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$!
1. Uncompressed Trie Keys: "romane", "romanus" "r..m..a..n" e u s 2. Compressed Radix Tree Unary chain collapsed to 1 edge root "roman" branch "e" "us" leaf 1 leaf 2 3. Adaptive Radix Tree (ART) Nodes Node4: 4 keys + 4 ptrs (sorted) Node16: 16 keys (SIMD _mm_cmpeq) Node48: 256B idx[byte] -> 48 ptrs Node256: Direct ptr[256] array
flowchart LR N4["Node4 (1..4 children)"] <-->|"Grow > 4 / Shrink < 4"| N16["Node16 (5..16 children, SIMD)"] N16 <-->|"Grow > 16 / Shrink < 13"| N48["Node48 (17..48 children, 256B LUT)"] N48 <-->|"Grow > 48 / Shrink < 37"| N256["Node256 (49..256 children, Direct)"]
Hardware & Cache Reality — Adaptive Radix Tree (ART) in Modern In-Memory Databases (DuckDB, HyPer, Umbra): Why does ART beat both Hash Tables (on range scans) and B+ Trees (on point lookups)? Instead of allocating a 256-pointer array at every byte level, ART dynamically selects:
  • 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) broadcasts byte via _mm_set1_epi8 and 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 of Node256 while keeping $O(1)$ lookup).
  • Node256 (49–256 children): Direct 256-pointer array for dense nodes near the root.
Radix Tree (rax), Succinct Trie & Bitwise 0/1 XOR Trie Maximum-XOR Visualizer
O(W) Bitwise XOR · ≤ 2K−1 Radix Nodes

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.

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Greedy MSB-First Choice: 2^b > ∑_{i=0}^{b-1} 2^i -> winning bit b dominates all lower bits combined!
Bitwise 0/1 Trie Path for Query x = 0101₂ (5) vs Set {0010₂(2), 0111₂(7), 1100₂(12)}: root → b3=1 → b2=1 → b1=0 → b0=0 (y=12) Step b=3: x_b=0 wants opposite bit 1 → took 1 (XOR bit = 1)
[0]b3: 0want=1, got=1
[1]b2: 1
[2]b1: 0
[3]b0: 1
Query x
5 (0101₂)
Bit Level b
b = 3
Chosen Path y
1___₂
Accumulated x ⊕ y
1___₂ (≥ 8)
[Step 1] MSB b=3: Query x=5 (0101₂) has bit 0. Greedy opposite bit 1 exists in Trie! Take branch 1.

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
Common Pitfall — Skipping One-Way Edge Verification in PATRICIA Lookups: In a PATRICIA tree (or an ART using optimistic path compression where only the edge length 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.
Check yourself Question 1 of 4
Concept Check Radix Tree Node Count Bound recall

A compressed Radix Tree (Patricia Trie) stores $K$ distinct keys over alphabet $\Sigma$ by merging every unary chain of single-child nodes into a single edge labeled with a substring slice.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does this code corrupt heap memory or crash with a segmentation fault when `arena` grows beyond its initial capacity?

Key Takeaways

Uncompressed Tries achieve O(L) lookup independent of N, but suffer O(N * L) worst-case node count on long unique strings and high pointer overhead on large alphabets.
Compressed Radix Trees collapse unary paths into single edges labeled with substring slices, bounding total nodes to at most 2N (<= N - 1 internal branching nodes).
PATRICIA trees store only the critical bit/byte offset at internal nodes and perform a single full-key verification at the leaf.
Adaptive Radix Trees (ART) dynamically transition nodes between Node4, Node16 (SIMD), Node48 (256B LUT), and Node256 to combine trie speed with B-tree cache density.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue