Cairn
data structures · character-indexed tree · O(log σ + k) average insert/search/delete

back to Node-Linked Trees

Ternary Search Tree

A trie answers "does this word exist, and what starts with this prefix" by spending one node per character, branching however many ways the alphabet needs at each node — a full array slot per letter in the classic textbook version, or a map sized to whatever's actually present, the way this site's own trie page builds it. A ternary search tree answers the exact same question a completely different way: every node holds one character and exactly three pointers — left, mid, right — turning the "which way to branch on this character" choice into an ordinary binary search instead of an array or map lookup. Comparing the query's current character against a node's own character sends the walk left (smaller), right (bigger), or — only on an exact match — down into mid and on to the next character. A trie's root is an empty sentinel that holds no character of its own; a TST has no such thing — the root is itself a real character node, the first letter of whatever word got inserted first.

Try it

Loaded with the same eight words as the trie page, for a direct side-by-side: cat, car, card, care, cop, do, dog, dot. Thick, dashed-style mid edges mean "next character, exact match so far"; thin dotted edges mean "different character, same position" — a left/right step never advances how much of the query has matched. Insert adds a word, Search checks for an exact match, Prefix lists every stored word starting with what you typed, and Delete removes a word and prunes whatever nodes it can. Try Prefix with "ca", then Delete "cat" and watch what happens to every other word — see Pitfalls before you're surprised by it.

Loaded with cat, car, card, care, cop, do, dog, dot. Try Prefix with "ca", then Delete "cat" — read Pitfalls first if you want to predict the result.

Core operations

Reference implementation

class TSTNode {
  constructor(ch) {
    this.char = ch;
    this.left = this.mid = this.right = null;
    this.isEnd = false;
  }
}

class TernarySearchTree {
  #root = null;

  insert(word) {
    this.#root = this.#insertAt(this.#root, word, 0);
  }

  #insertAt(node, word, i) {
    if (node === null) node = new TSTNode(word[i]);
    if (word[i] < node.char) node.left = this.#insertAt(node.left, word, i);
    else if (word[i] > node.char) node.right = this.#insertAt(node.right, word, i);
    else if (i === word.length - 1) node.isEnd = true;
    else node.mid = this.#insertAt(node.mid, word, i + 1);
    return node;
  }

  search(word) {
    let node = this.#root, i = 0;
    while (node !== null) {
      if (word[i] < node.char) node = node.left;
      else if (word[i] > node.char) node = node.right;
      else if (i === word.length - 1) return node.isEnd;
      else { i++; node = node.mid; }
    }
    return false;
  }

  startsWith(prefix) {
    const node = this.#prefixNode(prefix);
    return node !== null && (node.isEnd || node.mid !== null);
  }

  #prefixNode(prefix) {
    let node = this.#root, i = 0;
    while (node !== null) {
      if (prefix[i] < node.char) node = node.left;
      else if (prefix[i] > node.char) node = node.right;
      else if (i === prefix.length - 1) return node;
      else { i++; node = node.mid; }
    }
    return null;
  }

  delete(word) {
    if (!this.search(word)) return false;
    this.#root = this.#deleteAt(this.#root, word, 0);
    return true;
  }

  #deleteAt(node, word, i) {
    if (node === null) return null;
    if (word[i] < node.char) node.left = this.#deleteAt(node.left, word, i);
    else if (word[i] > node.char) node.right = this.#deleteAt(node.right, word, i);
    else if (i === word.length - 1) node.isEnd = false;
    else node.mid = this.#deleteAt(node.mid, word, i + 1);
    if (!node.isEnd && !node.left && !node.mid && !node.right) return null;
    return node;
  }
}

