前言:
教练讲旋转的时候摸鱼去了,然后就不会旋转操作了T_T,那怎么办呢,要做题的啊,诶,替罪羊树好像是不用旋转的诶qwq,就它了。
替罪羊树这样直接讲不直观,还是看题来讲吧。
概念/思想
替罪羊树属于平衡树的一种,但是他维护平衡的方式不是复杂的旋转,而是直接把这棵子树直接掰下来拍扁重建,再放回去就okk了所以暴力天下第一啊。
由于拍扁重建用的是二分,所以建树的复杂度最多为为(O(logn)),具查找的时候也是平衡的,复杂度也就为(O(logn))了,还是很快的。
实现:
- 变量名的定义/含义:
struct node{
int ls , rs , tsize/*真正的大小,包括删除的*/ , fsize/*只有真正存在的点的大小*/ , date/*值*/ , f/*是否存在*/;
};
node a[400040];
int root/*根节点*/ , pool/*内存池的指针*/ , poi/*存储中序遍历后的数组的指针*/ , n;
double alpha = 0.75;
int memo[400040]/*内存池*/ , c[400040]/*存储中序遍历后的数*/;
内存池:因为动态开点会很慢,不如提前申请空间,要用的时候从里面取就是了。注意!因为我们程序里面的树层序遍历的点并不是有序的,所以这样给点是没问题的(就像有可能节点(3)的儿子可能是(6)和(11),而父亲又为(33))。
而为什么说(tsize)和(fsize)分为包括了删除了的和不包括删除了的呢?因为替罪羊树是惰性删除,只是打个标记而已,真正的删除在重建的时候。
- 核心操作:判断是否拍扁
这里对于一棵树是否需要拍扁,我们用到一个东西,叫平衡因子,当这个树总的大小乘一个平衡因子的时候,还没有其中大的那个儿子的子树大时,就重建。平衡因子一般取(0.5)~(1),但是由于太小的话,拍扁次数就太多了,太大的话,拍扁次数太少了,都不行,所以一般是去(0.7)~(0.8),我一般用(0.75)。
bool cheak(int now){ //判断是否重建
if((double)a[now].fsize * alpha >= (double)max(a[a[now].ls].fsize , a[a[now].rs].fsize)) return true;
return false;
}
- 插入:
这个就跟普通的二叉查找树一样的了,只不过插完后要判断一下是否重建。
void insert(int &now , int t){ //插入数t
if(!now){
now = memo[pool--]; //从内存池偷一个节点
a[now].date = t;
a[now].f = a[now].fsize = a[now].tsize = 1;
a[now].ls = a[now].rs = 0;
return;
}
a[now].fsize++ , a[now].tsize++; //下面会更新一个节点,所以上面的每一个节点都要加一
if(a[now].date >= t) insert(a[now].ls , t);
else insert(a[now].rs , t);
if(!cheak(now)) rebuild(now); //插入一个点后可能会不平衡的
}
- 建树/重建:
当我们的需要重建时,这颗树也一定是一颗二叉查找树,所以中序遍历一定是有序的,所以我们只需要进行一次中序遍历,然后对这个序列,从中间开始往两边二分进行建树(如果这里不是很懂的话可以先看看代码,然后手推一下,为什么对于一个有序序列从中间开始建树是平衡的)。
void dfs(int now){ //中序遍历
if(!now) return;
dfs(a[now].ls);
if(a[now].f) c[++poi] = now;
else memo[++pool] = now; //在这里真正删除点 ,然后把这个点丢回内存池
dfs(a[now].rs);
}
void build(int l , int r , int &now){ //建树
int mid = (l + r) / 2;
now = c[mid];
if(l == r){ //叶子节点
a[now].ls = a[now].rs = 0;
a[now].fsize = a[now].tsize = 1;
return;
}
if(l < mid) build(l , mid - 1 , a[now].ls); //在l=mid的时候再进行就会出现l>r的情况,直接令他左儿子为0就可以了
else a[now].ls = 0;
build(mid + 1 , r , a[now].rs); //因为除法总是向下取整,所以mid总是小于r的,所以不会出现l>r的情况
a[now].fsize = a[a[now].ls].fsize + a[a[now].rs].fsize + 1; //更新
a[now].tsize = a[a[now].ls].tsize + a[a[now].rs].tsize + 1;
}
void rebuild(int &now){ //重建
poi = 0; //记得清0!
dfs(now);
if(poi) build(1 , poi , now); //有可能这一颗树都是被过惰性过标记的,所以要判断一下是否有节点
else now = 0;
}
- 查找排名为rk的数:
这个也跟二叉查找树一样的,不断判断,然后看是否到达即可。注意判断的那里因为惰性标记所以不一样(具体为什么可以看下面的delet删除函数)。
int ft(int rk){ //查找排名为rk的数
int now = root;
while(now){ //递推查找(也可以递归的)
if(a[now].f && a[a[now].ls].fsize + 1 == rk) return a[now].date;
else if(a[a[now].ls].fsize + a[now].f >= rk) now = a[now].ls;
else{
rk -= a[a[now].ls].fsize + a[now].f;
now = a[now].rs;
}
}
}
- 查找数t的排名:
也跟二叉查找数一样,注意下对排名的统计是每次走右子树时累加左子树的个数(具体为什么还是看delet删除函数)。
int frk(int t){
int now = root , ans = 1;
while(now){ //递推查找(也可以递归的,只不过要重新写一个函数,麻烦点)
if(a[now].date >= t) now = a[now].ls;
else{
ans += a[a[now].ls].fsize + a[now].f;
now = a[now].rs;
}
}
return ans;
}
- 删除排名为rk的数/删除数k:
这里的删除只是打上标记而已,具体看代码吧~(上面的问题也都在代码里面)
void delet(int &now , int rk){ //删除排名为rk的数
if(a[now].f && a[a[now].ls].fsize + 1 == rk){
/*解释一下不是这样写的:a[a[now].ls].fsize + a[now].f == rk
因为有可能这我们走的这一条路有很多被删除了点(只打了标记),但是a[a[now].ls].fsize的值有可能为rk,
实际上是还没有到达那个该删除的点的 ,
所以一个先判断一下这个点先是否存在,再进行删除 */
a[now].f = 0; //惰性删除,只是打个标记,在中序遍历时才真正删除
a[now].fsize--;
return;
}
a[now].fsize--; //跟insert那个操作很像,下面删掉一个节点,所以一个将fsize减一,但是tsize不用减
if(a[a[now].ls].fsize + a[now].f >= rk) delet(a[now].ls , rk);
else delet(a[now].rs , rk - a[a[now].ls].fsize - a[now].f);
/*解释一下为什么rk要减去左子树和根节点
因为我们来到的是一颗新的子树,要减去左边的排名才可以进入新的子树,相当于把树不断变小,排名也不断变小了
但是左子树为什么不用减呢?
因为我们某个点的排名应该是从这个点往右子树走时累加左子树之和,所以这里是不能累加排名的(可以看下ft和frk函数里面的操作)
这个地方可能很不好理解,可以自己画图模拟一下,会好很多
这可能就是只可意会不可言传吧~*/
}
void rdelet(int t){ //删除值为t的数
delet(root , frk(t));
if((double)a[root].tsize * alpha >= a[root].fsize) rebuild(root); //删除太多了,惰性标记的节点太多也会降低查找速度
}
上完整代码:
#include <bits/stdc++.h>
using namespace std;
struct node{
int ls , rs , tsize/*真正的大小,包括未删除的*/ , fsize/*只有真正存在的点的大小*/ , date/*值*/ , f/*是否存在*/;
};
node a[400040];
int root/*根节点*/ , pool/*内存池的指针*/ , poi/*存储中序遍历后的数组的指针*/ , n;
double alpha = 0.75;
int memo[400040]/*内存池*/ , c[400040]/*存储中序遍历后的数*/;
bool cheak(int now){ //判断是否重建
if((double)a[now].fsize * alpha >= (double)max(a[a[now].ls].fsize , a[a[now].rs].fsize)) return true;
return false;
}
void dfs(int now){ //中序遍历
if(!now) return;
dfs(a[now].ls);
if(a[now].f) c[++poi] = now;
else memo[++pool] = now; //在这里真正删除点 ,然后把这个点丢回内存池
dfs(a[now].rs);
}
void build(int l , int r , int &now){ //建树
int mid = (l + r) / 2;
now = c[mid];
if(l == r){ //叶子节点
a[now].ls = a[now].rs = 0;
a[now].fsize = a[now].tsize = 1;
return;
}
if(l < mid) build(l , mid - 1 , a[now].ls); //在l=mid的时候再进行就会出现l>r的情况,直接令他左儿子为0就可以了
else a[now].ls = 0;
build(mid + 1 , r , a[now].rs); //因为除法总是向下取整,所以mid总是小于r的,所以不会出现l>r的情况
a[now].fsize = a[a[now].ls].fsize + a[a[now].rs].fsize + 1; //更新
a[now].tsize = a[a[now].ls].tsize + a[a[now].rs].tsize + 1;
}
void rebuild(int &now){ //重建
poi = 0; //记得清0!
dfs(now);
if(poi) build(1 , poi , now); //有可能这一颗树都是被过标记的,所以要判断一下是否有节点
else now = 0;
}
void insert(int &now , int t){ //插入数t
if(!now){
now = memo[pool--]; //从内存池偷一个节点
a[now].date = t;
a[now].f = a[now].fsize = a[now].tsize = 1;
a[now].ls = a[now].rs = 0;
return;
}
a[now].fsize++ , a[now].tsize++; //下面会更新一个节点,所以上面的每一个节点都要加一
if(a[now].date >= t) insert(a[now].ls , t);
else insert(a[now].rs , t);
if(!cheak(now)) rebuild(now); //插入一个点后可能会不平衡的
}
void delet(int &now , int rk){ //删除排名为rk的数
if(a[now].f && a[a[now].ls].fsize + 1 == rk){
/*解释一下不是这样写的:a[a[now].ls].fsize + a[now].f == rk
因为有可能这我们走的这一条路有很多被删除了点(只打了标记),但是a[a[now].ls].fsize的值有可能为rk,
实际上是还没有到达那个该删除的点的 ,
所以一个先判断一下这个点先是否存在,再进行删除 */
a[now].f = 0; //惰性删除,只是打个标记,在中序遍历时才真正删除
a[now].fsize--;
return;
}
a[now].fsize--; //跟insert那个操作很像,下面删掉一个节点,所以一个将fsize减一,但是tsize不用减
if(a[a[now].ls].fsize + a[now].f >= rk) delet(a[now].ls , rk);
else delet(a[now].rs , rk - a[a[now].ls].fsize - a[now].f);
/*解释一下为什么rk要减去左子树和根节点
因为我们来到的是一颗新的子树,要减去左边的排名才可以进入新的子树,相当于把树不断变小,排名也不断变小了
但是左子树为什么不用减呢?
因为我们某个点的排名应该是从这个点往右子树走时累加左子树之和,所以这里是不能累加排名的(可以看下ft和frk函数里面的操作)
这个地方可能很不好理解,可以自己画图模拟一下,会好很多
这可能就是只可意会不可言传吧~*/
}
int ft(int rk){ //查找排名为rk的数
int now = root;
while(now){ //递推查找(也可以递归的)
if(a[now].f && a[a[now].ls].fsize + 1 == rk) return a[now].date;
else if(a[a[now].ls].fsize >= rk) now = a[now].ls;
else{
rk -= a[a[now].ls].fsize + a[now].f;
now = a[now].rs;
}
}
}
int frk(int t){
int now = root , ans = 1;
while(now){ //递推查找(也可以递归的)
if(a[now].date >= t) now = a[now].ls;
else{
ans += a[a[now].ls].fsize + a[now].f;
now = a[now].rs;
}
}
return ans;
}
void rdelet(int t){ //删除值为t的数
delet(root , frk(t));
if((double)a[root].tsize * alpha >= a[root].fsize) rebuild(root); //删除太多了,惰性标记的节点太多也会降低查找速度
}
int main(){
cin >> n;
for(int i = 400000; i >= 1; i--) memo[++pool] = i; //预处理内存池
while(n--){
int opt , x;
cin >> opt >> x;
if(opt == 1) insert(root , x);
if(opt == 2) rdelet(x);
if(opt == 3) cout << frk(x) << endl;
if(opt == 4) cout << ft(x) << endl;
if(opt == 5) cout << ft(frk(x) - 1) << endl; //根据程序和题目,自己可以想下为什么输出前驱和后继的方式不一样
if(opt == 6) cout << ft(frk(x + 1)) << endl;
/*解释为什么驱和后继的方式不一样:
因为有可能有多个一样的数,查找后继的时候加一就可以找到后面的第一个比他大的数
即使这个数字不存在,由于我们程序编程的方式,也是可以找到正确的数哒
然后找前驱的时候不能ft(frk(x - 1)),因为这个数字如果不存在的话,找到的就是x - 1后面一个数,也就是原数*/
}
return 0;
}
个人认为替罪羊树比其他的平衡树更好写和好用(只是不支持区间操作qwq),时间复杂度也不高,算是比较良心的了。