Splay基本操作

我们以一道题来引入吧!

传送门

题目说的很清楚,我们的数据结构要支持:插入x数,删除x数,查询数的排名和排名为x的数,求一个数前驱后继。

似乎用啥现有的数据结构都很难做到在O(nlogn)的复杂度中把这些问题全部解决……(别跟我说什么set,vector……)

所以我们来介绍一种新的数据结构——平衡树splay!

什么是平衡树呢?这是一种数据结构,满足以下性质:

1.它是一棵二叉树

2.对于任意一个节点,它的左子树中的任意一个节点的权值比他小,右子树中任意一个节点的权值比他大

3.一棵平衡树的任意一棵子树也是一个平衡树(其实第二条已经说明了这个事了)

比如说这就是一棵平衡树(偷了yyb神犇的图),它现在非常优秀,深度是logn的。但是如果向一条链上插入多个数,或者出现什么奇怪的操作使平衡树退化为链,那么再进行查找等操作是很慢的,最坏甚至会到O(n)……比如下面这样(仍然偷了yyb神犇的图)

平衡树本身是可以支持上面所说的这些查询操作的(因为自身本身优秀的性质使得其可以进行二分),但是为了解决他有可能退化为链的这么个问题,神犇tarjan发明了一种新的数据结构——splay。

splay(伸展树),核心操作是rotate和splay(多个rotate),以使得对于每次操作之后树依然能保持一个良好的身材,使之不至于被退化为链,每次操作都能快速的在O(logn)中完成。这种操作使得每次改变之后这棵树仍然是一颗平衡树,但是节点的顺序发生了一些改变使得深度保持稳定。我们来看个图理解一下。

(以下图均偷自yyb神犇)

 

 

 如图,x,y,z是三个节点,A,B,C是三棵子树。因为是一棵平衡树所以现在必然满足x<y<z.

现在我们看似乎x所在的那一侧有点长……所以我们不妨尝试把x旋转到y的位置,这样就能重构这棵树的结构,或许能使这棵树的深度变得更优。

首先我们肯定要保证新构成的树还是一棵平衡树,所以对于x,y,z三个节点,必然x,y还是在z的左子树中,既然把x移动到y的位置,那么x自然就成了z的左子树。不过平衡树还是一棵二叉树,那么y节点肯定不能呆在那了,它应该去哪呢?

我们思考一下,首先因为必须满足y>x,y<z,而x又是z的左儿子,所以y必然在x的左右子树中。而y还比x大,所以y自然应该在x的右子树中。那我们就直接把y变成x的右儿子就好啦!不过这样的话,x,y的子树又应该怎么办呢?x原来是y的左儿子,现在x成了y的父亲,那么y的左儿子那个位置肯定是空的,然后又因为x现在右儿子是y,它原来的右儿子多了出来(就是上面图的B)。B必然保证所有节点都<y,y现在还缺左儿子,所以直接把B变为y的左子树即可。

这样它就变成了这个样子,而且仍然是一棵平衡树。(上面已经说明过了,也可以自己看看验证一下)

 

 不过其实不只有这一种情况,x,y,z之间的关系一共有四种,随便推一推画一画就能看出来。

但是我们不可能写四个函数的对吧!所以我们要找出其中的普遍性规律,使得我们写一个函数就能解决问题。

首先因为肯定是用x去代替y,那么y是z的哪个儿子,转化之后x也必然是z的哪个儿子。

之后,我们又发现,一开始x是y的哪个儿子,那么在进行一次转变之后x的那个儿子就不会变化。为什么呢?假如说现在x是y的右儿子,那么必然满足x>y.而经过一次转变之后,x成为了z的一个儿子,那么y只能成为x的其中一个儿子(这个上文已经提到过了),那么现在y<x,y自然就成为了x的左儿子,相对应的x左儿子移动,右儿子并没有变化。反过来也是一个道理。

