Splay 的口头禅很大胆:我不保证每一次都快,但我会把刚被你找过的节点拉到眼前。它不像 FHQ Treap 那样给每张卡发随机优先级,而是每次访问后认真整理桌面。今天先只学这件事:旋一下,到底保住了什么,又为什么要旋两下。
学完后,你能:
前置:二叉搜索树、左右儿子、父指针、子树大小。还没有接触 Lazy 标记也可以。
在纸上先画这棵普通 BST:
10
/ \
5 15
/ \ / \
3 7 12 18
贯穿任务是依次做:查找 7、插入 6、删除 10。纸上分四栏:对象(当前节点和三块子树)、状态(parent、left、right、size)、动作(本轮绕谁旋转)、账本(中序和父指针是否仍正确)。每次旋转后,把中序写一遍;这有点笨,但能抓到大多数 Splay 第一版的幽灵 bug。
从根查找 7:比较 7 与 10,向左;比较 7 与 5,向右;找到 7。若接下来又查 7、又查 7,每次仍要走同样两条边。对很深的节点,重复走路比比较本身更烦。
停顿 0:如果现在连续查找 7 五次,普通 BST 会总共经过哪些节点?哪一段路径被反复走了五遍?
设 x 是 p 的右儿子,x 的左子树叫 B;p 的左子树叫 A,x 的右子树叫 C。对 p 做一次左旋,x 上来、p 下去,B 从 x 的左边转为 p 的右边:
旋前: 旋后:
p x
/ \ / \
A x → p C
/ \ / \
B C A B
因为 A < p < B < x < C,旋前旋后中序都是 A,p,B,x,C。
停顿 1:若 x 是 p 的左儿子,应该用左旋还是右旋?B 在旋转后应该挂到 p 的哪一侧?请用不等式检查,而不要靠镜像记忆。
假设 x、父亲 p、祖父 g 连成同向链,例如 x 是 p 的左儿子且 p 是 g 的左儿子。若只旋 x 一次,p 会被拉下,但长链的整体形状没有得到充分压缩。Splay 的规定是:先旋 p 绕 g,再旋 x 绕 p;这叫 zig-zig。
若 x 和 p 方向相反,例如 x 是 p 的右儿子、p 是 g 的左儿子,则连续旋 x 两次;这叫 zig-zag。若 p 就是根,只剩一次 zig。
停顿 2:在初始树中,x=7、p=5、g=10。它属于哪一种?第一转绕谁,第二转绕谁?请画出两次后的根。
Splay 的目标可以是根,也可以是某个指定父亲 goal。后一种接口会在“文艺平衡树”中暴露区间:先把左边界伸到根,再把右边界伸到它的右儿子位置。
splay(x, goal):
while parent[x] is not goal:
p = parent[x]
g = parent[p]
if g is goal:
rotate(x) # zig
else if x and p are both left children,
or both right children:
rotate(p); rotate(x) # zig-zig
else:
rotate(x); rotate(x) # zig-zag
每次 rotate 前后,都要更新被移动的两个节点的 size;若以后加了翻转等 Lazy 标记,还要先从祖先到 x 依次 push,免得逻辑左右与指针左右不一致。
红黑树、AVL 等结构每次都努力保持严格高度界;Splay 换了一个愿望:最近访问的点、相近键或经常使用的区间,应该逐渐靠近根。它的优势不是“每次比别人少一转”,而是允许一次访问花较多旋转,换取随后很多次访问变便宜。
因此绝不能把“查找 7 后 7 在根”误解为一个小优化;它是所有操作完成后统一执行的自调整规则。
按 BST 规则走到 x 或空指针。若找到了 x,就 splay(x, null)。若没找到,最后停在某个叶边附近,也可把最后访问的节点伸根;它正是新键将来插入的邻居。
最直白的入门实现:普通 BST 插入新叶 x,设置 parent,再 splay(x, null)。这样根必是 x,左右子树天然分别存比 x 小和比 x 大的键。重复键的处理需统一:可存 count,也可规定同键向一侧走;本节实验室用无重复集合,避免把两个变量混在一起。
找到 x 并 splay 到根,断开左树 L 和右树 R。若 L 非空,取 L 的最大节点 m;在 L 内把 m splay 到根,它不会有右儿子,再把 R 挂为它的右儿子。若 L 为空,R 直接成为新根。
停顿 3:为什么“左树最大点”没有右儿子?把 R 接到它右边后,BST 键范围为什么仍正确?
旋转只改变 A,p,B,x,C 五块的连接,键范围证明保证中序不变;连续旋转因此始终保留 BST。每次 splay 循环都让 x 向目标父亲靠近,最终停止。若维护 size,每次改变孩子后由下而上 pull,size 仍等于左右子树节点数加一。
Splay 的访问引理以子树大小定义势能,说明任意 m 次操作总时间为 O(m log n) 的量级(更精细表述可包含初末势能项)。单次访问一条长度 n 的链仍可能 O(n)。所以它适合摊还保证,不能拿来应对题面明确要求每次最坏 O(log n) 的接口承诺。
从初始 BST 查到 7 后,填表:
| 阶段 | x 的父亲 | x 的祖父 | 情形 | 本轮后根 | 必须 pull 谁 |
|---|---|---|---|---|---|
| 开始 | |||||
| 第一次旋转后 | |||||
| 第二次旋转后 |
再自己插入 6:普通 BST 会把它放在 5 的右侧、7 的左侧。把 6 splay 到根之前,先预测它会经历 zig-zag 还是 zig-zig;若你能根据左右方向而非死记答案判断,就已经握住 Splay 的骨头了。
运行 让刚访问的点靠近你.py。它从同一棵小 BST 出发,先请你预测查找 7 的两次旋转,再打印每个 zig、zig-zig、zig-zag。可输入:
建议先执行 find 7,再 show;然后在纸上预测 add 6 的第一个祖孙形状。树会动,键的排序不会动,这正是游戏规则。