Splay是?

一种可以调整形态的平衡树,功能性很强

动态树就是基于Splay强大的变形功能实现的

这篇文章会浅谈Splay平衡树和动态树。

一颗Splay

前置芝士

二叉搜索树

模板传送门

P3369

【模板】普通平衡树 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

基本约定

我们会用 ch 数组存放左/右子信息

旋转会需要知道某个点的父亲,所以用 fa 数组存放 父亲的信息

旋转

rotate.drawio

因为这是二叉搜索树。

对于右旋,图左有 B

左旋同理。

所以左旋和右旋是正确的。

不论是左旋还是右旋,我们都把某个结点上旋了,而上旋结点的B子树总和他的父亲不在同一侧,所以我们有了一种简便的写法同时适用于左旋和右旋

1234567void rot(int x){//把x上旋 int y=fa[x],z=fa[fa[x]],k=get(x);//ch[x][k]即B子树 ch[z][get(y)]=x,fa[x]=z; ch[y][k]=ch[x][k^1],fa[ch[x][k^1]]=y;//步骤二 ch[x][k^1]=y,fa[y]=x;//步骤三,注意步骤三只能在步骤二之后 pushup(x),pushup(y);}

splay

Splay的核心操作就在于将某个结点旋至根结点

我们可以先考虑单旋。

假定有一个结点 x ,我们需要把 x

旋至根。想象最极端的一种情况,这棵树目前是一条链,而 x

正位于这棵链的底端。

Example1.drawio

如上图,我们把 x

旋到了根,但是这棵树还是一条链,如果我每次都旋这条链的底端,我能轻松的卡死这个单旋算法

于是我们考虑双旋。假设我们要旋转 x ,y 是 x 的父亲,z 是 y 的父亲

如果 x ,y ,z 不在同侧,旋转两次 x 即可

twoRot2.drawio

如果 x ,y ,z 在同侧,先旋转 y , 再旋转 x

twoRot1.drawio

123456789void splay(int x,int goal=0){ while(fa[x]){ //我们根据 如果fa[x]=0,代表x已经是根了 int y=fa[x]; if(fa[y]) //我们不能转x,不然会出问题 rot(get(x)^get(y)?x:y); //^ 等同于 != ,是一种偷懒()的写法,实际跑起来也没快多少,主要是美观 rot(x); } if(!goal) root=x;}

这时候我们再来看链状的情况,依照双选的策略就可以把链旋成优秀的形态。

Example1-2.drawio

这种双旋的思路可以巧妙地均摊复杂度

我们可以通过构造势能函数来证明这样均摊下来的复杂度是O(log n)

的(具体的数学证明可以移步oi-wiki

)。注意,这不是期望复杂度,他最坏的均摊复杂度就是 O(log

n)。

我们可以做一些感性的理解,对于树中一个很长的部分,splay他最低的结点可能是O

(n)的,但是旋转了他之后,这个“很长的部分”的高度必然以一半高度的级别衰减(我们可以从那个链的例子中看出,如果不明显,你可以手动画6个结点的链)。事实上,你要把某个部分卡的很长,也是需要很多步骤的,而这些步骤的便利,恰均摊给“很长的部分”以坏的复杂度。你旋转某个复杂度坏的结点之后,总能使他周围的结点受益,而这些结点则被均摊了好的复杂度。

所有的操作的次数做一个平均数,便是O(log n)级别的了。

删除

插入、查询前驱后继、查询排名的方法在二叉搜索树里是通用的,故不赘述。

Splay的删除非常简单。对于一个结点 x ,我们先把他的前驱 y

旋到根,再把他的后继 z 旋到后驱下面(z>y,

那么z应该为y的右儿子),那么这棵树的状态一定是下图这样的。

道理也很简单。 因为 z 在 y 的右侧, z 的左子必然大于 y,z

的左子必然小于 z 。那么大于前驱,小于后继的就只能是 x 。

delete.drawio

此时直接删除 x 即可。

为了满足 “把 z 转到 y 下面” 的需求,我们可以对 \(splay\) 函数作出一些微调

12345678910111213141516171819void splay(int x,int goal=0){//意思是不填第二个参数默认为0 while(fa[x]^goal){ int y=fa[x]; if(fa[y]^goal) rot(get(x)^get(y)?x:y); rot(x); } if(!goal) root=x;}void del(int x){ int a=prex(x,0),b=prex(x,1);//a为前驱,b为后继 splay(a),splay(b,a); int u=ch[b][0]; if(cnt[u]==1) ch[b][0]=0; else --cnt[u],--siz[u],splay(u); //这个splay(u)我认为是可以不加的 //你其实知道u的位置就在b的左子。你不会因为重复做访问u这一步而被卡飞,那么就不用均摊他 return;}

删除操作也体现了 Spaly 强大的变形功能,

我们可以主观的调整一棵树的形态来满足我们的需求,这在后续操作中非常重要。

细节和调试指南

Splay中的查询操作也都需要均摊复杂度,因为你没法保证单次查询的复杂度是O(log

n)的,所以只能均摊他,做法就是找到某个点之后splay(x)。如果你不进行均摊,我只要反复找那个点,复杂度就出错了。

我们会加入上界和下界,一个大于最大值,一个小于最小值,来保证前驱和后驱都必定能被找到。

不可以旋转根结点。这很大概率会使你的程序莫名WA。检查的

splay 函数是怎么判根的。

不可以旋转不存在的结点(包括空结点)。这会使你的程序TLE。你可能在查询函数里

splay

了值,而非结点的序号,或者你再调用查询函数的时候没有查询值,而查询了序号...各种各样,总之,注意你的函数调用。

以上两个非法操作都和 fa[0] 不为 0

有关,我们正常旋转时,如果某个结点有空子树,那空结点的父亲就不为 0

了,这是合法的。但是要旋转的点涉及到空结点时(他或他的父亲是空的)就出问题了。如果你的非法操作只有旋根,那还比较微妙,只会让你WA,因为你把空父亲旋下来相当于丧失了你的左子,还给自己的父亲连到一个未知的东西上。如果你旋了不存在的点,他甚至会让你的平衡树存在环,你就会TLE。

通用的检查方法。1. 你的root有没有更新(这会造成各种蜜汁错误)。2.

打印出 fa 数组,手动画一画看看你的树出现什么问题了

另一种写法

由于splay的过程中,我们实际只需要更新被转下去的结点,一直上旋的点始终只有

x

(可以自己手模一下两种双旋需要的更新操作)。所以最后更新一下根就可以。

1234567891011121314151617void rot(int x){ int y=fa[x],z=fa[fa[x]],k=get(x); ch[z][get(y)]=x,fa[x]=z; ch[y][k]=ch[x][k^1],fa[ch[x][k^1]]=y; ch[x][k^1]=y,fa[y]=x; pushup(y);}void splay(int x,int goal=0){ while(fa[x]^goal){ int y=fa[x]; if(fa[y]^goal) rot(get(x)^get(y)?x:y); rot(x); } pushup(x); if(!goal) root=x;}

但是在数据结构中,我们总希望每一次操作之后信息都是正确的,这样最简明和便于书写,所以我更喜欢在rot里push两次的写法。如果你要

push 两次,注意是先push(y) ,后 push(x) 。

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108#includeusing namespace std;typedef long long ll;inline int read(){ int x=0,f=1;char c=getchar(); for(;!isdigit(c);c=getchar()) if(!(c^'-')) f=-1; for(;isdigit(c);c=getchar()) x=(x<<3)+(x<<1)+(c^48); return x*f;}const int N = 1e5+10;const int INF = 1e7+10;int cnt[N],siz[N],ch[N][2],fa[N],val[N],tot,root;int get(int x){return (ch[fa[x]][1]==x);}void pushup(int x){siz[x]=siz[ch[x][0]]+siz[ch[x][1]]+cnt[x];}void rot(int x){ int y=fa[x],z=fa[fa[x]],k=get(x); ch[z][get(y)]=x,fa[x]=z; ch[y][k]=ch[x][k^1],fa[ch[x][k^1]]=y; ch[x][k^1]=y,fa[y]=x; pushup(x),pushup(y);}void splay(int x,int goal=0){ while(fa[x]^goal){ int y=fa[x]; if(fa[y]^goal) rot(get(x)^get(y)?x:y); rot(x); } if(!goal) root=x;}void find(int x){ if(!root) return; int u=root; while(ch[u][x>val[u]]&&x!=val[u]) u=ch[u][x>val[u]]; splay(u);}void ins(int x){ int u=root,f=0; while(u&&val[u]!=x) f=u,u=ch[u][x>val[u]]; if(u) ++cnt[u]; else{ u=++tot; if(!root) root=u; if(f) ch[f][x>val[f]]=u; ch[u][0]=ch[u][1]=0; fa[u]=f,val[u]=x; cnt[u]=siz[u]=1; } splay(u);}int prex(int x,int f){//0pre 1nxt find(x); int u=root; if(xval[u]&&!f) return u; u=ch[u][f]; while(ch[u][f^1]) u=ch[u][f^1]; return u;}void del(int x){ int a=prex(x,0),b=prex(x,1); splay(a),splay(b,a); int u=ch[b][0]; if(cnt[u]==1) ch[b][0]=0; else --cnt[u],--siz[u],splay(u); return;}int rnk(int x){ int u=prex(x,0); splay(u); return siz[ch[u][0]]+cnt[u]+1; }int kth(int x){ int u=root; while(1){ int ls=ch[u][0]; if(siz[ls]>=x) u=ls; else if(x>siz[ls]&&x<=siz[ls]+cnt[u]) return u; else x-=cnt[u]+siz[ls],u=ch[u][1]; }}int m;int main(){ scanf("%d",&m); ins(INF);ins(-INF); for(int i=1;i<=m;++i){ int opt=read(),x=read(); if(opt==1) ins(x); if(opt==2) del(x); if(opt==3) printf("%d\n",rnk(x)-1); if(opt==4) printf("%d\n",val[kth(x+1)]); if(opt==5) printf("%d\n",val[prex(x,0)]); if(opt==6) printf("%d\n",val[prex(x,1)]); } return 0;}