Cairn
data structures · compressed prefix tree · O(k) insert/search/delete, k = word length

back to Node-Linked Trees

Radix Tree (PATRICIA Trie)

A trie spends one node per character — "cat" and "car" share a c → a path, but every single character still gets its own node, even along a stretch where only one path forward ever exists. A radix tree (also called a PATRICIA trie, for "Practical Algorithm To Retrieve Information Coded In Alphanumeric") fixes exactly that waste: any chain of nodes that each have exactly one child gets collapsed into a single edge labeled with the whole shared substring, not one character. Walking the tree still costs one step per branch point, not one step per character — for a set of keys with long unbranching runs, that can be far fewer edges than characters.

Try it

The tree below is preloaded with the classic seven-word PATRICIA example: romane, romanus, romulus, rubens, ruber, rubicon, rubicundus. Every edge is labeled with the actual substring it represents — notice there's a single edge labeled "om" under the root's "r" edge, not two separate one-character edges for o then m, because nothing else ever branches off partway through "om". The small dot on a node marks it as a real stored word, same convention as the Trie page. Insert adds a word (new or split nodes get a thick border), Search checks whether an exact word is stored, Prefix lists every stored word starting with what you typed, and Delete removes a word — watch how a deletion can merge two edges back into one, not just prune a leaf.

