平衡树刚看的时候觉得很不好评价。
但它毕竟就是个数据结构,跟线段树的用途一样,都是用来维护数据。想想你刚看线段树时候的感受,是不是和现在刚看平衡树差不多。
事实来看,平衡树也不复杂。本质都是二叉搜索树,只不过维护平衡的方式不一样罢了。平衡树的类型看似那么多,实际上也就学两种:FHQ Treap 和 Splay。实在不行,还有 pb_ds 兜底。
那就开干。
二叉查找树 (Binary Search Tree, BST)
特征
BST 有以下特征。
- 每个节点有唯一的键值,可以比较大小。
- 任意节点的键值大于其左子树所有节点的键值,小于其右子树所有节点的键值。
- 以任意节点为根的一棵子树,仍然是 BST。
优点
- 可以轻松地插入节点、删除节点
- 可以轻松地查询节点的排名或根据排名查找节点
- 可以查询节点的前驱后继
尤其是第一点,相较于线段树等数据结构,BST 的轻松插入删除节点是其最大优势。
时间复杂度的退化
理论上来说,BST 的单次操作时间复杂度都是 \(O(\log N)\)。但是,在构造数据下,BST 会退化成链,时间复杂度退化至 \(O(N)\)。
平衡树就是为了解决这个问题的。平衡树有很多种,都是通过不同的方式维护 BST 的平衡。所以,平衡树就是 BST。
FHQ Treap
它是 Treap 的变体,由范浩强巨佬发明,救无数 OIer 于平衡树水火。
话说到平衡树要维护 BST 的平衡,Treap 就是其中一种维护方式。Treap 是 Tree 和 Heap 的合成词,顾名思义,就是给每个节点人为添加一个『优先级』,随机生成即可。对于键值,这棵树是一棵 BST;对于优先级,这棵树是一个堆。随机生成的优先级保证了时间复杂度从概率期望上看是 \(O(\log N)\)。
平衡树题目都需要动态维护树的平衡,在 FHQ 发明以前一直用的是旋转法。FHQ 相较于旋转法,不仅编码简单,还能用于区间翻转、移动、可持久化等多种场合,并一举代替了许多原本只能用 Splay 完成的题目。
FHQ 来维护所有平衡树操作的方法都只用到了分裂和合并这两个基本操作。而分裂的方法也有两种:按键值分裂和按排名分裂。
按键值分裂
分裂方法:将原树分裂为键值小于等于当前元素和键值大于当前元素的两棵。
这种适用于维护元素排名、不强调元素顺序的基本场合。
为什么说是基本呢?因为在吕教练给我们的平衡树题单中,所有的这种场合都被我用 pb_ds 水掉了
void split(int u, int x, int &l, int &r) {
if (u == 0) return l = r = 0, void();
if (t[u].key <= x) {
l = u;
split(t[u].rs, x, t[u].rs, r);
} else {
r = u;
split(t[u].ls, x, l, t[u].ls);
}
update(u);
}
int combine(int l, int r) {
if (!l || !r) return l | r;
if (t[l].pri > t[r].pri) {
t[l].rs = combine(t[l].rs, r);
return update(l);
} else {
t[r].ls = combine(l, t[r].ls);
return update(r);
}
}
可以看出,代码非常巧妙,也简单易写。
这里的 update
类似于线段树的 pushup
,主要内容就是将左右节点维护的信息合并到父节点上。在模板题中,我们只需维护一个子树大小,所以 update
函数中的内容也很简单:
int update(int u) {
t[u].sz = t[t[u].ls].sz + t[t[u].rs].sz + 1;
return u;
}
接下来我们来看分裂和合并是如何维护平衡树的基本操作的。
- 插入一个元素:将原树按键值分裂,然后新建一个节点,最后按『左树、新建节点、右树』的顺序将其合并。
int newnode(int x) {
t[++tot].sz = 1;
t[tot].key = x;
t[tot].pri = rand();
return tot;
}
void ins(int x) {
int l, r;
split(rt, x, l, r);
rt = combine(combine(l, newnode(x)), r);
}
- 删除一个元素:分裂两次,将原树分裂为左树、中树(键值只有待删除元素)、右树,将中树的左右子树合并(相当于删除了一个待删除元素),最后再将其和左右树合并。(注意顺序)
void del(int x) {
int l, r, p;
split(rt, x, l, r);
split(l, x - 1, l, p);
p = combine(t[p].ls, t[p].rs);
rt = combine(combine(l, p), r);
}
- 查询一个元素的排名:按键值分裂,排名即为左树的大小加一,最后合并(相当于恢复原状)。
int rnk(int x) {
int l, r;
split(rt, x - 1, l, r);
int res = t[l].sz + 1;
rt = combine(l, r);
return res;
}
- 按排名查询元素:简单,从根节点开始,如果排名等于左子树,即为查询成功,直接返回;如果排名小于左子树,向左递归查询;如果排名大于左子树,向右递归查询(函数参数中的待查排名要减去左子树大小)。
int kth(int k, int u = rt) {
if (k == t[t[u].ls].sz + 1) return t[u].key;
if (k <= t[t[u].ls].sz) return kth(k, t[u].ls);
return kth(k - t[t[u].ls].sz - 1, t[u].rs);
}
- 查询元素前驱:按键值分裂,查找左树中的最大元素(利用
kth
函数操作),最后合并。
int pre(int x) {
int l, r;
split(rt, x - 1, l, r);
int res = kth(t[l].sz, l);
rt = combine(l, r);
return res;
}
- 查询元素后继:同理,按键值分裂后查找右树中的最小元素,最后合并。
int nxt(int x) {
int l, r;
split(rt, x, l, r);
int res = kth(1, r);
rt = combine(l, r);
return res;
}
可以看出,所有的操作都只需维护一个根节点位置即 \(\textit{rt}\),所有的函数都只需传入一个待查参数即可返回结果,非常地模板化。
例题:P3369,P6136,P2286,P1486。
以上题目全部能用 pb_ds 解决,见下文。
按排名分裂
分裂方法:将原树分裂为位于当前元素左侧和位于当前元素右侧的两棵。
适用于强调元素顺序、不强调元素排名的高级场合。
void split(int u, int x, int &l, int &r) {
if (u == 0) return l = r = 0, void();
if (t[t[u].ls].sz + 1 <= x) {
l = u;
split(t[u].rs, x - t[t[u].ls].sz - 1, t[u].rs, r);
} else {
r = u;
split(t[u].ls, x, l, t[u].ls);
}
update(u);
}
实际上也很显然,有点类似上文 kth
函数的查找方法。
例题:P3391,P4309,P4036。
P3391 中的『翻转』在二叉树中的处理方法是:以区间中任意数为轴翻转左右两边,然后对左右两部分再递归处理。所以我们很自然地有类似线段树的懒标记思想,每次通过分裂提取出翻转的区间,给这个区间打上懒标记,随后定期 pushdown
即可。
P4309 是一个 LIS 问题加入动态维护。我们注意到题目中的一个特性:插入元素是从小到大插入的。这意味着我们但凡插入元素,从序列开始到新元素的这个部分的 LIS 长度必定会加一。我们在 pushup
函数中多维护一个变量 \(\textit{len}\) 表示总树的 LIS 长度,而 \(\textit{key}\) 维护当前节点的 LIS 长度,按照如下方法去 pushup
\(\textit{len}\) 即可。
P4036 题目给定的操作是求最长公共前缀 + 动态插入修改。首先字符串肯定 Hash 存储没有问题,插入修改编码都容易,重点在于如何维护 Hash 值。但前提是我们知道了子树大小,Hash 值也很好维护了。
\[\text t[u].\textit{hs}=\text t[\textit{ls}].\textit{hs}\times\mathrm P^{\text t[\textit{rs}].\textit{sz}+1}+\text t[u].\textit{key}\times\mathrm P^{\text t[\textit{rs}].\textit{sz}}+\text t[\textit{rs}].\textit{hs} \]还是很好理解的。最后就是求 \(\operatorname{LCQ}\) 函数,相较于暴力的比较,由于范围是确定的,所以可以二分答案解决。时间复杂度 \(O(M\log L)\)。当然此题暴力可过。
Splay
在 FHQ Treap 下被边缘化的平衡树。不想讲,等到我学某更高级的数据结构时再说吧。
pb_ds
相当于按键值分裂的平衡树模板,简单讲一下它的用法。
头文件 + 命名空间:
#include <bits/extc++.h>
using namespace __gnu_pbds;
格式:
tree<pii, null_type, less<pii>, rb_tree_tag, tree_order_statistics_node_update> t;
- 第一个参数表示要维护的平衡树的元素类型。这里填成
pii
是因为 pb_ds 默认情况下类似一个 set 而不是 multiset,所以插入相同元素时要赋予不同的第二值。 - 第二个参数表明你想不想实现 map 的效果,如果不想就填成
null_type
。 - 第三个参数是排序函数,如果填成
greater<pii>
就变成从大到小查找排名了。 - 第四个参数决定了平衡树的内部实现类型,一般填
rb_tree_tag
即红黑树效率最高。 - 第五个参数决定了你的平衡树是否支持上文的
rnk
函数和kth
函数的功能。很长,但打多了也就记住了。如果你不需要,你就只需填前两个参数就行了,后三个参数都有默认指定。
下面说一下如何用 pb_ds 的平衡树通过 P3369 模板题。
- 插入一个元素:如下,其中 \(i\in[1,n]\),相当于给相同元素赋予了不同的第二值。
t.insert(make_pair(k, i));
- 删除一个元素:题目保证了待删除的元素一定存在,所以用
lower_bound
可以删除一个存在于平衡树中的待删除的元素。
t.erase(t.lower_bound(make_pair(k, 0)));
- 查询一个元素的排名:排名的定义为小于待查询元素的元素个数加一,所以函数参数不一定需要在平衡树中存在。
cout << t.order_of_key(make_pair(k, 0)) + 1 << '\n';
- 按排名查询元素:注意给出的排名要减一作为函数参数。
cout << t.find_by_order(k - 1)->first << '\n';
- 查询元素前驱:pb_ds 对此没有特定函数,需要我们善用
lower_bound
。
cout << (--t.lower_bound(make_pair(k, 0)))->first << '\n';
- 查询元素后继:pb_ds 对此也没有特定函数,需要我们善用
upper_bound
。
cout << (t.upper_bound(make_pair(k, INT_MAX)))->first << '\n';
由此可见,pb_ds 的平衡树基本可以实现按键值分裂的 FHQ Treap 的所有功能了。
pb_ds 的缺点是过于模板化导致不够灵活,只能拿来做一些板题。pb_ds 默认不支持 multiset 也导致其很麻烦,做树套树就更不容易。