一、Splay 树核心思想
Splay 树(伸展树)是由 Tarjan 和 Sleator 于 1985 年提出的自调整二叉搜索树。与普通平衡树(如 AVL、红黑树)通过显式维护平衡因子不同,Splay 树通过伸展操作(Splay)将最近访问的节点旋转到根,利用局部性原理使得高频访问的元素靠近根节点,从而在均摊意义下达到 O(log n) 的操作复杂度。
核心优势:
– 无需存储额外的平衡信息(高度、颜色等)
– 高频访问的元素自动上浮,缓存友好
– 实现简洁,常数因子小
– 支持分裂(Split)与合并(Merge)操作
二、节点定义与基础结构
/**
* Splay 树(伸展树)完整实现
* 支持插入、删除、查找、分裂、合并操作
*/
public class SplayTree<T extends Comparable<T>> {
/**
* 树节点
*/
static class Node<T> {
T key;
Node<T> left, right, parent;
int size; // 子树大小,用于顺序统计
Node(T key) {
this.key = key;
this.size = 1;
}
}
private Node<T> root;
private int size;
public SplayTree() {
this.root = null;
this.size = 0;
}
/**
* 获取树的大小
*/
public int size() {
return size;
}
/**
* 判断树是否为空
*/
public boolean isEmpty() {
return root == null;
}
/**
* 更新节点大小(子树节点数)
*/
private void updateSize(Node<T> node) {
if (node != null) {
node.size = 1 + getSize(node.left) + getSize(node.right);
}
}
private int getSize(Node<T> node) {
return node == null ? 0 : node.size;
}
三、核心旋转操作:Zig、Zig-Zig、Zig-Zag
Splay 操作根据目标节点与其父节点、祖父节点的相对位置,分为三种旋转:
/**
* 右旋(Zig):目标节点是父节点的左孩子
* p x
* / \ / \
* x C --> A p
* / \ / \
* A B B C
*/
private void rotateRight(Node<T> p) {
Node<T> x = p.left;
if (x == null) return;
p.left = x.right;
if (x.right != null) x.right.parent = p;
x.parent = p.parent;
if (p.parent == null) {
root = x;
} else if (p == p.parent.left) {
p.parent.left = x;
} else {
p.parent.right = x;
}
x.right = p;
p.parent = x;
updateSize(p);
updateSize(x);
}
/**
* 左旋(Zig 的镜像):目标节点是父节点的右孩子
*/
private void rotateLeft(Node<T> p) {
Node<T> x = p.right;
if (x == null) return;
p.right = x.left;
if (x.left != null) x.left.parent = p;
x.parent = p.parent;
if (p.parent == null) {
root = x;
} else if (p == p.parent.left) {
p.parent.left = x;
} else {
p.parent.right = x;
}
x.left = p;
p.parent = x;
updateSize(p);
updateSize(x);
}
/**
* 伸展操作:将指定节点旋转到根
* 分类讨论三种情况:
* 1. Zig:父节点是根,单旋一次
* 2. Zig-Zig:目标节点和父节点同为左/右孩子,先旋父节点再旋目标节点
* 3. Zig-Zag:目标节点和父节点方向不同,旋两次目标节点
*/
private void splay(Node<T> x) {
while (x.parent != null) {
Node<T> p = x.parent;
Node<T> g = p.parent;
if (g == null) {
// Zig:父节点是根
if (x == p.left) {
rotateRight(p);
} else {
rotateLeft(p);
}
} else if (x == p.left && p == g.left) {
// Zig-Zig:左-左
rotateRight(g);
rotateRight(p);
} else if (x == p.right && p == g.right) {
// Zig-Zig:右-右
rotateLeft(g);
rotateLeft(p);
} else if (x == p.right && p == g.left) {
// Zig-Zag:左-右
rotateLeft(p);
rotateRight(g);
} else {
// Zig-Zag:右-左
rotateRight(p);
rotateLeft(g);
}
}
root = x;
}
四、查找、插入与删除
/**
* 查找键值,并将该节点伸展到根
* @return 如果找到返回对应节点,否则返回最后访问的节点(用于插入定位)
*/
public Node<T> search(T key) {
Node<T> curr = root;
Node<T> last = null;
while (curr != null) {
last = curr;
int cmp = key.compareTo(curr.key);
if (cmp == 0) {
splay(curr);
return curr;
} else if (cmp < 0) {
curr = curr.left;
} else {
curr = curr.right;
}
}
// 未找到,将最后访问的节点伸展到根(为插入做准备)
if (last != null) {
splay(last);
}
return null;
}
/**
* 插入键值
*/
public void insert(T key) {
if (root == null) {
root = new Node<>(key);
size++;
return;
}
Node<T> found = search(key);
if (found != null && found.key.compareTo(key) == 0) {
return; // 已存在,不重复插入
}
// 此时 root 是最后访问的节点(最接近 key 的节点)
Node<T> newNode = new Node<>(key);
int cmp = key.compareTo(root.key);
if (cmp < 0) {
newNode.left = root.left;
if (root.left != null) root.left.parent = newNode;
newNode.right = root;
root.left = null;
root.parent = newNode;
} else {
newNode.right = root.right;
if (root.right != null) root.right.parent = newNode;
newNode.left = root;
root.right = null;
root.parent = newNode;
}
root = newNode;
root.parent = null;
size++;
// 更新路径上的 size
updateSize(root.left);
updateSize(root.right);
updateSize(root);
}
/**
* 删除键值
* 策略:将目标节点伸展到根,然后合并左右子树
*/
public boolean delete(T key) {
Node<T> target = search(key);
if (target == null || target.key.compareTo(key) != 0) {
return false;
}
// 此时 target 是根
Node<T> leftTree = root.left;
Node<T> rightTree = root.right;
if (leftTree != null) leftTree.parent = null;
if (rightTree != null) rightTree.parent = null;
if (leftTree == null) {
root = rightTree;
} else {
// 将左子树的最大节点伸展到根
root = leftTree;
Node<T> maxNode = leftTree;
while (maxNode.right != null) {
maxNode = maxNode.right;
}
splay(maxNode);
// 将右子树接在最大节点右侧
root.right = rightTree;
if (rightTree != null) rightTree.parent = root;
updateSize(root);
}
size--;
return true;
}
五、高级操作:分裂与合并
/**
* 分裂:以 key 为界将树分裂为两棵子树
* @return [小于等于 key 的树, 大于 key 的树]
*/
public SplayTree<T>[] split(T key) {
@SuppressWarnings("unchecked")
SplayTree<T>[] result = new SplayTree[2];
if (root == null) {
result[0] = new SplayTree<>();
result[1] = new SplayTree<>();
return result;
}
// 查找 key,将最接近的节点伸展到根
search(key);
result[0] = new SplayTree<>();
result[1] = new SplayTree<>();
int cmp = key.compareTo(root.key);
if (cmp >= 0) {
// root 及左子树属于左树,右子树属于右树
result[0].root = root;
result[0].size = getSize(root);
result[1].root = root.right;
if (result[1].root != null) {
result[1].root.parent = null;
result[1].size = getSize(result[1].root);
}
root.right = null;
} else {
// root 及右子树属于右树,左子树属于左树
result[1].root = root;
result[1].size = getSize(root);
result[0].root = root.left;
if (result[0].root != null) {
result[0].root.parent = null;
result[0].size = getSize(result[0].root);
}
root.left = null;
}
if (result[0].root != null) {
result[0].root.parent = null;
updateSize(result[0].root);
}
if (result[1].root != null) {
result[1].root.parent = null;
updateSize(result[1].root);
}
// 修正 size(精确计算)
result[0].size = getSize(result[0].root);
result[1].size = getSize(result[1].root);
this.size = 0;
this.root = null;
return result;
}
/**
* 合并:将两棵树合并(要求 left 树的所有键小于 right 树)
*/
public static <T extends Comparable<T>> SplayTree<T> merge(
SplayTree<T> left, SplayTree<T> right) {
SplayTree<T> result = new SplayTree<>();
if (left.root == null) {
result.root = right.root;
result.size = right.size;
} else if (right.root == null) {
result.root = left.root;
result.size = left.size;
} else {
// 将 left 树的最大节点伸展到根
Node<T> maxNode = left.root;
while (maxNode.right != null) {
maxNode = maxNode.right;
}
left.splay(maxNode);
left.root.right = right.root;
right.root.parent = left.root;
result.root = left.root;
result.size = left.size + right.size;
updateSize(result.root);
}
left.root = null;
right.root = null;
return result;
}
六、顺序统计:第 K 小元素
/**
* 查找第 k 小元素(1-based)
*/
public T kthSmallest(int k) {
if (k < 1 || k > size) {
throw new IllegalArgumentException("k out of range");
}
Node<T> curr = root;
while (curr != null) {
int leftSize = getSize(curr.left);
if (k == leftSize + 1) {
splay(curr);
return curr.key;
} else if (k <= leftSize) {
curr = curr.left;
} else {
k -= leftSize + 1;
curr = curr.right;
}
}
return null;
}
/**
* 中序遍历输出
*/
public List<T> inorder() {
List<T> result = new ArrayList<>();
inorderHelper(root, result);
return result;
}
private void inorderHelper(Node<T> node, List<T> result) {
if (node == null) return;
inorderHelper(node.left, result);
result.add(node.key);
inorderHelper(node.right, result);
}
七、摊还分析概述
Splay 树的均摊复杂度为 O(log n),可通过势能法严格证明:
定义节点 x 的势能为:
Φ(x) = log₂(size(x))
整棵树的势能为所有节点势能之和。Splay 操作的摊还代价为:
amortized_cost = actual_cost + ΔΦ
可以证明,每次 Splay 操作的摊还代价不超过 3 × log₂(n) + 1。因此,m 次连续操作的序列总时间为 O(m log n)。
关键洞察:Zig-Zig 和 Zig-Zag 旋转虽然做了两次旋转,但能将目标节点的深度大幅减小,势能下降足够多,从而抵消实际旋转代价。
八、性能测试
public static void main(String[] args) {
SplayTree<Integer> tree = new SplayTree<>();
Random rand = new Random(42);
int n = 100000;
// 插入测试
long start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
tree.insert(rand.nextInt(n * 10));
}
long insertTime = System.currentTimeMillis() - start;
System.out.println("插入 " + n + " 个元素耗时: " + insertTime + "ms");
System.out.println("树大小: " + tree.size());
// 查找测试(重复查找热点元素)
start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
tree.search(rand.nextInt(n * 10));
}
long searchTime = System.currentTimeMillis() - start;
System.out.println("查找 " + n + " 次耗时: " + searchTime + "ms");
// 顺序统计测试
System.out.println("第 100 小: " + tree.kthSmallest(100));
System.out.println("第 1000 小: " + tree.kthSmallest(1000));
// 删除测试
start = System.currentTimeMillis();
for (int i = 0; i < n / 2; i++) {
tree.delete(rand.nextInt(n * 10));
}
long deleteTime = System.currentTimeMillis() - start;
System.out.println("删除 " + (n / 2) + " 个元素耗时: " + deleteTime + "ms");
System.out.println("剩余大小: " + tree.size());
}
}
九、与其他平衡树对比
| 特性 | Splay 树 | AVL 树 | 红黑树 |
|---|---|---|---|
| 平衡信息 | 无 | 高度差 | 颜色位 |
| 单次操作 | O(n) | O(log n) | O(log n) |
| 均摊/摊还 | O(log n) | O(log n) | O(log n) |
| 常数因子 | 小 | 中 | 中 |
| 顺序统计 | 支持 | 支持 | 支持 |
| 实现复杂度 | 低 | 中 | 高 |
| 缓存友好性 | 优(热点上浮) | 中 | 中 |
Splay 树以其简洁的实现和优秀的局部性表现,在需要频繁访问热点数据的场景(如缓存、内存管理器)中有着不可替代的价值。