Splay学习笔记

Splay

参考的博客

https://www.luogu.com.cn/blog/pks-LOVING/more-senior-data-structure-te-bie-qian-di-qian-tan-splay

https://oi-wiki.org/ds/splay/(一些图片也来自于此)

介绍

Splay相对来说可以我维护一些其他平衡树无法维护的东西,但其实大部分FHQ-TREAP也可以,如一些区间操作。但最重要的还是为了LCT要不是为了它,我是真不想学Splay。但因为需要旋转,所以均摊复杂度会比FHQ-TREAP要慢一些,常数比较大。但是理论复杂度较好,不会被卡。因为带旋的话会比较难,所以篇幅会比较长。

时空复杂度

因为Splay是一颗平衡树,所以他的空间也与其他的平衡树一样,为 O ( N ) O(N) O(N) ,而Splay的平均复杂度为均摊 O ( l o g n ) O(logn) O(logn) ,注意是均摊,不是像红黑树与AVL树那样的严格 O ( l o g n ) O(logn) O(logn) ,但是要卡Splay还是非常难的。

思路:

旋转

首先,Splay最重要的就就是旋转操作,这也是它与FHQ_TREAP所不同的地方。旋转,顾名思义,就是将某个节点与其的父亲进行旋转操作。如图所示:

先讲右旋,是以2为点,1为父节点,旋转2,1两个节点。首先,因为要维护BIT的性质,所以直接选的话肯定会出现一个点有是三个子节点的情况,显然不能这样做。

为了维护这个性质,如果2有右子树,先将1的左子树设为2的右子树,再将2的右子树的父节点设为1。再讲2的右子树设为1。2的父亲节点设为1原来的父亲节点,1的父亲节点设为2,1原来的父亲节点的儿子设为2。

因为2的右子树也是1的左子树,所以直接连在1上面是正确的。而因为2的右子树与1的右子树都是都是大于2的节点,所以都放在2的右子树是正确的。

而左旋就正好与右旋相反。如图,是以1为点,与2旋转。首先将2的右子树设为1的左子树。1的左子树设为1,其他的节点关系也相应改变就可以了。而其实所有的旋转操作的本质就是讲一边的树高加一,一边的树高减一。

void rotate(int rt){//旋转当前节点
        int x=t[rt].fa,y=t[x].fa,chx=get(rt),chy=get(x);//x为父亲节点,y为爷爷节点,chx表示当前节点的父子关系
        t[x].ch[chx]=t[rt].ch[chx^1];//先将rt的父节点的儿子更新了 
        if(t[rt].ch[chx ^ 1])t[t[rt].ch[chx ^ 1]].fa=x;
        t[rt].ch[chx^1]=x;
        t[x].fa=rt;
        t[rt].fa=y;
        if(y)t[y].ch[chy]=rt;
        update(x);//更新几个节点的值,因为他们的结构改变了
        update(rt);
        //不用更新爷爷节点 y ,因为他的子树总和还是一样的
        return ;
    }

Splay操作

在Splay中1,为了保证树的平衡性,每次询问或插入一个点的时候都需要将其提为根节点。而在其不断旋转的过程中,会出现许多不同的情况,我们对于每一种情况进行具体分析(在之后将需要旋转的节点设为x,x的父节点设为p):

  1. 若p是根节点,直接旋转两个节点就可以了。此时整棵树的大体结构并不会发生变化(但其实两边的子树深度一边+1,一边-1,需要靠一些其他的操作维持平衡还是FHQ_TREAP好,随机就行)。

  2. 若p不是根节点,即p还有父亲,且x,p,fa[p] ,三个节点在一条直线上(或是说x与p的父子关系相同,都为左儿子或都为右儿子),就先旋转p,之后在旋转x节点。(许多题解这里都没有讲为什么,自己想了许久,这里讲清楚希望能让其他人更好的理解)看下面第二张图可知,如果这样直接旋转的话,x,p,g三点的关系就乱掉了,从xpg变成了xgp,三点也不再同一条直线上了。而如果我们先旋转p,再旋转x的除了x,其他所有点的相对关系还没有改变。(为什么不能改变还不懂,所有操作搞懂以后再回来补)。

    update :破案了,就是没有区别。其实不管怎么选都是x在最上面,gp在x的下面,且g在p下面或p在g下面。即两边子树的树高还是一定的。结果是一样的,只是这样不一样的旋转方法或许可以让这颗树更平衡一点吧。因为一直从一边旋转是容易让某一颗子树的高度过低,也容易被特殊数据卡掉。所以说随机旋转是不是也可以?话说好像是有TREAPLAY。(等我几种都试试在回来打结果)

  3. 若p不是根节点,但x,p,fa[p],不在一条直线上的话,可以直接将x旋转两次。

    void splay(int rt,int to){//splay操作,即将当前节点旋转到to的子节点
        while(t[rt].fa!=to&&rt){//还有父节点,即还没旋转到根节点
            if(t[t[rt].fa].fa!=to)rotate(get(rt) == get(t[rt].fa) ? t[rt].fa:rt);
            else rotate(rt);
            //还有爷爷节点,需要特判一下,防止被卡
        }
        if(to==0)root=rt;//更新根节点
    }