Loaded with romane, romanus, romulus, rubens, ruber, rubicon, rubicundus. Try Search "ruben" (not stored — it's a strict prefix of "rubens", ending mid-edge), then Prefix "rub".

Core operations

Reference implementation

function commonPrefixLen(a, b) {
  let i = 0;
  const m = Math.min(a.length, b.length);
  while (i < m && a[i] === b[i]) i++;
  return i;
}

function insert(root, word) {
  let node = root, i = 0;
  while (i < word.length) {
    const ch = word[i];
    const edge = node.children.get(ch);
    if (!edge) {
      node.children.set(ch, { label: word.slice(i), child: makeNode(true) });
      return;
    }
    const rem = word.slice(i);
    const cpl = commonPrefixLen(edge.label, rem);
    if (cpl === edge.label.length) {
      node = edge.child;
      i += cpl;
      if (i === word.length) node.isEnd = true;
      continue;
    }
    const mid = makeNode(false);
    mid.children.set(edge.label[cpl], { label: edge.label.slice(cpl), child: edge.child });
    edge.label = edge.label.slice(0, cpl);
    edge.child = mid;
    i += cpl;
    if (i === word.length) mid.isEnd = true;
    else mid.children.set(rem[cpl], { label: rem.slice(cpl), child: makeNode(true) });
    return;
  }
}

function deleteWord(root, word) {
  const path = []; // { parent, ch, edge }
  let node = root, i = 0;
  while (i < word.length) {
    const edge = node.children.get(word[i]);
    if (!edge) return false;
    const rem = word.slice(i);
    const cpl = commonPrefixLen(edge.label, rem);
    if (cpl !== edge.label.length) return false;
    path.push({ parent: node, ch: word[i], edge });
    node = edge.child;
    i += cpl;
  }
  if (!node.isEnd) return false;
  node.isEnd = false;
  let keepGoing = true;
  for (let k = path.length - 1; k >= 0 && keepGoing; k--) {
    const { parent, ch, edge } = path[k];
    const child = edge.child;
    if (child.children.size === 0 && !child.isEnd) {
      parent.children.delete(ch);
      keepGoing = true;
    } else if (child.children.size === 1 && !child.isEnd) {
      const [, onlyEdge] = [...child.children.entries()][0];
      edge.label += onlyEdge.label;
      edge.child = onlyEdge.child;
      keepGoing = false;
    } else {
      keepGoing = false;
    }
  }
  return true;
}

The split step in insert is the one piece with no equivalent in a plain trie: an existing edge can get cut in two, with a brand-new branch node inserted at the cut, whenever a new word agrees with it for a while and then diverges (or ends). Verified from scratch against a plain-array oracle (search checked against includes, prefix checked against a linear filter by prefix) with three separate passes: an exhaustive sweep of all 64 subsets of a 6-word set chosen for heavy prefix overlap (insert each subset, check every word plus every non-member, delete the whole subset back out, confirm the tree returns to exactly one node — the empty root — every time); an exhaustive sweep of all 720 insertion orders (permutations) of that same 6-word set, confirming search/prefix/collect results never depend on which order the words arrived in; and 45,000 randomized interleaved insert/search/prefix/delete trials across three alphabet sizes (2, 8, and 26 symbols, to force heavy, moderate, and sparse prefix sharing respectively), checking agreement with the oracle after every single operation — zero mismatches across all three passes. A structural invariant (no non-root, non-word node may have exactly one child — if it does, compression has a bug) was also checked after every operation in the randomized pass, with zero violations. Rebuilding the classic seven-word example above and comparing node counts confirms the compression claim directly rather than just asserting it: this exact word set costs 14 nodes as a radix tree versus 28 as a plain trie — exactly half, for this particular set. The real shipped insertRadix/searchRadix/prefixRadix/deleteRadix functions below were then re-verified the same way (10,000 more randomized trials against the same oracle, zero mismatches) and separately driven through their actual click handlers with a fake-DOM harness, replaying a fixed sequence of operations against the preloaded tree and confirming every log line and rendered edge label match exactly.

Pitfalls

"The walk didn't hit a dead end" is not the same as "found." Because an edge now represents several characters at once, a query can walk correctly through every character it has and still stop partway through a compressed edge — nowhere near a real node boundary. A search that forgets to check for this and just tests whatever node the walk last touched will report false positives whenever that node happens to be a real word further down the same edge. Concretely, on the demo's own preloaded tree: querying "ruben" (not a stored word — "rubens" is) walks correctly through r → ub → e and then only one more character, n, into the two-character edge "ns" leading to "rubens." A boundary-blind search that skips the "did I actually reach a real node" check reports "ruben" as found anyway, because the node at the far end of that edge (reached by continuing to "rubens") is a real word. A sweep of every strict-prefix truncation of all seven demo words (dropping the last 1 through k−1 characters) found this boundary-blind version reports 10 of 41 such truncated, never-inserted queries as falsely "found."

Delete has to merge, not just prune — otherwise compression erodes back to a plain trie, one deletion at a time. Removing a dead leaf is the easy half; the part that's easy to skip is checking whether the leaf's parent just dropped to a single remaining child and needs to be spliced back into its own parent's edge. A version of delete that only ever removes empty leaves — never re-merging — stays structurally valid (search and prefix still give correct answers) but slowly gives back every space saving the tree was built for. Concretely: build one 20-character word plus 19 "side branch" words, each one sharing a successively longer prefix of it and diverging by one extra character at the very end, then delete every side branch, leaving only the original 20-character word. A correct delete collapses the tree back down to 2 nodes — root plus one edge holding the whole word, exactly as if it had been inserted alone. The never-merge version instead leaves 21 nodes: a straight chain of one node per character, the exact shape a plain trie would produce, with none of the compression this structure exists to provide.

Where radix trees show up

Complexity

Time: insert, search, and startsWith are all O(k) for a word or prefix of length k, the same bound as a plain trie — but the constant behind that bound is smaller in practice, since each step advances by a whole edge label, not one character. On the demo's own tree, searching "rubicundus" (10 characters) takes exactly 4 edge traversals (r → ub → ic → undus) instead of a trie's 10, one per branch point rather than one per character. Space: proportional to the number of edges, not the total character count — the classic seven-word example above costs 14 nodes as a radix tree against 28 for the same words in a plain trie. Worst case (no two stored words share any prefix at all) degrades to the same O(n · k) bound a trie has in that case too, since there's nothing left to compress.

This site's guide, Choosing a Search Tree, places this entry alongside Trie and Ternary Search Tree as answering the same different question the guide's seven comparison entries don't: storing strings and answering prefix queries by spending nodes on characters, not whole keys. It's the third of three routes to that question — instead of changing what a node costs the way Ternary Search Tree does, it changes how many nodes exist at all, collapsing every run of single-child trie nodes into one edge labeled with the whole shared substring.