数据结构专题-学习笔记:无旋平衡树(替罪羊树,fhq Treap)
update
update on 2022/10/28:发现文中对引用的描述不是很准确,但是比较多也不想改了,大家忽略引用就好。
1.概述
平衡树,是一种高级数据结构,是基于二叉查找树的一种数据结构。
对二叉查找树不理解的读者这里有一个作者的简单总结:
对于一棵二叉查找树(又名 BST):
- 这是一棵二叉树。
- 对于树上的任意节点,其左孩子的权值一定小于父亲,右孩子的权值一定大于等于父亲。
二叉查找树支持插入一个数,删除一个数,查找某数的排名,查找某数的前驱/后继等等。
比如下面这个就是一棵二叉查找树。
那么想必现在你已经知道二叉查找树是什么了。
根据理论证明,二叉查找树在 随机数据 的情况下表现良好,平均时间复杂度是 \(O(n \log n)\)。
然而我们知道数据并不总是随机的,也可以构造,比如这样一组数据:
1 2 3 4 5 6 7......
那么二叉查找树就变成了这样:
于是在这种情况下,二叉查找树就被卡成了 \(O(n^2)\)。
甚至如果随机数据不是那么的随机(指其中有几段长区间是连续的),那么二叉查找树同样会退化。(比如今年的 CSP-J2 直播获奖,如果没有将两个相同的分数合并到一个点上,考场上写二叉查找树的人就会被卡成 \(O(n^2)\),只有 90pts)(话说这玩意桶排不就结束了,为什么会有人写 BST?)。
于是就在这个时候,平衡树就出现了,而它的作用就是让 BST 变得平衡。
那么什么是平衡的 BST 呢?直观感受就是相对矮矮胖胖的 BST,而算法中就是指树高尽量靠近 \(O(\log n)\),因为这样才能做到 \(O(n \log n)\)。
比如上面这棵树,变成下面这样就是比较平衡的 BST。
你看这样是不是就好多了~
平衡树的种类有很多种,比如红黑树,B树等等。
但是这里面我个人认为最重要的而且一定要掌握的是这四种:替罪羊树,FHQ Treap,AVL 树,Splay。
每种平衡树都有它的“手段”来使 BST 平衡。
这里对上述 4 个平衡树作一个简介以及优劣对比:
- 替罪羊树:特别容易理解,时间比较优秀,空间有一点劣,码量较大,但适合用于树套树等场合。
- FHQ Treap:也很容易理解,时间优秀,空间优秀,码量特别小,而且能支持的操作最多,并且支持可持久化!
- AVL 树:最早被发现的平衡树,有一点点难理解(当然还是比较清晰的),时间最优秀,空间十分优秀,码量稍微有点大,但是正在逐渐的从算法竞赛中退出,对于一些卡常的题目倒是有很好的表现。
- Splay:伸展树,与 FHQ Treap 有“平衡树双子星”之称,但是理解难度比较大,常数比较大,空间比较劣,而码量并不是特别大。它能支持的操作不比 FHQ Treap 少(除了可持久化),而且 Splay 的扩展性特别强,能够融合于很多的算法/数据结构(比如 LCT),这一点是 FHQ Treap 无法媲美的。
而根据它们对 BST 的处理“手段”,又可以分为如下两大类:
- 无旋平衡树:替罪羊树,FHQ Treap。
- 有旋平衡树:AVL 树,Splay。
对于每一类平衡树将会使用一篇博文来讲解。
那么接下来,开始讲解无旋平衡树吧!
接下来的讲解都将以 这道题 为模板。
2.无旋平衡树-替罪羊树
1.思路
替罪羊树的思路在这里面我认为是比较好理解的一种平衡树,其优劣性在前面已经说过。
我们知道,一条链是我们在 BST 中最不希望看到的,也是最不平衡的一种树,那么替罪羊树又会怎么办呢?或者是针对一棵不平衡的树(或者是子树),要怎么办呢?
很简单,直接拍扁重构!
比如还是这棵树:
显然不平衡,那么替罪羊树就是要把这棵树拍扁重构。
我们知道,最好的二叉查找树是完全二叉树,那么替罪羊树拍扁重构的步骤如下:
- 首先得到这棵树的中序遍历,记长度为 \(n\) ,序列为 \(A_{1...n}\)。
- 然后将最中间的节点“拎”起来,作为这棵树的根节点。
- 然后左右递归继续执行得到左右子树,遇到叶子节点时停止执行。
看不懂?没关系,我画几张图片。
我们要将上面那棵树拍扁重构,那么首先得到中序遍历:
然后我们将最中间这个节点“拎”起来,也就是将 5 号节点“拎”起来。
于是这棵树就变成了这样:
红色的是左右两边的递归区间。
现在我们对左右两边分别递归,将中间的节点仿照同样的方式“拎”起来。
那么这棵树就变成了这样:
于是我们继续递归。
咦?左边怎么只有一个点了?此时说明已经到了叶子节点,直接提上去就好。
那么这棵树就变成了这样:
最后将剩下的节点提上去,调整之后这棵树最终变成了这样:
这就是替罪羊树拍扁重构的过程,是不是很简单~
但是我们不能随便拍扁重构啊,因为拍扁重构的时间是 \(O(n)\) 的,如果我们不加节制的拍扁重构那么就会导致 TLE,我们只有在不平衡的时候才会拍扁重构。
那么怎么才算平衡呢?
替罪羊树判断平衡的原理:取一个平衡因子 \(alpha \in [0.5,1]\)(一般取 \(0.75\)),如果左右子树中有一棵子树其节点个数比上这棵树的节点个数大于等于 \(alpha\),那么就不平衡,需要重构。否则就不需要。
但是还有一种可能也会导致替罪羊树需要拍扁重构,这个到 Delete 函数的时候再谈。
那么有了这个基础之后,我们看看对于题目当中所给的 6 个操作我们要怎么解决。
2.结构体建立
我们需要在替罪羊树中记录这样 6 个值:
- \(l,r\):表示左右儿子的编号。
- \(size\):表示子树大小。
- \(fact\):表示真实存在的节点个数(具体在 Delete 函数的时候谈)。
- \(val\):表示这个节点的值。
- \(exist\):表示这个节点是否存在(具体在 Delete 函数的时候谈)。
3.插入-Insert
替罪羊树的 Insert 跟二叉查找树无异,直接模仿二叉查找树写即可。
不过注意:在每一次插入数据之后都要检查一下插入的路径上的所有节点的子树是否不平衡。
代码:
void make_node(int &now, int x)//注意 now 是引用
{
now = ++cnt;//计数器 +1
tree[now].size = tree[now].fact = 1;
tree[now].val = x;
tree[now].exist = 1;//三句初始化
}
void Insert(int &now, int x)//now 是现在的节点编号,注意 now 是引用
{
if (!now)//叶子节点
{
make_node(now, x);//新建一个节点
check(root, now);//检查是否平衡
return ;
}
tree[now].size++;
tree[now].fact++;//修改 size 和 fact
if (x >= tree[now].val) Insert(tree[now].r, x);//按照二叉查找树的插入方式插入
else Insert(tree[now].l, x);
return ;
}
其中 check 函数在讲完 Delete 函数后会重点讲解。
4.删除-Delete
替罪羊树的删除节点跟别的平衡树都不一样。替罪羊树的删除节点采取的是 懒删除 的思想。
还记得 \(fact,exist\) 吗?
我们在删除节点的时候,首先要找到这个节点对不对?
然后呢?如果我们直接删除,就会因为父亲,左右儿子的一些奇奇怪怪的关系而导致非常难搞。那如果放到叶子节点再删呢?那这样就需要用到树旋转了,但这不是我们想要的。
于是替罪羊树就搞了一个这样的东西:懒删除。
在搜到某个需要被删除的节点的时候,我们先判断它有没有被删除(相同的数可能有很多个),如果没有,将 \(exist = 0\),\(fact--\),但是 \(size\) 不变,不删除这个节点,然后判断路径上的节点是否平衡即可。
等等,既然这个节点没有被真正的删除,那么我们为什么还要判断节点是否平衡呢?
是这样的,对于一棵子树而言,如果这棵子树中被删除的节点过多,我们同样需要对这棵树拍扁重构,以减少其对运行效率的干扰。一般情况下,如果被删除的节点占总节点个数的 30% ,那么这棵树需要拍扁重构。
而拍扁重构的过程上面已经详细的说明过了,在找中序遍历时我们需要 跳过所有被删除的节点。
代码:
void Delete(int now, int x)//注意这里 now 不是引用
{
if (tree[now].exist && tree[now].val == x)//找到了,注意判断是否存在
{
tree[now].exist = false;//作懒删除
tree[now].fact--;
check(root, now);//检查是否平衡
return ;
}
tree[now].fact--;//fact 不要忘记减 1
if (x >= tree[now].val) Delete(tree[now].r, x);//按照二叉查找树的查找方式找节点
else Delete(tree[now].l, x);
return ;
}
5.检查平衡 & 拍扁重构-(一堆函数)
5.1 检验单棵树是否平衡-Isimbalance
如果一棵树不平衡有以下两种可能(取 \(alpha = 0.75\)):
- 左右子树当中某棵子树的节点个数超过这棵子树节点个数的 75%。
- 被删除节点超过这棵子树节点个数的 30%。
代码:
bool Isimbalance(int x)//可能有点长,请见谅
{
if (max(tree[tree[x].l].size, tree[tree[x].r].size) > tree[x].size * alpha || tree[x].size - tree[x].fact > tree[x].size * 0.3) return 1;
// 取出左右子树中较大的节点数 超过 75% (alpha = 0.75) 被删除节点超过 30% 返回 1
return 0;
}
5.2 检查一条路径上的点是否平衡-check & 更新函数 update
设当前节点为 \(now\),终点为 \(end\),那么我们采取从上往下递归的形式检查是否平衡。
注意重构完之后要更新重构之后路径上的所有节点。
代码:
void update(int now, int end)
{
if (!now) return ;//叶子节点,返回
if (tree[end].val >= tree[now].val) update(tree[now].r, end);//继续更新
else update(tree[now].l, end);
tree[now].size = tree[tree[now].l].size + tree[tree[now].r].size + 1;//更新 size
tree[now].fact = tree[tree[now].l].fact + tree[tree[now].r].fact + tree[now].exist;//更新 fact,注意判断当前节点是否存在
}
void check(int &now, int end)
{
if (now == end) return ;//终点,返回
if (Isimbalance(now)) {rebuild(now); update(root, now); return ;}//不平衡就重构
if (tree[now].val > tree[end].val) check(tree[now].l, end);//二叉查找树法检查
else check(tree[now].r, end);
}
5.3 重构函数-rebuild
先得到中序遍历,然后判断是否为空树(如果子树被删光了?),不是就重构。我用 vector 存中序遍历。
代码:
void rebuild(int &now)//注意 now 是引用
{
v.clear(); Get_ldr(now);//得到中序遍历
if (v.empty()) {now = 0; return ;}//空树不需要重构
lift(0, v.size() - 1, now);//重构子树
}
5.4 中序遍历-Get_ldr
树结构基础操作。唯一需要注意只有节点存在才能加入中序遍历。这样同时真正的删除了节点。
代码:
void Get_ldr(int now)
{
if (!now) return ;
Get_ldr(tree[now].l);
if (tree[now].exist) v.push_back(now);//注意判断是否存在
Get_ldr(tree[now].r);
}
5.5 重新建立子树-lift
按照最开始说的方法建立即可。不过细节有一点多,需要注意。
void lift(int l, int r, int &now)//l,r 是中序遍历的区间,now 是节点编号,注意 now 是引用
{
if (l == r)//到头了
{
now = v[l];//取出编号
tree[now].l = tree[now].r = 0;
tree[now].size = tree[now].fact = 1;//重新创建节点
return ;
}
int mid = (l + r) >> 1;
while (mid && l < mid && tree[v[mid - 1]].val == tree[v[mid]].val) mid--;
now = v[mid];//这里需要注意,替罪羊树中所有相同的节点是在右子树的
if (l < mid) lift(l, mid - 1, tree[now].l);
else tree[now].l = 0;//提左边
lift(mid + 1, r, tree[now].r);//考虑到 >>1 是向 0 取整,因此右边一定有没有跑完的区间
tree[now].size = tree[tree[now].l].size + tree[tree[now].r].size + 1;
tree[now].fact = tree[tree[now].l].fact + tree[tree[now].r].fact + 1;//更新,由于保证每个节点都存在,所以 fact 可以和 size 一样更新
}
6.找 x 的排名(Find_Rank) & 找第 k 大(Find_kth)
跟二叉查找树一个搞法,还是要注意判断节点是否存在。
代码:
int Find_Rank(int x)
{
int o = root, ans = 1;
while (o)
{
if (x <= tree[o].val) o = tree[o].l;
else {ans += tree[tree[o].l].fact + tree[o].exist; o = tree[o].r;}//注意判断节点是否存在
}
return ans;
}
int Find_kth(int x)
{
int o = root;
while (o)
{
if (tree[o].exist && tree[tree[o].l].fact + tree[o].exist == x) return tree[o].val;//注意判断节点是否存在
if (tree[tree[o].l].fact < x) {x -= tree[tree[o].l].fact + tree[o].exist; o = tree[o].r;}//同上
else o = tree[o].l;
}
}
7.找前驱(Find_pre) & 找后继(Find_aft)
因为作者比较懒,所以作者直接使用 Find_kth(Find_Rank(x) - 1)
和 Find_kth(Find_Rank(x + 1))
代替了。注意括号不要搞错。代码不给了。
8.最后的代码
限于篇幅问题,完整代码请在 这里(on github) 查看。
3.无旋平衡树-FHQ Treap
FHQ Treap 好啊!
码量小,时间快,常数小,可持久化,理解简单,支持树套树等等······有“平衡树双子星”的美称(另一棵是 Splay)。
那么我们看看 FHQ Treap 又有什么操作。不过在这之前你需要了解一下 Treap。
1.思路
1.1 前置知识-Treap
Treap者,Tree + Heap 也。是 BST 和堆的结合体。
我们知道 BST 在随机数据下表现良好,那么 Treap 正是利用了这一点的性质,来使得 BST 趋近平衡。
当然 Treap 是一种有旋平衡树,不过我们重点是讲 FHQ Treap,因此只要知道 Treap 的思路就好。
Treap 的核心思路就是:对每一个节点 随机 分配一个 Key 值,使得在 BST 中 val 满足 BST 的性质,而 Key 满足堆的性质(至于是大根堆还是小根堆随意)。
那么这样,BST 就能比较平衡 (我不会证明,不过用着就行了)。
当然如果这样你的 BST 还是不平衡那只能说明你的运气不好。
那么知道了这些,我们看看 FHQ Treap 又是怎么一回事。
1.2 思路-FHQ Treap
FHQ Treap 是由神犇 fhq 发明的一种一种数据结构,基于 Treap,而 FHQ Treap 的核心操作只有两个:分裂(Split)和合并(merge)。
在知道分裂(Split)和合并(merge)之后,你就可以玩转 FHQ Treap了。
2.一些基础函数
新建节点:
int Make_Node(int x)
{
++cnt;//不想写引用了qwq
tree[cnt].size = 1;
tree[cnt].val = x;
tree[cnt].key = rand();//随机赋予 Key 值
return cnt;
}
void update(int x)
{
tree[x].size = tree[tree[x].l].size + tree[tree[x].r].size + 1;
}//更新
3.分裂-Split
分裂有两种形式:按值分裂和按大小分裂。
在题目当中使用哪种方法是不固定的,我们需要根据题目灵活应变。
3.1 按值分裂
比如现在有这样一棵 Treap,节点上蓝色的是 val,绿色的是 Key。
现在我们按值 17 分裂。
按值分裂的标准是:将所有小于等于 17 的节点分裂成一棵 FHQ Treap,将所有大于 17 的节点分裂成一棵 FHQ Treap。
分裂之后的树如下。
那么我们如何分裂呢?
首先看根节点。\(val = 19 > 17\),根据 BST 的性质,当前节点及其右子树的 \(val\) 值都大于 17,直接将当前节点以及右子树裂开,同时往左子树查找有没有大于 17 的点(注意:虽然当前节点左儿子 \(val = 16 < 17\),但是还有一个叶子节点 \(val = 18\)),将其作为当前节点的左儿子。
然后看其左儿子。\(val = 16 < 17\),根据 BST 的性质,当前节点及其左子树的 \(val\) 值都小于 17,直接将当前节点以及左子树裂开,同时往右子树查找有没有小于 17 的点(注意:虽然当前节点右儿子 \(val = 18 > 17\),但是下面可以在一开始的时候接一个 \(val = 15\) 的点),将其作为当前节点的右孩子。
其实从上面的话你就可以看出来,FHQ Treap 是复读机式的操作。
那么如何确定根节点呢?将第一个分裂的节点作为根节点。
代码:
void split(int now, int val, int &x, int &y)//好吧还是写了引用
{
if (!now) x = y = 0;//叶子节点
else
{
if (tree[now].val <= val)
{
x = now;//分裂左子树
split(tree[now].r, val, tree[now].r, y);//找右子树
}
else
{
y = now;//分裂右子树
split(tree[now].l, val, x, tree[now].l);//找左子树
}
update(now);//不要忘记更新
}
}
3.2 按大小分裂
前面说了按值分裂,接下来我们看看如何按大小分裂。
按大小分裂的标准是:将前 \(k\) 小分裂到一棵树上,将剩余节点分裂到另一棵树上。
比如上面这棵树,如果我们按照大小 2 分裂,那么最后 13,16 这两个节点就会在一棵树内。
而按大小分裂的代码与按值分裂的代码惊人搬的相似。
代码:
void split(int now, int k, int &x, int &y)
{
if (!now) x = y = 0;//叶子节点
else
{
if (tree[tree[now].l].size + 1 <= k)//往右子树找
{
x = now;
split(tree[now].r, k - tree[tree[now].l].size + 1, tree[now].r, y);
}
else//往左子树找
{
y = now;
split(tree[now].l, k, x, tree[now].l);
}
update(now);
}
}
4.合并-merge
将上面的操作倒过来就是合并了qwq,是不是很简单。
围观群众:你是不是在逗我。
作者:我好好讲,我好好讲。
实际上你会发现上面两幅图倒换一下就是合并了qwq,那么合并的时候对于两棵树,我们按照其 Key 值合并,而且在合并的时候需要保证前面这棵子树的 \(val_{max}\) 小于等于后面这棵子树的 \(val_{min}\)。
围观群众:这跟最前面的话不是没区别吗qwq
我们从两棵子树的根节点出发,每一次我们对比两个的 Key 值,按照 Key 的大小合并(至于是大根堆还是小根堆随意),然后不断往下找即可。
代码:
int merge(int x, int y)//不想写引用qwq
{
if (!x || !y) return x + y;//其中一棵子树到了叶子节点
if (tree[x].key > tree[y].key)//按照 Key 值合并
{
tree[x].r = merge(tree[x].r, y);//继续合并,将 x 往下走一层
update(x); return x;//不要忘记更新
}
else
{
tree[y].l = merge(x, tree[y].l);//继续合并,将 y 往下走一层
update(y); return y;//不要忘记更新
}
}
5.插入(Insert) & 删除(Delete)
插入:先按照插入值 \(val\) 分裂成 \(x,y\) 两棵树,然后新建一个节点,再合并回去即可。
删除:先按照删除值 \(val\) 分裂成 \(x,z\) 两棵树,然后再按照删除值 \(val - 1\) 将 \(x\) 分裂成 \(x',y\) 两棵树,合并 \(y\) 的左右子树(相当于自动删除根节点)为 \(y'\),最后合并 \(x',y',z\)。
代码:
void Insert(int val)
{
int x, y;
split(root, val, x, y);
root = merge(merge(x, Make_Node(val)), y);
}
void Delete(int val)
{
int x, y, z;
split(root, val, x, z); split(x, val - 1, x, y);
y = merge(tree[y].l, tree[y].r); root = merge(merge(x, y), z);
}
6.其他操作
6.1 找 x 的排名(Find_Rank_of_x) & 找第 k 大(Find_xth)
找 x 的排名:按照 \(val-1\) 分裂成 \(x,y\) ,输出 \(size(x) + 1\),然后再合并回去。
找第 k 大:替罪羊树怎么搞我们就怎么搞。
代码:
void Find_Rank_of_x(int val)
{
int x, y;
split(root, val - 1, x, y);
printf("%d\n", tree[x].size + 1);
root = merge(x, y);
}
void Find_xth(int val)
{
int now = root;
while (now)
{
if (tree[tree[now].l].size + 1 == val) break;
if (tree[tree[now].l].size >= val) now = tree[now].l;
else {val -= tree[tree[now].l].size + 1; now = tree[now].r;}
}
printf("%d\n", tree[now].val);
}
6.2 找前驱(Find_pre) & 找后继(Find_aft)
找前驱:按照 \(val - 1\) 分裂成 \(x,y\) 两棵树,在 \(x\) 内找最大值输出,然后合并。
找后继:按照 \(val\) 分裂成 \(x,y\) 两棵树,在 \(y\) 内找最小值输出,然后合并。
代码:
void Find_pre(int val)
{
int x, y;
split(root, val - 1, x, y);//分裂
int now = x;
while (tree[now].r) now = tree[now].r;//找最大值
printf("%d\n", tree[now].val);
root = merge(x, y);//不要忘记合并
}
void Find_aft(int val)
{
int x, y;
split(root, val, x, y);//分裂
int now = y;
while (tree[now].l) now = tree[now].l;//找最小值
printf("%d\n", tree[now].val);
root = merge(x, y);//不要忘记合并
}
7.最后的代码
限于篇幅问题,完整代码请在 这里(on github) 查看。
4.总结
在这一片博文中,我们学习了替罪羊树,FHQ Treap这两种无旋平衡树,其中 FHQ Treap 一定要重点掌握,在后面的代码中很多都是用 FHQ Treap写的;但是替罪羊树也要熟练,因为以后也有用得到的地方。
这里再放一下这两种无旋平衡树的主要思路:
- 替罪羊树:采取懒删除,哪棵子树不平衡就拍扁重构哪棵子树。
- FHQ Treap:使用 Split 和 Merge 函数来实现各种操作。
接下来,在 数据结构专题-学习笔记:有旋平衡树(AVL 树,Splay) 中,将会详细讲解有旋平衡树:AVL 树,Splay树,顺便会根据自己写的代码列一张表格做个对比。