插入

插入操作是SPLAY中一种非常恶心的操作,很麻烦,一下设要加入的值为 k。(好吧,其实也还好)

  1. 先比较当前节点的值与 k 的大小差别,如果在根据BIT的性质进入左右儿子。

  2. 若果当前节点的值等于 k 或是当前节点为空节点,加将数量加一(Splay是将所有权值相等的节点放在同一个点中的,即每个点还会记录一个出现次数)或是插入新节点 。

  3. 对进行修改的节点进行一次 SPLAY操作(即将他提到根节点)

    void init(int rt,int f,int k){//插入节点
        if(!rt){//如果当前节点为空,就添加一个新节点
            t[++tot].value=k;
            t[tot].fa=f;
            t[tot].cnt++;//计数器
            if(f)t[f].ch[t[f].value < k]=tot;//记得要一起更新父节点
            splay(tot,0);//所有的update在splay操作中更新
            return ;
        }
        if(t[rt].value==k){
            t[rt].cnt++;
            splay(rt,0);//千万不要忘记splay,所有return操作前都要splay
            return ;
        }
        init(t[rt].ch[t[rt].value < k ],rt,k);//根据BIT的性质进入左右儿子
        return ;
    }

查询 k 的排名

后面的操作就都差不多了,都是在树上根据BIT性质跑,并且在最后进行SPLAY操作就可以了(下面的排名将1看为第一名)。

  1. 如果当前节点比 k 小,将贡献加上左子树的size再+1,然后进入右儿子。

  2. 如果当前节点比 k 大,直接进入左儿子。

  3. 如果当前节点的值等于 k ,返回当前贡献+1,并对当前节点进行一次SPLAY操作(请一定要记住,所有操作后都要进行SPLAY操作)。

    int rank_k(int rt,int k,int sum){
        if(t[rt].value==k){
            sum+=t[t[rt].ch[0]].size;
            splay(rt,0);//进行splay操作,不然会炸
            return sum;
        }
        if(t[rt].value<k)return rank_k(t[rt].ch[1],k,sum+t[t[rt].ch[0]].size+t[rt].cnt);
        //左子树都是比 k 要小的数,直接加上就可以了
        else return rank_k(t[rt].ch[0],k,sum);
    }

查询排名为 k 的数

还是根据BIT的性质,在树上查找就可以了。

  1. 如果当前节点的左儿子的size大于 k ,进入左儿子。

  2. 不然将k减去(size+cnt),如果 k 小于等于 0 的话,就返回当前节点的值,不然进入右儿子。

  3. 记得进行 SPLAY操作。

    int find_k(int rt,int k){//查询排名为 k 的数
        if(t[t[rt].ch[0]].size>=k)return find_k(t[rt].ch[0],k);//记得是大于等于,因为等于也要往左子树走
        else if(k-t[t[rt].ch[0]].size-t[rt].cnt<=0)return t[rt].value;//如果小于等于0说明是当前节点
        return find_k(t[rt].ch[1],k-t[t[rt].ch[0]].size-t[rt].cnt);
    }

查询 x 的前驱

前驱定义为小于 x 的最大的树。所以我们可以进行一些转换,将x提为根,在查询就可以了。

  1. 先在BIT上进行查找,找到x的位置,对其进行SPLAY操作,将其提到根节点。

  2. 在现在的树上找 x 的左子树中最右的一个节点。

  3. 返回最右的节点并进行SPLAY操作

    int pre(int rt,int k){//求前驱
        if(!rt)return 0;
        if(t[rt].value==k){//如果当前节点是要找的节点
            splay(rt,0);//先将这个节点提到根节点
            int cur=t[rt].ch[0];//cur表示的即为当前点的前驱
            if(!cur)return cur;
            while(t[cur].ch[1])cur=t[cur].ch[1];//找左子树中最大的节点
            return cur;
        }
        return pre(t[rt].ch[t[rt].value < k],k);
    }

查询 x 的后继

