平衡树——Splay
0. 前言
前置知识:二叉查找树(BST)。
我们知道,BST 是会被恶意数据卡成\(O(n^2)\)的。(如下图)
树变成了一条链,这很显然不是我们想要的,我们需要找到一种方法来让 BST 保持平衡。
于是我们就有了各式各样的平衡树,而在 OI 中应用最为广泛的就是 Splay 和 fhq_treap 了,并且这两个数据结构互相不能替代。特点如下:
Splay:码量较大,常数较大,能够进行序列操作,LCT 的专用树,不能可持久化。
fhq_treap:码量适中(比 Splay 短一些),常数较大,能够进行序列操作,维护 LCT 的时间复杂度多一只 log(但是可以写 ETT),可以可持久化。
这篇文章我们主要讨论 Splay 作为单纯的平衡树时的操作。
1. 树旋转
Splay 使用树旋转来保持平衡。
如图,\(x\) 为当前结点,\(f\) 为 \(x\) 的父亲,\(g\) 为 \(f\) 的父亲。\(A,\ B,\ C,\ D\) 为子树。
从上图到下图,我们把 \(x\) 旋转到了\(f\) 的位置。我们称其为左旋,右旋同理。
我们称下图中的左旋为"将 \(x\) 旋转到父结点",右旋为"将 \(f\) 旋转到父结点"。
换言之,旋转谁就是说将哪个结点提升到其父结点的位置。
不妨用 \(\text{connect}(u,v)\) 表示连接 \(u,v\) 且使 \(v\) 是 \(u\) 的父亲,同时 \(u,v\) 原有的父子关系被删除。
记节点 \(x\) 的父亲为 \(f\),祖父为 \(g\),\(x\) 的靠近 \(f\) 的儿子为 \(u\),并且现在要旋转结点 \(x\) 。
我们可以分为以下三步:
- \(\text{connect}(u,f)\)
- \(\text{connect}(x,g)\)
- \(\text{connect}(f,x)\)
图上看就是这样:
上图中演示的是左旋,右旋同理。
2. Splay 操作
利用树旋转,我们可以实现 Splay 的另一个非常重要的操作:Splay 操作。
这个操作能够把一个结点 \(x\) 旋转到 \(top\) 结点的子结点的位置(如果给定 \(top=0\) 则认为是旋转到根结点),让 Splay 保持大致平衡。
(事实上,因为这个操作,我们必须将相同的值合并到一个结点上)
看上去 Splay 操作十分简单,只需要不停地将当前结点向上旋转就行了。我们可以自己来模拟一下^_^
如图,这是一个由 \(1,2,3,4,5\) 组成的链。我们现在尝试把 \(1\) 旋转到根结点:
额(⊙o⊙)…还是一条链(╯‵□′)╯︵┻━┻
这种做法又被称为单旋,也就是每次只关心当前结点及其父结点。可以看出单旋是不能保证复杂度的(用单旋写的 Splay 又被称为 Spaly,会被卡)
由此,我们引入双旋的概念。双旋就是根据当前结点 \(x\),父结点 \(f\) 和祖父结点 \(g\) 的形态(如果没有祖父结点,那么做一次单旋就行了)来决定具体的旋转方案。具体分为两类:
异构调整:\(x,f,g\) 不在一条线上。此时操作和单旋一样,只需要将当前结点旋转两次即可(如图)。
同构调整:\(x,f,g\) 在一条线上。此时我们需要先旋转 \(f\),再旋转 \(x\)(如图)。
单独看好像区别不大。我们还是用刚才的例子来感受一下单旋和双旋的不同。
可以看到,经过两次双旋(后一次双旋将两步分开来画了,看得更清楚),树的形态发生了变化,不只是一条链了。
这样双旋虽然不能把树变得非常平衡,但是均摊复杂度是正确的。这里对 Splay 的均摊时间复杂度有更详细的分析。
3. 删除操作
事实上,有了 Splay 操作和 BST 的知识,就已经可以基本实现 Splay 了。
具体地,每次插入结点最后要将这个结点 Splay 到根结点;根据值查排名,根据排名查值和寻找前驱后继在寻找到最终结点后都要将结点 Splay 到根结点。
但是删除操作就比较复杂了。具体操作如下:
首先,我们把当前结点 Splay 到根结点。
如果相同值的节点不止一个,那很简单,只要将值的数量减一就行了;
如果当前结点没有右儿子,也就是没有后继,那只需要将根节点的指针指到左儿子上就行了;
否则,我们找到当前结点的后继并将其 Splay 到当前结点的儿子结点处,此时这个后继一定没有左儿子。于是我们直接将后继设为根结点,并 \(connect\) 后继和当前节点的左儿子就行了。
对于最后一种情况画图如下:
Splay 如图,现在我们要删掉 \(2\) 。
首先,我们把 \(2\) Splay 到根结点。
很明显 \(2\) 有右子树,也就是有后继。我们从右子树的根开始不停地找左子树,最终找到 \(3\) 是 \(2\) 的后继。于是我们把 \(3\) Splay 到 \(2\) 的子节点处。
\(3\) 没有左儿子了。现在我们可以直接把 \(2\) 删掉了。具体地,让 \(2\) 的左儿子 \(1\) 成为 \(3\) 的左儿子。
删除操作完成。
4. 代码实现
压行警告。
Splay 的本体需要储存以下信息:
int tot,rt;//tot是内存池计数,rt是根
struct node{int fa,ch[2],val,cnt,siz;}tree[maxn];//fa是父亲,ch[0]是左儿子,ch[1]是右儿子,val是值,cnt为值的个数,siz为子树大小
首先,为了减少代码量,我们可以定义以下几个有用的函数:
inline bool dir(int x,int f){return tree[f].ch[1]==x;}//判断x是f的哪个儿子
inline void con(int x,int f,int s){tree[f].ch[s]=x;tree[x].fa=f;}//将f的s儿子设为x,即connect函数
inline void upd(int x){tree[x].siz=tree[tree[x].ch[0]].siz+tree[tree[x].ch[1]].siz+tree[x].cnt;}//更新siz的值
inline void newnode(int &x,int f,int val){x=++tot;tree[x]=(node){f,{0,0},val,1,1};}//新建一个结点,使用引用返回
树旋转:
inline void rotate(int x)
{
int f=tree[x].fa,g=tree[f].fa,k=dir(x,f);
con(tree[x].ch[k^1],f,k);con(x,g,dir(f,g));con(f,x,k^1);//写的很抽象,但也可以看出用ch[0],ch[1]的简洁
upd(f);upd(x);//记得更新siz
}
Splay操作:
inline void splay(int x,int top)
{
if(!top)rt=x;//前面说了,top为0则Splay到根结点
while(tree[x].fa!=top)
{
int f=tree[x].fa,g=tree[f].fa;
if(g!=top)dir(x,f)^dir(f,g)?rotate(x):rotate(f);//判断同构异构,即判断两个方向是否相同。这里使用异或操作来简便判断。
rotate(x);
}
}
接下来就是一个操作一个操作地写了。
插入:
void ins(int val,int &now,int fa)//传引用是好的
{
if(!now){newnode(now,fa,val);splay(now,0);}//记得要Splay到根结点
else if(val<tree[now].val)ins(val,tree[now].ch[0],now);
else if(val>tree[now].val)ins(val,tree[now].ch[1],now);
else{tree[now].cnt++;splay(now,0);}//记得要Splay到根结点
}
删除:
void delnode(int x)//删除单点
{
splay(x,0);//先Splay到根结点
if(tree[x].cnt>1)tree[x].cnt--;
else if(tree[x].ch[1])
{
int now=tree[x].ch[1];
while(tree[now].ch[0])now=tree[now].ch[0];//找后继
splay(now,x);con(tree[x].ch[0],now,0);//将后继Splay到当前结点的儿子结点处
rt=now;tree[now].fa=0;upd(rt);//记得更新siz
}
else{rt=tree[x].ch[0];tree[rt].fa=0;}
}
void del(int val,int now)//根据val找到点的标号,再删
{
if(val==tree[now].val)delnode(now);
else if(val<tree[now].val)del(val,tree[now].ch[0]);
else del(val,tree[now].ch[1]);
}
查值查排名,前驱后继:不多解释。
int getrank(int val)
{
int now=rt,rk=1;
while(now)
{
int lsiz=tree[tree[now].ch[0]].siz;
if(val==tree[now].val){rk+=lsiz;splay(now,0);break;}//记得要Splay到根结点
else if(val<tree[now].val)now=tree[now].ch[0];
else{rk+=lsiz+tree[now].cnt;now=tree[now].ch[1];}
}
return rk;
}
int getnum(int rk)
{
int now=rt;
while(now)
{
int lsiz=tree[tree[now].ch[0]].siz;
if(lsiz+1<=rk&&rk<=lsiz+tree[now].cnt){splay(now,0);break;}//记得要Splay到根结点
else if(rk<=lsiz)now=tree[now].ch[0];
else{rk-=lsiz+tree[now].cnt;now=tree[now].ch[1];}
}
return tree[now].val;
}
int getpre(int x){return getnum(getrank(x)-1);}
int getsuc(int x){return getnum(getrank(x+1));}