目录
什么是Splay
对于普通的二叉查找树,每个节点的左子树里的权值均小于该节点权值,右子树里的权值均大于该节点权值。当我们向二叉查找树中添加数据时,按照上述规则就可以建立起一棵看上去还算平衡的二叉树。如果我们以 3 , 6 , 5 , 2 , 1 , 4 , 7 3,6,5,2,1,4,7 3,6,5,2,1,4,7的顺序添加数据,效果如下:
事实上,对于完全随机的数据,普通的二叉查找树就可以做到 O ( l o g 2 n ) O(log_2n) O(log2n)的复杂度。然而出题人往往不会如此好心,通过控制添加数据的顺序,就可以让二叉查找树退化为一条链,比如按照 1 , 2 , 3 , 4 , 5 , 6 , 7 1,2,3,4,5,6,7 1,2,3,4,5,6,7的顺序添加就只能得到这样的一棵“二叉查找树”:
在上述例子里,因为数据按照升序添加,所以每个节点都只有右子树,最终二叉查找树变成了一条链,让我们的操作都变成了 O ( n ) O(n) O(n)级别的复杂度。可以看出,这个查找二叉树之所以低效,是因为整棵树“左轻右重”,如果能让整棵树长的“平衡”一些,尽量向完全二叉树靠近,就可以始终维持复杂度在 O ( l o g 2 n ) O(log_2n) O(log2n)级别,由此就有了“平衡树”的概念。
Splay正是一种平衡树,它的基本思想是对于“访问频繁”的节点,就把这些节点移动到靠近根的位置,以此提高整体操作的效率。我们可以认为每次访问的目标节点就是访问频繁的节点,每次操作后,就对二叉查找树进行重构,把被访问的节点搬到离根近的地方。
于是,每次都把被访问节点搬到根节点的Splay应运而生。
原理详解
通过上面的简介,可以看到实现Splay的关键在于实现“搬运”节点的操作,即将一个节点移动到根的位置同时保持查找二叉树的性质。
旋转操作(spin)
将一个节点搬运到根的第一步就是让该节点先“向上”搬运一次,使该节点的深度 − 1 -1 −1,根据当前节点 v v v与其父节点 f f f的关系,又有“左旋”和“右旋”两种旋转方式( v v v为需要搬运的当前节点, f f f为 v v v的父节点, f f ff ff为 f f f的父节点):
通过以上旋转操作,节点 v v v成功朝根靠近了一点,而且在旋转的同时,二叉搜索树的性质和中序dfs的遍历顺序均未发生改变。
虽然看上去有两种旋转,但在实际代码实现的时候,可以将其合并为一个函数。我们发现在一次旋转中,节点信息变化的节点有 f f , f , v ff,f,v ff,f,v和与 f , v f,v f,v的左右关系相反的 v v v的子节点 w w w(当 v v v为 f f f右儿子的时候 w w w是 v v v的左儿子,当 v v v为 f f f左儿子的时候 w w w是 v v v的右儿子),记录一下这些节点编号,通过少许判断就可以用一个函数实现两种旋转:
void spin(int v)
{
int f=dad[v],ff=dad[f],k=son[f][1]==v,w=son[v][!k];
son[ff][son[ff][1]==f]=v,dad[v]=ff;
son[v][!k]=f,dad[f]=v;
son[f][k]=w,dad[w]=f;
up(f),up(v);//更新子树大小、子树和等信息,具体内容视题目而定
}
伸展操作(splay)
接下来就是把节点 v v v旋转为节点 t o to to的子节点的操作了,大部分情况下都是讲 v v v旋转到根节点,所以 t o to to一般等于 0 0 0,但在某些时候(如删除节点时)也可以是其他节点,所以设置了该参数。
最简单的一个想法是,一直旋转目标节点直到成为根节点,称之为“单旋”。但在某些情况下(如一条链),仅使用单旋不能优化查找二叉树的结构,所以我们需要“双旋”操作:当 f f , f , v ff,f,v ff,f,v的位置关系相同(即 f , v f,v f,v都为自己父节点的左子树或都为自己父节点的右节点)时,先旋转 f f f节点,再旋转 v v v节点。
可以用势能分析证明双旋Splay的时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)级别。
代码实现较易:
void splay(int v,int to)
{
for(int f,ff;dad[v]!=to;spin(v))
{
f=dad[v],ff=dad[f];
if(ff!=to)spin((son[f][0]==v)^(son[ff][0]==f)?v:f);
}
if(!to)root=v;//当目标为根时,记得更新根节点编号
}
应用详解
维护数集
查找二叉树的本职工作,支持插入、删除、查询数的排名、查询第 k k k大、查询前驱、查询后继等操作。
用到的非记录树形结构数组有:记录节点存储的数的权值 v a l [ i ] val[i] val[i],记录存储的 v a l [ i ] val[i] val[i]的个数 c o t [ i ] cot[i] cot[i],记录所在子树的大小 s i z [ i ] siz[i] siz[i]
维护 s i z [ i ] siz[i] siz[i]的up
函数:
void up(int v)
{
siz[v]=siz[son[v][0]]+siz[son[v][1]]+cot[v];
}
插入(ins)
顺着查找二叉树左小右大的规则直接一路找下去,如果找得到对应权值的节点,直接在 c o t [ i ] cot[i] cot[i]上加一;如果找不到就新开一个节点,初始化储存信息,连接到Splay上。
连接完了记得讲新节点转到根节点,有事没事转一转维持整棵查找二叉树的平衡性是Splay复杂度的保证。
void ins(int x)
{
int v=root,f=0;
for(;v&&val[v]!=x;f=v,v=son[v][x>val[v]]);
if(v)++cot[v];
else
{
v=++tot,dad[v]=f,cot[v]=siz[v]=1,val[v]=x;
if(f)son[f][x>val[f]]=v;
}
splay(v,0);
}
查询某权值对应的节点(find)
同样的,按照左小右大的规则找下去,找到了就把相应节点转到根。
代码中写的void
函数,需要直到对应节点编号时直接用根节点编号即可,根据实际需要可以写成int
函数返回找到的节点编号:
void find(int x)
{
if(!root)return;
int v=root;
for(;val[v]!=x&&son[v][x>val[v]];v=son[v][x>val[v]]);
splay(v,0);
}
查询某个数的前驱/后继(next)
先调用find
函数,这样新的根节点一定是前驱、后继或者本身中的一个,如果新的根节点恰好满足前驱/后继的要求直接返回即可。如果与要求相反,根据左小右大的规则寻找对应节点即可。
以查找前驱为例,如果新的根节点不是前驱,那么其要么是欲查询数本身或者后继,我们要找的就是比当前根节点小的权值里最大的一个。所以我们先进入根节点的左子树,这个子树里的权值都比根节点权值小。之后,再一路沿着右儿子走,就可以找到左子树里最大的数。
找后继的情形类似,可结合代码理解。
int next(int x,int p)
{
find(x);int v=root;
if((val[v]<x&&!p)||(val[v]>x&&p))return v;
for(v=son[v][p];son[v][!p];v=son[v][!p]);
return v;
}
删除(del)
删除操作不能简单的通过find
函数找到对应节点后直接删除,因为find
之后若直接删去根节点,左右子树并不能快速地合并。所以要想简单直接地删去对应节点,需要让对应节点“净身出户”,即该节点不能有左右子树。
要构造这样的情形,可以调用前面编写的next
函数,先将要删除的数的前驱转到根节点,再把要删除的数的后继转成根节点的右儿子。这样,根据查找二叉树的性质,后继节点的左儿子就是欲删除的节点,且该节点没有左右儿子,就可以直接删去(如果整个题目中插入的总节点数不多,可以直接将 c o t [ i ] cot[i] cot[i]设为 0 0 0,这样实现更简单,缺点是Splay中的节点数可能会较多)。
void del(int x)
{
int up=next(x,1),low=next(x,0);
splay(low,0),splay(up,low);
if(cot[son[up][0]]>1)--cot[son[up][0]],splay(son[up][0],0);
else son[up][0]=0;
}
查询第k大(rk)
在VScode里起名rank的时候居然因为命名冲突编译失败了……
同样的,借助我们维护的 s i z [ i ] siz[i] siz[i]数组以及左小右大的规则,分情况讨论一下朝哪个子树走即可,结合代码非常易懂:
int rk(int x)
{
if(siz[root]<x)return 0;
for(int v=root;;)
{
if(x>siz[son[v][0]]+cot[v])x-=siz[son[v][0]]+cot[v],v=son[v][1];
else if(x<=siz[son[v][0]])v=son[v][0];
else return v;
}
}
例题
例题链接:https://www.luogu.com.cn/problem/P3369
模板题,涵盖了前面介绍的所有操作,学习和练手Splay的上上之选,这里给出完整代码:
#include<bits/stdc++.h>
using namespace std;
const int M=1e5+5;
int n,tot,root,val[M],cot[M],siz[M],son[M][2],dad[M];
void up(int v)
{
siz[v]=siz[son[v][0]]+siz[son[v][1]]+cot[v];
}
void spin(int v)
{
int f=dad[v],ff=dad[f],k=son[f][1]==v,w=son[v][!k];
son[ff][son[ff][1]==f]=v,dad[v]=ff;
son[v][!k]=f,dad[f]=v;
son[f][k]=w,dad[w]=f;
up(f),up(v);
}
void splay(int v,int to)
{
for(int f,ff;dad[v]!=to;spin(v))
{
f=dad[v],ff=dad[f];
if(ff!=to)spin((son[f][0]==v)^(son[ff][0]==f)?v:f);
}
if(!to)root=v;
}
void ins(int x)
{
int v=root,f=0;
for(;v&&val[v]!=x;f=v,v=son[v][x>val[v]]);
if(v)++cot[v];
else
{
v=++tot,dad[v]=f,cot[v]=siz[v]=1,val[v]=x;
if(f)son[f][x>val[f]]=v;
}
splay(v,0);
}
void find(int x)
{
if(!root)return;
int v=root;
for(;val[v]!=x&&son[v][x>val[v]];v=son[v][x>val[v]]);
splay(v,0);
}
int next(int x,int p)
{
find(x);int v=root;
if((val[v]<x&&!p)||(val[v]>x&&p))return v;
for(v=son[v][p];son[v][!p];v=son[v][!p]);
return v;
}
void del(int x)
{
int up=next(x,1),low=next(x,0);
splay(low,0),splay(up,low);
if(cot[son[up][0]]>1)--cot[son[up][0]],splay(son[up][0],0);
else son[up][0]=0;
}
int rk(int x)
{
if(siz[root]<x)return 0;
for(int v=root;;)
{
if(x>siz[son[v][0]]+cot[v])x-=siz[son[v][0]]+cot[v],v=son[v][1];
else if(x<=siz[son[v][0]])v=son[v][0];
else return v;
}
}
void in(){
scanf("%d",&n);}
void ac()
{
ins(-INT_MAX),ins(INT_MAX);
for(int i=1,a,b;i<=n;++i)
{
scanf("%d%d",&a,&b);
if(a==1)ins(b);
else if(a==2)del(b);
else if(a==3)find(b),printf("%d\n",siz[son[root][0]]);
else if(a==4)printf("%d\n",val[rk(b+1)]);
else if(a==5)printf("%d\n",val[next(b,0)]);
else printf("%d\n",val[next(b,1)]);
}
}
int main()
{
in(),ac();
system("pause");
}
维护序列
因为Splay不会改变中序遍历的遍历顺序,我们把节点权值设置为序列里的编号就可以用Splay维护一个序列。
跟线段树维护序列类似,Splay同样可以通过打标记、合并左右子树信息来完成线段树支持的区间修改,但是如果线段树都能完成维护,为什么要用Splay呢?
区间翻转
因为从Splay还原到序列是通过中序遍历,所以对于序列里的一个区间进行翻转就相当于把该区间对应的子树里每个节点的左右儿子都交换一次,这是线段树无法实现的功能,而Splay用打标记的方式可以轻松实现。
带标记下放的Splay就是在spaly
函数里多加上push
函数就行了,记得从上到下依次下放。
例题
例题链接:https://www.luogu.org/problemnew/show/P3165
#include<bits/stdc++.h>
using namespace std;
int root,tot,f,ff;
bool k;
struct node{
int son[2],dad,fir,rk,sz;
bool rev;
node()
{
memset(this,0,sizeof(this));
}
};
struct sd{
int n,fir;
bool operator < (const sd &x) const
{
if(n!=x.n) return n<x.n;
return fir<x.fir;
}
};
sd x[100005];
node tree[100005];
void up(int v)
{
tree[v].sz=tree[tree[v].son[0]].sz+tree[tree[v].son[1]].sz+1;
}
void push(int v)
{
if(tree[v].rev)
{
tree[tree[v].son[0]].rev^=1;
tree[tree[v].son[1]].rev^=1;
swap(tree[v].son[0],tree[v].son[1]);
tree[v].rev=0;
}
}
void spin(int v)
{
f=tree[v].dad;
ff=tree[f].dad;
k=(tree[f].son[1]==v);
tree[ff].son[tree[ff].son[1]==f]=v;
tree[v].dad=ff;
tree[f].son[k]=tree[v].son[!k];
tree[tree[v].son[!k]].dad=f;
tree[v].son[!k]=f;
tree[f].dad=v;
up(f);up(v);
}
void splay(int v,int goal)
{
while(tree[v].dad!=goal)
{
f=tree[v].dad;
ff=tree[f].dad;
if(ff)push(ff);
if(f)push(f);
if(v)push(v);
if(ff!=goal)
((tree[ff].son[0]==f)^(tree[f].son[0]==v))?spin(v):spin(f);
spin(v);
}
if(!goal) root=v;
}
void insert(int fir,int rk)
{
int v=root;f=0;
while(tree[v].fir!=fir&&v)
f=v,v=tree[v].son[fir>tree[v].fir];
v=++tot;
if(f) tree[f].son[fir>tree[f].fir]=v;
tree[v].sz=1;
tree[v].fir=fir;
tree[v].rk=rk;
tree[v].dad=f;
splay(v,0);
}
int s;
int rank(int x)
{
int v=root;
while(1)
{
push(v);
s=tree[v].son[0];
if(x>tree[s].sz+1)
{
x-=tree[s].sz+1;
v=tree[v].son[1];
}
else
if(tree[s].sz>=x) v=s;
else return v;
}
}
int n;
void in()
{
scanf("%d",&n);
for(int i=1;i<=n;++i)
scanf("%d",&x[i].n),x[i].fir=i;
sort(x+1,x+n+1);
for(int i=1;i<=n;++i)
insert(x[i].fir,i);
insert(-1,-1);
insert(n+1,1e9+5);
}
void ac()
{
int le,ri,hh;
splay(1,0);
hh=tree[tree[root].son[0]].sz;
printf("%d",hh);
le=rank(1);
ri=rank(hh+2);
splay(le,0);
splay(ri,le);
tree[tree[ri].son[0]].rev^=1;
for(int i=2;i<=n;++i)
{
splay(i,0);
hh=tree[tree[root].son[0]].sz;
printf(" %d",hh);
if(i==n) return;
ri=rank(hh+2);
le=i-1;
splay(le,0);
splay(ri,le);
tree[tree[ri].son[0]].rev^=1;
}
}
int main()
{
in();ac();
return 0;
}