你说得对,但 Splay 是最优美的平衡树!

admin 2025-10-30 10:52:38 世界杯最佳球员

你说得对,但 Splay 是最优美的平衡树!

你说得对,但 Splay 是最优美的平衡树!

DX3906_ourstar

·

2025-03-10 13:17:13

·

算法·理论

更好的阅读体验 & 同步发表于博客园。

upd 7/06:之前的版本有若干处格式错误,修正后再投一次。

本文主要介绍了 Splay 树的基本操作,以及相应的代码实现。有疑问请私信交流。

由于个人习惯,我会称“Splay 树”为“Splay”,“Splay 操作”为“伸展操作”或“splay”。

另外,为了表述方便,我将称“u 号点的父亲”为“\text{t[u].fa}”,称“u 号点的左儿子”为“\text{t[u].ls}”,称“u 号点的右儿子”为“\text{t[u].rs}”。

关于“平衡”

“平衡树”这个名称中的“平衡”二字,可以通过两个例子来理解。

现在我们有一棵树,记 n 为它的结点总数。当 n=5 时,如果它退化成了链,也就是下面这样:

那么从根到叶子就需要经过 5 个结点,这也就意味着,它的复杂度是 O(n) 的。

但是,如果它是下面这样:

那么从根到叶最多只需要经过 3 个结点,也就是说,这个形态下,它的复杂度为 O(\log{n})。

一般地,我们认为前一幅图“不平衡”,而后一幅图是“平衡”的。也就是说,一棵树的结点分布越均匀,它就越“平衡”。

个人感觉 OI-wiki 写得太绕了,就像上面这样表述就挺好的。

而平衡树,顾名思义,就是维护一棵“平衡”的二叉搜索树。平衡树在“平衡”的同时又具有 BST(即二叉搜索树)的所有性质,因而可以在对数级复杂度内解决众多问题。

什么是 Splay?

怎么又是 Tarjan 老爷子的发明。

OI-wiki 云:

Splay 树,或 伸展树,是一种平衡二叉查找树,它通过 伸展(splay)操作 不断将某个节点旋转到根节点,使得整棵树仍然满足二叉查找树的性质,能够在均摊 O(\log n) 时间内完成插入、查找和删除操作,并且保持平衡而不至于退化为链。

Splay 的基本操作

旋转

Splay 的灵魂,在于伸展;而伸展的灵魂,又在于旋转。

旋转,本质上就是在保持 BST 性质不变的同时,将某个结点上移一个位置,并将原有的父级结点作为自己的儿子。

Splay 定义了两种旋转操作:左旋和右旋。

这是 OI-wiki 上的示意图。

可能不太直观,我们以右旋为例,一起来分析一下。

这是某棵树本来的样子。

我们对 2 号点进行右旋。先把它拎出来,放在根的位置,让 1 号点变成它的儿子,像下面这样。

这时,我们注意到 2 号点有三个儿子了。这与 BST 的性质相悖,所以我们决定删去 2 号点和 5 号点之间的连线。于是 5 号点变成了 \text{t[1].ls},树就变成了下面这样:

于是乎,右旋操作结束。容易发现,右旋前后,树的中序遍历不发生变化,BST 性质得以保持。

左旋类似于右旋,同理推导即可,这里不再赘述。关于它,@lcy6 云:

用一句话概括把左儿子转到自己位置的左旋:自己的左儿子改为左儿子的右儿子,左儿子的爹改为自己的爹,自己的爹认自己的左儿子,自己的爹改为左儿子。

附一个口诀:左旋拎右左挂右,右旋拎左右挂左。

为了保持树的平衡,我们常对左儿子进行右旋,对右儿子进行左旋。这就提供了一个机会,使得两种旋转可用同一函数实现。

伸展

旋转操作说完了,接下来看看伸展操作。

用人话表述这一操作:通过旋转,把某一结点弄到另一结点下方。

所以我们规定, splay(x,k) 为将 x 转到 k 下方;特别地,splay(x,0) 为将 x 转到根。上述规定有意义,当且仅当 k 是 x 的祖先。这不是废话吗。

