这一份零食专门给 FHQ Treap 准备。正式课一开口就是
split/merge,确实像有人把你带到乐高城堡前,只递来两块奇形零件说“拼吧”。今天我们从最笨的队伍开始,一块一块看它们为什么会出现。
学完后,你能:
split 与 merge;pull、按值 split、merge、插入、删除一个重复值和第 $k$ 小;前置:数组、二分查找、递归、二叉树、随机数的基本直觉。
这节要用同一组数反复做实验:
$$ S=[4,2,7,5,2]. $$
请在纸上画四栏:结构里存了什么、本次操作看了/搬了哪些元素、操作后仍必须成立的性质、下一步还缺什么。每读到“停顿”时先停下来;不要把折叠区当成自动播放按钮。
我们的贯穿任务是依次执行:插入 6、删除一个 2、求 rank(6)、求第 3 小。它们不是题目,而是用来观察结构是否真的在工作。你要反复问自己:我现在维护的是一串数、一个顺序,还是一棵带摘要的树?
先不许说 Treap,也不许说 split。对一个能重复的整数集合,写下下面四句话各自真正需要的信息:
| 动作 | 人脑最直接的做法 | 若没有顺序,最先卡在哪 |
|---|---|---|
insert(x) |
把 x 放进某个容器 |
放哪儿才能仍有序? |
erase_one(x) |
找到一个 x 并删掉 |
怎么确认只删一个? |
rank(x) |
数有多少数严格小于 x |
要把所有数都看一遍吗? |
kth(k) |
排序后取第 k 格 |
每次都重新排序吗? |
停顿 0:在 $S$ 中执行
rank(6)。请先圈出严格小于 6 的元素,并写下你看了几张“数卡”。
无序数组把 6 直接追加,几乎不用移动;但 rank(6) 和 kth(3) 都要重新认识全体成员。有序数组先把 $S$ 写成:
下标: 1 2 3 4 5
数值: 2 2 4 5 7
现在插入 6。请在纸上画一个空格,并用箭头表示谁要右移。
停顿 1:二分能很快找到
6应在5和7之间。那为什么插入仍不是对数复杂度?这一步到底移动了哪几格?
二叉搜索树(Binary Search Tree, BST)把一个数放在节点上。对键 4,小于它的数永远向左,大于它的数永远向右;相同键先暂时记在同一张卡的 count 里。请按顺序插入 4,2,7,5,2,每一步只回答两个问题:
建议把每个节点画成卡片:
key = 4 count = 1
left = ? right = ?
停顿 2:在你画出的 BST 中,找
6会经过哪些键?插入第二个2时,为什么“另建一个同键节点”和“把count加一”都可行,但后续规则必须统一?
重新从空树依次插入 1,2,3,4,5,6。不要跳步,画出每条新边。然后在表中填写:
| 插入到 | 需要比较几次 | 新节点深度 | 树高 |
|---|---|---|---|
| 1 | 0 | 0 | 0 |
| 2 | |||
| 3 | |||
| 6 |
停顿 3:这棵树和有序数组的“从左走到右”相比,真的躲开线性代价了吗?若想修复它,绝不能破坏哪一件事?
现在用下面这组固定优先级做纸上实验,先别把它当随机数:
键 key |
2 | 4 | 5 | 7 |
|---|---|---|---|---|
优先级 priority |
35 | 60 | 83 | 20 |
约定“大优先级在上”。请先回答:最高优先级是谁?它的左边只能放哪些键,右边只能放哪些键?然后在左右两边重复同样问题。
停顿 4:不看下方答案,画出整棵树,并在每条边旁写两句证词:一条关于键大小,一条关于优先级大小。
split 时,插入到底在重复做什么想把 6 放进刚才的树。你当然可以沿 5→7 找位置,再想办法让优先级关系恢复;但请换一种描述:我们真正想要的是把旧集合切成“比 6 小”“等于 6”“比 6 大”三堆,处理中间那堆,再按原顺序接回去。
这句话就是 FHQ Treap 的框架,不是代码技巧:
输入集合
↓ 按一个边界切开
几块各自仍合法的树
↓ 只处理目标块
按键范围正确地接回
停顿 5:对边界
x=4,把上一步的树切成两棵。根5应去左树还是右树?既然根去右树,哪一侧子树完全不用递归?
动态有序多重集常问:插入/删除一个数、某数排名、第 $k$ 小、前驱后继。三个朴素选项各有一处疼:
| 结构 | 擅长 | 动态时的瓶颈 |
|---|---|---|
| 无序数组 | 末尾插入 | 排名、第 $k$ 小要扫描/排序 |
| 有序数组 | 二分、按下标取数 | 中间插删要搬很多元素 |
| 普通 BST | 按键走左/右 | 恶意或递增插入会变成链 |
我们希望像 BST 一样按键找位置,又希望树高不会总被输入顺序欺负。Treap 的做法非常俏皮:键 key 决定中序顺序,另发一张独立随机的优先级 priority 决定谁更靠近根。键管排队,优先级管谁坐树顶;两位领导分工,不抢麦。
Treap 同时满足:
priority 大于等于孩子。给每个键独立随机优先级后,键的中序顺序固定,树形等同于这些键按优先级建出的笛卡尔树(Cartesian tree)。随机优先级让任一键出现在很深位置的概率很小,所以树高与随机 BST 同阶,期望 $O(\log n)$。
这里“随机”只影响复杂度,不影响集合内容正确性。若随机源很差或面对能预测优先级的对手,树仍可能长歪;别把“期望”听成“永远不会翻车”。
split:按键把一棵树变成两棵设 split(root, x) 返回 (a,b),其中 a 所有键 <=x,b 所有键 >x。观察根:
root.key<=x,根和左子树必在 a;只需继续切右子树,把切出的左半接回 root.right;否则根和右子树必在 b;只需继续切左子树,把切出的右半接回 root.left。
split(root, x):
if root is null: return (null, null)
if root.key <= x:
(middle, right) = split(root.right, x)
root.right = middle
pull(root)
return (root, right)
else:
(left, middle) = split(root.left, x)
root.left = middle
pull(root)
return (left, root)
你没有“重建”任何树,只改递归路径上的一条孩子指针。其余子树原封不动,BST 和堆性质仍成立。
merge:已排好前后关系的两棵树接回去merge(a,b) 的前提是:a 所有键都不大于 b 所有键。谁应做根?优先级更高者。若 a 的根优先级更高,a.left 不动,只需把 a.right 和 b 继续合并;否则对称处理。
merge(a, b):
if a is null: return b
if b is null: return a
if a.priority > b.priority:
a.right = merge(a.right, b)
pull(a)
return a
else:
b.left = merge(a, b.left)
pull(b)
return b
pull(node):
node.size = size(node.left) + node.count + size(node.right)
pull 每次孩子变动后立刻重算。它很朴素,却是排名和第 $k$ 小不会悄悄坏掉的账房先生。
为了清晰处理重复值,这里令一个节点有 count 个相同键。插入/删除键 $x$ 时,先切出 <x、=x、>x 三块:
(less, at_least) = split(root, x - 1)
(equal, greater) = split(at_least, x)
insert x: equal.count += 1, or equal = new_node(x)
erase one x: if equal exists: equal.count -= 1; remove it if count becomes 0
root = merge(less, merge(equal, greater))
正式课会采用“重复值各建一个节点”的统一写法;两种方案都可以,但不要把 count 的 size 公式和“每个节点只算 1”混在一起。
下面不许只在脑中说“切一下”。仍从中序多重集 [2,2,4,5,7] 开始,执行 insert(6)。每一格先自己填,再核对。
| 阶段 | 你应写下的键集合 | 这一阶段允许改什么 | 需要保持什么 |
|---|---|---|---|
| 初始 | 无 | 中序有序、每个 size 正确 |
|
split(root,5) |
less / at_least |
递归路径的孩子指针 | less≤5<at_least |
split(at_least,6) |
equal / greater |
同上 | equal=6,其余大于 6 |
| 新建节点 | count 或新节点 |
新节点是合法单点 Treap | |
两次 merge |
连接边与 pull |
左树键范围不越过右树 |
接着从 [2,2,4,5,6,7] 删除一个 2,并求第 3 小。请把这两个问题拆开:
2 时,三次切分后的 equal 里应有多少个元素?count 从几变成几?5,左子树元素数为 3;第 3 小首先向哪边走?再假设左孩子为 4、它左子树大小为 2,第 3 小落在哪里?split 的返回值满足键范围分界;merge 的输入满足整体键序;二者只让高优先级根处在上方,因此保留堆序。每次改孩子后 pull,所以 size 始终等于子树元素总数。于是沿 left_size 走第 $k$ 小时,丢弃的元素数恰好正确,不会漏也不会重。
每个基本操作只沿一条期望对数高的递归路径走,故插入、删除、排名和第 $k$ 小期望 $O(\log n)$,空间 $O(n)$。若题目要求严格最坏 $O(\log n)$,要看红黑树、AVL 等确定性结构;FHQ Treap 不假装有这张证书。
split 边界:本篇是左边 <=x、右边 >x。若改成 <x/>=x,所有插删公式都要一起改。None 时,size(空)=0;每次改完 left/right 后再 pull。rand() 低位和固定可预测模式都不理想。kth(k) 前检查 $1\le k\le size(root)$;递归深度在极端退化时可能危险。运行 从排好队到会分身的树.py。程序先要求你预测有序数组的搬动和优先级根,再使用固定种子的随机优先级构建同一棵树;因此每次树形一致、便于复盘。启动日志会插入一组小数并打印 split/merge/pull;之后可以输入:
+ x / - x:插入或删除一个整数;rank x:查询严格小于 x 的元素数加一;k k:查询第 k 小;show:打印侧着的树、中序序列和每个节点的 size。建议先执行 + 6,猜一猜它被切到哪一边;再 - 2 两次,观察重复值的 count 如何影响 size。树会“分身”只是指针改了归属,不会偷偷复制数据,放心玩。
把 split 的依据从键改成子树大小,就得到下一节的隐式 Treap,可维护序列区间;给节点加 Lazy 标记可支持区间翻转。Splay 用旋转把最近访问点伸到根,适用思想不同;ODT 则依赖输入契约,不能当作平衡树的安全替代品。
merge(a,b) 为什么一定要先保证所有 a 的键不大于所有 b 的键?split 时为什么只递归到一侧孩子?pull 若漏调一次,第 $k$ 小为什么可能错误?split(root, x),在每一步标注左右键范围。merge,每次选择优先级更高的根。