你在给学校做一个"成绩查询系统"。核心需求很朴素:维护一个学生集合,支持四种操作——
· 插入一个学号;· 删除一个学号;· 查询某个学号在集合中的排名(比它小的有几个);· 查询第 k 小的学号。
你毫不犹豫地选择了二叉搜索树(BST):每个节点存一个学号,左子树全比我小、右子树全比我大。查找从根一路比较下去,每一步都能甩掉一半——理论复杂度 O(log n),完美。
你写了 200 行代码,测试通过了。可上线第一天,系统就卡死了。
排查发现,学生们是按学号顺序注册的:20230001、20230002、20230003……每一个新学号都比之前所有的都大,于是它每次都从根一路往右插,成了最右下的那个叶子。
一万个学生,树高一万层。BST 退化成了链表,每次查询都要比较一万次。
更糟的是,查询第 k 大时需要递归一万层——栈溢出,程序直接崩溃。
你盯着那棵歪成一条线的"树",忽然明白了:BST 的 O(log n) 并不是它的性质,而是一个"承诺"。这个承诺只在"树是随机长成的"前提下成立,而真实数据从来不随机。
教练把三种解法写在了黑板上:
解法一(AVL):「插入后发现左右子树高度差超过 1,就旋转把它掰回来。」——严格保持平衡,树高永远是 O(log n)。
解法二(Treap):「给每个节点随机配一个优先级,让树同时满足 BST 性质(按学号)和堆性质(按优先级)。随机性保证了期望高度是 O(log n),而且不用管什么平衡因子。」
解法三(Splay):「每次访问一个节点,就把它一路旋转到根。经常被访问的元素自然浮到上面,靠这个"自调整"保持均摊 O(log n)。而且它还能顺手解决区间翻转这种序列问题。」
你听出了三者的共同点:它们都在做同一件事——用旋转把树的高度控制在 O(log n)。只是"什么时候转、往哪转"的判据不同。
教练又补了一句:「顺便说,如果你只是想求一个数组所有子区间的最小值,别用平衡树。有一棵叫笛卡尔树的树,它用单调栈能在 O(n) 时间内建出来,本质上是"下标满足 BST、值满足堆"的结构——它其实就是一个固定形态的 Treap。」
「所以这一讲,我们其实是在学一件事:如何在二叉树上维持"有序"和"平衡"这两个约束。」
💡 故事背后的算法思想
① BST 的复杂度取决于树高。所有操作(查找、插入、删除、求排名、求第 k 小)都沿着一条从根到叶的路径进行,代价 O(h)。所以要保证效率,就必须保证 h = O(log n)。
② 旋转是调节高度的唯一基本手段。在保持 BST 中序遍历不变的前提下,"左旋"和"右旋"能够改变局部的高度分布。所有平衡树都建立在旋转之上(AVL、Treap、Splay 都是),只有替罪羊树等极少数例外。
③ 三种平衡策略的哲学不同。AVL 是严格平衡(高度差 ≤ 1,查询最快但旋转最频繁);Treap 是随机平衡(靠随机优先级,代码最短);Splay 是自适应平衡(靠访问频率,均摊最优且能处理序列问题)。
④ 笛卡尔树把"平衡"做成了静态结构。给定数组,下标做 BST 键、值做堆键,形态被唯一确定。配合单调栈 O(n) 建树,常用于求 RMQ、划分树、以及和 Treap 的等价性证明。
⑤ 竞赛中的选择。写代码量少选 Treap(约 100 行);要处理区间问题选 Splay 或 FHQ-Treap;要严格理论保证用 AVL;但绝大多数题目都可以用 std::set / pb_ds 库解决——先看能不能用库。