接下来分别考虑 splay(x,k) 的几种情况。为了表述方便,我们记初始时 \text{t[x].fa} 为 y,\text{t[t[x].fa].fa} 为 z。

说得通俗一点,就是记 x 的父亲为 y,爷爷为 z。

1.y 即为 k

什么都不需要做。废话。

2.z 即为 k

只需旋转一次即可。具体地,如果 x 是 \text{t[y].ls},则右 x;反之,左旋 x。

下面演示右旋 x 的情况。

我们要执行 splay(5,1) 这一操作(两个节点均已加粗)。注意到 \text{t[3].ls} 是 5 号点,则应当对 5 号点进行右旋,变成下面这样:

操作完成。

3.y 和 z 都不是 k,且 x,y,z 共线。

那么,如果链 z\to y\to x 向左延伸,则先右旋 y,再右旋 x;反之,则先左旋 y,再左旋 x。

“先旋转父亲,再旋转自己”的旋转方式称为双旋操作,双旋具有折叠效果,保证了每次操作的均摊复杂度为 O(\log n)。

以两次右旋为例。对于下面这张图:

应当先右旋 2 号点,得到:

再右旋 1 号点,即:

操作完成。

4.y 和 z 都不是 k,且 x,y,z 呈“之”自形分布

转两次 x 即可。具体地,x 为 \text{t[t[x].fa].ls} 则右旋,反之则左旋。

还是演示一下。这是树本来的样子:

先右旋 3 号点,得:

再左旋 3 号点,也就是:

操作完成。

至此,分讨完毕,Splay 最基本的两种操作讲完了。

查找

记查找数字 v 对应的点号为 prepare(v)。这个操作的定义是,找到 v 在树中的位置,并将它旋转到根。

具体地,记当前所在结点为点 u。我们先令 u 为根,然后依照 BST 性质逐层向下寻找(v 大于 u 的权值就走右子树,否则走左子树),直到 u 的权值即为 v 或我们要走的子树为空时停止。最后 splay(u,0) 即可。

插入

用查找过程找到要插入的位置,进行插入。随后将新元素旋转到根。插入一段序列到 y 后,假设 y 的后继是 z,我们会先将 y 旋转到根,然后再 splay(z,y)。此时只需将序列插入 z 的左子树即可。

删除

首先在树中找到要删除的元素 x,将它转到根节点并删去,这样原来的树就分裂成了两棵树。接下来将左子树中拥有最大权值的那一个元素转到根,由于它是左子树中的最大元素,所以它不存在右儿子(BST 性质显然),这样只要把原来的右子树作为它的右子树,就重新合并成了一棵树。

查排名

通过查找操作把对应权值结点转到根,左子树大小加一即为所求(由 BST 性质可知,左子树内的所有结点的权都小于根)。

查找前驱、后继

首先将给定结点转到根,然后观察权值间的关系。以查找前驱为例。如果根的权值大于给定值,则此时的根即为答案;否则,继续向下查找。后继则可以同理,将判定条件变为“如果根的权值小于给定值”即可。

上述两个操作极为相似,因而可以在同一个函数内巧妙地实现。

第 k 小

可以根据左右子树的大小来判断。若 k 小于根的左子树大小(由 BST 性质,亦可理解为第 k 小的数比根的权值更小),则第 k 小的数一定在左子树;否则,在右子树。对于后一种情况,更新 k,然后查找右子树中的第 k 小。上述过程重复进行,直到我们找到想要的结点。

可以想见,在右子树中查找时,k 应该减去对应的的左子树的大小。我们记左子树的大小为 x,则我们要找的整棵树的第 k 小,实际上是右子树的第 (k-x) 小。由 BST 性质可知,左子树的任意结点小于右子树的任意结点,因此,对于右子树,我们要把左子树的所有结点忽略掉。

同样由于 BST 性质,我们在查找左子树时无需考虑右子树,因为右子树的任意结点都更大,不可能挤进前 k小。

什么玩意,绕口令吗。

正确性证明

额,主播语文水平有限,说不太清楚,可以参考这篇博客。

代码实现

下面,我们以

