Designing A Self Adjusting Binary Search Tree (splay Tree) With Amortized Analysis

A comprehensive technical exploration of designing a self adjusting binary search tree (splay tree) with amortized analysis, covering key concepts, practical implementations, and real-world applications.
Contents
The Beautiful Trade-Off: Why Splay Trees Ditch Perfection for Performance
You are debugging a system that feels sluggish. Your first instinct? Profile the database queries, check the network latency, maybe even blame the CPU cache. But then, you realize the bottleneck is something far more fundamental: the humble binary search tree (BST) you used to store a rapidly shifting set of “hot” data. A standard BST works beautifully in theory—logarithmic time for search, insertion, and deletion. But in practice? It’s a ticking time bomb. Insert data in sorted order, and your beautifully balanced tree degrades into a linked list. The asymptotic guarantee of O(log n) becomes a painful O(n). You’re left with a choice: either use a more complex data structure like an AVL or Red-Black tree to enforce strict balance, or accept the performance penalty.
But there is a third path, one that rejects the deterministic rigidity of perfect balance in favor of a more organic, self-improving structure. It’s called the splay tree, and it’s one of the most elegant—and misunderstood—data structures in computer science. It doesn’t care about being perfectly balanced at every single moment. Instead, it plays a long game, betting that the future will look a lot like the present.
This post isn’t just another tutorial on rotations. We’re going to deconstruct the splay tree from first principles, asking a deceptively simple question: What if a data structure could learn from its users? We’ll explore how this structure achieves near-optimal performance through a process of “self-adjustment,” and we’ll arm ourselves with the mathematical toolkit needed to prove it works—amortized analysis. By the end, you’ll not only understand how to implement a splay tree, but also appreciate the profound beauty of a data structure that dares to be imperfect in the short term for extraordinary performance in the long term.
1. The Canonical Problem: The Tree That Forgets
Let’s step back and examine the foundational assumption behind every balanced search tree. Standard BSTs (without any rebalancing) provide O(log n) performance only when insertions and deletions are random. The moment the data exhibits locality—for example, when you repeatedly access the same few keys or when you insert them in sorted order—the tree becomes pathological. In computer science, this is known as the “tree that forgets”: a BST has no memory of which keys have been accessed most frequently. It treats every search, insertion, or deletion as if it were equally likely to access any key in the universe.
But real-world workloads are rarely uniform. Consider a web cache: users tend to access the same popular pages over and over. In a database, frequently queried rows stay hot for a while, and then shift to new rows. In a garbage collector, the same pointer path might be followed repeatedly. These workloads exhibit temporal locality: recently accessed items are likely to be accessed again soon.
A standard BST cannot exploit this locality. Even if you access the same element a million times, each search still takes O(log n) time. An AVL tree would be even worse: after each access, it rigorously rebalances itself, possibly moving the frequently accessed node far from the root, thus punishing you for your repeated interest.
What if we could build a structure that rewards locality? A structure that, when you access a node, actively moves it closer to the root so that future accesses to that same node (or its neighbors) become cheaper? That is the central insight behind the splay tree.
1.1 The Costs of Deterministic Balance
Before we dive into splay trees, it’s worth understanding why deterministic rebalancing (AVL, Red-Black) is not the ideal solution for all scenarios.
- AVL trees: Maintain strict height balance (height difference ≤ 1). This gives O(log n) worst-case per operation, but the constant factors are high due to frequent rotations and the need to recompute heights. More importantly, AVL trees do not adapt to access patterns. If you access a node 100 times in a row, it remains in the same position; you pay O(log n) each time.
- Red-Black trees: A more relaxed balance criterion (only black-heights need to be equal, red nodes can appear but not consecutively). They offer O(log n) worst-case with fewer rotations on average than AVL, but still no adaptation.
- B-trees: Optimized for block storage, but again deterministic.
All these structures share a common flaw: they are oblivious to history. They treat the underlying data as a static set, when in reality the access pattern is a dynamic stream. The splay tree, on the other hand, is a self-adjusting data structure—it changes its shape based on the operations performed, aiming to keep frequently accessed nodes near the top.
1.2 The Amortized Bargain
The splay tree does not guarantee O(log n) worst-case per operation. In fact, a single operation could take O(n) time—for instance, if you splay a leaf in a deep tree. But the magic is that such expensive operations cannot happen too often. Over a sequence of M operations, the total time is O(M log n), which gives an amortized O(log n) per operation. Furthermore, if the access pattern exhibits locality, the amortized cost can be much better—even O(1) per access for a stream of repeated hits.
This trade-off is beautiful: we sacrifice the guarantee of a perfectly balanced tree at every instant in exchange for a system that continuously improves itself, focusing its shape on the current working set. The splay tree is, in a sense, a learning data structure.
2. What Is a Splay Tree?
A splay tree is a binary search tree in which every operation—search, insertion, deletion—ends with a special operation called splaying. Splaying takes a node and moves it to the root of the tree by performing a sequence of rotations. The rotations are chosen based on the node’s position relative to its parent and grandparent.
2.1 The Core Operations
Splaying consists of three primitive moves, each applied repeatedly until the target node becomes the root:
- Zig: When the node has a parent but no grandparent (i.e., it is a child of the root). Simply rotate the node with its parent.
- Zig-Zig: When the node and its parent are both left children (or both right children). Rotate the parent with the grandparent, then rotate the node with the (new) parent. This is a double rotation that moves the node up two levels and brings the subtree that was the node’s sibling up as well.
- Zig-Zag: When the node is a left child and its parent is a right child (or vice versa). Rotate the node with its parent, then rotate the node with its grandparent (which is now the parent after the first rotation). This is essentially a double rotation in the opposite direction.
These three cases cover all possible configurations. The process is repeated until the target node is at the root.
2.2 Why Splaying Works
The genius of the splay operation is not just that it moves the accessed node to the root—it also dramatically restructures the tree, making it more balanced in the process. Specifically, each splay operation approximately halves the depth of the nodes on the path from the root to the node. This global restructuring is what allows the splay tree to achieve amortized logarithmic cost.
To see the effect, consider a path of length L from root to leaf. After splaying the leaf, the depth of most nodes on that path is reduced, and the tree becomes “bushier”. In fact, after enough splay operations on different nodes, the tree tends to stay roughly balanced on its own, without any explicit balance condition.
2.3 An Example: Splaying After a Search
Suppose we have a simple BST with keys [1, 2, 3, 4, 5] inserted in that order (no rebalancing). The resulting tree is a degenerate chain:
1
\
2
\
3
\
4
\
5
Now we search for key 5. With a standard BST, this would take 5 comparisons. In a splay tree, after finding the node, we splay it to the root. Let’s walk through the splay steps (assuming bottom-up splaying):
- Node 5 has parent 4 and grandparent 3. Both are right children → Zig-Zig case.
- Rotate 4 and 3: tree becomes
1 \ 3 / \ 2 4 \ 5 - Now rotate 5 and 4 (but 4 is now child of 3? wait, need to do double rotation consistently). Actually, in Zig-Zig, we first rotate parent with grandparent, then rotate node with parent. After first rotation, node 5’s parent is 3? Let’s do carefully:
- Rotate 4 and 3: tree becomes
Original: 1-2-3-4-5 chain (right children).
- Grandparent of 5 is 3, parent is 4.
- Rotate 4 up, 3 down: becomes
1 \ 4 / \ 3 5 / 2 - Now node 5 has parent 4 and grandparent 1? Actually after first rotation, parent of 5 is 4, grandparent is 1 (since 4 is now child of 1). Since both are right children again? 5 is right child of 4, 4 is right child of 1 → another Zig-Zig.
- Rotate 4 and 1:
4 / \ 1 5 \ 3 / 2 - Now node 5 is at root? No, node 5 is still child of 4. We need to splay 5 further: now parent is 4, but 4 is root so it’s a Zig: rotate 5 and 4 → final tree:That seems unbalanced too. Actually, splaying a leaf in a chain does not instantly produce a perfectly balanced tree—but it does move the accessed node to the root. Over repeated accesses to various nodes, the tree becomes roughly balanced. The key is that the worst-case shape is a chain, but after a splay operation, the path from root to any of the other nodes on the original chain is cut roughly in half. In our example, after splaying 5, the depth of node 4 is 1, node 1 is 2, node 3 is 3, node 2 is 4. Not perfectly balanced, but much better than depth 5.
5 / 4 / 1 \ 3 / 2
If we then search for 4, we would splay 4 to the root, further improving the shape. Over many operations, the tree self-organizes.
3. Splaying in Detail
To truly understand the splay tree, we need to examine the three cases with precise diagrams and code. Let’s define the splay operation as a function that takes a node x and performs rotations until x becomes the root.
3.1 Notation
Let:
p= parent ofxg= grandparent ofx(parent ofp)
We also need to know whether x is a left child or right child. In code, we can check pointers. For simplicity, we’ll assume nodes have left, right, and parent pointers (or we can use recursion and return the root). The classical splay tree implementation uses a top-down approach (more efficient), but we’ll start with bottom-up for clarity.
3.2 Zig Case
If p is the root, we do a single rotation:
if x == p.left:
p.left = x.right
if x.right: x.right.parent = p
x.right = p
else:
p.right = x.left
if x.left: x.left.parent = p
x.left = p
x.parent = None
p.parent = x
Now x becomes the root.
3.3 Zig-Zig Case
Both x and p are left children (or both right children). First rotate p with g, then rotate x with p. This is equivalent to a double rotation that moves x up two levels.
Left-left case:
// Rotate p and g
g.left = p.right
if p.right: p.right.parent = g
p.right = g
// Now p becomes child of g's parent (which was the parent of g)
// Then rotate x and p
But careful: after the first rotation, the tree has changed; we then rotate x with its new parent p. The sequence is simpler if we think of the double rotation as:
// Standard double rotation: first rotate p and g, then x and p.
// Actually for zig-zig (left-left):
// Rotate g right so p becomes root of this subtree.
// Then rotate p right? No, we need to bring x up.
// Let's use the classic textbook definition:
def splay(x):
while x.parent != None:
p = x.parent
g = p.parent
if g == None:
# zig
if x == p.left:
// right rotate p
p.left = x.right; if x.right: x.right.parent = p
x.right = p
else:
// left rotate p
p.right = x.left; if x.left: x.left.parent = p
x.left = p
p.parent = x
x.parent = None
else:
# zig-zig or zig-zag
if (x == p.left) == (p == g.left): # both left or both right
# zig-zig
if x == p.left:
// right rotate g, then right rotate p? Wait.
// Actually for left-left: rotate g right, then rotate p right.
// But we can do both rotations in one: it's equivalent to:
// 1. rotate g right (so p becomes child of g's parent)
g.left = p.right; if p.right: p.right.parent = g
p.right = g
// 2. now p is parent of g, and x is left child of p
// rotate p right? No, x is already child of p, we need move x up instead?
// The standard approach: after first rotation, p is where g was, x still child of p.
// Then we rotate p right? That would move x up? No.
This is getting confusing because there are multiple ways to implement the rotations. The bottom-up splay is easier to understand if we just perform two separate rotations, using the rotate function that takes a parent and child and moves the child up.
Define a generic function rotate_up(x) that assumes x has a parent p, and performs the rotation to make x the parent of p (maintaining BST order). Then splay is simply:
def splay(x):
while x.parent != None:
p = x.parent
if p.parent == None:
rotate_up(x) # zig
else:
g = p.parent
if (p.left == x) == (g.left == p): # same side
rotate_up(p) # first rotation in zig-zig
rotate_up(x) # second
else:
rotate_up(x) # zig-zag first
rotate_up(x) # second (now x is at g level)
This is much cleaner. The key insight: for zig-zig, we first rotate the parent up, then the node up. For zig-zag, we rotate the node up twice.
Proof that this works: After rotate_up(p), x becomes child of g? Actually if both are left children: p is left child of g, x is left child of p. Rotating p up makes p the parent of g, and x remains left child of p. Then rotating x up makes x parent of p. So after two rotations, x ends up where g was originally. For zig-zag (p right child of g, x left child of p): Rotate x up first: x becomes parent of p, and since x was left child, p becomes right child of x? Actually we need to be careful with trees. The generic rotate_up function handles all cases: it takes a child, makes it parent, and adjusts subtrees accordingly.
Below is a concrete implementation in Python (using recursion in a top-down style is usually done without parent pointers, but for bottom-up we need them):
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
self.parent = None
def rotate_up(x):
"""Rotate x with its parent p, making x the new parent of p.
Assumes x has a parent."""
p = x.parent
if p.left == x: # x is left child
p.left = x.right
if x.right:
x.right.parent = p
x.right = p
else: # x is right child
p.right = x.left
if x.left:
x.left.parent = p
x.left = p
x.parent = p.parent
p.parent = x
# Update grandparent's child pointer
if x.parent:
if x.parent.left == p:
x.parent.left = x
else:
x.parent.right = x
def splay(x):
while x.parent:
p = x.parent
if p.parent is None: # zig
rotate_up(x)
else:
g = p.parent
if (g.left == p) == (p.left == x): # same orientation
rotate_up(p) # first rotation of zig-zig
rotate_up(x) # second
else: # opposite orientation
rotate_up(x) # first rotation of zig-zag
rotate_up(x) # second
This implementation assumes we have a reference to the node x that we want to splay. If we are searching for a key, we first find the node (if exists), then splay it. If the key is not present, we splay the last internal node accessed (or the parent of where the new node would be inserted).
3.4 Matching Splay Cases to the Parent-Grandparent Relationship
It’s important to understand why these three cases are sufficient. The while loop continues until x becomes the root. The cases are:
- Zig (p has no parent): simple single rotation.
- Zig-Zig (x and p are both left children or both right children): double rotation where we first rotate p up, then rotate x up.
- Zig-Zag (x is left child, p is right child, or vice versa): double rotation where we rotate x up twice (same as a standard double rotation: rotate x up with p, then rotate x up with the new parent g).
All three cases are covered. This ensures that each iteration moves x up two levels (except the last iteration which moves it up one level). Thus the number of rotations is at most 2 * depth of x.
3.5 Splaying During Insertion and Deletion
Insertion in a splay tree is straightforward: insert the new node as in a standard BST (at a leaf), then splay the new node to the root.
Deletion is slightly more involved. The standard approach:
- Search for the node to delete (and splay it to the root).
- Remove the root. Now you have two subtrees: left and right.
- The new root must be chosen such that the BST property holds. The typical method: splay the maximum node in the left subtree (if it exists) to the root of that subtree, then attach the right subtree as its right child. This yields a single node with two children.
- Alternatively, you can splay the minimum of the right subtree. One of these must work if the subtree is non-empty.
If the left subtree is empty, the right subtree becomes the new root (and vice versa). This is O(log n) amortized.
4. Amortized Analysis: The Magic of Potential
Now for the deep theoretical foundation: why does the splay tree achieve O(log n) amortized time per operation? We need a potential function that measures the “disorder” or “depth” of the tree, such that expensive operations increase the potential, and cheap operations decrease it, balancing out over time.
4.1 The Potential Function
Sleator and Tarjan (1985) introduced the following potential function for a splay tree:
For each node x, define s(x) as the size of the subtree rooted at x (number of nodes in that subtree). Then define r(x) = log(s(x)) (log base 2, or natural log, it doesn’t matter constants). This r(x) is called the rank of node x. The potential of the entire tree T is:
Φ(T) = Σ (r(x) over all nodes x)
That is, the sum of the ranks of all nodes.
Why this potential? Because it’s large when the tree is very unbalanced (some subtrees are huge, others tiny, ranks vary greatly) and small when the tree is balanced (all subtree sizes are about n/2, ranks are near log n). Specifically, the maximum potential occurs for a degenerate chain: each node’s subtree size equals the number of nodes below it, so s(x) ranges from 1 to n, and the sum of logs is O(n log n). The minimum potential occurs for a perfectly balanced tree: each size is around n/2, n/4, etc., and the sum of logs is O(n log n) as well? Actually both are Θ(n log n), but the difference between potentials is what matters. The key is that splaying reduces the potential, and the reduction can offset the cost of rotations.
4.2 The Amortized Cost Lemma
We need to bound the amortized time of a splay operation. The actual time (number of rotations) is proportional to the number of rotations, which itself is O(depth of node). We want to show that the amortized time per splay is O(log n).
Let’s denote:
R(x)= rank of node x before the splay step.R'(x)= rank of node x after the splay step.
The actual cost (in unit steps) for a splay operation is the number of rotations, which we can think of as 1 per rotation. For each of the three cases (zig, zig-zig, zig-zag), we can bound the amortized cost as:
Amortized cost = actual cost + change in potential.
We want to show that the amortized cost for a splay operation is at most 3 * (R(root) - R(x)) + O(1), which telescopes to O(log n).
Lemma: For each splay step (shown for zig-zig and zig-zag), the amortized cost is at most 3*(R’(x) - R(x)) for zig-zig/zig-zag, and at most 1 + 3*(R’(x) - R(x)) for zig (where the +1 is for the final step).
Proving this lemma is the heart of the analysis. I’ll present a simplified version.
Let’s consider a single zig-zig step (both x and p are left children). The tree transformation (after rotation) affects ranks of nodes x, p, and g. The actual cost is 2 rotations. Let A be the subtree of x that remains its child, B the subtree that moves from p to become sibling of x, C the subtree that moves from g to become sibling of p, and D the subtree that is the other child of g (or the rest of tree). The ranks of x, p, g change:
- Before: s(x) = size(A) + 1
- s(p) = size(B) + s(x)
- s(g) = size(C) + s(p)
After rotation:
- The new root is
x. x.leftis same (A).x.rightisp.p.left= B,p.right= g.g.left= C,g.right= D (assuming original orientation).
Thus:
- s’(x) = size(A) + size(B) + size(C) + size(D) + 3? Actually s’(x) = size(A) + size(B) + size(C) + size(D) + 3 (including x, p, g). That equals s(g) + 1? Wait, original s(g) included all those nodes. So s’(x) = s(g). Good.
- s’(p) = size(B) + size(C) + size(D) + 2? Actually p’s subtree after rotation: includes B, g, C, D, but not A. So s’(p) = size(B) + size(C) + size(D) + 2 (p and g? plus maybe? Let’s carefully count: p has left child B, right child g. g has left C, right D. So p’s subtree includes B, C, D, plus p and g: that’s 2 + |B|+|C|+|D|. Original s(p) = |A|+|B|+1 (p) +? Actually original s(p) = |A|+|B|+1 (x) +? Wait confusing. Better to use the known relationship: s’(p) ≤ s(p) - 1? Not essential.
The key inequality used in the proof is:
R'(x) + R'(p) + R'(g) ≤ R(x) + R(p) + R(g) - 2? Not exactly. The standard proof shows that the decrease in potential from the step is at least 2* (R(x) - R’(x))? Actually we need to bound change in potential in terms of R’(x) - R(x).
I’ll present the standard result without fully deriving it (many textbooks do). The lemma yields:
- For a zig-zig step: amortized cost ≤ 3*(R’(x) - R(x))
- For a zig-zag step: amortized cost ≤ 3*(R’(x) - R(x))
- For a zig step: amortized cost ≤ 1 + 3*(R’(x) - R(x))
Summing over all steps during a splay operation from depth d to root gives a telescoping sum: the total amortized cost ≤ 1 + 3*(R(root) - R(x_initial)). Since R(root) = log(n) and R(x_initial) ≥ 1 (if tree nonempty), we get amortized cost = O(log n). Note that the actual worst-case cost of a single splay could be O(n), but it’s counterbalanced by a large decrease in potential.
4.3 Amortized Cost of Other Operations
- Search: splay, O(log n) amortized.
- Insert: standard BST insertion (O(1) pointer changes) plus splay of new node, O(log n) amortized.
- Delete: search (splay), then remove root, then possibly splay the max of left subtree (O(log n) amortized). Overall O(log n) amortized.
Thus all dictionary operations run in amortized O(log n) time.
4.4 Beyond the Balance Theorem: Static Optimality
The splay tree has even stronger properties. It satisfies:
- Balance theorem: Amortized O(log n) per operation over any sequence.
- Static optimality theorem: Over a sequence of operations, the total cost is within a constant factor of the optimal static BST (one that is built optimally for a given static distribution, i.e., a binary search tree that minimizes total search cost given known frequencies). This means that if some keys are accessed far more often than others, the splay tree nearly matches the performance of a tree specifically built to minimize weighted path length (like an optimum binary search tree). This is remarkable because the splay tree does not know the frequencies in advance.
- Working set theorem: The amortized cost of accessing a key
xcan be bounded by O(log(1 + w(x))), where w(x) is the number of accesses to keys that are more recent than the last access to x. This formalizes locality: if you frequently revisit a small working set, the cost per access is roughly O(log k) where k is the size of the working set, not the total number of keys. - Static finger theorem: Access times are logarithmic in the distance from a fixed pointer (like sequential access in a sorted order).
- Dynamic optimality conjecture: Splay trees are dynamically optimal, i.e., they achieve the best possible amortized cost among any binary search tree that can only restructure via rotations. This is a major open problem in computer science (2024 update: still open, but strong evidence supports it).
These properties make splay trees theoretically unbeatable for many workloads, yet they remain underused in practice due to perceived complexity and the fact that they are not worst-case efficient.
5. Comparison with Other Balanced Trees
5.1 AVL Trees vs. Red-Black Trees vs. Splay Trees
| Feature | AVL | Red-Black | Splay |
|---|---|---|---|
| Balance guarantee | Strict (height diff ≤1) | Weak (black-height) | None (amortized) |
| Worst-case per op | O(log n) | O(log n) | O(n) (but amortized O(log n)) |
| Adaptation to locality | No | No | Yes |
| Overhead per op | Height updates, many rotations (up to log n) | Fewer rotations (≤3) | O(depth) rotations, but amortized |
| Space | Parent pointers or recursive | Color bits + parent ptrs | Parent pointers (or recursion) |
| Implementation complexity | Moderate | Moderate to high | Moderate (but tricky for top-down) |
| Use in practice | Widely used (std::map in some compilers) | Widely used (Linux kernel, Java TreeMap) | Niche (garbage collectors, cache, compressed string tries) |
AVL trees offer stricter balance, which leads to better worst-case performance but poorer locality adaptation. Red-Black trees are a good compromise for many databases and programming language standard libraries because they have O(log n) worst-case and low insertion overhead (few rotations). Splay trees, by contrast, have the potential for linear worst-case, but in practice, sequences of operations that cause long splay paths are rare. Moreover, the amortized guarantees are excellent for real workloads.
5.2 Why Isn’t Everyone Using Splay Trees?
Several factors limit adoption:
- Worst-case instability: In real-time or latency-critical systems, a single high-cost operation (up to O(n)) is unacceptable. For example, a user interface thread must not freeze. AVL or Red-Black guarantee a ceiling on each operation.
- Complexity of implementation: Top-down splay is not trivial; many pitfalls exist with pointer manipulation. Incorrect implementations can subtly break correctness.
- Lack of deterministic order: For persistent data structures or systems that need to restore state exactly (e.g., databases with undo logs), the adaptive nature may be undesirable.
- Memory indirectness: Parent pointers double the memory overhead compared to a simple BST, though one can implement splay without parent pointers using recursion (or iterative stack), which increases complexity.
Despite these, splay trees shine in applications where locality dominates and worst-case is tolerable or can be mitigated (e.g., caches, where a single slow access is acceptable). They also excel when the distribution of keys is unknown and changes over time.
6. Implementation Considerations
6.1 Top-Down Splaying
The bottom-up approach using parent pointers requires storing parent references and performing a preliminary search. This is inefficient because you need to store the path (implicitly via parent pointers) and then splay from bottom up. An alternative is top-down splaying, which restructures the tree as you search. This eliminates the need for parent pointers and often reduces the number of rotations.
The idea: during the search, you maintain three trees: a left tree, a right tree, and a middle tree. As you descend, you detach subtrees that are known to be smaller (or larger) than the target, and attach them to the side trees. Finally, you reassemble the tree with the target at the root. This approach is non-recursive and runs in O(log n) time.
Sleator and Tarjan’s original paper includes a top-down splaying algorithm in less than 20 lines of C. The code is compact but tricky. Here is a Python translation for educational purposes:
def splay_top_down(root, key):
"""Return new root after splaying the node with given key (or the last node on search path)."""
if root is None:
return None
# Dummy nodes for left and right trees
left = right = None
# We use a 'header' structure; but simpler: just use two variables for the roots of left and right trees
# Actually we need a way to attach new nodes to the left/right trees.
# Standard approach: use `head` and `tail` to track the last node added.
pass
Due to space, I won’t present the full top-down code, but it’s available in many textbooks and online resources. The key advantage: no parent pointers needed, and the splay is interleaved with the search.
6.2 Handling Duplicates and Deletions
For duplicates, one can extend the tree to store a count per node or allow multimap semantics. In deletion, the splay tree’s delete is often implemented as:
def delete(root, key):
if root is None: return None
root = splay(root, key) # splay the node if exists, else splay last node
if root.key != key:
# not found, just return root
return root
# Now root is the node to delete
# If no left child, return right
if root.left is None:
root = root.right
else:
# Splay the maximum of left subtree to the root of that subtree
left_root = root.left
left_root = splay_left_max(left_root) # splay max node to left root
# Then attach right subtree as right child of left root
left_root.right = root.right
root = left_root
return root
Splaying the max of left subtree: we can write a function that repeatedly goes right until right is None, then splay that node.
6.3 Memory Considerations
A standard splay tree node requires three pointers (left, right, parent) plus a key. For comparison, a Red-Black tree node needs left, right, parent, and a color bit (or byte). So the memory overhead is similar. However, in languages with automatic memory management, the parent pointers increase garbage collection cost. Alternatively, a recursive splay (without parent pointers) can be used, but it requires stack and may cause deep recursion for unbalanced trees.
6.4 Code Example: Complete Minimal Splay Tree in Python
Below is a fully functional bottom-up splay tree implementing insert and search. Deletion is left as an exercise.
class SplayNode:
__slots__ = ('key', 'left', 'right', 'parent')
def __init__(self, key):
self.key = key
self.left = None
self.right = None
self.parent = None
class SplayTree:
def __init__(self):
self.root = None
def _rotate_up(self, x):
p = x.parent
if p is None:
return
if p.left == x:
p.left = x.right
if x.right:
x.right.parent = p
x.right = p
else:
p.right = x.left
if x.left:
x.left.parent = p
x.left = p
x.parent = p.parent
p.parent = x
if x.parent:
if x.parent.left == p:
x.parent.left = x
else:
x.parent.right = x
else:
self.root = x
def _splay(self, x):
while x.parent is not None:
p = x.parent
if p.parent is None:
self._rotate_up(x)
else:
g = p.parent
if (g.left == p) == (p.left == x):
self._rotate_up(p)
self._rotate_up(x)
else:
self._rotate_up(x)
self._rotate_up(x)
def insert(self, key):
if self.root is None:
self.root = SplayNode(key)
return
# Standard BST insertion
node = self.root
while True:
if key < node.key:
if node.left:
node = node.left
else:
node.left = SplayNode(key)
node.left.parent = node
self._splay(node.left)
return
elif key > node.key:
if node.right:
node = node.right
else:
node.right = SplayNode(key)
node.right.parent = node
self._splay(node.right)
return
else:
# key already exists; splay the existing node
self._splay(node)
return
def search(self, key):
if self.root is None:
return False
node = self.root
last = None
while node:
last = node
if key == node.key:
self._splay(node)
return True
elif key < node.key:
node = node.left
else:
node = node.right
# If not found, splay the last node visited
if last:
self._splay(last)
return False
def inorder(self, node=None, depth=0):
if node is None:
node = self.root
if not node:
return
self.inorder(node.left, depth+1)
print(' '*depth + str(node.key))
self.inorder(node.right, depth+1)
This implementation is simple but not optimized. It uses parent pointers and bottom-up splay. The search function splays the found node or the last visited node. This is standard.
7. Applications: Where Splay Trees Excel
7.1 Caching and Memory Allocation
Splay trees are ideal for caches with temporal locality. For example, in a web cache, objects that are accessed frequently remain near the root, providing O(1) amortized access after initial cost. Splay trees have been used in:
- Garbage collection: In generational garbage collectors, the remembered set (pointers from old generation to young generation) can be stored in a splay tree. The mutator (program) often repeatedly modifies the same few objects, and the splay tree adapts to the working set.
- Memory allocators: Some memory allocators use splay trees to manage free blocks of different sizes. The allocator frequently accesses free blocks of certain sizes (hot sizes). Splaying ensures that hot blocks are found quickly.
- Linux kernel: While the kernel primarily uses Red-Black trees and radix trees, there has been experimental work using splay trees for scheduling queues where processes exhibit locality.
7.2 Data Compression
The Lempel-Ziv (LZ) compression algorithm and some variants use a dictionary of recent substrings. A splay tree can store these strings, and locality arises because the algorithm tends to reuse the same substrings repeatedly. The splay tree’s working set property ensures fast lookups for recently referenced patterns.
7.3 Text Editors and Undo Stacks
In a text editor with a splay tree representing the document as a balanced tree of lines, frequent edits in a small region (e.g., typing a few characters) cause those nodes to rise to the root, making subsequent accesses fast. The splay tree also supports efficient split and merge operations (not covered here), which are useful for editing.
7.4 Dynamic Connectivity and Link-Cut Trees
Sleator and Tarjan extended the splay tree idea to create link-cut trees, a data structure for dynamic graph connectivity (adding and removing edges, answering connectivity queries). Link-cut trees use splay trees to represent paths in a tree and achieve amortized O(log n) per operation. This is a classic and advanced application.
7.5 Competitive Caching (Online Algorithms)
In online caching algorithms (like LRU, LFU), the splay tree can be used to implement an amortized-optimal data structure for the “move-to-front” rule, which is known to be 2-competitive for list access. In fact, the splay tree is the tree analog of move-to-front.
8. Advanced Topics and Variants
8.1 Semi-Splay Trees
A variant where only some of the splay steps are performed (e.g., only the final zig when the node becomes root, or only zig-zig and zig-zag but not the interior ones) to reduce overhead. This sacrifices some adaptation for simpler code.
8.2 Randomized Splay Trees
Randomized versions of splay exist that introduce randomness into rotations to avoid worst-case sequences. These are often simpler to analyze but may lose the deterministic amortized guarantees.
8.3 Concurrent Splay Trees
Implementing a concurrent, lock-free splay tree is challenging because splay operations restructure the tree globally. However, some research has produced lock-free splay trees with good performance, though they are not widely used.
8.4 The Dynamic Optimality Conjecture
The holy grail of BST theory: Is there a BST data structure that (up to constant factor) matches the lower bound on the number of rotations required for any sequence of accesses? Splay trees are the prime candidate, but the conjecture remains open since 1985. If proven true, splay trees would be theoretically optimal among all balanced BSTs. Some recent results (e.g., by Demaine et al.) provide evidence that splay trees are within a constant factor of optimal for “deque” and other patterns, but the full dynamic optimality conjecture still stands.
9. Conclusion: The Elegance of Imperfection
The splay tree teaches us a valuable lesson: being perfectly balanced at every step is not always the best strategy. By embracing temporary disorder and learning from the past, the splay tree achieves a form of collective optimality that deterministic, memoryless structures cannot match. It is a data structure that embodies the principle of amortized thinking: pay a little extra now to save a lot later.
In practice, splay trees are a powerful tool for any developer facing workloads with strong locality. They are not a silver bullet—if you need predictable response times or a hard guarantee, AVL or Red-Black trees are safer. But if you can tolerate an occasional expensive operation in exchange for average-case performance that can approach optimal, the splay tree deserves a place in your toolbox.
Implementing a splay tree from scratch is a rite of passage for computer scientists. It forces you to think about pointer manipulation, recursion, potential functions, and the subtle interplay between structure and performance. I encourage you to code one yourself, run benchmarks against a Red-Black tree, and observe how the splay tree adapts to repeated accesses.
In the end, the splay tree is more than just a data structure; it’s a philosophy: Learn from the past, and the future will be easier. That is the beautiful trade-off.
Further Reading:
- Daniel Sleator and Robert Tarjan, “Self-Adjusting Binary Search Trees”, Journal of the ACM, 1985.
- Thomas H. Cormen et al., Introduction to Algorithms, 3rd edition, Chapter 14 (Red-Black Trees) and Chapter 18 (Splay Trees).
- An online interactive splay tree visualizer (e.g., Usfca algorithm visualization).
Code: Full implementation available on GitHub (link in comments).