一、核心定义
- Splay树(伸展树)是一种自调整二叉搜索树(BST),无需额外存储平衡因子、颜色等信息,核心特性是「访问即调整」------每次对节点执行查询、插入、删除操作后,会通过「伸展操作(Splay)」将该节点移动到树根位置。
- 资料:
https://pan.quark.cn/s/43d906ddfa1b、https://pan.quark.cn/s/90ad8fba8347、https://pan.quark.cn/s/d9d72152d3cf
其设计理念是「局部性原理」:近期访问过的节点,未来大概率会被再次访问。通过伸展操作,将高频访问节点靠近树根,缩短后续访问路径,确保** amortized(均摊)时间复杂度为 O(log n)**(单次操作最坏 O(n),但长期平均高效)。
二、核心特性
- 继承BST特性:左子树所有节点值 < 根节点值,右子树所有节点值 > 根节点值,中序遍历为有序序列;
- 自调整机制:无固定平衡约束,依赖伸展操作动态调整树结构,高频访问节点自动靠近树根;
- 无额外空间开销:无需存储高度、颜色等辅助信息,仅需维护BST的父子关系;
- 均摊高效:查询、插入、删除的均摊时间复杂度为 O(log n),适合「访问局部性强」的场景(如缓存、数据压缩);
- 最坏情况不稳定:单次操作可能退化为 O(n)(如有序插入时树退化为链表),但多次操作后会自动调整。
三、核心操作:伸展(Splay)
伸展操作是Splay树的灵魂,目标是将指定节点 x 移动到树根,通过「旋转」实现,旋转规则根据 x 与其父节点(p)、祖父节点(g)的位置关系分为3种情况:
1. 旋转的3种场景(优先级:先处理祖父关系,再处理父子关系)
| 场景 | 条件(x 的位置) |
旋转策略 | 核心目的 |
|---|---|---|---|
| zig(单旋) | x 是根节点的子节点(无祖父节点,或 g 是根) |
对 x 和 p 执行一次旋转(左旋/右旋) |
将 x 直接提升为根 |
| zig-zig(双旋) | x 是 p 的左子树,且 p 是 g 的左子树(LL型);或 x 是 p 的右子树,且 p 是 g 的右子树(RR型) |
1. 先对 p 和 g 旋转; 2. 再对 x 和 p 旋转 |
避免单旋导致树高增加,保持树的平衡性 |
| zig-zag(双旋) | x 是 p 的右子树,且 p 是 g 的左子树(LR型);或 x 是 p 的左子树,且 p 是 g 的右子树(RL型) |
1. 先对 x 和 p 旋转; 2. 再对 x 和 g 旋转 |
快速将 x 提升两层,优化路径长度 |
2. 旋转示例(图文结合)
(1)zig-zig(LL型双旋)
初始结构(x是p的左,p是g的左):
g
/
p
/
x
旋转步骤:
1. 对 p 和 g 执行右旋转:
p
/ \
x g
2. 对 x 和 p 执行右旋转:
x
/ \
p
\
g
结果:x 成为根节点,路径长度缩短
(2)zig-zag(LR型双旋)
初始结构(x是p的右,p是g的左):
g
/
p
\
x
旋转步骤:
1. 对 x 和 p 执行左旋转:
g
/
x
/
p
2. 对 x 和 g 执行右旋转:
x
/ \
p g
结果:x 成为根节点,结构更平衡
(3)zig(单旋)
初始结构(x是p的左,p是根):
p
/
x
旋转步骤:对 x 和 p 执行右旋转:
x
\
p
结果:x 成为根节点
3. 伸展操作的完整流程
函数 splay(x):
while x 不是根节点:
p = x 的父节点
g = p 的父节点(可能为null)
if g 是 null:
zig(x) # 无祖父,单旋
elif (x 是 p 的左子树) and (p 是 g 的左子树):
zig-zig(x) # LL型双旋
elif (x 是 p 的右子树) and (p 是 g 的右子树):
zig-zig(x) # RR型双旋(旋转方向相反)
else:
zig-zag(x) # LR/RL型双旋
return x # x 成为新根
四、核心操作(查询、插入、删除)
所有操作的核心逻辑:先执行BST的常规操作,再对目标节点执行伸展操作,确保其成为树根。
1. 查询操作(find(val))
流程:
1. 按BST规则查找值为 val 的节点 x(若不存在,找到最后访问的节点);
2. 对 x 执行伸展操作,将其移动到树根;
3. 返回 x(若 x.val == val 则存在,否则不存在)。
关键:即使查询失败,也会伸展最后访问的节点,优化后续访问路径。
2. 插入操作(insert(val))
流程:
1. 按BST规则插入新节点 x(常规BST插入,新节点为叶子);
2. 对 x 执行伸展操作,将其移动到树根;
3. 返回新根。
关键:新节点插入后立即成为根,后续访问该节点时路径最短。
3. 删除操作(delete(val))
流程:
1. 调用 find(val) 查找节点 x(若不存在,直接返回);
- 此时 x 已被伸展为树根(若存在);
2. 若 x 无左子树:将 x 的右子树设为新根,断开 x 的连接;
3. 若 x 无右子树:将 x 的左子树设为新根,断开 x 的连接;
4. 若 x 有左右子树:
a. 找到 x 左子树的最大值节点 y(中序前驱);
b. 对 y 执行伸展操作,将其移动到 x 左子树的根(此时 y 无右子树);
c. 将 x 的右子树设为 y 的右子树;
d. 将 y 设为新根,断开 x 的连接;
5. 返回新根。
关键:删除根节点后,通过伸展左子树的最大值节点,保持树的BST特性和自调整能力。
五、时间复杂度分析
| 操作 | 最坏时间复杂度 | 均摊时间复杂度 | 说明 |
|---|---|---|---|
| 查询 | O(n) | O(log n) | 单次有序查询可能退化为链表,多次后自调整 |
| 插入 | O(n) | O(log n) | 有序插入时树暂时退化,后续插入会伸展 |
| 删除 | O(n) | O(log n) | 依赖查询和伸展操作,均摊高效 |
均摊复杂度证明核心
Splay树的均摊复杂度基于「势能函数(Potential Function)」分析:定义势能为树中所有节点的「深度的对数和」,每次伸展操作会减少势能,而单次操作的总代价(实际代价+势能变化)被证明为 O(log n),因此长期均摊复杂度为 O(log n)。
六、Splay树 vs AVL树 vs 红黑树(面试高频对比)
| 对比维度 | Splay树 | AVL树 | 红黑树 |
|---|---|---|---|
| 平衡机制 | 自调整(访问即伸展) | 严格平衡(平衡因子≤1) | 近似平衡(黑高一致) |
| 额外空间开销 | 无(仅BST结构) | 有(高度字段) | 有(颜色字段) |
| 单次操作最坏情况 | O(n) | O(log n) | O(log n) |
| 均摊时间复杂度 | O(log n) | O(log n) | O(log n) |
| 旋转次数 | 多次(取决于节点位置) | 插入最多2次,删除较多 | 插入最多2次,删除最多3次 |
| 适用场景 | 访问局部性强(缓存、压缩、编辑器) | 查询密集型(数据库辅助索引) | 插入删除频繁(集合、映射) |
| 工业界应用 | 较少(特定场景) | 较少 | 广泛(Java集合、Linux内核) |
七、代码实现示例(Java:Splay树核心逻辑)
java
// Splay树节点定义
class SplayNode {
int val;
SplayNode left, right, parent;
public SplayNode(int val) {
this.val = val;
this.left = this.right = this.parent = null;
}
}
// Splay树核心实现
class SplayTree {
private SplayNode root;
// 右旋转(x是p的左子树)
private void rightRotate(SplayNode x) {
SplayNode p = x.parent;
SplayNode g = p.parent;
// 1. x的右子树设为p的左子树
p.left = x.right;
if (x.right != null) {
x.right.parent = p;
}
// 2. x的父节点设为g
x.parent = g;
if (g == null) {
root = x;
} else if (g.left == p) {
g.left = x;
} else {
g.right = x;
}
// 3. p的父节点设为x,x的右子树设为p
x.right = p;
p.parent = x;
}
// 左旋转(x是p的右子树)
private void leftRotate(SplayNode x) {
SplayNode p = x.parent;
SplayNode g = p.parent;
// 1. x的左子树设为p的右子树
p.right = x.left;
if (x.left != null) {
x.left.parent = p;
}
// 2. x的父节点设为g
x.parent = g;
if (g == null) {
root = x;
} else if (g.left == p) {
g.left = x;
} else {
g.right = x;
}
// 3. p的父节点设为x,x的左子树设为p
x.left = p;
p.parent = x;
}
// 伸展操作:将x移动到根
private void splay(SplayNode x) {
while (x.parent != null) {
SplayNode p = x.parent;
SplayNode g = p.parent;
if (g == null) {
// zig:单旋
if (p.left == x) {
rightRotate(x);
} else {
leftRotate(x);
}
} else if (g.left == p && p.left == x) {
// zig-zig(LL型):先旋p,再旋x
rightRotate(p);
rightRotate(x);
} else if (g.right == p && p.right == x) {
// zig-zig(RR型):先旋p,再旋x
leftRotate(p);
leftRotate(x);
} else {
// zig-zag(LR/RL型):先旋x,再旋x
if (p.left == x) {
rightRotate(x);
leftRotate(x);
} else {
leftRotate(x);
rightRotate(x);
}
}
}
}
// 查询操作:查找val,找到后伸展到根
public SplayNode find(int val) {
SplayNode curr = root;
SplayNode prev = root; // 记录最后访问的节点(用于查询失败时伸展)
while (curr != null) {
prev = curr;
if (val < curr.val) {
curr = curr.left;
} else if (val > curr.val) {
curr = curr.right;
} else {
splay(curr); // 找到,伸展到根
return curr;
}
}
// 查询失败,伸展最后访问的节点
if (prev != null) {
splay(prev);
}
return null;
}
// 插入操作:BST插入 + 伸展
public void insert(int val) {
SplayNode newNode = new SplayNode(val);
if (root == null) {
root = newNode;
return;
}
SplayNode curr = root;
SplayNode parent = null;
// 1. BST插入
while (curr != null) {
parent = curr;
if (val < curr.val) {
if (curr.left == null) {
curr.left = newNode;
newNode.parent = parent;
break;
}
curr = curr.left;
} else if (val > curr.val) {
if (curr.right == null) {
curr.right = newNode;
newNode.parent = parent;
break;
}
curr = curr.right;
} else {
// 已存在,伸展后返回
splay(curr);
return;
}
}
// 2. 伸展新节点到根
splay(newNode);
}
// 删除操作:查找 + 伸展 + 删除根
public void delete(int val) {
SplayNode target = find(val);
if (target == null || target.val != val) {
return; // 不存在
}
// 此时target是根节点
if (target.left == null) {
// 左子树为空,右子树成为新根
root = target.right;
if (root != null) {
root.parent = null;
}
} else if (target.right == null) {
// 右子树为空,左子树成为新根
root = target.left;
if (root != null) {
root.parent = null;
}
} else {
// 左右子树都存在:找左子树最大值(中序前驱)
SplayNode leftMax = target.left;
while (leftMax.right != null) {
leftMax = leftMax.right;
}
// 伸展leftMax到左子树的根(此时leftMax无右子树)
splay(leftMax);
// 将target的右子树设为leftMax的右子树
leftMax.right = target.right;
if (target.right != null) {
target.right.parent = leftMax;
}
// leftMax成为新根
root = leftMax;
leftMax.parent = null;
}
// 断开target的连接
target.left = target.right = target.parent = null;
}
// 中序遍历(验证有序性)
public void inorder(SplayNode node) {
if (node != null) {
inorder(node.left);
System.out.print(node.val + " ");
inorder(node.right);
}
}
// 获取根节点(供遍历调用)
public SplayNode getRoot() {
return root;
}
}
// 测试代码
public class SplayTreeTest {
public static void main(String[] args) {
SplayTree splayTree = new SplayTree();
int[] nums = {10, 20, 30, 15, 5, 25};
for (int num : nums) {
splayTree.insert(num);
}
System.out.println("插入后中序遍历(有序):");
splayTree.inorder(splayTree.getRoot()); // 输出:5 10 15 20 25 30
// 查询20(会伸展到根)
splayTree.find(20);
System.out.println("\n查询20后根节点:" + splayTree.getRoot().val); // 输出:20
// 删除15
splayTree.delete(15);
System.out.println("删除15后中序遍历:");
splayTree.inorder(splayTree.getRoot()); // 输出:5 10 20 25 30
}
}
八、扩展知识
- Splay树的变种 :
- 加权Splay树(Weighted Splay Tree):节点带权重,伸展时考虑权重,优化区间查询;
- 拓扑Splay树(Top-down Splay Tree):非递归实现,避免栈溢出,更适合大规模数据;
- 与B树的对比:Splay树是二叉树,适合内存数据;B树是多路树,适合磁盘数据(减少IO);
- 实际应用案例 :
- 早期的Linux内核虚拟内存管理;
- 文本编辑器Vim的查找功能优化;
- 缓存系统中热点数据的快速访问。