平衡树(7.10讲课)
浅谈平衡树相关
前言
先来看一些操作:
您需要动态地维护一个可重集合 \(M\),并且提供以下操作:
- 向 \(M\) 中插入一个数 \(x\)。
- 从 \(M\) 中删除一个数 \(x\)。(若有多个相同的数,应只删除一个)
- 查询 \(M\) 中有多少个数比 \(x\) 小,并且将得到的答案加 \(1\)。
- 查询如果将 \(M\) 从小到大排列后,排名位于第 \(x\) 位的数。
- 查询 \(M\) 中 \(x\) 的前驱(定义为 \(M\) 中小于 \(x\),且最大的数)。
- 查询 \(M\) 中 \(x\) 的后继(定义为 \(M\) 中大于 \(x\),且最小的数)。
怎么做?鉴于大家学过线段树和分块,有一些简单方法:
- 权值线段树/树状数组:优点是好写并且是单 \(\log\),但是如果遇到值域很大的则需要动态开点(或者离线)。
- 值域分块:优点是好写,但是值域过大则需要离线,复杂度是单根的,通常配合莫队使用。
- 01trie:没什么人写,在维护前缀异或最大值非常的有用。
这时候有的人要问了,那要平衡树有什么好的?
确实上面的操作有更好写的方法,但是显然他们有局限性,例如 P3391 【模板】文艺平衡树。
一般平衡树有这么几种:
主流的几个:
-
有旋 Treap:优点就是代码量小,速度较快,可以可持久化,但是不能分裂合并,导致操作有局限性。
-
无旋 Treap aka FHQ-Treap:优点代码量小,速度比较慢,可以分裂合并可持久化,功能相当完备。
-
Splay:代码量不小,速度也比较慢,可以分裂合并不能可持久化。
-
WBLT aka RRFGT:效率较高,代码量最小,可以分裂合并,可持久化。
剩下的 AVL,B树,RBT,替罪羊树好像出现的都比较少。但是替罪羊树比较好写而且可以维护 KDT 虽然复杂度是假的。AVL 除非你写 ynoi 可能需要用,但是联赛不涉及。其实块状链表也可以实现上面的这些东西不过是单根的。
我们今天讲的主要是 FHQ-Treap 和 Splay,关于为什么讲他们俩呢?因为 FHQ-Treap 功能齐全,可以分裂合并可持久化,Splay 的话后面学 Link-Cut-Tree 需要用到。其他的可以自学。
说句题外话 FHQ-Treap 可以维护 Link-Cut-Tree 但是是二老哥的(常数较小),Splay 是一老哥的(常数较大)。如果说你动态树写 SATT 你也需要 Splay。
好的进入正题:
前置知识:
BST(二叉搜索树):满足左儿子的值小于根小于右儿子的值的二叉数。
Heap(堆):这个大家应该都知道比如我们平常用的 priority_queue,以及斐波那契堆等等。在这里指的是二叉堆。大根堆就是根节点的 \(val\) \(>\) 左子树的任意 \(val\) 且根节点的 \(val\) \(>\) 右子树的任意 \(val\) ,小根堆反之。
FHQ-Treap

Treap(笛卡尔树) = BST + Heap
接下来介绍一下 Treap 的结构:
Treap 的结点除了被维护的 权值 之外,还附加了一个随机的 优先级。其中,权值满足二叉搜索树性质,优先级满足堆性质(小根堆或大根堆)。
如图:
这样一看你的二叉搜索树加上优先级权值是不是让他变得“平衡”一些了?由于有 BST 的性质限制,又有了 Heap 的性质限制后我们就发现这棵树的结构是一定的,这个大家可以自己想想其实不是很难。
由于优先级是随机的,所以可以证得最终的树高是 \(O(\log n)\) 级别的。由于证明比较困难,大家可以感性理解一下。
有了 \(O(\log n)\) 的树高,我们就可以肆意妄为的在树上做操作了。
FHQ 的最重要的两个操作就是分裂和合并了,这里重点讲一下。
首先我们需要弄清楚一件事,对于大部分平衡树都是依靠旋转来维护自己的深度平衡,但是 FHQ 就不一样了,由于前面提到的他的结构一定的性质,所以只要我们每次保证他的结构符合 BST + Heap 的结构那么他的复杂度就是对的。
观察 treap 的结构,我们可以注意到一颗 treap 相当于一个区间,那么比如说一个 \(val\) 序列为 \((l,r)\),你想要将 \(val\) 小于等于 queryval 的所有节点和大于 \(queryval\) 的所有节点分开,就相当于将 \(val\) 的区间 \((l,r)\) 拆成两个区间 \((l,queryval)\) 和 \((queryval+1,r)\) ,就等同于将一棵大 Treap 按照 val 分成两棵小Treap,这就是分裂操作。
好的看一个例子:

