Splay

Splay.

(2022年5月24日重构)

Splay是一种很好地维护一棵二叉搜索树的方法。
如果不知道什么是二叉搜索树,请看此页面

基本函数

Splay的基本思想是,通过一系列的旋转操作,维持整棵二叉搜索树的平衡。

首先,我们假设我们需要维护的结构体是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
struct Node
{
int s[2], fa;
int v, w;
int sz;
void init(int _v, int _fa)
{
v = _v, fa = _fa;
sz = w = 1;
}
}tr[N];

解释:

  • s[2]:左右子树。
  • fa:父亲。
  • v:节点权值。
  • w:节点大小。
  • sz:节点及其子树大小。

还有一些其他的东西就一一列举了,比如懒标记等等。

旋转

Splay之所以能够维持其平衡,依赖的就是这样的简单旋转操作。

左旋与右旋

对于这样一个图:

splay1.png

其中x节点有两个子树,A与B;x节点的父亲y节点还有一棵子树C;z节点是y节点的父亲。
我们将x节点旋转(这里x节点是其父亲y节点的左儿子,所以我们会将其右旋),结果是这个样子的:

splay2.png

当然,如果我们把x旋转回去的话,那就是左旋操作了。

总体来看是这个样子:

splay3.png

在进行旋转操作时,我们需要保持其中序遍历序列不变。

在刚刚的右旋操作中,我们来分析一下我们需要改变的边:

splay4.png

就是这三条标红的边。

那么对于这三条边,我们分别进行重构操作。

代码如下:

1
2
3
4
int k = tr[y].s[1] == x;//x是y的哪个儿子
tr[z].s[tr[z].s[1] == y] = x, tr[x].fa = z;//重构z-x边
tr[y].s[k] = tr[x].s[k ^ 1], tr[tr[x].s[k ^ 1]].fa = y;//重构y-B边
tr[x].s[k ^ 1] = y, tr[y].fa = x;//重构x-y边

旋转完成之后,因为我们改变了树的结构,所以我们需要重新计算x和y的大小,有时候还有需要维护的其他信息。
注意这里需要先维护较低的y,再维护较高的x。

所以总的函数是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void pushup(int p)
{
tr[p].sz = tr[p].w;
if(tr[p].s[0])tr[p].sz += tr[tr[p].s[0]].sz;
if(tr[p].s[1])tr[p].sz += tr[tr[p].s[1]].sz;
}
void rotate(int x)
{
int y = tr[x].fa, z = tr[y].fa;
int k = tr[y].s[1] == x;
tr[z].s[tr[z].s[1] == y] = x, tr[x].fa = z;
tr[y].s[k] = tr[x].s[k ^ 1], tr[tr[x].s[k ^ 1]].fa = y;
tr[x].s[k ^ 1] = y, tr[y].fa = x;
pushup(y), pushup(x);
}

核心函数

我们在向splay中插入一个数之后,会强制将其旋转到根。
而在刚才的示例中,我们看到了,我们将x右旋之后,x就向上走了一点。
而经过不断的旋转,我们就可以让x节点走到根。

当然,我们也不是随便瞎转,因为旋转操作也是需要复杂度的。
而我们的最后目标是使之均摊之后达到尽量小的复杂度。
引用闫学灿的一句话:

“如果我们瞎转的话,就达不到$O(\log n)$的复杂度了。”

所以我们要根据x所处的位置来制定不同的旋转方案。

首先,我们对于x可能出现的几种情况分析一下:

  1. x就是目标节点。
    那么就不用转了。

  2. x是目标节点的子节点。
    那我们直接转一下x就可以了。

对于x的父亲也不是目标节点的情况,我们也分两种情况讨论。

  1. x的父亲也不是目标节点,且x与其父亲的所在子树类型相同。
    可以理解为x,x的父亲和x的父亲的父亲三个节点在一条直线上。
    这样的话,我们就先旋转x的父节点,再旋转x。

  2. x的父亲也不是目标节点,且x与其父亲的所在子树类型不同。
    可以理解为x,x的父亲和x的父亲的父亲三个节点的连线是一条折线。
    这样的话,我们旋转两次x。

这样不断判断,直到x到达目标节点。

同时我们需要注意,因为我们根节点是随着我们的不断旋转而不断变化的,所以我们需要即使更新根节点的信息。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void splay(int x, int k)
{
while(tr[x].fa != k)
{
int y = tr[x].fa, z = tr[y].fa;
if(z != k)
{
if((tr[y].s[1] == x) ^ (tr[z].s[1] == y))
rotate(x);
else
rotate(y);
}
rotate(x);
}
if(!k) rt = x;
}

其他操作