简单的来说就一句话:x是y的哪个儿子,那么相对应的x的那个儿子一定比其父亲(x)在权值方面(大/小)更优,所以y必然不会更新到它。

最后就很容易看出来了,x是y的哪个儿子,y就会成为x与之相对应的儿子(比如x是y的左儿子,y就会成为x的右儿子)

总结一下:(直接抄yyb神犇的话了,比较简洁)

所以总结一下: 
1.X变到原来Y的位置 
2.Y变成了 X原来在Y的 相对的那个儿子 
3.Y的非X的儿子不变 X的 X原来在Y的 那个儿子不变 
4.X的 X原来在Y的 相对的 那个儿子 变成了 Y原来是X的那个儿子

我们上一份代码来看一下吧!

bool get(int x)//get用于返回当前节点是自己父亲的哪个儿子
{
    return t[t[x].fa].ch[1] == x;
}

void pushup(int x)
{
    t[x].son = t[t[x].ch[0]].son + t[t[x].ch[1]].son + t[x].cnt;
}//更新自己为根的子树大小,其中cnt是这个数出现的次数

void rotate(int x)//旋转操作
{
    int y = t[x].fa,z = t[y].fa,k = get(x);
    t[z].ch[t[z].ch[1] == y] = x,t[x].fa = z;//更改z节点的儿子,x节点的父亲
    t[y].ch[k] = t[x].ch[k^1],t[t[y].ch[k]].fa = y;//更改y节点的一个儿子和它的父亲
    t[x].ch[k^1] = y,t[y].fa = x;//更改x节点的儿子和y节点的父亲
    pushup(x),pushup(y);//更新状态
}

是不是炒鸡好理解呀QWQ

那么splay最核心的操作我们就说完了。下面我们再来说说splay操作!(要不这玩意为啥叫splay啊……)

其实splay操作就是rotate操作的叠加,本来rotate操作是很优秀的一种操作,不过实际上还会发生一些奇怪的事情,我们要考虑这样一些问题。

比如说我们看这样一棵平衡树。

好的,如果你直接两次右rotate把x旋到z的位置,那么你将得到这样一棵平衡树:

我们仔细观察之后发现一个神奇的问题,有一条链其实没有发生长度变化。就是z-y-x-b这条链,两次旋转之后变成了z-x-y-b,但是本身的长度是没有改变的。

这样splay就容易被卡,比如你一直往这条链里面插入元素,它可能就变得很长很长很长,你查询的速度又会很慢很慢很慢。

那咋办?难道伟大的splay就不能实现了嘛?当然不是,我们只要更改一下rotate的办法即可。对于上面这种状况,我们可以自己手画一下,如果先把y旋转到z的位置,再把x旋转到y的位置,这条链的长度就缩短了。

这个也是有普遍性结论的!我们发现如果x,y是y,z的同一个儿子,那么就先旋转y再旋转x,否则直接旋转两次x即可。(这段大家手画一下理解一下更好)

可能有人会提出这样的疑问,就是在更改了rotate顺序之后,当前这条链确实缩短了,但是相对的还有一(多)条链可能没有长度变化,这个不会被卡吗?

仔细想之后是不会的,因为我们每次splay操作是从当前被修改的节点开始的。所以只要保证当前所在链长度不会增大即OK。况且从长远来看,你构造出一个splay之后,它本身是会接近满二叉树的一种状态,你对其进行一次splay,它仍然是接近满二叉树的状态,故进过多次splay之后,这棵splay依然是接近满二叉树的良好身材。

我们来看一下splay操作的代码!

void splay(int x,int goal)//goal是要旋转到的目标节点
{
    while(t[x].fa != goal)
    {
    int y = t[x].fa,z = t[y].fa;
    if(z != goal) (t[y].ch[0] == x) ^ (t[z].ch[0] == y) ? rotate(x) : rotate(y);//对于左右儿子的讨论
    rotate(x);
    }
    if(goal == 0) root = x;//更改根节点
}

