Splay算法详解

Splay算法详解

本篇随笔浅谈一下算法竞赛中的\(Splay\)算法。

Splay的概念

Splay在我看来应该算作一种算法而非数据结构。无论是Treap,AVL,SBT,替罪羊树还是Splay其实都应该算作算法,因为它们都在解决一种数据结构存在的问题:二叉搜索树\(BST\)

对于二叉搜索树和Treap(平衡树概念)不了解的,可以移步下面两篇博客:

浅谈二叉搜索树

详解Treap

有了前置知识,我们可以发现问题的根源在于\(BST\)在数据极端的情况下会退化成链。而什么Treap啊,AVL啊,SBT啊,Splay啊,替罪羊树啊都是在干一件事情:如何让这棵树尽可能平衡,不要退化成链。

有个叫做Tarjan的大佬YY出了Splay这种算法。

Splay的实现方式

Treap的实现原理在于为所有节点附加一个键值。这个键值按堆的形式排序。让整个数据结构在节点权值上是BST,但在优先级(键值)上却是一个堆。

但Splay的实现方式是对节点的序号进行重构,从而达到平衡树要求的标准。


splay操作

splay操作是指将一个点一直上旋,知道某个指定点。默认是根节点。

Splay的精髓在于旋转,这已经是不争的事实了。

如果对于树的旋转这个知识点有些不明白,请移步Treap的博客:

Treap详解


相比于Treap只有单旋这一种旋转方式,Splay的优秀之处在于它有着4种旋转方式:单旋和双旋。

什么是双旋?为什么要双旋?

双旋的适用范围在于:祖父、父亲和儿子节点连成一条线。

就像这样:

1595832450131

如果这样的结构不去双旋而简单粗暴地用两次单旋(先将需要旋转的节点上旋,再转他的父亲)来解决,最后造成的结果就像是右边的样子(自己手画模拟一下两次单旋即可发现)。我们发现,左侧的树是四层,右侧的树仍然是四层:对于复杂度来讲,没有任何优化。因为我们的平衡树是尽量把复杂度变成\(\log\)级别的。

那么,我们就需要使用双旋。双旋的操作方式是:先将父节点上旋,再将需要旋转的节点上旋。别看这个双旋只是改变了其旋转顺序,但是可是大有文章,经过这样的旋转策略,最终的旋转结果就变成了这样:

1595833252570

嗯,不错,少了一层,看起来很平衡。

所以,我们的Splay的精华是Splay操作,也就是把某个节点一直转到根节点,顺道改变整棵树的形态。具体实现方式是:如果当前节点的父亲节点就是根节点或者自己、父亲、祖父不在一条直线上,就单旋;如果自己、父亲、祖父正好在同一条直线上,那么就双旋:双旋的策略是:先转父亲后转自己。

所以我们有四种旋转方式:单旋左旋,单旋右旋,双旋左旋,双旋右旋。

这也是Splay的基本操作。


Splay和Treap在旋转上的区别

学过Treap的小伙伴(包括我)都在觉得Splay的单旋和Treap的左右旋好像是一样的。但是实际上它们却有所不同。在本蒟蒻的理解上,这种不同体现在“对象”上。

Treap的旋转是将儿子节点旋到自己的位置。左旋是把右儿子转到自己的位置,右旋是把左儿子转到自己的位置。

但是Splay的旋转则是将自己转到父亲节点的位置,虽然形式上都是上旋,但是作用对象是不一样的。


Splay的代码实现

一般来讲,Splay支持的操作和Treap是差不多的。其实所有的平衡树都是这种操作。动态插入删除,然后去动态查询第k大、数的排名、前驱、后继等等。因为平衡树的本质是BST,所以BST所拥有的性质,Splay全都可以支持和维护。

就拿洛谷的例题来讲吧:P3369的模板。

题目传送门

前置信息

前置信息包括三个函数:maintain函数(来维护节点的size,后期统计排名用),get函数(确认当前节点是它爹的左儿子还是右儿子,旋转要用)以及find函数(在树中找到某个值的节点位置)

void maintain(int x)
{
    size[x]=cnt[x]+size[ch[x][0]]+size[ch[x][1]];
}
int find(int x)
{
    int pos=root;
    while(val[pos]!=x)
        pos=ch[pos][x>val[pos]];
    return pos;
}
int get(int x)
{
    int f=fa[x];
    return ch[f][1]==x;
}

代码很简单,应该一看就能懂。

旋转&Splay操作

上面的Splay的实现原理就在讲旋转和Splay操作的原理。依照上面讲的模拟即可得到下面的模板:

void rotate(int x)
{
    int y=fa[x],z=fa[y];
    int k=get(x);//k表示x是y的什么儿子
    ch[y][k]=ch[x][k^1],fa[ch[y][k]]=y;
    ch[x][k^1]=y,fa[y]=x,fa[x]=z;
    if(z)
        ch[z][ch[z][1]==y]=x;
    maintain(y),maintain(x);
}
void splay(int x,int &goal)
{
    int f=fa[goal];
    while(fa[x]!=f)
    {
        if(fa[fa[x]]!=f)
            rotate(get(x)==get(fa[x])?fa[x]:x);
        rotate(x);
    }
    goal=x;
}

