平衡树-Splay(学习笔记)
P3369 【模板】普通平衡树
借鉴文章
二叉搜索树:
定义:
- 他是一颗节点上带有权值的二叉树
- 空树是二叉搜索树
- 若根节点的左子树不为空,则左子树内点的权值均小于根节点的权值
- 若根节点右子树不为空,则右子树内点的权值均大于根节点的权值
换句话说,若用中序遍历这棵树,随后的序列单调不减:
如图
这里要注意区分点的权值和编号,我们要求单调不降的是点的权值,不是编号。下文出现的所有图,点上的数字均表示权值。
用途:
二叉搜索树显然是用来搜索的数据结构,它可以以 \(O(h)\) 的复杂度完成:加点,删点,求排名,求第 \(k\) 大的数,求 \(x\) 的前驱(及比他小的最大数),求 \(x\) 的后驱(及比他大的最小数)等;
对于有多个相同的数的情况,一般有两种解决方法:
- 对树上的每个点记录 \(cnt\) 表示该点的权值在序列中出现了几次
- 直接把多个权值相同的点都塞到树上
这里建议用第一种,比较好维护调试,第二种不符合二叉搜索树的定义,但能减少跟多特判
对于操作的实现,留在平衡树中一起讲,主要也没有什么区别
平衡树:
特点:
由于二叉搜索树的操作的复杂度都为 \(O(h)\) 即都与深度有关,一般来说其深度不会太深,但是也很容易卡二叉搜索树,使其退化为一条链
那高度就变成了 \(n\),复杂度也自然变为 \(O(n)\) 较慢。甚至不如暴力。
对于同一个序列,能构成合法的二叉搜索树有很多种形态,我们要保证自己构造的这棵树不出现上面的情况。
平衡树就是解决这个问题的算法。顾名思义,找一种调整方法让这棵二叉搜索树平衡,保持它的高度为 \(O(logn)\),从而保证各个操作的时间复杂度。
种类:
平衡树的种类较多,对于处理不同类型的平衡树主要分为,普通平衡树和文艺平衡树。
而对于平衡树维护平衡的方法也有很多种:
常见的平衡树有 AVLTree、Splay、Treap、B(+)Tree、RBTree、SBTree、替罪羊树:
-
AVLTree:追求极致完美,任何两子树的高度差 \(\le 1\),增删时调整,树高总是严格保持在 \(O(logN)\)
-
Splay:通过巧妙设计的四种旋转来调整树高,增删查都伴随调整,使树高保持在 \(O(logN)\)
-
Treap:通过随机权值强行模拟随机数据插入的二叉树,增删时调整,使树高期望保持在 \(O(logN)\)
-
B(+)Tree:和其他平衡树不同,它是多叉树,并且利用索引实现高效的查找。其树高能保持在常数更小的 \(O(logN)\)
-
RBTree:emm…及其复杂精密的平衡树,增删时调整,有一套复杂的规则使树高保持在常数较小的 \(O(logN)\)
-
SBTree:通过子树 \(size\) 域的差距表现来进行旋转维护,增时调整,使树高保持在 \(O ( log N ) O(\log N)O(logN)\)
-
替罪羊树:暴力美学,必要时直接拍平子树,然后从中间给拎起来重建二叉树,树高可“均摊”地保持在 \(O(logN)\)
这里主要使Splay平衡树的讲解
\(Splay\)
原理:
Splay 的核心是通过对一些节点进行旋转,改变这棵树的结构,让它趋于平衡。
接下来将对操作和模板代码进行分段讲解。
实现:
1.准备工作
我们先把树的结构体写出来,由于原型是二叉搜索树,所以我们要维护一个节点的左右儿子,父亲,权值,子树大小。为了方便后面调用,可以用#define
把结构体调用简写,使其更美观。
#define lc(x) tr[(x)].s[0]//左儿子都小于当前节点
#define rc(x) tr[(x)].s[1]//右儿子都大于当前节点
#define fa(x) tr[(x)].fa
struct tree{
int s[2],siz,fa,key;//siz为子树大小,key为节点值
tree(){s[0]=s[1]=siz=fa=key=0;}
}tr[N];
2.常用函数:
对于建新点,更新根节点的子树大小,清空一个点可以直接写一个函数方便调用。并写以个 \(get(x)\)函数来获得 \(x\) 是其父亲节点的那个儿子,方便后面操作:
int newnode(int key){//建新点,并赋编号为idx
tr[++idx].key=key;
tr[idx].siz=1;
return idx;
}
void pushup(int x){//向上传递子树大小
tr[x].siz=tr[lc(x)].siz+tr[rc(x)].siz+1;
}
void clear(int x){//清空一个节点,及全部赋值为0
lc(x)=rc(x)=fa(x)=tr[x].siz=tr[x].key=0;
}
bool get(int x){//确定x节点为父节点的哪个儿子
return x==rc(fa(x));
}
3.旋转rotate:
\(rotate(x)\)目的是将 \(x\) 向上移动一层,并保证二叉搜索树的性质不改变。这个操作是 \(Splay\) 的核心基本操作
在 Splay 中,旋转操作分为左旋(Zag) 和右旋(Zig):
发现旋转时,不只是简单地改变根节点,还改变了树的结构。或许看了上图令人迷惑,我们分步演示一个 Splay 的右旋操作步骤。
对于下面这棵树的点 \(2\) 执行 \(rotate\) 操作,可以想象最后 \(4\) 会变成 \(2\) 的右儿子,所以先把 \(2\) 现在的右儿子断开,连到 \(4\) 下面:
这样把父节点与子节点的反向相反的孙子节点连在父节点原本与子节点的位置,将子节点向上旋转后父节点的空给补全了
接下来,我们把 \(2\) 移到 \(4\) 上面,让它成为 \(4\) 的父亲:
这一步上,虽然树的形态(连边状态)不改变,但平衡树是有根树,我们改变的是儿子和父亲的关系。
最后,把 \(2\) 和原来 \(4\) 的父亲 \(6\) 连起来:
这时候我们就成功把点 \(2\) 上移了一个位置,而且保证了中序遍历没变。
代码实现时无需区分左旋和右旋,因为它们本质上都是根据 \(x\) 是父亲的哪个儿子进行的方向判断。操作完成后,要更新节点的 \(siz\) 信息。
具体的代码实现详见代码注释:
void rotate(int x){//将x节点向上旋转一层
int y=fa(x),z=fa(y),c=get(x);//y父亲节点,z爷爷节点,c为x是y的哪个儿子
if(tr[x].s[c^1])fa(tr[x].s[c^1])=y;//如果x的与c相反的子节点存在,则将y设为它的父亲
tr[y].s[c]=tr[x].s[c^1];//将x的与c相反的子节点设为y的c儿子
tr[x].s[c^1]=y;//再将y设为x的儿子
fa(y)=x;//y的父亲设为x
fa(x)=z;//x的父亲设为z
if(z)tr[z].s[y==rc(z)]=x;//如果z不是根节点,则将x设为z的儿子,方向为如果y原本是z的方向
pushup(y);
pushup(x);//向上确定子树大小
}
4.Splay 操作:
这是 \(Splay(x)\)中最重要的操作,及把选定节点移到根节点,这样在每一次的操作后,都将操作的树移到根节点,这样就变相维护了数的平衡性。
对于将节点向上移动主要分为两种情况:
像这种子节点与父节点的方向与父节点与爷节点的方向不同的情况直接将 \(x\) 给 \(rotate\) 一下使其变为
这样就成功的向上移了,只需在 \(rotate\) 即可
若对于这样两个的方向相同,则就必须要先移父亲节点后才能移该节点
对于所有深度为一的节点,都只需要 \(rotate\) 一下即可。
void splay(int x){//重点!将x节点旋转到根节点
for(int f=fa(x);f=fa(x),f;rotate(x)){
if(fa(f))rotate(get(x)==get(f)?f:x);
//如果x与y对于父亲节点是否同向,如果同向就要先转父亲节点,如果反向就直接转x节点,一直转到父亲节点为0
}
rt=x;//储存当前父亲节点
}
这样后面的操作就是普通的二叉搜索树的操作,唯一不同就是要在每次操作结束将操作的点给 \(Splay\) 一下以维持平衡
5.添点:
对于添加一个点的操作,我们只需要根据大小关系不断向下找,直到找到适合它的也直接点,并储存该节点,然后将新点连在后面,最后别忘了 \(Splay\) 一下
void ins(int key){//添加一个点
int now=rt,f=0;//先从根节点开始遍历
while(now){//不断向下找,根据大小到左子树或右子树
f=now;//先赋值父亲,这样出循环时就是对应找到的父亲节点
now=tr[now].s[key>tr[now].key];
}
now=newnode(key);//根据位置建点
fa(now)=f;
tr[f].s[key>tr[f].key]=now;
splay(now);//将建的点移到根节点
}
6.删点:
这个的过程较为复杂,先用图分析一下过程
形如插入节点,我们先通过权值大小沿着树走,找到这个要被删除的点的位置。
当找到这个点后判断,如果这个点没有儿子,那直接删掉
只有一个儿子,我们直接把它的儿子和它的父亲连在一起。比如说删点 \(6\):
对于有两个儿子的情况,就较为复杂,像我们要删点 \(4\):
先把 \(4\) 及和它相连的边删掉,再考虑怎么把剩下的点重新接成一棵树。
我们为了维护二叉搜索树的性质,那就先把钦定的左儿子给连接到根节点
那剩下的 \(6,5\) 怎么办,很显然,适合他的位置在左子树的最右边的节点,因此我们只需遍历到那里,在连接上即可。
这样就完成了删除操作
具体的代码实现看注释
void del(int key){//删除一个点
int now=rt,f=0;
while(tr[now].key!=key && now){
f=now;
now=tr[now].s[key>tr[now].key];
}//同样根据大小向下找,直到找到或到叶节点
if(!now){
splay(f);
return;
}//如果为叶子节点,则直接返回
splay(now);//先将要删的点移到根节点
int cur=lc(now);
if(!cur){//判断是否有左儿子,没有的话就直接将右儿子设为根节点
rt=rc(now);
fa(rt)=0;
clear(now);
return;
}
while(rc(cur))cur=rc(cur);//如果有左儿子,则将左儿子一直向下遍历,直到找到最右节点,及最大节点
rc(cur)=rc(now);//在将最大节点与右儿子连接,保证了二叉搜索树的性质
fa(rc(now))=cur;
fa(lc(now))=0;
clear(now);
pushup(cur);//修改删点
splay(cur);
}
7.查排名:
我们只需要照样向下找这个数,(后面都统一是从小到大,反之亦然),每次转弯时都把比他小的加上,等找到这个点时,所有比他小的点都加上了,也自然就是他的排名。
int rnk(int key){//求排名
int res=1,now=rt,f;
while(now){
f=now;//不断向下找
if(tr[now].key<key){
res+=tr[lc(now)].siz+1;//每次将比他小的数数量加上
now=rc(now);
}
else{
now=lc(now);
}
}
splay(f);//每次操作完都要移到根节点,保证平衡性
return res;
}
8.求第 k 大:
只需要在转弯时判断比他小的个数:
- 若比他小的大于 \(k\) 那就继续往下找
- 若比他小的小于 \(k\) 那就将 \(k\) 减去比他小的,然后在右子树继续找右子树的第减去后的 \(k\) 名
- 若相同,那很明显就是它。
int kth(int rk){//求第k大
int now=rt;
while(now){
int sz=tr[lc(now)].siz+1;
if(sz>rk){//如果当前节点子树大小大于rk,则向左子树找
now=lc(now);
}
else if(sz==rk){
break;
}
else{//如果当前节点子树大小小于rk,则向右子树找,并减去左子树大小和当前节点大小
rk-=sz;
now=rc(now);
}
}
splay(now);
return tr[now].key;
}
9.求前后驱:
也就不断的去找这个点,根据大小关系去找,注意要先储存答案,在向下转移,这样等我们找到后答案储存的就是它的前后驱。
int pre(int key){//求前驱,及比key小的最大值
int now=rt,ans=0,f;
while(now){
f=now;
if(tr[now].key>=key){//如果当前节点大于等于key,则向左子树找
now=lc(now);
}
else{//如果当前节点小于key,则将当前节点赋值给ans,并继续向右子树找
ans=tr[now].key;
now=rc(now);//由于是先赋值在转移,所以ans一直比目标key小
}
}
splay(f);
return ans;
}
int nxt(int key){//求后继,及比key大的最小值,同理
int now=rt,ans=0,f;
while(now){
f=now;
if(tr[now].key<=key){
now=rc(now);
}
else{
ans=tr[now].key;
now=lc(now);
}
}
splay(f);
return ans;
}
基本的操作已经结束,跟多的功能后面会讲解
完整代码:
#include<bits/stdc++.h>
#define lc(x) tr[(x)].s[0]//左儿子都小于当前节点
#define rc(x) tr[(x)].s[1]//右儿子都大于当前节点
#define fa(x) tr[(x)].fa
using namespace std;
const int N=1e5+10;
int rt,idx;
struct tree{
int s[2],siz,fa,key;//siz为子树大小,key为节点值
tree(){s[0]=s[1]=siz=fa=key=0;}
}tr[N];
int newnode(int key){//建新点,并赋编号为idx
tr[++idx].key=key;
tr[idx].siz=1;
return idx;
}
void pushup(int x){//向上传递子树大小
tr[x].siz=tr[lc(x)].siz+tr[rc(x)].siz+1;
}
void clear(int x){//清空一个节点,及全部赋值为0
lc(x)=rc(x)=fa(x)=tr[x].siz=tr[x].key=0;
}
bool get(int x){//确定x节点为父节点的哪个儿子
return x==rc(fa(x));
}
void rotate(int x){//将x节点向上旋转一层
int y=fa(x),z=fa(y),c=get(x);//y父亲节点,z爷爷节点,c为x是y的哪个儿子
if(tr[x].s[c^1])fa(tr[x].s[c^1])=y;//如果x的与c相反的子节点存在,则将y设为它的父亲
tr[y].s[c]=tr[x].s[c^1];//将x的与c相反的子节点设为y的c儿子
tr[x].s[c^1]=y;//再将y设为x的儿子
fa(y)=x;//y的父亲设为x
fa(x)=z;//x的父亲设为z
if(z)tr[z].s[y==rc(z)]=x;//如果z不是根节点,则将x设为z的儿子,方向为如果y原本是z的方向
pushup(y);
pushup(x);//向上确定子树大小
}
void splay(int x){//重点!将x节点旋转到根节点
for(int f=fa(x);f=fa(x),f;rotate(x)){
if(fa(f))rotate(get(x)==get(f)?f:x);
//如果x与y对于父亲节点是否同向,如果同向就要先转父亲节点,如果反向就直接转x节点,一直转到父亲节点为0
}
rt=x;//储存当前父亲节点
}
void ins(int key){//添加一个点
int now=rt,f=0;//先从根节点开始遍历
while(now){//不断向下找,根据大小到左子树或右子树
f=now;//先赋值父亲,这样出循环时就是对应找到的父亲节点
now=tr[now].s[key>tr[now].key];
}
now=newnode(key);//根据位置建点
fa(now)=f;
tr[f].s[key>tr[f].key]=now;
splay(now);//将建的点移到根节点
}
void del(int key){//删除一个点
int now=rt,f=0;
while(tr[now].key!=key && now){
f=now;
now=tr[now].s[key>tr[now].key];
}//同样根据大小向下找,直到找到或到叶节点
if(!now){
splay(f);
return;
}//如果为叶子节点,则直接返回
splay(now);//先将要删的点移到根节点
int cur=lc(now);
if(!cur){//判断是否有左儿子,没有的话就直接将右儿子设为根节点
rt=rc(now);
fa(rt)=0;
clear(now);
return;
}
while(rc(cur))cur=rc(cur);//如果有左儿子,则将左儿子一直向下遍历,直到找到最右节点,及最大节点
rc(cur)=rc(now);//在将最大节点与右儿子连接,保证了二叉搜索树的性质
fa(rc(now))=cur;
fa(lc(now))=0;
clear(now);
pushup(cur);//修改删点
splay(cur);
}
int rnk(int key){//求排名
int res=1,now=rt,f;
while(now){
f=now;//不断向下找
if(tr[now].key<key){
res+=tr[lc(now)].siz+1;//每次将比他小的数数量加上
now=rc(now);
}
else{
now=lc(now);
}
}
splay(f);//每次操作完都要移到根节点,保证平衡性
return res;
}
int kth(int rk){//求第k大
int now=rt;
while(now){
int sz=tr[lc(now)].siz+1;
if(sz>rk){//如果当前节点子树大小大于rk,则向左子树找
now=lc(now);
}
else if(sz==rk){
break;
}
else{//如果当前节点子树大小小于rk,则向右子树找,并减去左子树大小和当前节点大小
rk-=sz;
now=rc(now);
}
}
splay(now);
return tr[now].key;
}
int pre(int key){//求前驱,及比key小的最大值
int now=rt,ans=0,f;
while(now){
f=now;
if(tr[now].key>=key){//如果当前节点大于等于key,则向左子树找
now=lc(now);
}
else{//如果当前节点小于key,则将当前节点赋值给ans,并继续向右子树找
ans=tr[now].key;
now=rc(now);//由于是先赋值在转移,所以ans一直比目标key小
}
}
splay(f);
return ans;
}
int nxt(int key){//求后继,及比key大的最小值,同理
int now=rt,ans=0,f;
while(now){
f=now;
if(tr[now].key<=key){
now=rc(now);
}
else{
ans=tr[now].key;
now=lc(now);
}
}
splay(f);
return ans;
}
int main(){
int t;
scanf("%d",&t);
while(t--){
int op,x;
scanf("%d%d",&op,&x);
if(op==1)ins(x);
if(op==2)del(x);
if(op==3)printf("%d\n",rnk(x));
if(op==4)printf("%d\n",kth(x));
if(op==5)printf("%d\n",pre(x));
if(op==6)printf("%d\n",nxt(x));
}
}
例题:
1.P2234 [HNOI2002] 营业额统计 题解
2.P1486 [NOI2004] 郁闷的出纳员 题解
P2286 [HNOI2004] 宠物收养场 题解
本文作者:XichenOC
本文链接:https://www.cnblogs.com/XichenOC/p/18682818
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步