ovo其实到这里splay最核心的俩操作已经结束了。那我们来继续说一说怎么支持上面的这些操作。

首先是查找find操作,查找一个数的位置。对于一个节点,左面全比他小,右面全比他大,那我们就可以直接愉快的二分,向左/右递归即可。然后我们把这个节点splay到根,方便接下来肆意(划死)操作。

我们直接看代码吧!

void find(int x)
{
    int u = root;
    if(!u) return;
    while(t[u].ch[x > t[u].val] && x != t[u].val) u = t[u].ch[x > t[u].val];//通过x和当前节点值的大小比较来确定应该向哪里递归
    splay(u,0);
}

然后是insert操作,插入一个数。我们首先找到这个数在平衡树中哪个位置,如果这个节点已经存在的话,那我们把这个节点出现次数++,否则直接新建一个节点。

void insert(int x)
{
    int u = root,f = 0;
    while(u && t[u].val != x) f = u,u = t[u].ch[x > t[u].val];
    if(u) t[u].cnt++;
    else
    {
    u = ++idx;
    if(f) t[f].ch[x > t[f].val] = u;
    t[u].ch[0] = 0,t[u].ch[1] = 0;
    t[u].fa = f,t[u].val = x,t[u].cnt = 1,t[u].son = 1;
    }
    splay(u,0);
}

在之后,前驱/后继操作(通过传0/1可以实现用一个函数同时找前驱/后继)。一个数的前驱就是其左子树中最靠右的节点,后继同理。

int next(int x,int f)
{
    find(x);
    int u = root;
    if((t[u].val > x && f) || (t[u].val < x && !f)) return u;//如果当前节点权值大于x而且要找后继,或者当前节点权值小于x同时要找前驱
    u = t[u].ch[f];
    while(t[u].ch[f^1]) u = t[u].ch[f^1];//要往反方向跳
    return u;
}

当然我们可以选择分开写(这个是求根的前驱后继,实际使用的时候可以转化为先求一个点的编号再这样做)

int pre()
{
    int u = t[root].ch[0];
    while(t[u].ch[1]) u = t[u].ch[1];
    return u;
}
int next
{
  int u = t[root].ch[1];
    while(t[u].ch[0]) u = t[u].ch[0];
    return u;
}

最后是删除del操作。这个还是稍微有点麻烦。我们首先找到x的前驱,把它旋转到根,再找到x的后继把它旋转到当前的根(x的前驱)的右子树,那么这个时候x的后继的左子树里面就只可能有x,把它删了。如果出现次数大于1就cnt--并且splay到根,否则直接删了完事。

void del(int x)
{
    int la = next(x,0),ne = next(x,1);
    splay(la,0),splay(ne,la);
    int g = t[ne].ch[0];
    if(t[g].cnt > 1) t[g].cnt--,splay(g,0);
    else t[ne].ch[0] = 0;
}

哎不对,还有一个排名为x的数。这个也比较容易,我们还是用二分的思想即可。

int rk(int x)
{
    int u = root;
    if(t[u].son < x) return 0;
    while(1)
    {
    int y = t[u].ch[0];
    if(x > t[y].son + t[u].cnt) x -= (t[y].son + t[u].cnt),u = t[u].ch[1];//这时要向右子树找
    else if(t[y].son >= x) u = y;向左子树找
    else return t[u].val;//否则说明找到了,返回
    }
}

 

那我们就成功的用splay解决了这些问题!我们来看一下完整代码。

 

// luogu-judger-enable-o2
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<iostream>
#include<cmath>
#include<queue>
#include<set>
#define rep(i,a,n) for(int i = a;i <= n;i++)
#define per(i,n,a) for(int i = n;i >= a;i--)
#define enter putchar('\n')

using namespace std;
typedef long long ll;
const int M = 100005;
const int INF = 2147483647;

