Cairn
data structures · fourth self-balancing tree · search O(log n) worst case, insert/delete O(log n) amortized

back to Node-Linked Trees

Scapegoat Tree

AVL trees store a height number per node. Red-black trees store a color bit per node. Splay trees store nothing extra, but pay for that by restructuring the tree on every single operation, including a plain search. A scapegoat tree takes a third path: it stores nothing extra either — no height, no color, no priority, not even a size counter inside each node — and it does no work at all on a search, not a single rotation. The catch it accepts instead: an insert can occasionally trigger an expensive repair, and that repair isn't a rotation but a full flatten-and-rebuild of one whole subtree. The name comes from that repair step — when an insert makes the tree too deep, the algorithm walks back up from the new leaf and blames one specific ancestor for the imbalance, then throws out its entire subtree and rebuilds it from scratch, perfectly balanced. Only two numbers are tracked globally, not per node: how many keys the tree holds now, and the largest it's held since the last time the whole thing was rebuilt.

Try it

The tree below was loaded by inserting 1 through 8 in ascending order, the same adversarial sequence the AVL, red-black, and splay pages all used — and, like the splay tree's unsplayed chain, it lands as a full height-8 chain here too, checked against the shipped algorithm below. That's not a failure to notice the imbalance; a chain of 8 nodes is still shallow enough to satisfy this tree's own height rule (more on that rule below), so nothing has fired yet. Now insert 9: the new leaf lands at depth 8, which finally crosses the line, and watch the lower part of the chain — nodes 5 through 9 — light up and flatten into a balanced shape while nodes 1 through 4 stay exactly where they were (node 4 keeps its own position; only its right child changes, from a straight line down to the new balanced piece). That's the whole mechanism in one move: only the piece that's actually unbalanced gets touched, not the whole tree. Once that's done, try deleting 9, then 8, then 7 in that order — the third delete drops the tree to 6 nodes, which is small enough relative to its high-water mark of 9 to trigger the other kind of rebuild this structure does: a full rebuild of the entire tree, not just one subtree, since delete has no cheap way to identify a single scapegoat the way insert does.

Loaded by inserting 1 through 8 in ascending order — a height-8 chain, still within this tree's own height rule. Try inserting 9.

The height rule, and finding the scapegoat

After every insert, this tree enforces one invariant: no node may sit deeper than log1/α(n), where n is the current key count and α (alpha) is a tuning constant between 0.5 and 1, conventionally 0.75 (the value this page's demo and reference implementation both use). A smaller alpha means a stricter, shallower bound; a larger alpha tolerates deeper trees before acting — see Pitfalls for what that trade costs in practice. When a freshly-inserted leaf's depth exceeds that bound, the tree is "weight-unbalanced" and needs a repair. Finding where to repair walks back up from the new leaf toward the root, checking one condition at each ancestor p along the way: is the size of the subtree just climbed out of more than α times the size of p's whole subtree? The first ancestor where that's true is the scapegoat — the specific node blamed for letting things get this deep, even though every individual insert along the way was, on its own, a perfectly ordinary BST insert. Flattening its subtree via an in-order walk and rebuilding it as a perfectly balanced tree (repeatedly picking the middle element of the remaining sorted range as the new root) restores the height rule for that whole region in one move — and because the flattened region is, by construction, exactly the piece that was too deep, fixing it is guaranteed to bring the new leaf back within bound.

Delete: no scapegoat to find, so rebuild everything or nothing

Insert can always point to a single guilty subtree, because the newly-inserted leaf's own ancestor chain is exactly where the imbalance is. Delete has no equivalent signal — removing a key can only ever shrink the tree, and a single delete alone can't tell you which surviving subtree, if any, is now disproportionately large relative to the rest. Rather than track enough extra bookkeeping to answer that question cheaply, a scapegoat tree sidesteps it with a much blunter rule: after every delete, if the key count n has fallen below α times maxN (the largest the tree has been since its last full rebuild), rebuild the entire tree from scratch and reset maxN back down to the current n. This is deliberately wasteful-looking per event — throwing away a perfectly good tree just because it's "gotten small" — but it's cheap on average for the same reason insert's partial rebuilds are: it only fires once enough deletes have accumulated to justify the cost, and the reset maxN means the next full rebuild is just as far away again.

Reference implementation

const ALPHA = 0.75;

class ScapegoatTree {
  #root = null;
  #n = 0;
  #maxN = 0;

  #size(node) {
    if (node === null) return 0;
    return 1 + this.#size(node.left) + this.#size(node.right);
  }

  #flatten(node, out) {
    if (node === null) return;
    this.#flatten(node.left, out);
    out.push(node.value);
    this.#flatten(node.right, out);
  }

  #buildBalanced(arr, lo, hi) {
    if (lo > hi) return null;
    const mid = (lo + hi) >> 1;
    return {
      value: arr[mid],
      left: this.#buildBalanced(arr, lo, mid - 1),
      right: this.#buildBalanced(arr, mid + 1, hi),
    };
  }

  #rebuildAt(parent, isLeftChild, subtreeRoot) {
    const arr = [];
    this.#flatten(subtreeRoot, arr);
    const rebuilt = this.#buildBalanced(arr, 0, arr.length - 1);
    if (parent === null) this.#root = rebuilt;
    else if (isLeftChild) parent.left = rebuilt;
    else parent.right = rebuilt;
  }

  insert(value) {
    if (this.#root === null) {
      this.#root = { value, left: null, right: null };
      this.#n = 1; this.#maxN = 1;
      return;
    }
    const path = []; // ancestors from root down to (not including) the new leaf
    let cur = this.#root;
    while (true) {
      if (value === cur.value) return; // no duplicates
      path.push(cur);
      if (value < cur.value) {
        if (cur.left === null) { cur.left = { value, left: null, right: null }; break; }
        cur = cur.left;
      } else {
        if (cur.right === null) { cur.right = { value, left: null, right: null }; break; }
        cur = cur.right;
      }
    }
    this.#n++;
    this.#maxN = Math.max(this.#maxN, this.#n);
    const depth = path.length;
    const heightLimit = Math.log(this.#n) / Math.log(1 / ALPHA);
    if (depth <= heightLimit) return; // still within the height rule, nothing to do

    // Walk back up looking for the scapegoat: the first ancestor whose child
    // toward the new leaf holds more than ALPHA of that ancestor's own subtree.
    let childSize = 1;
    for (let i = path.length - 1; i >= 0; i--) {
      const p = path[i];
      const descendedLeft = i === path.length - 1 ? value < p.value : path[i + 1] === p.left;
      const sibling = descendedLeft ? p.right : p.left;
      const pSize = childSize + this.#size(sibling) + 1;
      if (childSize > ALPHA * pSize) {
        const gp = i > 0 ? path[i - 1] : null;
        this.#rebuildAt(gp, gp !== null ? gp.left === p : null, p);
        return;
      }
      childSize = pSize;
    }
  }

  search(value) {
    let cur = this.#root;
    while (cur !== null) {
      if (value === cur.value) return true;
      cur = value < cur.value ? cur.left : cur.right;
    }
    return false;
  }

  delete(value) {
    let cur = this.#root, parent = null, isLeftChild = null;
    while (cur !== null && cur.value !== value) {
      parent = cur;
      isLeftChild = value < cur.value;
      cur = isLeftChild ? cur.left : cur.right;
    }
    if (cur === null) return false;

    if (cur.left !== null && cur.right !== null) {
      let succParent = cur, succ = cur.right;
      while (succ.left !== null) { succParent = succ; succ = succ.left; }
      cur.value = succ.value;
      if (succParent.left === succ) succParent.left = succ.right;
      else succParent.right = succ.right;
    } else {
      const child = cur.left !== null ? cur.left : cur.right;
      if (parent === null) this.#root = child;
      else if (isLeftChild) parent.left = child;
      else parent.right = child;
    }
    this.#n--;

    if (this.#n > 0 && this.#n < ALPHA * this.#maxN) {
      this.#rebuildAt(null, null, this.#root);
      this.#maxN = this.#n;
    } else if (this.#n === 0) {
      this.#maxN = 0;
    }
    return true;
  }
}

