这个板子写得有点丑……建议去别的博客 QAQ。

一、简介

Splay(伸展树)是平衡树中的一种。它通过不断将某个节点旋转到根节点的位置,使整棵树仍满足 BST 的性质,并且保持平衡而不至于退化为链。

频繁访问的节点会被移动到离根节点较近的位置,进而获得更快的访问速度。

可以通过均摊复杂度证明,\(n\) 个点,进行 \(m\) 次 Splay 操作,最终的时间复杂度是 \(\mathcal{O}((n+m)\log n)\)证明

二、基本操作

一些维护的信息:

\(rt\) \(tot\) \(fa(x)\) \(lc(x)\) \(rc(x)\) \(val(x)\) \(cnt(x)\) \(sz(x)\)
根节点编号 节点个数 父亲 左儿子 右儿子 节点权值 权值出现次数 子树大小

基本操作:

  • \(\text{getnew}(k)\):新建一个关键码(即节点权值)为 \(k\) 的节点。

  • \(\text{upd}(x)\):更新节点 \(x\)\(sz\)

int getnew(int k){
    val[++tot]=k,cnt[tot]=sz[tot]=1;
    return tot;
}
void upd(int p){
    sz[p]=sz[lc[p]]+sz[rc[p]]+cnt[p];
}

三、旋转操作

为了使 Splay 保持平衡而进行旋转操作,旋转的本质是将某个节点上移一个位置。

旋转需要保证

  • 整棵树的中序遍历不变(不能破坏二叉查找树的性质)。

  • 受影响的节点维护的信息依然正确有效。

  • root​ 必须指向旋转后的根节点。

Treap 的旋转操作差不多,只是多了对 \(fa\) 数组的维护。接下来对 Treap 中的旋转操作进行搬运、修改和补充(可能会有锅 QAQ)。

1. 左旋与右旋

在 Splay 中的旋转分为两种:左旋右旋

「算法笔记」Splay

以右旋为例。如图所示,在初始情况下,\(x\)\(y\) 的左子节点,\(A\)\(B\) 分别是 \(x\) 的左右子树,\(C\)\(y\) 的右子树。

“右旋”操作在保持 BST 性质的基础上,把 \(x\) 变为 \(y\) 的父节点。因为 \(x\) 的关键码小于 \(y\) 的关键码,所以 \(y\) 应该作为 \(x\) 的右子节点。

\(x\) 变成 \(y\) 的父节点后, \(y\) 的左子树就空了出来,于是 \(x\) 原来的右子树 \(B\) 就恰好作为 \(y\) 的左子树。

  • 左旋:将右儿子提到当前节点,自己作为右儿子的左儿子,右儿子原来的左儿子变成自己新的右儿子。

  • 右旋:将左儿子提到当前节点,自己作为左儿子的右儿子,左儿子原来的右儿子变成自己新的左儿子。

右旋将左儿子上移,左旋将右儿子上移。左右旋并 没有本质区别。其目的相同,即将指定节点上移一个位置。

2. 代码实现