P3369 【模板】普通平衡树为例,探讨 Splay 的代码实现。建议单开一个窗口,以便将代码和上面的描述相比对。

首先观察下面的 AC 代码,尝试理解它,再接着往后看。

#include

#define INF 0x7fffffff

using namespace std;

int n;

namespace OIfast{

char buf[1<<21],*p1,*p2,*top, buffer[1<<21];

#define getchar() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?0:*p1++)

inline int read(){

register int n=0;

register short f=1;

register char c=getchar();

while(c<'0'||c>'9'){

if(c=='-')f=-1;

c=getchar();

}

while(c>='0'&&c<='9'){

n=(n<<1)+(n<<3)+(c^48);

c=getchar();

}

return n*(int)f;

}

inline void print(register int n){

register short sta[19];

register short top=0;

if(n<0)n=~n+1,putchar('-');

do{

sta[top++]=n%10;

n/=10;

}while(n);

while(top)putchar(sta[--top]^48);

return ;

}

inline void write(register int n,register char c){

print(n),putchar(c);

return ;

}

}using namespace OIfast;

namespace splayTree{

#define ls s[0]

#define rs s[1]

#define tmp(a) t[t[a].fa].rs==a

const int N=1e6+5;

int tot,root;

struct node{

int v,fa,size,cnt;

int s[2];

}t[N];

inline void init(int u,int _v,int _fa){

t[u].cnt=t[u].size=1;

t[u].v=_v,t[u].fa=_fa;

return ;

}

inline void pushup(int u){

t[u].size=t[t[u].ls].size+t[t[u].rs].size+t[u].cnt;

return ;

}

inline void rotate(int x){

int y=t[x].fa;

int z=t[y].fa;

bool k=tmp(x);

t[z].s[tmp(y)]=x,t[x].fa=z;

t[y].s[k]=t[x].s[k^1],t[t[x].s[k^1]].fa=y;

t[x].s[k^1]=y,t[y].fa=x;

pushup(y),pushup(x);

return ;

}

inline void splay(int x,int k){

while(t[x].fa!=k){

int y=t[x].fa;

int z=t[y].fa;

if(z!=k)rotate(((tmp(x))^(tmp(y)))?x:y);

rotate(x);

}

if(k==0)root=x;

return ;

}

inline void prepare(int v){

int u=root;

if(u==0)return ;

while(t[u].v!=v&&t[u].s[v>t[u].v]!=0){

u=t[u].s[v>t[u].v];

}

splay(u,0);

return ;

}

inline void add(int v){

int u=root,fa=0;

while(u!=0&&t[u].v!=v){

fa=u;

u=t[u].s[v>t[u].v];

}

if(u!=0){

++t[u].cnt;

}else{

u=++tot;

init(u,v,fa);

if(fa!=0){

t[fa].s[v>t[fa].v]=u;

}

}

splay(u,0);

return ;

}

inline int get(int v,bool f){

prepare(v);

int u=root;

if(v

if(v>t[u].v&&(!f))return u;

u=t[u].s[f];

while(t[u].s[f^1]!=0){

u=t[u].s[f^1];

}

splay(u,0);

return u;

}

inline void del(int v){

int l=get(v,0),r=get(v,1);

splay(l,0),splay(r,l);

if(t[t[r].ls].cnt>1){

--t[t[r].ls].cnt;

splay(t[r].ls,0);

}else{

t[r].ls=0;

}

return ;

}

inline int rk(int v){

prepare(v);

return t[t[root].ls].size;

}

inline int kth(int k){

int u=root;

if(t[u].size

while(1){

if(k>t[t[u].ls].size+t[u].cnt){

k-=t[t[u].ls].size+t[u].cnt;

u=t[u].rs;

}else{

if(t[t[u].ls].size>=k){

u=t[u].ls;

}else{

splay(u,0);

return t[u].v;

}

}

}

return 5201314;

}

}using namespace splayTree;

inline void work(){

int opt=read(),x=read();

if(1==2)puts("wow");

else if(opt==1)add(x);

else if(opt==2)del(x);

else if(opt==3)add(x),write(rk(x),'\n'),del(x);

else if(opt==4)write(kth(x+1),'\n');

else if(opt==5)write(t[get(x,0)].v,'\n');

else if(opt==6)write(t[get(x,1)].v,'\n');

return ;

}

signed main(){

add(-INF),add(INF);

n=read();

while(n--)work();

return 0;

}

接下来,我们将代码分成多个部分,分开讲解。

框架

#include

#define INF 0x7fffffff//极大值

using namespace std;

int n;

namespace OIfast{//快读快写

char buf[1<<21],*p1,*p2,*top, buffer[1<<21];

#define getchar() (p1==p2&&(p2=(p1=buf)+fread(buf,1,1<<21,stdin),p1==p2)?0:*p1++)

inline int read(){

register int n=0;

register short f=1;

register char c=getchar();

while(c<'0'||c>'9'){

if(c=='-')f=-1;

c=getchar();

}

while(c>='0'&&c<='9'){

n=(n<<1)+(n<<3)+(c^48);

c=getchar();

}

return n*(int)f;

}

inline void print(register int n){

register short sta[19];

register short top=0;

if(n<0)n=~n+1,putchar('-');

do{

sta[top++]=n%10;

n/=10;

}while(n);

while(top)putchar(sta[--top]^48);

return ;

}

inline void write(register int n,register char c){

print(n),putchar(c);

return ;

}

}using namespace OIfast;

namespace splayTree{//平衡树相关代码

/*这部分代码略*/

}using namespace splayTree;

inline void work(){//处理单次操作

/*这部分代码略*/

return ;

}

signed main(){

add(-INF),add(INF);//插入哨兵

n=read();

while(n--)work();

return 0;

}

很好理解吧。由于个人习惯,我喜欢把功能相对独立的东西分开,封装在不同的命名空间里。其他部分 显然 应该是不需要解释的,重点放在主函数。

注意到主函数在进行其他操作前,先插入了一个最大值和最小值(称为 “哨兵”)。哨兵的存在,避免了 访问空子树 或 操作时没有有效结点 的情况,但同时,也会对其余部分的代码产生一系列影响。后面再来探讨这个问题。

基本定义

#define ls s[0]//便捷地表示左右儿子,一定程度上可以简化后面的代码。

#define rs s[1]

#define tmp(a) t[t[a].fa].rs==a//以后有用,先不管。

const int N=1e6+5;//结点数量上限。

int tot/*结点总数*/,root/*根*/;

struct node{

int v/*权值*/,fa/*父亲*/,size/*子树大小*/,cnt/*重复次数*/;

int s[2];//s[0] 表示左儿子,s[1] 表示右儿子。

}t[N];

可以发现,我们用一个数组来存储左右儿子。可能你会问:为什么不直接像线段树的结点定义一样,直接用 ls 和 rs 来表示左右儿子?

答案也很简单:之前的介绍中,我们提到了可以将两个相似的操作写在同一函数内。这些操作可能在面对同一条件时,一个会选择左儿子,另一个会选择右儿子;这时候,我们就可以 将这个条件(或对它进行某种运算的结果)直接作为下标,从而大大简化代码。

满满的都是前人的智慧啊,很巧妙吧?

init() 和 pushup()

inline void init(int u,int _v,int _fa){//创建节点后对它进行初始化。

t[u].cnt=t[u].size=1;

t[u].v=_v,t[u].fa=_fa;

return ;

}

inline void pushup(int u){//旋转后重新整合节点信息。

t[u].size=t[t[u].ls].size+t[t[u].rs].size+t[u].cnt;

return ;

}

都很好理解。容易发现,旋转后结点的左右子树可能发生变化,这时,之前的记录的子树大小就可能是错误的。因此,我们需要对它进行更新。左子树大小、右子树大小 和 结点本身的重复次数之和,即为以结点为根的子树的大小。

rotate()

inline void rotate(int x){

int y=t[x].fa;//和之前的介绍保持一致。

int z=t[y].fa;

bool k=tmp(x);//这就是那个宏定义的用处之一,可参照之前的介绍。

t[z].s[tmp(y)]=x,t[x].fa=z;

t[y].s[k]=t[x].s[k^1],t[t[x].s[k^1]].fa=y;

t[x].s[k^1]=y,t[y].fa=x;

pushup(y),pushup(x);

return ;

}

注意到调用时只需指定点号,并不需要考虑是左旋还是右旋,函数会自己决定。

可以看到,中间的三行就是旋转操作的核心。每行都可以认为是将前一个结点变成后一个结点的儿子,再把后一个结点变成前一个结点的父亲。

操作结束后,我们进行了两次 pushup 操作。仿照线段树,整合应当先下后上,所以我们先 pushup(y),后 pushup(x)。(旋转后 y 在 x 下方。)

splay()

inline void splay(int x,int k){

while(t[x].fa!=k){//只要还没有到 k 下方就不结束。

int y=t[x].fa;//如上。

int z=t[y].fa;

if(z!=k)rotate(((tmp(x))^(tmp(y)))?x:y);//三目运算符压了一下行,很合理吧。

rotate(x);

}

if(k==0)root=x;//如果是转到根的话,记得更新根的位置。

return ;

}

这个就没有太多可以讲的了,把之前的介绍写成代码即可。当然,你也可以写成这样:

inline void splay(int x,int k){

while(t[x].fa!=k){

int y=t[x].fa;

int z=t[y].fa;

if(z!=k){

if((t[y].rs==x)^(t[z].rs==y))rotate(x);//就是这里不一样。

else rotate(y);

}

rotate(x);

}

if(k==0)root=x;

return ;

}

这个版本易于理解、更贴近我们之前的描述,但显然码量更大。

prepare()

inline void prepare(int v){

int u=root;

if(u==0)return ;//此时 u 已经在 0 的位置了,不能继续操作。

while(t[u].v!=v&&t[u].s[v>t[u].v]!=0){//查找 u 的位置。

u=t[u].s[v>t[u].v];//向下找儿子。

}

splay(u,0);

return ;

}

我们从根开始,先特判一步,然后利用 BST 性质逐层向下寻找。找到之后转到根即可。

由 BST 性质可知,任意树节点的权值都大于左儿子且小于右儿子。因此,若给定权值大于当前结点的权值,则去找当前结点的右儿子;否则,找左儿子。再来观察表达式 v>t[u].v。在前一种情况下,它的值为 1,而我们要找 t[u].s[1];后一种情况下,它的值为 0,而我们要找 t[u].s[0]。

所以,我们可以 直接将这个表达式作为下标,向下寻找的过程,直接写作 u=t[u].s[v>t[u].v] 即可。

这一点非常有用,下面还会用到。

add()

inline void add(int v/*给定的数*/){

int u=root,fa=0;//初始时,u 为根,t[u].fa 自然就为 0。

while(u!=0&&t[u].v!=v){//向下找。

fa=u;//更新父亲。

u=t[u].s[v>t[u].v];//BST 性质。

}

if(u!=0){//这说明 v 已经在树中出现过。

++t[u].cnt;

}else{//新建结点。

u=++tot;

init(u,v,fa);//初始化。

if(fa!=0){//u 不在根的位置上。

t[fa].s[v>t[fa].v]=u;

}

}

splay(u,0);

return ;

}

对于要加入树中的一个数,我们需要考虑这个数是否在树中已经存在。若是,则仅需将对应结点的重复次数加上一;反之,新建一个结点。

新建结点包括更新点数(即 ++tot)、初始化结点信息(调用 init())两个基本步骤。另外还需判断新建的结点是否有父亲;若有,更新父亲的儿子信息;否则,什么都不用做。

另外,无论这个数是否已经存在,我们都应将其对应的结点转到根。别问为什么,问就是 Splay 就是这样写的,不加上会 WA。

get()

inline int get(int v,bool f/*为 0 表示找前驱,为 1 表示找后继。*/){

prepare(v);

int u=root;

if(v

if(v>t[u].v&&(!f))return u;//找前驱时,根的权值小于给定值。

u=t[u].s[f];//准备跳。

while(t[u].s[f^1]!=0){//此时 u 还有儿子,往下跳。

u=t[u].s[f^1];

}

splay(u,0);//同样,这一句也是不能去掉的。

return u;

}

这个也没有太多要讲的东西,把之前讲的直接实现即可。

注意跳之前的两个特判,它们分别对应了查找两种东西时根即为答案的情况。

del()

inline void del(int v){

int l=get(v,0),r=get(v,1);//查找前驱后缀时就已经转到根了,无需单独 splay。

splay(l,0),splay(r,l);//将前驱转到根,将后继转到前驱下方。

if(t[t[r].ls].cnt>1){//有重复,减去重复次数即可。

--t[t[r].ls].cnt;

splay(t[r].ls,0);

}else{

t[r].ls=0;//删除左儿子。

}

return ;

}

和之前的叙述保持一致。可能比较难想,但我觉得我已经尽力说清楚了。实在不行就把这段代码背下来吧。

rk()

inline int rk(int v){

prepare(v);//转到根。

return t[t[root].ls].size;//左子树大小。

}

这是全场最简单的操作力!

需要注意的是,按照我们之前的讲述,排名应为左子树大小加一。但由于哨兵的影响,左子树中还会有一个极小值,它让子树大小增加了一。因此直接返回 t[t[root].ls].size 即可。

kth()

inline int kth(int k){

int u=root;

if(t[u].size

while(1){

if(k>t[t[u].ls].size+t[u].cnt){//k 大于左子树大小,答案必然在右子树。

k-=t[t[u].ls].size+t[u].cnt;//更新 k,如上所述。

u=t[u].rs;

}else{

if(t[t[u].ls].size>=k){//k 不大于左子树大小,答案在左子树。

u=t[u].ls;

}else{//答案不在左子树也不在右子树,必然就是当前结点。

splay(u,0);//可以不写,但那样更慢。

return t[u].v;

}

}

}

return 5201314;//美观起见,我在每个函数后面都写了返回语句。但程序不可能来到这里,所以就随便安了个返回值。

}

好像也没什么可讲的。 关于找到答案之后的 splay(u,0),虽不是必需品,但体现了 Splay 特有的“缓存”机制,即将访问过的点尽可能靠近根,以提升整体的运行速度。

work()

inline void work(){

int opt=read(),x=read();

if(1==2)puts("wow");//还是为了美观,随便加个 if 可以让所有操作前都是 else if。

else if(opt==1)add(x);

else if(opt==2)del(x);

else if(opt==3)add(x),write(rk(x),'\n'),del(x);

else if(opt==4)write(kth(x+1),'\n');

else if(opt==5)write(t[get(x,0)].v,'\n');

else if(opt==6)write(t[get(x,1)].v,'\n');

return ;

}

直接按题意书写即可。需要注意的是,对于操作 3,x 不一定存在,需要先插入 x 以保证不出错,再删除 x 以避免后续影响;对于操作 4,考虑哨兵影响,存在极小值的第 k 小实际上是第 (k+1) 小;对于操作 5 和操作 6,我们是直接用权值进行查找的,因此无需先加入后删除。

代码部分就结束力!

说句闲话——选排优于快排,梦境还是现实?

前几天数学在讲第二十章,我感觉把那堆弱智问题放到 OI 里会有意思得多……

我注意到在求中位数时,laoshi 对数列进行了排序,其手法大约等价于选排(即先找出原序列最小值,拿到新序列里,再把它扔出原序列,重复这一步骤直到原序列中没有剩余元素,排序结果即为新序列)。

同学们都知道,这一操作是 O(n^ 2 ) 的,慢得令人发指。于是闲的没事的我昨晚做了个梦,梦里,我口胡出了用平衡树优化区间最小值的操作。

具体地,维护一棵 Splay 树,将原序列的所有数加入进去;然后不断执行第 k 小操作并删除找到的数。这样,整个排序过程就优化到了 O(n \log n)——加入元素是 O(n) 的,查找第 k 小是 O(\log n) 的。

这就意味着,这样操作之后的选排具有跟快排同等的复杂度,应该能够通过 P1177 【模板】排序!

后来试了一下,你别说,还真行。横向对比一下,甚至优于快排、归并和堆排!

当然,也可以通过不断查找后继来实现,但缺陷在于需要想办法记录每个数出现的次数。