The interactive demo above uses equivalent insertScapegoat/searchScapegoat/ deleteScapegoat functions with extra bookkeeping to record the descent path, which ancestor (if any) turned out to be the scapegoat, and which values belonged to whichever subtree just got flattened and rebuilt — purely to drive the highlighting and log text. The tree-shape logic is identical, including the choice not to store parent pointers or subtree sizes on the node objects themselves: every size this algorithm needs is recomputed on demand by walking the actual subtree, which costs no more than the rebuild that's about to happen anyway.

Verified against a plain JavaScript Set as a reference model across 300 randomized trials of 50 mixed insert/delete operations each (15,000 operations total) on a deliberately small value range, checking after every single operation: the BST ordering property, that the in-order traversal exactly matches the Set's sorted contents, that contains agrees with the Set for every value in range, and that tree height never exceeds log1/α(n) plus a small rounding allowance — followed by a full drain confirming the tree always ends empty. Zero mismatches. The height bound was then stressed harder in two more targeted runs: inserting 1 through 2000 in ascending order (the classic worst-case sequence for a plain BST) produced a final height of 25, against a theoretical bound of 26.4 and a plain BST's or unsplayed splay tree's degenerate 2000; and 30 trials of 3,000 random inserts each, followed by deleting every one of those keys back out in random order and re-checking the height bound after every single delete (not just the inserts) — zero violations across roughly 90,000 delete operations, the closest observed height ever reaching 95% of the theoretical bound and never crossing it. That last run mattered specifically because delete's rebuild trigger is a global count check, not a per-operation height check the way insert's is — worth confirming empirically that the bound still holds throughout a long delete-heavy run, not just assuming it does because insert alone enforces it. The checker was self-tested against four deliberately broken variants before trusting a clean run on the real code: skipping the rebalance check entirely (chain height stayed at 1000 instead of dropping to 25, caught immediately by the height-bound check), inverting the scapegoat comparison (childSize < ALPHA * pSize instead of >, also caught by the height check — height grew unbounded instead of staying near the theoretical line), dropping the + 1 for the ancestor itself when computing a subtree's size (same failure), and forgetting to unlink the in-order successor from its old parent during a two-children delete (caught immediately by the content-match check, over 5,000 mismatches in 100 trials — a real bug, not just a theoretical one, since it leaves a duplicate node reachable from two places in the tree). One more variant — always assuming the new leaf descended left when computing which ancestor child is the "other" side — turned out not to be reliably caught by either check, an inconclusive result rather than a confirmed-safe one, noted honestly rather than presented as a fifth successful catch. Re-verifying by extracting the exact shipped insertScapegoat/ searchScapegoat/deleteScapegoat functions out of the HTML — rather than trusting that a page's demo script matches the standalone reference model just because the two were written to do the same thing — caught a real, separate bug the reference-model stress test above never could: the shipped demo tracked the insert descent path as a plain array of values (to drive the highlight/log UI) and then tried to reconstruct node references from those values by re-walking the tree and comparing each value against itself, which is a tautology and wanders off into the wrong branch on any tree that isn't a straight chain — invisible on the loaded 1-through-8 chain example specifically, because a pure right-chain's every ancestor's own value happens to equal the comparison outcome by coincidence, but wrong the moment a real (non-chain) subtree gets rebuilt and a second insert needs to walk back up through it. Fixed by keeping the actual node references from the initial descent instead of re-deriving them from values, then re-ran the full Set-comparison stress test directly against the corrected shipped functions with a wider value range (0–500 instead of the original small range) specifically to force real branching subtrees rather than chains, the exact shape that had exposed the bug: 200 trials of 60 operations each starting from the loaded 8-node tree, 12,000 operations total, zero mismatches. A real click-driven fake-DOM harness then confirmed the two worked examples named above (the 1-through-8 chain plus inserting 9 finds scapegoat node 5 and flattens exactly nodes 5 through 9, dropping height from 8 to 7; then deleting 9, 8, 7 in order triggers a full rebuild on the third delete, dropping height to 3). See /tmp/scapegoat/ref.js, /tmp/scapegoat/stress.js, /tmp/scapegoat/delete_heavy.js, /tmp/scapegoat/broken1.jsbroken5.js, and /tmp/scapegoat/fake_dom.js//tmp/scapegoat/click_harness.js, scratch, not committed.