在之前 Treap 代码上的修改:

  • 之前的 Treap 没有记录父节点,方便起见,把左右旋定义为一个节点的子节点绕其向左或向右旋转。(某些书籍中不是这样的)

  • 这里的 Splay 记录了父节点,把左右旋定义为一个节点绕其父节点向左或向右旋转。(当然要跟之前 Treap 一样也可以,Code

  • Splay 需要维护 \(fa\) 数组。

具体步骤: (假设需要旋转的节点为 \(x\),其父亲为 \(y\),以右旋为例)

  1. \(y\) 的左儿子指向 \(x\) 的右儿子,且 \(x\) 的右儿子的父亲指向 \(y\)

  2. \(x\) 的右儿子指向 \(y\),且 \(y\) 的父亲指向 \(x\)

  3. 如果原来的 \(y\) 还有父亲 \(z\),那么把 \(z\) 的某个儿子(原来 \(y\) 所在的儿子位置)指向 \(x\),且 \(x\) 的父亲指向 \(z\)

void zig(int &p){    //Treap 右旋 
    int q=lc[p];
    lc[p]=rc[q],rc[q]=p,p=q,upd(rc[p]),upd(p);
}
void zig(int p){    //Splay 右旋。p 不需要引用。
    int q=fa[p],k=fa[q];
    lc[q]=rc[p],fa[rc[p]]=q;
    rc[p]=q,fa[q]=p,fa[p]=k;
    if(q==lc[k]) lc[k]=p;    //如果原来的 q 还有父亲 k,那么把 k 的某个儿子(原来 q 所在儿子的位置)指向 p。前面已将 p 的父亲指向 q。
    else if(q==rc[k]) rc[k]=p;
    upd(q),upd(p);    //先更新 q 再更新 p
    if(rt==q) rt=p;    //如果原来 q 是根,那么现在 p 就是根
}

Splay 左右旋:(换了个变量名 233)

void zig(int x){    //右旋
    int y=fa[x],z=fa[y];
    lc[y]=rc[x],fa[rc[x]]=y;
    rc[x]=y,fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    upd(y),upd(x);
    if(rt==y) rt=x;
}
void zag(int x){    //左旋
    int y=fa[x],z=fa[y];
    rc[y]=lc[x],fa[lc[x]]=y;
    lc[x]=y,fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    upd(y),upd(x);
    if(rt==y) rt=x;
}

四、伸展操作

Splay 利用伸展操作趋近平衡。具体描述为,将当前访问过的节点旋转至根节点的位置。(伸展操作也称 Splay 操作

这样旋转的好处在于,经常访问的节点访问速度将速度很快(因为就在根结点附近),而且在旋转的过程中,整棵树也会逐渐平衡。

单旋:反复旋转 \(x\) 的父节点直到 \(x\) 到达根节点为止。使用这种方法,复杂度无法保证,会退化。

双旋:讨论 \(x\) 和父亲的关系 与 \(x\) 的父亲和祖父的关系是否相同。

双旋操作

设访问过的节点为 \(x\)。分三种情况考虑,直到 \(x\) 成为根节点。

情况一: \(fa(x)\) 为根节点。则将 \(x\) 旋转一次到根即可。举个栗子:

「算法笔记」Splay

情况二:\(fa(x)\) 不是根节点,\(x\)\(fa(x)\) 的左(右)儿子且 \(fa(x)\)\(fa(fa(x))\) 的左(右)儿子。则先右(左)旋 \(fa(x)\),再右(左)旋 \(x\) 。栗子:

「算法笔记」Splay

情况三:\(y\) 不是根节点,\(x\)\(fa(x)\) 的左(右)儿子且 \(fa(x)\)\(fa(fa(x))\) 的右(左)儿子。则先右(左)旋 \(x\),再左(右)旋 \(x\) 。栗子:

「算法笔记」Splay

\(\text{Splay(x,g)}\) 表示把 \(x\) 旋转到 \(g\) 的儿子(当 \(g=0\) 时表示旋转到根)。

void rotate(int x){    //通过旋转把节点 x 上移 
    int y=fa[x];
    if(x==lc[y]) zig(x);
    else zag(x);
}
void splay(int x,int g){    //把 x 旋转到 g 的儿子的位置(当 g=0 时表示旋转到根) 
    while(fa[x]!=g){
        int y=fa[x],z=fa[y];
        if(z!=g) rotate((x==lc[y])==(y==lc[z])?y:x);  
        rotate(x);
    }
    if(!g) rt=x;    //标记根节点 
}

五、其他操作

1. 查找操作

在 Splay 中查找一个值就需要查找操作。

跟 BST 的查找过程一样,每次根据待查找的值 \(k\) 与当前节点的值的关系,来判断进入左、右儿子。

不过它不会给出返回值,而是把查找到的 \(k\) 对应的节点(若没有找到,就是离 \(k\) 最近的节点)通过 Splay 操作旋转到根节点。

void find(int k){
    if(!rt) return ;    //树是空的就返回  
    int x=rt,y;
    while(val[x]!=k&&(y=k<val[x]?lc[x]:rc[x])) x=y;
    splay(x,0);    //伸展到根节点 
}

2. 插入操作

按照二叉查找树的性质向下查找,找到待插入的值 \(k\) 应该插入的节点并插入。如果 \(k\) 原来就存在,那么直接更新 \(cnt\),否则新建一个空节点。

最后把插入的 \(k\) 对应的节点通过 Splay 操作到根节点。

void insert(int k){
    int x=rt,y=0;    //y 是 x 的父节点 
    while(x&&val[x]!=k) y=x,x=k<val[x]?lc[x]:rc[x];
    if(x) cnt[x]++;    //找到 k 
    else x=getnew(k),y?(k<val[y]?lc[y]=x:rc[y]=x):0,fa[x]=y;    //更新 x 的信息 
    splay(x,0);    //伸展到根节点
}

3. 查询排名

\(k\) 的排名定义为第一个等于 \(k\) 的值的排名。

只需把 \(k\) 旋转到根节点,返回根的左子树的 \(sz\) 再加 \(1\) 即可。

(代码中没有加 \(1\) 的原因:为了避免越界,减少边界情况的判断,我们在初始时额外插入了关键码为 \(+\infty\)\(−\infty\) 的节点。所以代码中是不用 \(+1\) 的)

int rank(int k){
    find(k);
    return sz[lc[rt]]; 
}

4. 第 k 小数

\(rk\) 为剩余排名,具体步骤如下:

  • 如果 \(rk\) 大于左子树大小与当前节点大小的和,那么向右子树查找。

  • 如果 \(rk\) 不大于左子树的大小,那么向左子树查找。

  • 否则直接返回当前节点的值。

int Kth(int rk){
    int x=rt;rk++;     //rk+1 是因为有关键码为负无穷大的节点 
    while(1){
        if(rk>sz[lc[x]]+cnt[x]) rk-=sz[lc[x]]+cnt[x],x=rc[x];    //右子树 
        else if(rk<=sz[lc[x]]) x=lc[x];    //左子树 
        else return val[x];
    }
}

5. 查询前驱/后继

前驱定义为小于 \(k\) 且最大的数,后继定义为大于 \(k\) 且最小的数。

查询前驱:\(k\) 旋转到根节点, 前驱即为 \(k\) 的左子树中最右边的节点。注意当 \(k\) 不存在时,根节点的值比 \(k\) 小的情况要特判。

查询后继:同理,找 \(k\) 的右子树中最左边的节点即可。

int pre(int k){    //查询前驱 
    find(k);
    if(val[rt]<k) return rt;
    int x=lc[rt];
    while(rc[x]) x=rc[x];
    return x;    //这里返回了节点编号,因为后面的删除操作需要用到前驱的编号 
}
int nxt(int k){    //查询后继 
    find(k);
    if(val[rt]>k) return rt;
    int x=rc[rt];
    while(lc[x]) x=lc[x];
    return x;
}

6. 删除操作

\(\text{splay}(x,g)\)\(g\) 参数就是这里用哒。

删除关键码为 \(k\) 的节点。步骤如下:

  • 首先找到 \(k\) 的前驱 \(last\) 和后继 \(next\)。将 \(last\) 旋转到根节点,将 \(next\) 旋转到 \(last\) 的儿子(显然是右儿子)。

  • 观察这个过程可以发现:若 \(k\) 存在,那么这时的 \(k\) 一定是 \(next\) 的左儿子,且 \(k\) 对应的节点没有儿子。将这个节点的 \(cnt\)\(1\)(需要 Splay 操作),或者直接删除即可。

void erase(int k){
    int p1=pre(k),p2=nxt(k);    //找到前驱后继 
    splay(p1,0),splay(p2,p1);    //把前驱伸展到根结点,把后继伸展到前驱的子节点
    int x=lc[p2];    //这时的 k 一定是该后继的左子结点,且这个节点 x 没有子节点 
    if(cnt[x]>1) cnt[x]--,splay(x,0);    //cnt-=1,将 x 伸展到根节点 
    else lc[p2]=0;    //删除节点 x
} 

六、模板

禁止某莱白嫖板子

//Luogu P3369
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5; 
int n,opt,x,tot,rt,lc[N],rc[N],val[N],sz[N],cnt[N],fa[N],ans;
void upd(int p){
    sz[p]=sz[lc[p]]+sz[rc[p]]+cnt[p];
}
int getnew(int k){
    val[++tot]=k,cnt[tot]=sz[tot]=1;
    return tot;
}
void zig(int x){    //右旋
    int y=fa[x],z=fa[y];
    lc[y]=rc[x],fa[rc[x]]=y,rc[x]=y,fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    upd(y),upd(x);
    //if(rt==y) rt=x; 这一步在 splay 操作的末尾有,可以省略 
}
void zag(int x){    //左旋
    int y=fa[x],z=fa[y];
    rc[y]=lc[x],fa[lc[x]]=y,lc[x]=y,fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    upd(y),upd(x);
    //if(rt==y) rt=x;
}
void rotate(int x){
    x==lc[fa[x]]?zig(x):zag(x);
}
void splay(int x,int g){ 
    while(fa[x]!=g){
        int y=fa[x],z=fa[y];
        if(z!=g) rotate((x==lc[y])==(y==lc[z])?y:x);  
        rotate(x);
    }
    if(!g) rt=x;
}
void find(int k){
    if(!rt) return ;
    int x=rt,y;
    while(val[x]!=k&&(y=k<val[x]?lc[x]:rc[x])) x=y;
    splay(x,0);
}
void insert(int k){
    int x=rt,y=0;
    while(x&&val[x]!=k) y=x,x=k<val[x]?lc[x]:rc[x];
    if(x) cnt[x]++;
    else x=getnew(k),y?(k<val[y]?lc[y]=x:rc[y]=x):0,fa[x]=y;
    splay(x,0); 
}
int rank(int k){
    find(k);
    return sz[lc[rt]]; 
}
int Kth(int rk){
    int x=rt;rk++;
    while(1){
        if(rk>sz[lc[x]]+cnt[x]) rk-=sz[lc[x]]+cnt[x],x=rc[x]; 
        else if(rk<=sz[lc[x]]) x=lc[x];
        else return val[x];
    }
}
int pre(int k){
    find(k);
    if(val[rt]<k) return rt;
    int x=lc[rt];
    while(rc[x]) x=rc[x];
    return x;
}
int nxt(int k){
    find(k);
    if(val[rt]>k) return rt;
    int x=rc[rt];
    while(lc[x]) x=lc[x];
    return x;
}
void erase(int k){
    int p1=pre(k),p2=nxt(k); 
    splay(p1,0),splay(p2,p1);
    int x=lc[p2]; 
    if(cnt[x]>1) cnt[x]--,splay(x,0); 
    else lc[p2]=0;
} 
signed main(){
    scanf("%lld",&n),insert(-1e18),insert(1e18);
    while(n--){
        scanf("%lld%lld",&opt,&x),ans=-1;
        if(opt==1) insert(x);
        else if(opt==2) erase(x);
        else if(opt==3) ans=rank(x);
        else if(opt==4) ans=Kth(x); 
        else if(opt==5) ans=val[pre(x)];
        else ans=val[nxt(x)];
        if(~ans) printf("%lld\n",ans);
    }
    return 0;
}

附:快乐压行

void rotate(int x){
    int y=fa[x],z=fa[y];
    if(x==lc[y]) lc[y]=rc[x],fa[rc[x]]=y,rc[x]=y;    //zig(x)
    else rc[y]=lc[x],fa[lc[x]]=y,lc[x]=y;    //zag(x)
    fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    upd(y),upd(x);
}

七、维护区间

Luogu P3391 文艺平衡树 为例:维护一个有序数列,支持 区间翻转

总体思路:用 Splay 维护这个序列,节点的关键码为每个数在原数列中的 下标。显然一个子树对应一个区间。每次提取区间 \([l,r]\) 就可以将左右子树全部交换,就实现了区间翻转。

(平衡树上的每个节点存 \(1,2,\cdots,n\)。如果不进行翻转操作的话,这个平衡树在任意时刻,无论经过了多少次 Splay 操作,它的中序遍历都是 \(1,2,\cdots,n\) 这个序列,并且容易发现,一个子树,一定是连续的一段区间。)

提取区间:对于区间 \([l,r]\),我们可以把 \(l-1\) 旋转到根,\(r+1\) 旋转到根的儿子(显然是右儿子)。那么 根的右儿子的左子树 就是区间 \([l,r]\) 的全部元素。

注意这里的 \(l-1\)\(r+1\) 指的是序列中第 \(l-1\) 和第 \(r+1\) 个元素对应的节点(即平衡树中序遍历的第 \(l-1\) 和第 \(r+1\) 个节点),而不是下标为 \(l-1\)\(r+1\) 的节点。因此我们要调用 Kth(l-1)Kth(r+1) 找到对应节点的编号。

交换子树:加一个 \(\text{tag}\) 标记就好啦,作用就跟线段树的懒标记差不多。每次维护的时候,将标记下传、交换左右子树、清空自身标记。

时间复杂度:\(\mathcal{O}(n\log n)\)

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+5; 
int n,m,l,r,tot,rt,lc[N],rc[N],val[N],sz[N],cnt[N],fa[N],tag[N],ans;
int getnew(int k){
    val[++tot]=k,cnt[tot]=sz[tot]=1;
    return tot;
}
void pushup(int p){
    sz[p]=sz[lc[p]]+sz[rc[p]]+cnt[p];
}
void pushdown(int p){
    if(!tag[p]) return ;
    swap(lc[p],rc[p]),tag[lc[p]]^=1,tag[rc[p]]^=1,tag[p]=0;
}
void zig(int x){    //右旋
    int y=fa[x],z=fa[y];
    lc[y]=rc[x],fa[rc[x]]=y,rc[x]=y,fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    pushup(y),pushup(x);
}
void zag(int x){    //左旋
    int y=fa[x],z=fa[y];
    rc[y]=lc[x],fa[lc[x]]=y,lc[x]=y,fa[y]=x,fa[x]=z;
    if(y==lc[z]) lc[z]=x;
    else if(y==rc[z]) rc[z]=x;
    pushup(y),pushup(x);
}
void rotate(int x){
    x==lc[fa[x]]?zig(x):zag(x);
}
void splay(int x,int g){ 
    while(fa[x]!=g){
        int y=fa[x],z=fa[y];
        if(z!=g) rotate((x==lc[y])==(y==lc[z])?y:x);  
        rotate(x);
    }
    if(!g) rt=x;
}
void insert(int k){
    int x=rt,y=0;
    while(x&&val[x]!=k) y=x,x=k<val[x]?lc[x]:rc[x];
    if(x) cnt[x]++;
    else x=getnew(k),y?(k<val[y]?lc[y]=x:rc[y]=x):0,fa[x]=y;
    splay(x,0); 
}
int Kth(int rk){
    int x=rt;rk++;
    while(1){
        pushdown(x);
        if(rk>sz[lc[x]]+cnt[x]) rk-=sz[lc[x]]+cnt[x],x=rc[x]; 
        else if(rk<=sz[lc[x]]) x=lc[x];
        else return x;
    }
}
void rev(int l,int r){
    splay(Kth(l-1),0),splay(Kth(r+1),rt);
    tag[lc[rc[rt]]]^=1;
}
signed main(){
    scanf("%lld%lld",&n,&m),insert(-1e18),insert(1e18);
    for(int i=1;i<=n;i++) insert(i);
    for(int i=1;i<=m;i++)
        scanf("%lld%lld",&l,&r),rev(l,r);
    for(int i=1;i<=n;i++)
        printf("%lld%c",val[Kth(i)],i==n?'\n':' ');
    return 0;
}

关于“翻转操作”是否改变 BST 性质的一些解释:(开始瞎编)

  • 从表面上看,交换左右儿子,相当于打乱了关键码的排列方式,破坏了 BST 的性质。

  • 事实上,它并没有破坏 BST 的性质。我们需要保证平衡树的中序遍历,就是我们 想要的序列(即经过翻转操作后的序列)。区间翻转时,我们想要的那个序列变了,所以要交换节点。可以理解为,在执行区间翻转时,把 < 号的定义改变了,所以 BST 的结构要做相应的变化,才能维持它 BST 的性质。而这个 < 号的定义,就是它在我们 想要的序列 中的出现位置。(即每个数在我们想要的序列中的出现位置仍满足 BST 性质)

这时的 Kth(rk) 可以理解为是平衡树中序遍历的第 \(rk\) 个数,也就是我们想要的序列里面的第 \(rk\) 个数。

八、Treap 和 Splay 的适用范围

Treap 用于维护集合的信息,支持排名相关的操作,优点是简单易写(相对其他平衡树而言),而且常数较小。

Splay 常数较大且没有那么好写,但是 Splay 能够支持普通 Treap 无法完成的序列操作,一般用于维护动态序列操作。

补充:Treap 还有一种非旋转的实现(核心操作为分裂与合并),可以支持 Splay 的所有操作,还可以 Splay 不支持的可持久化。

九、习题

模板题就不放了叭 QAQ

  • Luogu P1486 [NOI2004] 郁闷的出纳员(Code
  • Luogu P2596 [ZJOI2006] 书架Code
  • Luogu P3224 [HNOI2012] 永无乡(并查集 + 平衡树启发式合并,Code
  • Luogu P1110 [ZJOI2007] 报表统计(线段树 + 平衡树,Code
  • Luogu P2042 [NOI2005] 维护数列(练手题,听说写完这个题就会写一堆动态序列操作的裸题了!打标记 + 内存回收,Code
  • BZOJ 2827 千山鸟飞绝(map + 打标记,Code
  • BZOJ 4923 K 小值查询(分类 + 打标记,Code

相关文章: