平衡树学不会的,这辈子都学不会的
一、前置芝士
平衡树本质上看是一种 BST(二叉排序树)
而 BST 其实是一种非常容易理解的良心数据结构
BST 保证了这颗树的中序遍历是单调上升的
举个例子:
BST 支持插入、删除以及查询等多种操作,我们一一来讲述
- 插入
插入很简单
我们知道二叉排序树有一个性质:对于每一个结点,设它的点权为 v a l val val,那么它的左子树中的每一个点权值必定小于 v a l val val,它的右子树中的每一个点权值必定大于 v a l val val
插入时我们对于当前访问到的点的权值分讨
-
如果这个点的权值等于插入点的权值,直接放在这个点上即可,有的题目会把计数器加 1
-
如果这个点的权值小于插入点的权值,跳右子树
-
如果这个点的权值大于插入点的权值,跳左子树
最后,如果访问到一个空点,那么就把空点变成这个插入点即可
- 查询
为什么先讲查询呢?
其一是因为删除操作较为复杂
其二是因为删除操作将要涉及到一些新的概念,我们先在查询中阐述
首先有一种常见的查询——第 K 大数
我们仍然利用 BST 的性质,每访问到一个点,就对于子树的大小情况进行如下分讨:
-
如果左子树的大小加 1 刚好等于 K,那么访问到的点即为答案
-
如果左子树大小小于等于 K,跳左子树
-
如果左子树大小大于 K + 1,我们把 K 减去左子树的大小,再减去 1,随后跳右子树
为什么要减掉一定的大小呢?
因为第 K 大指的是以当前访问到的点为根的子树的第 K 大
如果访问左子树那没有关系
如果访问右子树,那么这个第 K 大,需要减去左子树的大小,再减去当前访问到的这个点(大小为 1),才能往右子树跳
然后是另外一种形式的查询——前驱和后继
在单调上升的有序数列中,一个数的前驱即为它的前一个数,后继即为它的后一个数
换句话说,一个数的前驱是小于这个数的最大数,后继是大于这个数的最小数
(如果您觉得上述定义有歧义,那么你可以把本文中出现的所有前驱和后继理解为直接前驱和直接后继)
查询就比较简单了,也是对于当前访问到的点的权值分讨,这里就不在阐述
- 删除
前面说了,删除操作比较复杂
因为它需要考虑多种情况
下面我们一一来讨论:
- 叶子结点
最简单的情况,直接删除即可(具体实现会有多种方式,对应多种不同的效果)
- 仅有一个子树的结点
由于仅有一个子树,那么我们可以直接用它的唯一子树代替这个结点
代替说得更具体一点,就是把这个唯一子树的根节点放在被删除的结点的位置
举个例子,在上文给出的图中,2 号结点是满足条件的一个结点
假设我们现在要删除 2 号结点
那么它唯一子树的根节点即为 1 号结点,我们直接用 1 号结点替换掉 2 号结点即可
- 有两个子树的结点
有两个子树的结点,我们就不能直接用子树的根替换删除掉的结点了(因为有两个根)
还是拿上文给出的图举例:
假设我们现在要删掉 3 号结点
我们在序列中来看这个操作造成的影响:
原序列: 1 2 3 4 5 6
新序列: 1 2 4 5 6
可以发现,3 号结点的前驱和后继接在了一起
那么我们任取前驱和后继中的一个放在这个删除的结点位置即可
如果你选的这个点是一个叶子结点,直接放即可
如果不是,可以证明它的父亲一定是被删除的结点,直接类似第二种情况替换即可
然而,学会了这些操作,你的平衡树才刚刚入门
为什么???
(因为你学的东西是 BST,而这玩意叫平衡树)
平衡树,顾名思义,它会保证整颗树趋于相对平衡
那为什么 BST 就不平衡呢?
举个例子,如果你按照 1 2 3 4 5 6 7 8 的顺序插入,那么这颗树会退化成一条链
而前面三个操作的复杂度是 O ( h ) O(h) O(h) 的, h h h 为树的深度
也就是说,如果数据中出现了一些比较长的单调上升子段,那么树的高度会很高,操作的时间复杂度从会从最优的 O ( log 2 n ) O(\log_2n) O(log2n) 退化至 O ( n ) O(n) O(n)
为了解决这种情况,伟大的数据结构——平衡树应运而生
(注意:各类平衡树的以上三种操作都是大同小异的,区别在于平衡整颗树的方法)
二、常见的平衡树
1. S p l a y 1.~Splay 1. Splay
S p l a y Splay Splay 又称伸展树,它的很多操作都基于伸展操作
而伸展操作又是基于旋转操作
旋转操作是平衡树的一种基本操作,用于调整一颗树至平衡状态,除了 S p l a y Splay Splay 外,下文将要介绍的 T r e a p Treap Treap 也会有这种操作
下面来介绍一下旋转操作:
旋转分为左旋和右旋,又称 zag 和 zig,但是它的本质都是一个结点往它的父结点旋转,区别仅在于被旋转的结点是左儿子还是右儿子,所以下文在讲解过程中不一定会作区分
放一张图:
在左边的树中,我们把 7 号结点旋转,即可得到右边的树
同理,在右边的树中,如果我们把 5 号结点旋转,即可得到左边的树
拿左边的树旋转到右边来举例,在一次旋转中,3、5、6、7 四个结点的父子关系会有所改变
它们分别对应:爷爷、爸爸、自己和儿子
其中儿子有一些特殊,因为儿子相对自己的方向和自己相对爸爸的方向是相反的
可以发现旋转之后,树的结构仍然保持 BST 的性质,这就是我们想要的效果
给出示例代码:
void Rotate(int u)
{
int f = fa[u], g = fa[f], k = (son[f][1] == u); //k = 0 表示左儿子,反之则表示右儿子
son[g][son[g][1] == f] = u, fa[u] = g;
son[f][k] = son[u][k ^ 1], fa[son[u][k ^ 1]] = f;
son[u][k ^ 1] = f, fa[f] = u;
return Pushup(f), Pushup(u); //要先上传点 f 的信息,再上传点 u 的信息,很多情况下不需要上传点 g 的信息(如果点 f 原本为根节点,那么点 g 甚至不存在)
}
中间的三个转换关系可以自己手推一下,如果弄懂了其实就会很简单
那么下面就进入到伸展操作:
伸展操作可以简单描述为把某个结点一直旋转到自己祖先的下方,成为这个祖先的儿子,如果祖先为 0 则表示把这个结点转到根结点上
所以,伸展操作实际上并不是所谓的平衡操作,而是一种重构树的方法,类似搜索引擎,把可能会访问到较多次的结点尽量靠近根,这样每次操作的均摊复杂度是 O ( log 2 n ) O(\log_2n) O(log2n)
你可以通过把一个结点不断往上旋转 ,然后就会被某些毒瘤数据给卡掉
为了避免这种情况产生,我们采用双旋的方法
下面给出伸展操作的代码:
void Splay(int u, int to = 0)
{
while(fa[u] != to)
{
int f = fa[u], g = fa[f];
if(g != to) (son[f][1] == u) ^ (son[g][1] == f) ? Rotate(u) : Rotate(f); //第一次旋转,如果点 u 和点 f 相对于自己父亲的方向相同,就把点 f 旋转上去,否则把点 u 旋转上去
Rotate(u); //第二次旋转,把点 u 旋转上去
}
if(!to) rt = u; //特判点 u 旋转到根结点的情况
return;
}
至于具体证明,有兴趣的读者可以参考 S p l a y Splay Splay 原论文,这里不再阐述 (其实是根本不会)
如果实在理解不了,你可以拿一条链,把链底转到链顶,看看单旋和双旋的区别
可以发现,单旋后的树还是一条链,不过双旋后整颗树的深度规模整整缩小了一半!
这就是双旋的优势所在,所以以后一定别用单旋!
下面给出 P3369 【模板】普通平衡树 的 S p l a y Splay Splay 写法:
#include<iostream>
#include<cstdio>
#include<cstdlib>
#define MAXN 500005
#define INF 0x7FFFFFFF
using namespace std;
int n, tot = 0, rt = 0;
struct Tree {
int fa, son[2], val, si, cnt; } t[MAXN];
void Link(int x, int y, int k)
{
t[x].son[k] = y;
t[y].fa = x;
return;
}
void Update(int x)
{
t[x].si = t[x].cnt;
if(t[x].son[0]) t[x].si += t[t[x].son[0]].si;
if(t[x].son[1]) t[x].si += t[t[x].son[1]].si;
return;
}
void Rotate(int x)
{
int y = t[x].fa, z = t[y].fa, k = (t[y].son[1] == x);
Link(z, x, (t[z].son[1] == y));
Link(y, t[x].son[k ^ 1], k);
Link(x, y, k ^ 1);
Update(y); Update(x);
return;
}
void Splay(int x, int Goal)
{
while(t[x].fa != Goal)
{
int y = t[x].fa, z = t[y].fa;
if(z != Goal) (t[z].son[1] == y) ^ (t[y].son[1] == x) ? Rotate(x) : Rotate(y);
Rotate(x);
}
if(!Goal) rt = x;
return;
}
void Insert(int x)
{
int u = rt, f = 0;
while