数据结构 | 平衡树 | FHQTreap 函数式 平衡树:学习笔记
FHQTreap:函数式平衡树
FHQTreap 是一种特殊的平衡树( Invented By FHQ ),不同于普通平衡树 Splay 的是,它无需旋转操作(Rotate)就可以达到 Splay 的速度,而是用两个基本操作实现序列操作:Merge 和 Split
从名字 Split 就可以发现,它的核心思想是将一颗树分裂,使之两个部分满足堆序,在上面操作之后再合并为一棵树
FHQTreap 的本质还是 BST ,所以区间操作和 Splay 没有多大区别(经过优化后好像还比Splay跑得快?)
基础操作:Split (分裂)
Split 分裂 是按照某种规定将当前的树分成两棵子树的操作(当前的树也有可能是分裂之后的树,因此操作具有递归性),分裂操作可以按照给定权值分,也可以按照子树大小分
按照权值分裂
将当前树分裂成权值均小于等于 X 的一棵树 A 和权值均大于 X 的一棵树 B
A,B 的意义为分裂后两棵树的根结点,需要实时更新,引用传参
空树将会被拆分成 2 棵空树,即A=B=0(边界),然后:
如果正在考虑的点 K 权值比 X 大,说明 K 的右子树的权值均比 X 大,K 的右子树放到 B 中,同时走 K 的左子树,把左子树中所有权值大于 X 的点分到 B 中,剩下的点分到 A 中
如果正在考虑的点 K 权值比 X 小,说明 K 的左子树的权值均比 X 小,K 的左子树放到 A 中,同时走 K 的右子树,把右子树中所有权值小于 X 的点分到 A 中,剩下的点分到 B 中
1 | void SplitVal(int k,int tar,int &x,int &y){ // 按权值分裂 |
按照排名分裂
基本思想和上面的 按照权值分裂 一致,判断条件从权值改为了子树的大小 Size(K)
这里注意,如果要走当前点的右子树,目标大小应该减去它左子树大小加上他自身的 1 ,和 Kth 的思想相似
1 | void SplitRnk(int k,int tar,int &x,int &y){ // 按排名分裂 |
pushup(K) 是更新 K 的子树大小,跟线段树的 pushup 相似
基础操作:Merge (合并)
Merge 合并 是把当前分开的两棵子树合并成一颗新的树,函数需要返回合并之后的根结点,合并时需要按照堆序,使得随机值较小的结点作为新树的根,假设合并前有两棵树 A,B,A 中的所有元素都小于B 中的元素,则:
- 若 A 树的根成为了新树的根,则 B 树不会对 A 树的左子树造成影响,递归合并 A 的右子树和 B 即可
- 若 B 树的根成为了新树的根,则 A 树不会对 B 树的右子树造成影响,递归合并 B 的左子树和 A 即可
边界条件:若 A,B 中有空树,那么以不是空树的结点作为新树的根结点,此时无需再往下递归
1 | int merge(int x,int y){ |
实际使用时要用 root 来维护当前树的根结点
区间操作:插入(Insert)
Insert 插入 是把新的值放入当前的 FHQTreap 中,依靠 Split 和 Merge 实现插入操作,我们只需要将当前的树按照插入的权值 X 分裂成两棵树,此时左边的 A 树权值均小于等于 X,右边的 B 树权值均大于 X,把 A,X,B 按顺序合并,插入就完成了,举个例子:
插入之前的树,需要插入的值为 5
graph TD 4---2 4---7 7---6 7---8 2---1 2---3
一次分裂之后:
graph TD 4---2 4---7 7---6 7---8 2---1 2---3
合并之后:
graph TD 4---2 4---7 7---6 7---8 2---1 2---3
1 | inline void Insert(int N){ |
区间操作:第 K 小(Kth)
Kth 第 K 小 是寻找当前序列中升序排列后第 K 位的数,这里的第 K 小返回的是这个数的结点编号,与 Splay 以及 BST 的思路完全一致,就不多说了
1 | int kth(int k,int tar){ |
区间操作:排名问题(Rank)
Rank 排名问题 是指在区间中询问某一个数的升序排列后位置(确保存在),和询问排名为 K 的数(升序排列),通常与 Kth 合起来使用,思路同 BST
查询某数的排名
将原树按照给定数 N 的值减 1 分为 X,Y 两棵树,然后左边的 X 树的大小就是处在 N 前面的数的个数,加上 N 本身,就是他的排名
1 | int GetRnk(int N){ |
查询第 K 名的数
直接 Kth 解决问题
1 | inline int GetRanK(int N){ |
元素操作:前驱与后继(Precursor & Successor)
元素的前驱(Precursor)
元素的前驱定义为 小于等于此元素的元素组成的集合中最大的一个,要求一个元素的前驱,只需将整颗树按照 N-1 进行分裂(N是元素的值),然后在左数中找最大值即可
1 | int GetPre(int N){ |
元素的后继(Successor)
元素的后继定义为 大于此元素的元素组成的集合中最小的一个,要求一个元素的后继,只需将整颗树按照 N 进行分裂(N是元素的值),然后在右树中找最小值即可
1 | int GetSuc(int N){ |
元素操作:元素 N 的出现与计数(Exist & Count)
元素 N 是否出现(Exist)
判断给定的元素 N 是否存在,按照中序遍历整棵树即可
由于 FHQTreap 本质是一颗 BST ,符合 BST 的特征,所以若 N 小于等于当前结点的值,N 一定在其左子树,否则 N 只能位于其右子树
1 | inline bool ExistK(int k,int N){ |
小于等于 N 的数的个数(Count)
这里的 Count 与前面的 Kth 和 GetRnk 不同的是,N 可以不是当前序列中的数,按照 BST 的性质,如果当前结点 K 的值小于等于 N, 那么 K 的左子树一定全部小于等于 N ,把这部分加上后再去其右子树递归寻找,否则只用去 K 的左子树递归寻找(K 的右子树一定都比 N 大)
1 | inline int CntK(int k,int N){ |
扩展操作:垃圾回收(Recycle)
Recycle 垃圾回收 是对那些毒瘤数据,会让平衡树频繁删点和建点设计的优化算法,可以在一定程度上节省一些空间
利用 DUST 数组存储已经删除的点,下次新建时优先去 DUST 里寻找已经删除的点
1 | inline int New(int x){ |
扩展操作:延迟标记(Tag)
Tag 延迟标记 和线段树的 LazyTag 懒标记 相似,若操作多次能造成结果的周期性变化,在 Tag 中保存操作的次数而不用马上进行操作,可以降低时间复杂度,一个例子:
定义操作 SegReverse(L,R) 表示将区间 (L,R) 内的数字全部头尾翻转,给定原序列,输出经过 n 次给定操作后的序列
上面的翻转操作显然具有周期性,任何区间翻转偶数次之后不会发生变化,利用延迟标记降低操作的时间复杂度
1 | inline void pushdown(int k){ |
输出序列时要用到中序遍历
1 | void VisDfs(int k){ |
扩展操作:启发式合并(Enlightenment Merge)
Enlightenment Merge 启发式合并 是并查集里面的一个思想,即:每次合并时,将元素少的集合合并到元素多的集合,这样的合并在树中可以减少两棵树合并后对于原树的影响,从某种玄学的角度来说,能降低后面遍历的时间复杂度,对于多根树的合并操作,启发式合并比随机合并能提高后面遍历的效率,核心代码:
1 | inline int MultiUnion(int x,int y){ |
下面是一些例题:
题目 | 用到的操作 |
---|---|
【培训题】平衡树[1] | Split,Merge,Insert,Precursor,Successor,Exist,Rank,Eliminate |
【培训题】平衡树[2] | Split,Merge,Insert,Kth,Exist,Rank,Count,Eliminate |
【模板】文艺平衡树 | Split,Merge,Insert,Eliminate,Tag |
HNOI2012 永无乡 | Split,Merge,Insert,Eliminate,Enlightenment Merge |
代码整合:
1 | struct FHQTreap{ |