后继定义为大于 x 的最小的树。因为定义与前驱恰好相反,所以操作也反过来就可以了。

  1. 先在BIT上进行查找,找到x的位置,对其进行SPLAY操作,将其提到根节点。

  2. 在现在的树上找 x 的右子树中最左的一个节点。

  3. 返回最左的节点并进行SPLAY操作

    int suf(int rt,int k){
        if(!rt)return 0;
        if(t[rt].value==k){//如果当前节点是要找的节点
            splay(rt,0);
            int cur=t[rt].ch[1];//cur表示的即为当前点的后继
            if(!cur)return cur;
            while(t[cur].ch[0])cur=t[cur].ch[0];//找右子树中最小的节点
            return cur;
        }
        return suf(t[rt].ch[t[rt].value < k],k);
    }

删除 x

Splay因为每个点还会存储出现次数,但又是平衡树,所以只在FHQ_TREAP上稍改一点就可以了。

  1. 先在BIT上找到 x 对其进行SPLAY操作并提到根节点。

  2. 如果当前节点的cnt(出现次数)大于 1 , 直接减一在退出就可以了。

  3. 如果cnt为1,先找到 x 的前驱与后继,分别为 a b ,现将a提到根节点, b 提到a的子节点。

  4. 将 b 的左儿子设为 0

关于这样做法的正确性,在下面给出图解证明:

首先,将 x 提到根节点,会有左右两颗子树,他的前驱 a 为 5 ,后继值为 7 。 而如果先将a,b提到 x 的儿子节点的话,a会没有右子树,b会没有左子树。而再将a与b旋转的话,x 会成为 a 的右儿子 ,而如果再将 b 与 x 旋转的话,x 会成为 b 唯一的一个左儿子。所以直接将 b 的左儿子设为 0 就可以了。

    void cut(int rt,int f,int k){//删除 k 
        if(t[rt].value == k){//已经找到了要删除的节点 k 了
            splay(rt,0);//将 k 提到根节点上去
            if(t[rt].cnt>1){
                t[rt].cnt--;//数量大于1的话直接减一就可以了,不用改变树的结构
                return ;
            }
            else{
                int x=pre(rt,k);//找到前驱
                int y=suf(rt,k);//找到后继
                splay(x,0);
                splay(y,x);
                t[y].ch[0]=0;//删除这个节点
                return ;
            }
        }
        cut(t[rt].ch[t[rt].value < k],rt,k);
        return ;
    }

特殊数据的卡法

根据SPLAY的插入可知,我们只要一次输入1~n ,就可以是Splay的树高变成 n ,而后面只要一次询问 n,1,n,1…这样循环就会将Splay卡成 $ O(n^2) $ ,这也就说明了上文特判的左右,因为先旋转父亲是会破坏链的结构的,这样就使得这样的循环询问可能降低树高,而不是一直为 n 。其实个人觉得这一操作改成随机化的话可能结果也差不多。

具体代码

定义的结构体:

struct node{
    int lc,rc;//左右儿子
    int cnt;//当前值的树出现了几次
    int size//左右子树的cnt之和,去其他的size有区别
    int fa;//父亲节点
    int value;//当前节点的大小
}t[N];

完整代码:

#include<bits/stdc++.h>
using namespace std;
const int N=100011;
struct node{
    int ch[2];//左右儿子 0为左儿子 1为右儿子
    int cnt;//当前值的树出现了几次
    int size;//当前点的cnt之和,去其他的size有区别
    int fa;//父亲节点
    int value;//当前节点的权值
}t[N<<1];
int n,tot;
int root;//根节点
struct Splay_tree{
    void update(int rt){//更新当前节点的值
        t[rt].size=t[t[rt].ch[0]].size+t[t[rt].ch[1]].size+t[rt].cnt;
        return ;
    }
    int get(int rt){//返回当前节点的父子关系,即当前节点是其父节点的左儿子还是右儿子
        return t[t[rt].fa].ch[1] == rt ;//若当前节点为右儿子,就会返回 1 ,不然返回 0
    }
    void clear(int rt){//清空当前节点
        t[t[rt].fa].ch[get(rt)]=0;//更新父亲节点
        t[rt].ch[0]=t[rt].ch[1]=t[rt].fa=t[rt].size=t[rt].cnt=0;//更新自己的值
        t[t[rt].ch[0]].fa=t[t[rt].ch[1]].fa=0;//将他的儿子也要更新
        return ;
    }
    void rotate(int rt){//旋转当前节点
        int x=t[rt].fa,y=t[x].fa,chx=get(rt),chy=get(x);//x为父亲节点,y为爷爷节点,chx表示当前节点的父子关系
        t[x].ch[chx]=t[rt].ch[chx^1];//先将rt的父节点的儿子更新了 
        if(t[rt].ch[chx ^ 1])t[t[rt].ch[chx ^ 1]].fa=x;
        t[rt].ch[chx^1]=x;
        t[x].fa=rt;
        t[rt].fa=y;
        if(y)t[y].ch[chy]=rt;
        update(x);//更新几个节点的值,因为他们的结构改变了
        update(rt);
        //不用更新爷爷节点 y ,因为他的子树总和还是一样的
        return ;
    }
    void splay(int rt,int to){//splay操作,即将当前节点旋转到to的子节点
        while(t[rt].fa!=to&&rt){//还有父节点,即还没旋转到根节点
            if(t[t[rt].fa].fa!=to)rotate(get(rt) == get(t[rt].fa) ? t[rt].fa:rt);
            else rotate(rt);
            //还有爷爷节点,需要特判一下,防止被卡
        }
        if(to==0)root=rt;//更新根节点
    }
    void init(int rt,int f,int k){//插入节点
        if(!rt){//如果当前节点为空,就添加一个新节点
            t[++tot].value=k;
            t[tot].fa=f;
            t[tot].cnt++;//计数器
            if(f)t[f].ch[t[f].value < k]=tot;//记得要一起更新父节点
            splay(tot,0);
            return ;
        }
        if(t[rt].value==k){
            t[rt].cnt++;
            splay(rt,0);//千万不要忘记splay,所有return操作前都要splay
            return ;
        }
        init(t[rt].ch[t[rt].value < k ],rt,k);//根据BIT的性质进入左右儿子
        return ;
    }
    int pre(int rt,int k){//求前驱
        if(!rt)return 0;
        if(t[rt].value==k){//如果当前节点是要找的节点
            splay(rt,0);
            int cur=t[rt].ch[0];//cur表示的即为当前点的前驱
            if(!cur)return cur;
            while(t[cur].ch[1])cur=t[cur].ch[1];//找左子树中最大的节点
            //splay(cur,0);
            return cur;
        }
        return pre(t[rt].ch[t[rt].value < k],k);
    }
    int suf(int rt,int k){
        if(!rt)return 0;
        if(t[rt].value==k){//如果当前节点是要找的节点
            splay(rt,0);
            int cur=t[rt].ch[1];//cur表示的即为当前点的后继
            if(!cur)return cur;
            while(t[cur].ch[0])cur=t[cur].ch[0];//找右子树中最小的节点
            return cur;
        }
        return suf(t[rt].ch[t[rt].value < k],k);
    }
    void cut(int rt,int f,int k){//删除 k 
        if(t[rt].value == k){//已经找到了要删除的节点 k 了
            splay(rt,0);//将 k 提到根节点上去
            if(t[rt].cnt>1){
                t[rt].cnt--;//数量大于1的话直接减一就可以了,不用改变树的结构
                return ;
            }
            else{
                int x=pre(rt,k);
                int y=suf(rt,k);
                splay(x,0);
                splay(y,x);
                t[y].ch[0]=0;
                return ;
            }
        }
        cut(t[rt].ch[t[rt].value < k],rt,k);
        return ;
    }
    int rank_k(int rt,int k,int sum){
        if(t[rt].value==k){
            sum+=t[t[rt].ch[0]].size;
            splay(rt,0);
            return sum;
        }
        if(t[rt].value<k)return rank_k(t[rt].ch[1],k,sum+t[t[rt].ch[0]].size+t[rt].cnt);
        //左子树都是比 k 要小的数,直接加上就可以了
        else return rank_k(t[rt].ch[0],k,sum);
    }
    int find_k(int rt,int k){//查询排名为 k 的数
        if(t[t[rt].ch[0]].size>=k)return find_k(t[rt].ch[0],k);
        else if(k-t[t[rt].ch[0]].size-t[rt].cnt<=0)return t[rt].value;
        return find_k(t[rt].ch[1],k-t[t[rt].ch[0]].size-t[rt].cnt);
    }
}ST;
int main(){
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin>>n;
    ST.init(root,0,INT_MAX);
    ST.init(root,0,-1000000000);
    //保证有前驱后继,不然查询的时候会出错
    for(int i=1;i<=n;i++){
        int opt,k;
        cin>>opt>>k;
        if(opt==1){//插入 k 
            ST.init(root,0,k);
        }
        if(opt==2){//删除 k
            ST.cut(root,0,k);
        }
        if(opt==3){//查询 k 的排名
            cout<<ST.rank_k(root,k,0)<<endl;
        }
        if(opt==4){//查询排名为 k 的数
            cout<<ST.find_k(root,k+1)<<endl;
        }
        if(opt==5){//找前驱
            ST.init(root,0,k);
            cout<<t[ST.pre(root,k)].value<<endl;
            ST.cut(root,0,k);
        }
        if(opt==6){//找后继
            ST.init(root,0,k);
            cout<<t[ST.suf(root,k)].value<<endl;
            ST.cut(root,0,k);
        }
    }
}

完结撒花٩(๑>◡<๑)۶

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值