插入

插入的时候需要一个动态开点。也就是用到哪开到哪,所以一开始首先要判整棵树是不是空的。然后再去进行插入。插入的时候要注意:因为有些数可以重复出现。所以当我们在原树中找到与插入节点完全相同的节点之后,不用去插入,直接把计数变量+1就可。

如果这是一个全新的节点,那么它一定是个叶子。所以如果我们没在原树中找到这个节点的话,就得用动态开点把点加进去。

动态开点不太会的小伙伴走这边:

浅谈动态开点

插入要注意两点:第一点是maintain,也就是统计节点的大小。第二点是Splay,每次插入和删除之后都要Splay,来维护树的形态。这也是Splay算法的精髓所在,一定不要忽略。这两种操作应该maintain在前,splay在后,因为在进入splay之前要保证当前状态下所有节点的信息都是对的,才能保证Splay之后所有节点的信息也都是对的。

插入代码:

void insert(int x)
{
    if(!root)
    {
        ++tot;
        root=tot;
        val[root]=x;
        cnt[root]=1;
        maintain(root);
        return;
    }
    int pos,f;
    pos=f=root;
    while(pos && val[pos]!=x)
        f=pos,pos=ch[pos][x>val[pos]];
    if(!pos)
    {
        ++tot;
        cnt[tot]=1;
        val[tot]=x;
        fa[tot]=f;
        ch[f][x>val[f]]=tot;
        maintain(tot);
        splay(tot,root);
        return;
    }
    ++cnt[pos];
    maintain(pos);
    splay(pos,root);
}

删除

删除的操作其实和Treap的有些像,但是又有些不同。作为一个树中节点来讲,尤其是一个BST的节点,我们肯定不能直接上来就暴力删掉这个节点,否则无法维护BST的性质。为了解决这个问题,我们选择把节点直接Splay转到根节点,这样的话,直接删根对左右子树是无影响的,直接合并就好。

但是要分几种情况讨论:(Splay之后)

首先,如果这个点有很多数,但是我们只删一个,所以直接cnt-1就可,其他不用变。

如果只有一个,那么就要删除,但是删除之后谁来当根节点呢?这又有三种情况可以讨论:根节点只有左儿子,根节点只有右儿子,根节点儿女双全。

前两种只能给唯一的儿子。如果儿女双全,就得在删掉当前根节点后再找一个根节点,但是问题来了,新的根节点到底是用左儿子合适还是用右儿子合适?

都不合适。根据BST的性质,因为左子树都比当前节点小,右子树都比当前节点大,所以我们选择根节点左子树中最大的点作为根节点(就是根节点向左,然后一直向右到头)。

别忘了Splay维护和maintain更新。

代码:

void del(int x)
{
    int pos=find(x);
    splay(pos,root);
    if(cnt[root]>1)
    {
        cnt[root]--;
        maintain(root);
        return;
    }
    if((!ch[root][0])&&(!ch[root][1]))
        root=0;
    else if(!ch[root][0])
        root=ch[root][1],fa[root]=0;
    else if(!ch[root][1])
        root=ch[root][0],fa[root]=0;
    else
    {
        pos=ch[root][0];
        while(ch[pos][1])
            pos=ch[pos][1];
        splay(pos,ch[root][0]);
        ch[pos][1]=ch[root][1];
        fa[ch[root][1]]=pos,fa[pos]=0;
        maintain(pos);
        root=pos;
    }
}

查询排名

当然可以用BST的性质来遍历树计算排名。但是好笨的样子。

我们会Splay啊,直接把要查的节点Splay上去,然后直接调用其左子树的size+1不就可以了嘛!

int rank(int x)
{
    int pos=find(x);
    splay(pos,root);
    return size[ch[root][0]]+1;
}

查询第k大

int kth(int x)
{
    int pos=root;
    while(1)
    {
        if(x<=size[ch[pos][0]])
            pos=ch[pos][0];
        else 
        {
            x-=(size[ch[pos][0]]+cnt[pos]);
            if(x<=0)
            {
                splay(pos,root);
                return val[pos];
            }
            pos=ch[pos][1];
        }
    }
}

查询前驱/后继

前驱和后继的定义已经给出。我们惊喜地发现和BST的性质真的是无比的契合。

所以我们直接用BST查就可以,任何平衡树算法都是一样的。

代码:

int pre(int x)//前驱
{
    int ans;
    int pos=root;
    while(pos)
    {
        if(x>val[pos])
        {
            ans=val[pos];
            pos=ch[pos][1];
        }
        else
            pos=ch[pos][0];
    }
    return ans;
}
int nxt(int x)//后继
{
    int ans;
    int pos=root;
    while(pos)
    {
        if(x<val[pos])
        {
            ans=val[pos];
            pos=ch[pos][0];
        }
        else
            pos=ch[pos][1];
    }
    return ans;
}
posted @ 2020-07-28 16:03  Seaway-Fu  阅读(3025)  评论(1编辑  收藏  举报