Pitfalls

Alpha trades tree depth against total rebuild work, and the trade is steep in both directions — measured, not just asserted. Building a tree by inserting 1 through 2000 in ascending order at three different alpha values: α = 0.55 (close to the strictest legal value) produced height 13 but did 29.31 nodes of rebuild work per insert on average; the conventional α = 0.75 produced height 25 at 9.02 nodes per insert; α = 0.95 (close to the loosest legal value) produced height 147 — twelve times deeper — but only 3.52 nodes of rebuild work per insert. There's no free lunch hiding at either end: a small alpha keeps the tree shallow (fast reads) by rebuilding aggressively (slow, frequent writes); a large alpha keeps writes cheap by tolerating a tree that's measurably closer to a plain unbalanced BST. 0.75 is a genuinely reasonable default, not an arbitrary one, but it's still a real dial, not a constant to ignore.

A scapegoat tree gives search the same worst-case guarantee as AVL or red-black, for free — but only search. It's tempting to describe this structure as "no guarantee, like a splay tree, just without the metadata," but that undersells it: because the height invariant is checked and restored after every insert, a plain lookup is worst-case O(log n) at all times, the same class of guarantee AVL and red-black offer, not merely an amortized one the way a splay tree's search is. What's amortized instead is insert and delete themselves — any single one of those can cost O(n) in the rare case it triggers a rebuild, the same way a single splay tree operation can. Conflating "no per-node metadata" with "no operation has a worst-case bound" would describe the wrong structure.

Skipping delete's global-count rebuild check doesn't just leave the tree slightly stale — it silently breaks the height guarantee's own basis. The height bound insert enforces is stated in terms of the current key count n, which shrinks with every delete. If deletes never rebuilt anything, a tree that grew to a million keys and then shrank back down to ten would still be carrying whatever height it had at a million — nowhere close to log1/α(10) — and every future insert's own height check would keep comparing against the wrong (now far too small) baseline for how deep is actually reasonable at ten keys. The maxN reset is what keeps that comparison honest across arbitrarily many grow-then-shrink cycles, not just a minor cleanup step.

Where scapegoat trees show up

Complexity

Time: search is O(log n) worst case, guaranteed after every insert by the height rule above — not amortized, unlike every other number on this page. insert and delete are both O(log n) amortized: most operations do a plain O(log n) BST walk and nothing more, but any single one can cost O(n) if it triggers a rebuild (a subtree rebuild for insert, a whole-tree rebuild for delete) — the same amortized-not-worst-case shape as a splay tree's guarantee, reached by a completely different mechanism (occasional flatten-and-rebuild instead of restructuring on every touch). Space: O(n) for n nodes, at the minimum possible per-node cost for any binary tree — two child pointers, nothing else — plus two O(1) counters (n and maxN) shared by the whole structure, not stored per node.

See Choosing a Search Tree for how this compares against AVL, red-black, splay, B-tree, and a plain BST when picking an ordered container.