我们将 \((l,queryval)\) 称为“左区间树”,将 \((queryval+1,r)\) 称为“右区间树”。
当 \(queryval=5\) 时,分裂的过程如图,图中的蓝色是已经归入左区间树的点,橙色是已经归入右区间树的点,左右区间树中的实圆是已经确定的节点,虚圆是尚未确定的节点。
我们首先建立两个虚拟节点,就是图中的虚圆,如果当前访问的节点 \(now\) 的 \(val \leq queryval\) ,那么 \(now\) 及 \(now\) 的左子树,都应归在左区间树里,就是将左区间树上一个虚拟节点赋为 \(now\) ,此节点便由虚变实,然后对 \(now\) 建立虚拟的右儿子。否则,\(now\) 及 \(now\) 的右子树应该归在右区间树里,就是将上一个右区间树的虚拟节点赋为 \(now\) ,此节点便由虚变实,然后对 \(now\) 建立虚拟的左儿子。如果 \(now\) 是空节点,则将左右区间树的虚拟节点赋为 \(0\) 。
为什么这样分裂是正确的?当我们对左区间树不断建立虚拟的右儿子时,而 \(now\) 的 \(val\) 都是单调不减的,所以左区间树满足二叉搜索树的性质,当我们对右区间树不断建立虚拟的左儿子时,而 \(now\) 的 \(val\) 都是单调不增的,所以右区间树满足二叉搜索树的性质。而这样建树又不会将祖先与后代的关系交换,即便祖父节点变成了父亲节点,它仍是祖先节点,所以左右区间树满足堆的性质。
友情声明:由于准备时间紧迫,我直接应用了原文的这一段,这一段讲的非常详细,希望大家谅解qwq。
此时我们可以写出 Split 的代码:
void Split(int now , int v, int &x, int &y){
if(!now) return x = y = 0 , void(); // 如果当前节点为空,我们把虚拟节点设成 0
if(val[now] <= v) x = now , Split(rs[now], v, rs[now], y); // 当前节点权值小于分裂的权值,左子树全归到左区间树,左区间树建立右边的虚拟节点,然后往右子树递归
else y = now, Split(ls[now], v, x, ls[now]); // 与上面相同
PushUp(now);
}
类似的,我们是不是可以思考一下如何 Merge。
首先在这里提醒,我们做分裂的时候是将区间 \((l, r)\) 分为 \((l, queryval)\) 和 \((queryval + 1, r)\) 的两个子区间树,仍然满足值域不交,且左子树的权值是要小于右子树的权值的。鉴于大家刚刚接触,我最后再讲平衡树有交并。
由 Treap 的性质可以得,对于左区间树 \(x\) 和右区间树 \(y\) 只有可能 \(x\) 是 \(y\) 的左儿子或者 \(x\) 是 \(y\) 的右儿子,究竟是哪一种我们就要看他的的优先值了。
int Merge(int u, int v){
if(!u or !v)return u | v;
if(rnd[u] < rnd[v]){ // 左边的优先级高,拿左边的右儿子和 $v$ 合并
rs[u] = Merge(rs[u], v);
PushUp(u);
return u;
}
else{ // 同理,拿右边的左儿子和 $u$ 合并
ls[v] = Merge(u, ls[v]);
PushUp(v);
return v;
}
}
学会了 Split 和 Merge 操作我们就完成一半了呼呼。
我们可以用 PushUp 维护子树 siz,从而进行后面的操作,这里的 PushUp 和线段树的类似,但是线段树是 leafy,但FHQ 不是,所以注意更新本节点的答案。
来讲几个基本操作:
- 新建一个节点:
int New(int v){
tot++ , val[tot] = v , rnd[tot] = rd() , siz[tot] = 1;
return tot ;
}
- 插入一个节点:
由于前面提到的要维护 BST 的性质,我们将树拆分成小于当前值 \(v\) 的和大于他的,新建一个节点,然后直接把他们合并即可。
void Insert(int v){
int x , y;
Split(root, v, x, y);
root = Merge(Merge(x, New(v)), y);
}
- 删除一个节点:
和上面差不多意思,不再赘述。
void Delete(int v){
int x , y , z ;
Split(root , v , x , z);
Split(x , v - 1 , x , y);
y = Merge(ls[y] , rs[y]);
root = Merge(Merge(x , y) , z);
}
- 查询第 \(k\) 大:
我们在树上搜索,如果左儿子的个数比 \(k\) 小,令 \(k \gets k - siz_{ls_u} - 1\),递归进入右子树。否则递归进入左子树。当 \(k=siz_{ls_u}+1\) 的时候就找到这个值了。比较显然。
int Find_Kth(int u, int k){
int cnt = 0;
while(1){
if(k <= siz[ls[u]]) u = ls[u];
else if(k == siz[ls[u]] + 1) return u;
else k -= (siz[ls[u]] + 1), u = rs[u];
}
}
- 查询前驱后继:
这里以前驱为例,我们将树分成小于 \(v\) 和大于等于 \(v\) 的,左半部分树中最大的就是前驱了,后继也是一样,不再叙述。
int Find_Prev(int v){
int x, y ;
Split(root, v - 1, x, y);
int tmp = val[Find_Kth(x, siz[x])];
root = Merge(x, y);
return tmp;
}
int Find_Next(int v){
int x, y;
Split(root, v, x, y);
int tmp = val[Find_Kth(y, 1)];
root = Merge(x, y);
return tmp;
}
这里给出 FHQ 的完整代码:
namespace FHQ{
mt19937 rd(123213);
int val[N] , siz[N] , rnd[N] , tot , ls[N] ,rs[N];
void PushUp(int id){
siz[id] = siz[ls[id]] + siz[rs[id]] + 1;
}
void Split(int now , int v, int &x, int &y){
if(!now)return x = y = 0 , void();
if(val[now] <= v)x = now , Split(rs[now] , v, rs[now] , y);
else y = now, Split(ls[now] , v , x , ls[now]);
PushUp(now);
}
int Merge(int u, int v){
if(!u or !v)return u | v;
if(rnd[u] < rnd[v]){
rs[u] = Merge(rs[u] , v);
PushUp(u);
return u;
}
else{
ls[v] = Merge(u , ls[v]);
PushUp(v);
return v;
}
}
int New(int v){
tot++ , val[tot] = v , rnd[tot] = rd() , siz[tot] = 1;
return tot ;
}
void Insert(int a){
int x , y ;
Split(root , a , x , y);
root = Merge(Merge(x , New(a)) , y);
}
int Find_Kth(int u , int k){
int cnt = 0 ;
while(1){
// ++cnt ;
if(k <= siz[ls[u]])u = ls[u];
else if(k == siz[ls[u]] + 1)return u;
else k -= (siz[ls[u]] + 1) , u = rs[u];
}
}
int Find_Siz(int v){
int x , y;
Split(root , v - 1 , x , y);
int k = siz[x] + 1 ;
root = Merge(x , y);
return k ;
}
int Find_Prev(int v){
int x , y ;
Split(root , v - 1 , x , y);
int tmp = val[Find_Kth(x , siz[x])];
root = Merge(x , y);
return tmp;
}
int Find_Next(int v){
int x , y;
Split(root , v , x, y);
int tmp = val[Find_Kth(y , 1)];
root = Merge(x , y);
return tmp;
}
void Delete(int v){
int x , y , z ;
Split(root , v , x , z);
Split(x , v - 1 , x , y);
y = Merge(ls[y] , rs[y]);
root = Merge(Merge(x , y) , z);
}
};
题外话:有喜欢科技的可以了解一下压位trie,可以做到 \(O(log_wn)\) 的插入删除以及查询前驱后继,可以暴力草很多题。如果你有兴趣你也可以看一看 van Emde Boas 树还有小波矩阵一类的东西。
基础的就大概讲完了,接下来说一些进阶的东西,前提是你确定你已经理解了 Treap 的结构。
维护区间修改标记:FHQ 可以像线段树一样维护区间修改的标记,比如区间翻转、区间加等等。需要及时注意标记的下放和标记的顺序:在每次树的结构需要修改的时候都需要下放标记
关于有交并这件事,在此题中已有记载,具体方法如下:
使用平衡树有交合并 \(x\)、\(y\),具体如下:
-
先找 \(val\) 值较小的为根(设是 \(x\))。
-
然后将 \(y\) 按照 \(x\) 的权值裂开为 \(l,r\)。
-
最后将 \(x\) 的左儿子与 \(l\) 合并,\(x\) 的右儿子与 \(r\) 合并。
-
注意若 \(y\) 中存在与 \(x\) 的权值相同的数,故使用并查集将其并在一起,然后在平衡树中删掉,不然无法保证复杂度。
关于最后一步如果删掉了复杂度是 \(O(n\log n)\) 但是如果没删的话会退化成 \(O(n \log^2 n)\),因为无法保证 Treap 的性质,lxl会卡。
区间复制,此题 的弱化版保证数据随机是可以 FHQ 通过的,但是区间复制 FHQ 复杂度无法保证,大家谨慎使用。
垃圾回收:把删除掉的无用的节点在下次新建节点的时候优先使用。具体实现方法为在删除节点的时候将删除的节点一个个放入队列里,在新建节点的时候判断队列是否为空,如果不为空就从队头取一个节点清空原有的所有信息并新建,否则就按正常情况新建节点。此种操作可以节省空间。
接下来说一说习题。
习题
你会发现如果按照权值分裂翻转的话不会满足 BST 的性质,怎么办呢?维护相对位置,按 siz 分裂即可,然后给打上标记。
Treap 显然非常好做,这里提一下 __gnu_pbds 这个东西,有的时候可以代替手写 Treap,大家可以学一学。
现在叫树套树,写法非常多,可以线段树套平衡树,大家自己发明一下,注意空间。
还有就是上面提到的题目,较难的这里不做讲述,可以去做ynoi。

浙公网安备 33010602011771号