Splay Trees
Ten keys and one deep node
Insert the keys 50, 20, 70, 10, 40, 30, 45, 25, 35, 22 into a plain binary search tree, in that order and with no rebalancing. Every key in a node's left subtree is smaller than the node's key, and every key in its right subtree is larger. A search walks down from the root, going left or right at each node.
Suppose a workload asks for 22 over and over. The tree answers each time by visiting the same six nodes, and it learns nothing from having been asked. A balanced tree (AVL, red-black) would put every key within about of the root, but only by storing balance information in every node and rebalancing on every update. And it too pays about for a key it returned a moment ago.
This module is about a tree that stores nothing but keys and pointers, rearranges itself on every access, and still guarantees per operation. The guarantee is amortized: it holds for the total of any sequence of operations, not for each one.
import math, sys
sys.setrecursionlimit(10_000)
# A tree is None or a tuple (key, left, right). This lesson's own helpers,
# written as recursions on tuples; the lab builds nodes with parent pointers.
def insert(t, k):
if t is None:
return (k, None, None)
key, left, right = t
return (key, insert(left, k), right) if k < key else (key, left, insert(right, k))
def build(keys):
t = None
for k in keys:
t = insert(t, k)
return t
def preorder(t):
return [] if t is None else [t[0]] + preorder(t[1]) + preorder(t[2])
def size(t):
return 0 if t is None else 1 + size(t[1]) + size(t[2])
def depth(t, k):
"""Depth of the last node a search for k visits."""
d = 0
while True:
nxt = t[1] if k < t[0] else t[2]
if k == t[0] or nxt is None:
return d
t, d = nxt, d + 1
T = build([50, 20, 70, 10, 40, 30, 45, 25, 35, 22])
assert preorder(T) == [50, 20, 10, 40, 30, 25, 22, 35, 45, 70]
assert depth(T, 22) == 5 and depth(T, 35) == 4
Three obvious trees, and where each wastes work
A plain search tree is fast on random insertions and hopeless on sorted ones. Insert in increasing order and every key hangs to the right of the one before: a path. Building it costs comparisons, and every later search for a small key walks the whole path.
A balanced tree keeps every search at in the worst case. It pays for that with a balance field in every node, rotations on updates, and roughly comparisons for every access, even when the same few keys are asked for all day.
Move to root is the obvious self-adjusting idea: after finding , rotate it up one level at a time until it is the root. Repeated requests for 22 then cost one comparison. But it can leave the tree as deep as it found it. Take the path built by inserting , with key 999 at the root and 0 at the bottom, and access three times over. Moving each key to the root costs 1,501,497 rotations in total, about per access. The splay tree of this module, on the same accesses, makes 13,122.
The waste in the first two is that nothing learned from an access is kept. The waste in move to root is subtler: it keeps the accessed key, but it does not shorten the path it came along.
The model: rotations
We work in the binary-search-tree model. Memory holds nodes with a key, a left and a right child pointer and a parent pointer. An access starts at the root and follows child pointers, comparing keys. The tree can be changed only by rotations.
A rotation of with its parent moves up one level. If is 's left child, 's right subtree becomes 's left subtree and becomes 's right child. The mirror case is symmetric. A rotation rewrites pointers.
Invariant
A rotation preserves the in-order sequence of keys, and it changes the subtree of exactly two nodes, and .
The cost of an access is the number of rotations it makes. That is fair: the splay tree always rotates the last node its search visited all the way to the root, so an access that visits nodes makes exactly rotations. Comparisons and rotations differ by one per access.
Splaying: rotations in pairs
To splay is to bring it to the root by repeating one of three steps. Let be 's parent and its grandparent.
- zig: is the root. Rotate . (This can happen only once, as the last step.)
- zig-zig: and are both left children, or both right children. Rotate first, then .
- zig-zag: one is a left child and the other a right child. Rotate twice.
Every operation ends with a splay of the last node it touched. A search splays the node it found or, on a miss, the last node it visited. An insert splays the new leaf. A split at key splays the last node on 's search path, after which the root's left or right pointer separates the keys from those . A join of and , where every key of is smaller than every key of , splays the largest key of to its root, which then has no right child, and hangs there. A delete splays the key and joins the root's two subtrees.
Move to root would rotate twice instead. It also ends with on top, but as : stays below , two levels under . The nodes that were on the search path rise by only one level each, so a long path stays long. Rotating first roughly halves the depth of every node on the path. That one-word difference is the whole algorithm.
Splaying 22
Here is 22 splayed in . Its parent is 25 and its grandparent 30, both left links: a zig-zig. Then 22's parent is 40 (22 is its left child) and its grandparent is 20 (40 is its right child): a zig-zag. Finally its parent is the root 50: a zig. That is rotations, one per edge of the original path.
The lesson checks the trace with its own splay, a recursion on tuples. It pairs steps from the bottom, as the definition does: when is at odd depth, the single zig happens at the top.
def splay(t, k, steps=None, d=None):
"""Splay the last node a search for k visits (at depth d); return the new tree."""
d = depth(t, k) if d is None else d
if d == 0:
return t
key, left, right = t
go_left = k < key
c = left if go_left else right
if d % 2 == 1: # zig at the root, last
ck, cl, cr = splay(c, k, steps, d - 1)
if steps is not None:
steps.append(("zig", key, None))
return (ck, cl, (key, cr, right)) if go_left else (ck, (key, left, cl), cr)
ck, cl, cr = c
below_left = k < ck
xk, xl, xr = splay(cl if below_left else cr, k, steps, d - 2)
if go_left == below_left:
if steps is not None:
steps.append(("zig-zig", ck, key))
if go_left:
return (xk, xl, (ck, xr, (key, cr, right)))
return (xk, (ck, (key, left, cl), xl), xr)
if steps is not None:
steps.append(("zig-zag", ck, key))
if go_left:
return (xk, (ck, cl, xl), (key, xr, right))
return (xk, (key, left, xl), (ck, xr, cr))
steps = []
T22 = splay(T, 22, steps)
assert steps == [("zig-zig", 25, 30), ("zig-zag", 40, 20), ("zig", 50, None)]
assert preorder(T22) == [22, 20, 10, 50, 40, 25, 30, 35, 45, 70]
assert depth(T22, 35) == 5
Predict: splaying just pushed 35 one level deeper. Is the tree now worse?
Not in the sense that matters. Splay 35 next: zig-zig with 30 and 25, zig-zig with 40 and 50, then a zig with 22, 5 rotations, and 35 is the root. Splaying is not balancing. What it does is shorten the path it came along: nodes on that path end up roughly half as deep, and the potential below shows that this is what pays for the next long search.
steps = []
T35 = splay(T22, 35, steps)
assert steps == [("zig-zig", 30, 25), ("zig-zig", 40, 50), ("zig", 22, None)]
assert preorder(T35) == [35, 22, 20, 10, 30, 25, 40, 50, 45, 70]
A potential for search trees
The amortized argument uses module 07's potential method: assign each tree a number , define an operation's amortized cost as its actual cost plus , and add up. The sum telescopes, so over any sequence of operations
For search trees, let be the number of nodes in 's subtree ( included), let the rank of be , and let
A leaf has rank 0 and the root has rank . A long path has large potential: its subtree sizes are , so , about . A balanced tree has . That is the right shape for an amortized argument: a deep tree has saved up potential, and a splay that shortens a long path releases it to pay for the rotations.
On , goes from 11.907 to 13.492 after the zig-zig, down to 12.229 after the zig-zag, and to 12.036 after the zig. Each step's amortized cost (rotations plus ) is within the bound the next section proves for it.
def phi(t):
return 0.0 if t is None else math.log2(size(t)) + phi(t[1]) + phi(t[2])
def rank(t, k):
while t[0] != k:
t = t[1] if k < t[0] else t[2]
return math.log2(size(t))
after = [build([50, 20, 10, 40, 22, 25, 30, 35, 45, 70]), # after the zig-zig
build([50, 22, 20, 10, 40, 25, 30, 35, 45, 70]), # after the zig-zag
T22] # after the zig
trees, cost = [T] + after, [2, 2, 1]
assert [round(phi(t), 3) for t in trees] == [11.907, 13.492, 12.229, 12.036]
for i, c in enumerate(cost):
amortized = c + phi(trees[i + 1]) - phi(trees[i])
bound = 3 * (rank(trees[i + 1], 22) - rank(trees[i], 22)) + (1 if c == 1 else 0)
assert amortized <= bound + 1e-9
whole = 5 + phi(T22) - phi(T)
assert round(whole, 2) == 5.13 and whole <= 3 * math.log2(10) + 1 # 10.97
The Access Lemma
Access Lemma (Sleator and Tarjan)
Splaying a node in a tree with root has amortized cost at most rotations, which is at most .
Write and for ranks and sizes before a step and , after it. A step changes the subtrees of , and (in a double step) only, so only those ranks change.
One fact about logarithms. If and , then . Indeed by the inequality of arithmetic and geometric means; take of both ends.
zig-zig, in full. The actual cost is 2. The change in potential is
After the step, 's subtree holds exactly the nodes 's subtree held before, so and those two terms cancel. Before the step was below , so ; after it is below , so . Hence
Now the step that must not be skipped. After rotating first and then , 's new subtree is with and , while 's old subtree was with and . The two are disjoint, and both lie inside 's new subtree, which also contains . So , and the fact about logarithms gives , that is, . Substituting,
The from the logarithm fact pays for the two rotations. Without the disjointness, there is no , and that is exactly where move to root fails: rotating twice leaves 's new subtree overlapping 's old one.
zig-zag, in brief. Again and , so . After the step and are the two children of with disjoint subtrees, so and . That gives .
zig, in brief. The cost is 1, and only and change: , so .
Adding up. Each step's bound is 3 times the rise in 's rank, so the bounds telescope: their sum is times ('s final rank minus its first rank). At the end is the root of the same set of nodes, so its final rank is . At most one step is a zig, which adds the .
From one splay to a sequence
Balance Theorem. Start from any binary search tree with nodes and perform accesses, each splaying the last node its search visits. Then the total number of rotations is at most
Proof. Sum the Access Lemma over the accesses and use the telescoping identity. Every rank is at least 0, so . For the other end, the -th largest subtree in any tree has at most nodes (the nodes with larger or equal subtrees are not below it), so , with equality for a path.
Do not drop the term. It is what lets a single access be expensive. On the path built by inserting , the very first access, to key 0, makes 999 rotations. The lemma charges it at most ; the rest is paid out of the path's potential, . Once , the initial term adds at most per access, and the bound is amortized per access.
def path(n): # what inserting 0..n-1 with splaying builds
t = None
for k in range(n):
t = (k, t, None)
return t
P = path(1000)
assert depth(P, 0) == 999 # 999 rotations to splay 0
assert round(phi(P)) == round(math.lgamma(1001) / math.log(2)) == 8529
def move_to_root(t, k, count, d=None):
"""Rotate the last node on k's path up one level at a time."""
d = depth(t, k) if d is None else d
if d == 0:
return t
key, left, right = t
count[0] += 1 # the rotation at this level
if k < key:
ck, cl, cr = move_to_root(left, k, count, d - 1)
return (ck, cl, (key, cr, right))
ck, cl, cr = move_to_root(right, k, count, d - 1)
return (ck, (key, left, cl), cr)
def three_passes(rule):
t, rotations = path(1000), 0
for _ in range(3):
for k in range(1000):
if rule == "splay":
rotations += depth(t, k)
t = splay(t, k)
else:
count = [0]
t = move_to_root(t, k, count)
rotations += count[0]
return rotations
assert three_passes("splay") == 13_122
assert three_passes("move to root") == 1_501_497
Other operations. Insert, delete, split and join are also amortized , with the largest size the tree reaches. Sketch: attaching a new leaf raises the ranks of its ancestors, but for consecutive ancestors below the new size of is at most the old size of , so the increases telescope to at most , and the splay that follows obeys the lemma. Removing the root or cutting a pointer only lowers ranks, and hanging under the root of raises one rank by at most . Starting from an empty tree, and there is no initial term at all.
Worst case versus amortized. One operation can cost , as the 999-rotation access shows. The guarantee is for every sequence, divided by its length. It involves no probability and no assumption about the input: an adversary who knows the code gets the same bound. A system with a deadline on each operation (an interrupt handler, a real-time controller) needs a worst-case structure such as a red-black tree instead.
Lower bound. In any binary search tree fewer than nodes have depth below , so fewer than a quarter of the keys are within depth of the root, whatever shape the tree has. A uniformly random access therefore visits more than nodes with probability at least , and per access in expectation is unavoidable on such sequences. The splay tree's bound is optimal up to its constant.
Beyond
The Access Lemma holds with any positive weights in place of 1 in the sizes, and choosing the weights cleverly proves much more. These results are stated here, not proved:
- Static optimality (Sleator and Tarjan, 1985). If key is accessed times, times in all, the total cost is . That is within a constant factor of the best fixed search tree for those counts, and the splay tree never learns the counts.
- Working set (same paper). An access to costs amortized , where is the number of distinct keys accessed since the previous access to (plus an start-up term for the whole sequence). Recently used keys are cheap.
- Sequential access (Tarjan, 1985). Accessing all keys in increasing order costs in total, whatever the starting tree. The 13,122 rotations above are that theorem at work.
- Dynamic optimality is a conjecture, open since 1985: that splay trees are within a constant factor of every binary-search-tree algorithm, even one that knows the whole access sequence in advance and rotates freely. Nobody has proved it or refuted it, for splay trees or for any other tree.
Measure the claim
Rotations per access, on trees built by inserting a shuffled (the sequential rows by inserting in increasing order, which builds a path), followed by accesses (seed 451, computed in the course's verification script):
| workload | |||
|---|---|---|---|
| uniform random keys | 11.38 | 14.30 | 17.22 |
| 16 hot keys | 3.30 | 3.28 | 3.27 |
| in order | 4.37 | 4.41 | 4.41 |
| bound | 30.9 | 36.9 | 42.9 |
On uniform keys the cost is about , well under the Access Lemma's (these are measurements on these inputs, not a sharper theorem). The hot-key row stays flat as grows sixteen-fold, as the working-set theorem predicts for a working set of 16, and the sequential row stays flat as the sequential-access theorem predicts. A balanced tree would pay about comparisons in all three rows.
In real software
Splay trees are used where lookups are local and a simple structure is worth more than a
per-operation guarantee. GCC's support library, libiberty (splay-tree.c), provides a splay tree
that the compiler uses, for example, to map the variables of each OpenMP construct while it
gimplifies the code (gimplify.cc). FreeBSD's sys/tree.h offers SPLAY_* macros that splay top-down, beside its
rank-balanced trees; its own comment warns that every lookup causes memory writes. That is the
price of self-adjustment: a read rewrites pointers, which costs cache traffic and needs a lock
where a balanced tree could let many readers in at once.
A problem that looks different
A text editor holds a document of a million characters. Users cut a range of text and paste it somewhere else, hundreds of times a second, and the cursor mostly moves around one area of the document. Characters have no keys: position 400,000 names a different character after every paste. Can anything from this module keep each edit cheap? (Not solved here; the lab's last problem is a different one.)
Practise
In the lab you write a rotation and the potential and watch after every rotation you make, predict and then trace the splay steps on a tree of your own, build a full splay tree with search, insert, delete, split and join, measure rotations per access for three workloads with your own experiments, and finish with a problem that doesn't say what it is.
Recap
- You can now: splay by hand with zig, zig-zig and zig-zag steps; implement every search-tree operation as a descent followed by a splay; prove the Access Lemma's zig-zig case from the rank potential; and state the Balance Theorem with its initial-potential term.
- Invariant: rotations keep the in-order sequence, and pays for long paths: a splay of costs amortized at most rotations.
- Complexity achieved: any accesses from an -node tree cost at most rotations, amortized per operation with no balance field, against per operation for a plain tree on sorted input and per access for move to root on sequential access. A single operation can still cost .
- Failure mode: rotating twice in the zig-zig case (move to root), or skipping the splay after an unsuccessful search, which leaves the long descent unpaid.
- In real software: GCC's libiberty
splay-tree.c(used for OpenMP variable maps) and FreeBSD'ssys/tree.hSPLAY_*macros. - Retrieval: module 07's doubling array has amortized cost at most 3 per append. What role does its potential play that plays here, and why must both be compared with their starting value?
Check yourself
- Why does the zig-zig step rotate the parent before , and at which line of the proof does that order matter?
- Drop the splay after an unsuccessful search, keeping it after successful ones. Give a sequence of operations on which the total cost is no longer .
- For a controller with a deadline on every operation, and for a symbol table where 16 names receive most lookups, would you choose a red-black tree or a splay tree? Defend each choice with the kind of bound each tree gives.
Topic list modeled on the public schedule of CMU 15-451/651 Algorithm Design and Analysis (Fall 2025). All lecture text, proofs, examples, code, and exercises are original to SciMigo; no CMU course materials are reproduced. This course is independent and not affiliated with or endorsed by Carnegie Mellon University.