我们假设我们需要维护的是一棵可重序列的二叉搜索树。

插入

Splay的插入操作是比较复杂的。
假设我们需要向树中插入一个元素 $k$,那么我们分下列几种情况讨论:

  • 如果树是空的,那就直接插入根节点。
  • 如果当前节点的权值等于 $k$,那就增加当前节点的大小,并更新其与其父亲的信息。
  • 否则就按照二叉搜索树的性质继续向下面的节点查找。

最后不要忘记将节点旋转到根。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void insert(int k)
{
if(!rt)
{
tr[++idx].init(k, 0);
rt = idx;
return;
}
int p = rt, fa = 0;
while(true)
{
if(tr[p].v == k)
{
tr[p].w++;
pushup(p), pushup(fa);
splay(p, 0);
break;
}
fa = p;
p = tr[p].s[tr[p].v < k];
if(!p)
{
tr[++idx].init(k, fa);
tr[fa].s[tr[fa].v < k] = idx;
pushup(fa);
splay(idx, 0);
break;
}
}
}

删除

Splay不使用惰性删除,其删除操作也是比较复杂的。
假设我们想要删除节点 $x$。

首先,我们将 $x$ 旋转到根。

然后我们分类讨论:

  • 如果 $x$ 的大小不为1,那就减少其大小。
  • 否则直接合并其两棵子树。

合并两棵树的操作很简单。如果我们假设需要合并的两棵树 $x$ 和 $y$ 中,$x$ 的最大值大于 $y$ 的话,只需要将的 $x$ 的最大值旋转到根,同时将 $y$ 设置为其根节点的右子树即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void loschn(int k)
{
get_rk(k);
if(tr[rt].w > 1)//节点内部包含多个相同元素
{
tr[rt].w--;
pushup(rt);
return;
}
if(!tr[rt].s[0] && !tr[rt].s[1])//全树上下只剩这一个点了!
{
tr[rt].clear();
rt = 0;
return;
}
if(!tr[rt].s[0])//没有左子树,这个点是整棵树最小的点了
{
int p = rt;
rt = tr[rt].s[1];
tr[rt].fa = 0;
tr[p].clear();
return;
}
if(!tr[rt].s[1])//没有右子树,这个点是整棵树最大的点了
{
int p = rt;
rt = tr[rt].s[0];
tr[rt].fa = 0;
tr[p].clear();
return;
}
//一般情况
int p = rt, x = precrs();
tr[tr[p].s[1]].fa = x;
tr[x].s[1] = tr[p].s[1];
tr[p].clear();
pushup(rt);
}

查询k的排名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int get_rk(int k)
{
int res = 0, p = rt;
while(true)
{
if(k < tr[p].v)//在左子树中
{
p = tr[p].s[0];
}
else
{
if(tr[p].s[0])res += tr[tr[p].s[0]].sz;
if(k == tr[p].v)//就是这个点了!
{
splay(p, 0);
return res + 1;
}
//搜索右子树
res += tr[p].w;
p = tr[p].s[1];
}
}
return -1;
}

查询排名为k的数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int get_k(int k)
{
int p = rt;
while(true)
{
if(tr[tr[p].s[0]].sz >= k)//在左子树中
{
p = tr[p].s[0];
}
else
{
k -= tr[p].w;
if(tr[p].s[0])k -= tr[tr[p].s[0]].sz;
if(k <= 0)//就是这个点了!
{
splay(p, 0);
return tr[p].v;
}
//搜索右子树
p = tr[p].s[1];
}
}
return -1;
}

查询k的前驱或后继

前驱定义为小于这个数的最大数,后继定义为大于这个数的最小数。

我们的思路是,先将其插入进去,这样它就会到根节点;然后查询其左子树内的最大值或右子树内的最小值即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int precsr()
{
int p = tr[rt].s[0];
if(!p)return p;
while(tr[p].s[1])p = tr[p].s[1];
splay(p, 0);
return p;
}
int succsr()
{
int p = tr[rt].s[1];
if(!p)return p;
while(tr[p].s[0])p = tr[p].s[0];
splay(p, 0);
return p;
}

例题

维护可重有序序列

洛谷板子题,要求我们支持维护一个有序序列,并支持上面讲的六种操作。

示例代码:Luogu P3369-splay

维护不可重序列,支持区间翻转

给个洛谷板子题的代码:

它这里面要求区间翻转,那么我们在进行每一次旋转操作时,我们首先将左边界的前驱旋转至根节点,接着再把右边界的后继旋转至根节点的下面,此时右边界的后继的左子树就是我们所要翻转的区间了。
我们顺便增加一个flag标记,用来标记翻转次数。

示例代码:Luogu P3391