Cairn
data structures · third self-balancing tree · amortized O(log n)

back to Node-Linked Trees

Splay Tree

AVL and red-black trees both bound height by enforcing an invariant after every write — a height number kept within 1, or a set of color rules — so that every single operation, not just most of them, is guaranteed O(log n). A splay tree gives that up entirely: it enforces no shape invariant at all, and a single unlucky operation can legitimately cost O(n). What it offers instead is a different kind of guarantee — amortized, not worst-case — earned by doing one extra thing on every insert, search, and delete: whichever node was just touched gets rotated all the way up to become the new root, via a sequence of paired rotations called splaying. Access something once and it's expensive to reach; access it again immediately after and it's O(1), because it's sitting at the root. That bet — that whatever was just used is likely to be used again soon — is the same principle an LRU cache runs on, just paid for with tree rotations instead of a linked list.

Try it

The tree below was loaded by inserting 1 through 7 in ascending order, the same sequence the AVL and red-black pages used — and here it lands somewhere neither of those trees would ever allow: a full linear chain, height 7, checked against the shipped algorithm below. That's not a bug — inserting a new maximum always attaches it as the current root's right child with nothing else nearby, so its splay is always a single "zig" rotation, never the tree-collapsing "zig-zig" case — it's the honest cost of having no balance invariant at all. Try searching for 4 now and watch it splay to the root: the walk down touches every node in the chain, but the splay back up collapses the whole thing, and the tree's height drops from 7 to 4 in that one operation (also checked below, not just eyeballed). Insert attaches a new value as a leaf and splays it up the same way; inserting an already-present value still splays the existing node to the root instead of silently no-oping, since a duplicate lookup is itself an access. Delete splays the target to the root, removes it, then joins its two subtrees by splaying the left subtree's maximum to its own top and hanging the right subtree off its (now guaranteed-empty) right side — that join step only has real work to do when the left subtree isn't itself already a plain chain (deleting straight from the loaded chain never needs one, checked: every value's left subtree comes out chainless). After searching 4 as suggested above, try deleting 6 to see a real one: the join logs its own separate splay, rotating 5 up to become the left subtree's new top before the right side reattaches.

Loaded by inserting 1 through 7 in ascending order — a full height-7 chain. Try searching 4.

Zig, zig-zig, zig-zag

Splaying a node x to the root repeats one of three moves, chosen by looking at x's parent and grandparent together, until x has no parent left:

Zig-zig, x and p both left children (mirror handles both-right):

       gp                    p                       x
      /                     / \                     / \
     p          -->        x   gp        -->        A   p
    /                     / \                           / \
   x                     A   B                          B   gp
  / \                                                       / \
 A   B                                                      C   D
                 (rotate at gp first)          (rotate at p second)

Zig-zag, x a right child, p a left child (mirror handles the other diagonal):

     gp                    gp                     x
    /                     /                      / \
   p           -->       x            -->       p   gp
    \                   / \                     / \  / \
     x                 p   C                   A  B C   D
    / \               / \
   B   C             A   B
              (rotate at p first)   (rotate at gp second)

Both multi-step cases rotate the node two levels up per pass instead of one — that's what makes splaying to the root take at most ⌈depth / 2⌉ passes, and it's also the mechanical reason zig-zig's rotation order matters: rotating grandparent-then-parent (shown above) roughly halves the depth of everything x passes on the way up, where rotating parent-then-grandparent first (the "naive" move-to-root strategy) does not — see Pitfalls for a checked, measured comparison.

Insert, search, delete

Search walks down like an ordinary BST search, then splays whatever node it last touched — the found node on a hit, or the node it fell off the tree from on a miss — to the root either way. Splaying on a miss is a deliberate, standard choice, not an oversight: it means a repeated search for the same absent value stays cheap (the node nearest to where it would be is already at the root), the same benefit a hit gets.

Insert is an ordinary BST insert — walk down by comparison, attach a new leaf where the walk falls off the tree — followed by splaying that new leaf to the root. If the value already exists, nothing is attached; the existing node is splayed instead, per the "any access counts" rule above.

Delete splays the target to the root first (same fall-off convention as search if it's missing, in which case nothing more happens), then removes it by joining its two subtrees: detach the left subtree, walk to its maximum, splay that node to the top of the left subtree alone — which leaves it with no right child, since it was already the largest value there — then attach the original right subtree as its new right child. The order matters: splaying the join node before reattaching the right subtree means that second splay only ever walks within the left subtree, never touching (or needing to be careful about) the right one at all.

Reference implementation

class SplayTree {
  #root = null;

  #rotateLeft(x) {
    const y = x.right;
    x.right = y.left;
    if (y.left !== null) y.left.parent = x;
    y.parent = x.parent;
    if (x.parent === null) this.#root = y;
    else if (x === x.parent.left) x.parent.left = y;
    else x.parent.right = y;
    y.left = x;
    x.parent = y;
  }

  #rotateRight(x) {
    const y = x.left;
    x.left = y.right;
    if (y.right !== null) y.right.parent = x;
    y.parent = x.parent;
    if (x.parent === null) this.#root = y;
    else if (x === x.parent.right) x.parent.right = y;
    else x.parent.left = y;
    y.right = x;
    x.parent = y;
  }

  #splay(x) {
    while (x.parent !== null) {
      const p = x.parent, gp = p.parent;
      if (gp === null) {                                  // zig
        if (x === p.left) this.#rotateRight(p); else this.#rotateLeft(p);
      } else if (p === gp.left && x === p.left) {          // zig-zig, left-left
        this.#rotateRight(gp); this.#rotateRight(p);
      } else if (p === gp.right && x === p.right) {        // zig-zig, right-right
        this.#rotateLeft(gp); this.#rotateLeft(p);
      } else if (p === gp.left && x === p.right) {          // zig-zag
        this.#rotateLeft(p); this.#rotateRight(gp);
      } else {                                              // zig-zag, mirror
        this.#rotateRight(p); this.#rotateLeft(gp);
      }
    }
  }

  // walks to `value`; returns the node itself if found, else the last node
  // visited before falling off the tree (or null, if the tree is empty).
  #findLast(value) {
    let cur = this.#root, last = null;
    while (cur !== null) {
      last = cur;
      if (value === cur.value) return cur;
      cur = value < cur.value ? cur.left : cur.right;
    }
    return last;
  }

  insert(value) {
    if (this.#root === null) { this.#root = { value, left: null, right: null, parent: null }; return; }
    const last = this.#findLast(value);
    if (last.value === value) { this.#splay(last); return; } // already present — splay it anyway
    const z = { value, left: null, right: null, parent: last };
    if (value < last.value) last.left = z; else last.right = z;
    this.#splay(z);
  }

  search(value) {
    if (this.#root === null) return false;
    const last = this.#findLast(value);
    this.#splay(last);
    return last.value === value;
  }

  delete(value) {
    if (this.#root === null) return false;
    const last = this.#findLast(value);
    this.#splay(last);
    if (last.value !== value) return false; // splayed the nearest node; nothing removed
    if (last.left === null) {
      this.#root = last.right;
      if (this.#root !== null) this.#root.parent = null;
    } else {
      const rightSub = last.right;
      const leftSub = last.left;
      leftSub.parent = null;
      let maxNode = leftSub;
      while (maxNode.right !== null) maxNode = maxNode.right;
      this.#root = leftSub;
      this.#splay(maxNode);       // splays within the detached left subtree only
      maxNode.right = rightSub;
      if (rightSub !== null) rightSub.parent = maxNode;
    }
    return true;
  }

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

The interactive demo above uses equivalent insertSplay/searchSplay/ deleteSplay functions with extra bookkeeping to record the descent path and which rotation(s) fired, purely to drive the highlighting and log text — the algorithm is identical, including the parent pointers, which (like red-black tree's fixups) this needs and the plain BST/ AVL pages didn't: identifying the zig-zig/zig-zag case requires seeing two ancestor levels above x at once.

Verified against a plain JavaScript Set as a reference model across 300 randomized trials of 40 mixed insert/search/delete operations each on a deliberately small value range (to force heavy collisions between inserts, duplicate-splays, and deletes of both present and absent values), checking after every single operation: the BST ordering property, that every node's parent pointer matches its actual position in the tree (not just that the tree "looks" right by value — see Pitfalls), that the root's own parent is always null, that size() matches the Set's size, that contains agrees with the Set for every value the Set holds, and that a freshly-inserted or freshly-found value is always the new root — 12,000 operations with zero mismatches, followed by a full drain (deleting every remaining value) confirming the tree always ends empty. The checker was self-tested against two deliberately broken variants first: swapping last.left/ last.right on insert (caught immediately — BST property violated) and dropping the rightSub.parent = maxNode line from delete's join step (caught on the very first trial's post-op drain — parent pointers inconsistent), before trusting a clean run on the real code. Re-verified by extracting the exact shipped insertSplay/searchSplay/ deleteSplay functions out of the HTML and re-running an equivalent pass directly against them, plus a real click-driven fake-DOM harness confirming the two worked examples named above (the 1-through-7 ascending load produces a height-7 chain and searching 4 drops it to height 4; then deleting 6 from that state logs a separate join-splay). See /tmp/splay/ref.js, /tmp/splay/test_harness.js, /tmp/splay/broken2.js, /tmp/splay/broken4.js, /tmp/splay/extracted_trial.js, and /tmp/splay/fake_dom.js//tmp/splay/click_harness.js, scratch, not committed.

Pitfalls

"Rotate to the root one level at a time" still finds the right answer, but it isn't splaying, and it quietly loses the whole point of the structure. A single rotation moving x up past its parent, repeated until x is the root, produces a correct BST with x at the top every time — it's tempting to write, and every correctness check on this page (BST order, Set agreement) would pass it. What it doesn't do is the two-level zig-zig lookahead's actual job: roughly halving the depth of everything on the accessed path, not just moving one node. Measured, not just claimed: build a fully linear chain of n nodes (plain descending-order BST insert, no splaying), then access every value once, from the deepest node to the shallowest — the textbook worst case a self-adjusting tree is supposed to handle well. At n = 1000, proper zig-zig splaying does 5,374 total comparison steps across all 1,000 accesses (~5.4 per operation, consistent with O(log n) amortized); naive single-rotation-per-step "splaying" does 501,499 (~501.5 per operation — growing linearly with n, i.e. O(n) amortized, exactly the guarantee splaying exists to avoid). The gap only widens with n: at 2,000 it's 10,804 vs. 2,002,999.

A splay tree can look right by value and still have a corrupted parent pointer. The mirror image of the red-black delete pitfall named above: dropping rightSub.parent = maxNode from the join step doesn't change any node's value, doesn't break the BST ordering, and doesn't make contains return a wrong answer for the sequence that exposed it — printing the tree by value looks completely normal. What's actually wrong only shows up by checking the pointer itself: inserting 5, 3, 8, 2, 4, 7, 9 then deleting 5 leaves node 8's .parent field pointing at the already-discarded node 5 instead of its real parent, the new root 4 — invisible to any check that only reads values top-down from the root, caught here only because the verification above walks the tree and confirms every node's parent independently rather than trusting the shape it renders.

No shape invariant means no single-operation guarantee, ever — not "usually fine." The loaded demo's own height-7 chain from an ascending 1-through-7 insert is the concrete version of this: a plain unbalanced BST given the same input produces the identical worst-case shape, and nothing about splaying prevents it, because splaying only ever touches the single path it just walked — it has no mechanism to notice or fix imbalance anywhere else in the tree. The amortized bound is a real, provable guarantee over any sequence of operations, but it says nothing about any one operation in isolation, unlike AVL or red-black's per-operation O(log n).

Where splay trees show up

Complexity

Time: insert, search/contains-via-splay, and delete are all O(log n) amortized over any sequence of operations — proven with a potential-function argument this page doesn't reproduce — but any single operation can cost O(n) in the worst case (the loaded demo's height-7 chain makes that concrete: searching for 1 in it walks all 7 nodes before the splay even starts). This is the one genuine trade against AVL and red-black trees, both of which guarantee O(log n) for every individual operation, not just on average over many. Space: O(n) for n nodes — two child pointers and one parent pointer each, the same per-node cost as a red-black tree, minus the one color bit (a splay tree stores no per-node metadata at all — nothing to update, nothing to get wrong, unlike AVL's height or red-black's color).

See Choosing a Search Tree for when that amortized bound and skew-friendly behavior beats a per-operation guarantee like AVL or red-black's.