爆炸的平衡树, 替罪羊树
由于Defad不太喜欢旋转, 所以一般用替罪羊树. 这里写个博客介绍一下.
什么是二叉搜索树
可以维护一个集合, 相比于权值线段树 (动态开点) 的时间复杂度 \(\log{N}\) 空间复杂度 \(N \log{N}\), 二叉搜索树理论上来说只需要 \(\log{N}\) 的时间复杂度 (最坏是 \(N\)), 但是空间复杂度可以达到 \(N\).
这里不过多胡扯, 只能说普通二叉搜索树时间复杂度期望 \(\log{N}\) 最坏是 \(N\).
权值数据结构水各种题
什么是替罪羊树
考虑优化刚才的二叉搜索树, 可以带着标题里的 "爆炸" 进行考虑.
替罪羊树的想法是, 当有一个结点左子树和右子树差距过大, 可以炸掉这个结点的子树, 然后重新构造.
记录子树大小
我们每个结点用 \(3\) 个变量记录子树大小, 分别维护元素数, 未删除结点数, 总结点数.
这么说似乎有点抽象, 就是说,
- \(sz0_{p}\) 维护这个子树里面有多少元素 (当然, 如果不是可重集就不用这个, 因为重复就是删除).
- \(sz1_{p}\) 维护这个子树里有多少结点还没有被删除, 被删除的不计.
- \(sz2_{p}\) 维护这个子树里面实际有多少结点, 被删除的也要记录.
结点 \(p\) 的值被删除时我们仅给了 \(cnt_{p} := \max(cnt_{p} - 1, 0)\), 炸子树时就无需打印了.
void push_up(int p){tr[p].sz0=tr[tr[p].ls].sz0+tr[tr[p].rs].sz0+tr[p].cnt;tr[p].sz1=tr[tr[p].ls].sz1+tr[tr[p].rs].sz1+(tr[p].cnt?1:0);tr[p].sz2=tr[tr[p].ls].sz2+tr[tr[p].rs].sz2+1;
}
爆炸
我们先不考虑怎么判断平衡, 考虑如何炸掉一个结点及其子树.
二叉搜索树的中序遍历是单调的, 那么我们可以打印中序遍历到一个数组里, 这里我们选择记录下标, 就不用记录值和次数, 然后申请很多结点去构造子树了, 只需要更改左右儿子指针和子树大小即可.
void squib(int p){if(p==0){return;}squib(tr[p].ls);if(tr[p].cnt){g[++cntg]=p;}squib(tr[p].rs);
}
这个函数在 debug 的时候可以直接炸掉根 \(rt\) 然后不重构, 然后挨个输出 \(i \in [1, cntg]\) 的 \(val_{g_{i}}\).
void print(){squib(rt);f1(i,1,cntg,1){cout<<tr[g[i]].val<<" \n"[i==cntg];}
}
愣着干什么, 重构啊
毕竟都炸完了, 重构吧.
重构基本和线段树建树一样, 区别仅仅是当前结点是 \(mid\), 然后左子树只是 \([1, mid - 1]\) 了.
虽然我讲线段树也没说过建树, 当时说调用 \(N\) 次修改即可.
线段树, 算法竞赛掌管区间的神
我再说一遍, 如果我在参数里写了指针, 那么我的建议还是传引用,
int &p
后面就不需要解引用了.
void build(int *p,int l,int r){if(l>r){*p=0;return;}if(l==r){tr[g[l]].ls=tr[g[l]].rs=0;push_up(g[l]);*p=g[l];return;}int m=l+r>>1;build(&tr[g[m]].ls,l,m-1);build(&tr[g[m]].rs,m+1,r);push_up(g[m]);*p=g[m];
}
void rebuild(int *p){cntg=0;squib(*p);build(p,1,cntg);
}
什么情况下就不够平衡, 需要重构呢?
首先, 每次插入元素和删除元素就有可能不平衡, 而查询 \(k\) th 和查询排名并不对树产生修改 (前驱后继都是用这个做的), 所以不可能需要重构.
考虑完什么时候有可能重构, 那么考虑在什么情况下重构.
替罪羊树考虑的是, 引入一个平衡因子 \(\alpha\), 在不满足 \(\alpha\) 的条件时重构子树.
又臭又长, 但是比旋转好记多了, 也不害怕写挂, 反正写挂最多写成普通二叉搜索树.
int check(int p){return tr[p].sz0&&(tr[p].sz2*alpha<=max(tr[tr[p].ls].sz2,tr[tr[p].rs].sz2)||alpha*tr[p].sz2>=tr[p].sz1);
}
然后在我们的插入元素和删除元素的递归的最后加上这个.
if(check(*p)){rebuild(p);
}
插入元素和删除元素
普通的二叉搜索树插入, 删除的时候给结点的 \(cnt_{p} := \max(cnt_{p} - 1, 0)\), 爆炸时如果 \(cnt_{p} = 0\) 则不打印.
if
和 else if
和 else
千万不能乱.
void push(int *p,int k){if(*p==0){*p=++cntt;tr[*p].val=k;tr[*p].cnt=1;push_up(*p);return;}else if(k==tr[*p].val){tr[*p].cnt++;}else if(k<tr[*p].val){push(&tr[*p].ls,k);}else{push(&tr[*p].rs,k);}push_up(*p);if(check(*p)){rebuild(p);}
}
void pop(int *p,int k){if(*p==0){return;}if(k<tr[*p].val){pop(&tr[*p].ls,k);}else if(k==tr[*p].val){tr[*p].cnt=max(tr[*p].cnt-1,0);}else{pop(&tr[*p].rs,k);}push_up(*p);if(check(*p)){rebuild(p);}
}
\(k\) th 和排名
类似线段树上二分, 这里用二叉搜索树上二分, 并不难.
需要注意的是 \(x\) 的排名是 rk(rt,x-1)+1
也就是最后一个比 \(x\) 小的元素的排名 \(+ 1\) 就是 \(x\) 的排名.
这么写表面是因为致敬权值线段树, 实际还是不习惯平衡树.
int kth(int p,int k){if(p==0){return -1;}else if(k<=tr[tr[p].ls].sz0){return kth(tr[p].ls,k);}else if(k<=tr[tr[p].ls].sz0+tr[p].cnt){return tr[p].val;}else{return kth(tr[p].rs,k-(tr[tr[p].ls].sz0+tr[p].cnt));}
}
int rk(int p,int k){if(p==0){return 0;}else if(k<tr[p].val){return rk(tr[p].ls,k);}else if(k==tr[p].val){return tr[tr[p].ls].sz0+tr[p].cnt;}else{return tr[tr[p].ls].sz0+tr[p].cnt+rk(tr[p].rs,k);}
}
前驱后继
不多说, 直接放代码, 理解起来很容易, 如果你用权值线段树水过平衡树板子.
kth(rt,rk(rt,x-1)) // 前驱
kth(rt,rk(rt,x)+1) // 后继
例题
平衡树
VJugde-LuoGu LuoGu
VJudge-DarkBZOJ DarkBZOJ
Tyvj 为什么找不到了 555
这里还是只给 main
函数.
read(&Q);
while(Q--){read(&op);if(op==1){read(&x);push(&rt,x);}else if(op==2){read(&x);pop(&rt,x);}else if(op==3){read(&x);cout<<rk(rt,x-1)+1<<endl;}else if(op==4){read(&x);cout<<kth(rt,x)<<endl;}else if(op==5){read(&x);cout<<kth(rt,rk(rt,x-1))<<endl;}else if(op==6){read(&x);cout<<kth(rt,rk(rt,x)+1)<<endl;}else{cout<<"_"<<endl;}
}