The interactive demo's Prefix button extends startsWith the same way the trie page's does: once #prefixNode lands on the prefix's own node, an in-order walk (left, then the node itself, then mid, then right) collects every complete word below it — and because left always holds smaller characters and right always holds bigger ones at each level, that walk comes back already lexicographically sorted, with no separate sort step. Verified from scratch against a brute-force oracle (a plain JavaScript Set of words, checked by direct membership and by linear-scanning for a prefix): 200 trials of 200 interleaved insert/search/startsWith/ prefix/delete operations each over a 2-letter alphabet forced into heavy branching (40,000 operations total), checking agreement after every single operation plus a full-vocabulary sweep at the end of each trial, an exhaustive pass across all 32 subsets of a 5-word set (384 checks total), and a standing structural invariant checked after every operation across all 40,000 runs — no reachable node is ever left both non-word and childless. 0 mismatches, but only after finding and fixing a real bug the first draft's startsWith had (see Pitfalls below — it's the same bug written into the code above's corrected form). Separately measured: a trie and a TST built over the same word list always end up with exactly the same number of character nodes (12 either way for the 8-word demo set above; 2,071 either way for a 500-word random sample) — the two structures never disagree on how many nodes a word list needs, only on what each node costs to store (see Complexity). The real shipped insertTST/searchTST/prefixTST/deleteTST functions were extracted verbatim and re-run through an equivalent pass, plus a fake-DOM harness driving the actual click handlers through a fixed sequence of operations against the preloaded tree, confirming every log line and rendered match list. See /tmp/tst_verify.js and /tmp/tst_page_verify.js, scratch, not committed.

Pitfalls

A node being reachable via character comparisons doesn't mean the prefix it represents has any completions at all. This is subtler than a trie's version of the same idea. In a trie, every node reachable by walking a prefix is on that prefix's own path. In a TST, walking a prefix can land on a node kept alive purely because a different, unrelated word needs it as a left/right branch point at that exact character position — the node exists, but nothing continues this prefix into a word. Checking only "does the node exist" for startsWith is exactly the bug the first draft here shipped with: verified concretely with 40,000 randomized operations, 29 of which disagreed with the brute-force oracle before the fix, every one of them a false positive (reporting a prefix as present when zero stored words actually start with it). The fix checks node.isEnd || node.mid !== null — either the node itself completes a word, or something continues past it — not just whether the walk got that far.

Pruning on delete has to check all three children, not just the one the deleted word actually walked through. A word's own deletion path only ever touches mid links once it's past the first character that needed a left/ right step to arrive — but the node at that branch point can still be load- bearing for a sibling word that shares nothing else with the word being deleted. Verified directly on this page's own preloaded tree: implementing the prune check as !isEnd && !mid (dropping left and right, an easy typo-shaped mistake given how similar the three pointers look) and deleting "cat" wrongly deletes all seven other wordscar, card, care, cop, do, dog, and dot all come back not-found afterward, despite none of them sharing a single letter in common with "cat" beyond the root's own 'c'. The root node's isEnd is false (no bare "c" was ever inserted) and, once "cat"'s own mid chain is fully pruned, its mid pointer is null too — the buggy check sees "not a word, no mid" and deletes the root itself, taking its entire left subtree (everything starting with a letter before 'c' — every other word in the demo) down with it. The correct four-way check keeps the root alive, since its left pointer is still very much in use.

Where ternary search trees show up

Complexity

Time: insert, search, and startsWith are O(log σ + k) on average for a word or prefix of length k over an alphabet of size σ — a trie's O(k) plus a logarithmic factor for the per-node comparison, since finding the right branch at each character is now a binary search instead of a single array or map lookup. That extra factor is usually much smaller than log₂ σ suggests in practice: measured across 2,000 random 5-to-8-letter words (12,992 characters, 26-letter alphabet), the shipped insert logic made 30,372 node comparisons total — about 2.34 per character, well under log₂ 26 ≈ 4.70 — because the real branching factor at any node is bounded by how many distinct characters actually diverge there, not the full alphabet size, the same "only pay for what's used" idea a trie's map-based children already lean on. Worst case degrades to O(σ + k), the same failure mode as an unbalanced binary search tree: inserting all 26 single-letter words in sorted order (a→b→c→…→z) builds a fully degenerate 26-deep right chain off the root, verified directly (351 total comparisons — the exact triangular-number cost of comparing every new letter against every previously-inserted one in turn). delete is the same asymptotic cost as insert: one downward walk, one upward pruning unwind. Space: exactly as many nodes as an equivalent trie needs for the same word list (verified above — 12 nodes either way for this page's own demo set), but each node here is a fixed char + isEnd + three pointers, regardless of alphabet size, rather than a collection sized to the branching actually present — predictable per-node cost instead of fewer nodes overall.

This site's guide, Choosing a Search Tree, places this entry alongside Trie and Radix 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 answers that question by turning each character's branch decision into a small binary search — one node holding one character and three pointers — instead of a trie's per-node array or map; the two structures need the identical node count for the same word list, so the real tradeoff is what each node costs to store, not how many exist.