int read()
{
    int ans = 0,op = 1;
    char ch = getchar();
    while(ch < '0' || ch > '9')
    {
    if(ch == '-') op = -1;
    ch = getchar();
    }
    while(ch >= '0' && ch <= '9')
    {
    ans *= 10;
    ans += ch - '0';
    ch = getchar();
    }
    return ans * op;
}

struct node
{
    int fa,son,ch[2],cnt,val;
}t[M<<2];

int n,root = 0,idx,op,x;

void pushup(int x)
{
    t[x].son = t[t[x].ch[0]].son + t[t[x].ch[1]].son + t[x].cnt;
}

bool get(int x)
{
    return t[t[x].fa].ch[1] == x;
}

void rotate(int x)
{
    int y = t[x].fa,z = t[y].fa,k = get(x);
    t[z].ch[t[z].ch[1] == y] = x,t[x].fa = z;
    t[y].ch[k] = t[x].ch[k^1],t[t[y].ch[k]].fa = y;
    t[x].ch[k^1] = y,t[y].fa = x;
    pushup(x),pushup(y);
}

void splay(int x,int goal)
{
    while(t[x].fa != goal)
    {
    int y = t[x].fa,z = t[y].fa;
    if(z != goal) (t[y].ch[0] == x) ^ (t[z].ch[0] == y) ? rotate(x) : rotate(y);
    rotate(x);
    }
    if(goal == 0) root = x;
}

void insert(int x)
{
    int u = root,f = 0;
    while(u && t[u].val != x) f = u,u = t[u].ch[x > t[u].val];
    if(u) t[u].cnt++;
    else
    {
    u = ++idx;
    if(f) t[f].ch[x > t[f].val] = u;
    t[u].ch[0] = 0,t[u].ch[1] = 0;
    t[u].fa = f,t[u].val = x,t[u].cnt = 1,t[u].son = 1;
    }
    splay(u,0);
}

void find(int x)
{
    int u = root;
    if(!u) return;
    while(t[u].ch[x > t[u].val] && x != t[u].val) u = t[u].ch[x > t[u].val];
    splay(u,0);
}

int next(int x,int f)
{
    find(x);
    int u = root;
    if((t[u].val > x && f) || (t[u].val < x && !f)) return u;
    u = t[u].ch[f];
    while(t[u].ch[f^1]) u = t[u].ch[f^1];
    return u;
}

void del(int x)
{
    int la = next(x,0),ne = next(x,1);
    splay(la,0),splay(ne,la);
    int g = t[ne].ch[0];
    if(t[g].cnt > 1) t[g].cnt--,splay(g,0);
    else t[ne].ch[0] = 0;
}

int rk(int x)
{
    int u = root;
    if(t[u].son < x) return 0;
    while(1)
    {
    int y = t[u].ch[0];
    if(x > t[y].son + t[u].cnt) x -= (t[y].son + t[u].cnt),u = t[u].ch[1];
    else if(t[y].son >= x) u = y;
    else return t[u].val;
    }
}

int main()
{
    insert(INF),insert(-INF);
    n = read();
    rep(i,1,n)
    {
    op = read();
    if(op == 1) x = read(),insert(x);
    else if(op == 2) x = read(),del(x);
    else if(op == 3) x = read(),find(x),printf("%d\n",t[t[root].ch[0]].son);
    else if(op == 4) x = read(),printf("%d\n",rk(x+1));
    else if(op == 5) x = read(),printf("%d\n",t[next(x,0)].val);
    else if(op == 6) x = read(),printf("%d\n",t[next(x,1)].val);
    }
    return 0;
}

 

 

 

一开始插入INF和-INF的操作其实可以暂时忽略,那个是保证splay翻转区间时的正确性,还有好多其他实际题中的应用正确性的。(区间反转下次再说)

splay是不是很好理解呢?(不过代码讲真还是比较难记……)

posted @ 2018-09-30 22:35  CaptainLi  阅读(1138)  评论(0编辑  收藏  举报