“文艺平衡树”是竞赛圈一个俏皮叫法:它通常不是指某一种额外发明的平衡条件,而是指把一段序列藏进平衡树的中序遍历,然后优雅地做翻转、剪贴、求和。值可以是字母、温度、角色台词;树真正维护的是它们的位置关系。今天我们就拿一句话开刀。
学完后,你能:
前置:序列下标、二叉树中序、子树大小。若已完成 让刚访问的点靠近你,会更顺;还没完成也可先从本节的纸上实验理解模型。
在纸上写一排卡片:
A B C D E F
1 2 3 4 5 6
贯穿任务有三步:翻转区间 [2,5]、询问 [2,5] 的元素数、再翻转 [1,6]。纸上仍写四栏:对象(哪一段卡片)、状态(size、sum、rev)、动作(分出还是暴露哪一段)、账本(中序位置是否仍等于逻辑序列)。先猜结果,再看图或程序。
“文艺”不在于把字母写得漂亮;它有趣在于,整段文本好像被一只手翻了过来,而程序只在根附近改了几条指针和一个小标签。
将 B,C,D,E 反转,得到 A,E,D,C,B,F。若还要每次询问区间和或最小值,数组可以预处理前缀;但一次中段翻转之后,许多前缀信息全失效,重新建表又要 O(n)。
停顿 0:若连续做 reverse(2,5)、reverse(1,6)、reverse(3,4),数组里哪些位置会被反复触碰?你能指出被重复维护的“前缀账本”吗?
若把字母/数值当 BST 键,重复字母、相同分数会没有唯一位置;更糟的是,字典序与句子位置毫无关系。隐式平衡树只要求:中序遍历的第 1、2、3……个节点,就是序列的第 1、2、3……项。
于是节点不必存排序键,但必须存:
$$ size[u]=size[left[u]]+1+size[right[u]]. $$
这份 size 就是“这张卡左边藏了多少张卡”的账本。
停顿 1:在一棵中序为 A,B,C,D,E,F 的树中,若节点 D 左子树 size 为 3,D 是第几项?若要找第 5 项,第一次比较 k 与什么数?
任何区间操作都可先在脑中切成:
前缀 | 目标区间 | 后缀
FHQ Treap 会真的做两次 split;Splay 常用一个更戏剧化的动作:让目标区间变成某个节点的左子树。想取得 [l,r],需要一对恰好夹住它的边界:
两端没有前驱或后继怎么办?给序列两侧各放一个永不参与答案的哨兵 L、R。完整中序变为 L,A,B,C,D,E,F,R;这不是多余装饰,而是把所有边界情况变成同一种情况。
停顿 2:在带左哨兵的完整中序中,原区间 [2,5] 左边界的全树排名是多少?右边界之后的节点全树排名又是多少?
令 x=kth(l),y=kth(r+2)。先把 x splay 到根,再把 y splay 到 x 的右儿子位置。因为中序中所有目标元素严格在 x 与 y 之间,y 的左子树恰好就是 [l,r]。
expose(l, r):
x = kth(l) # 左哨兵已计入排名
splay(x, goal=null)
y = kth(r + 2)
splay(y, goal=x)
return left_child(y)
这叫区间暴露(interval exposure)。它解决的瓶颈是“连续区间在树上并不天然是一棵子树”;两次 Splay 后,目标变成一棵真的可整体操作的子树。
得到目标子树 mid 后,最笨的办法是中序列出它、反转列表、逐个塞回。可反转一棵二叉树的中序,只需对每个节点交换左右孩子;若直接递归每个节点仍是 O(length)。Lazy 标记让我们先对根写一笔“这整棵树待翻转”,等以后真的走进孩子再下传。
apply_rev(u):
swap(left[u], right[u])
rev[u] = rev[u] XOR 1
交换左右会把该子树的中序整体倒过来;两次翻转应抵消,所以标签用 XOR/布尔取反复合。若节点维护 sum,翻转不改变和;若维护首字符、尾字符或方向敏感哈希,则更新规则必须跟着交换,不能只偷懒改一个 rev。
停顿 3:对 A,B,C,D,E,F 暴露 [2,5] 后,只在 mid 根调用 apply_rev。此时若你立刻中序遍历、却从不 push,为什么可能读到旧顺序?什么时候必须把 rev 下传?
Splay 会根据“x 是父亲左还是右儿子”决定旋转。若祖先带 rev,逻辑上的左/右与当前裸指针可能不同。于是从目标祖先到 x 的路径必须先自顶向下 push;这是文艺平衡树最容易出现的“明明旋对了,字符串却倒着”的 bug 来源。
目标子树一旦暴露,sum、min、max、hash、最大子段和都可作为摘要;区间加、赋值、翻转等 Lazy 也可组合,只是每种标记都要明确:
不能因为一棵树“文艺”就把任意操作混上去;例如区间排序会改变大量相对顺序,通常没有一个常数大小 Lazy 能表达。
| 账本 | 维护什么 | 何时更新 |
|---|---|---|
| 中序语义 | 节点排列就是逻辑序列 | 旋转、split/merge 后仍须保住 |
| size | 子树含多少位置 | 每次改孩子后 pull |
| 摘要 | sum/min/hash 等区间答案 | pull 或 apply 标记时 |
| rev | 这棵子树还欠一次方向翻转 | apply 时复合,走入孩子前 push |
Splay 通过摊还 O(log n) 的两次伸展暴露区间;一次 apply_rev 为 O(1),所以翻转、区间查询的摊还复杂度通常为 O(log n)。FHQ Treap 以两次 split 加 merge 达到期望 O(log n),接口不同,核心都是“把连续区间变成独立子树”。
完整中序是 L,A,B,C,D,E,F,R。对原 [2,5],先填写:
| 项目 | 你应写下的内容 |
|---|---|
| x=kth(l) | |
| y=kth(r+2) | |
| y.left 的中序 | |
| apply_rev 后逻辑中序 | |
| 去掉哨兵后的最终序列 |
再做 reverse(1,6)。这次 x=L、y=R;它看起来像特殊边界,哨兵恰好让它与普通区间使用同一行 expose 伪代码。这就是哨兵最值得的地方:少的不是两行 if,而是一类容易错的思维分叉。
运行 把一段话翻过来.py。程序先请你预测 [2,5] 的边界 x、y,再用带双哨兵的隐式 Splay 暴露该区间。可输入:
先写下你认为 y.left 应是哪几张卡,再执行 expose。若输出与预期不同,优先检查排名是否把左哨兵算进去了。