学 LCT 发现有点记不得 Splay 怎么写,又实在不知道这篇博客当时写了些什么东西(分段粘代码?),决定推倒重写。

好像高一学弟也在学平衡树,但相信大家都比樱雪喵强,都能一遍学会!/kel

写在前面

整合了一些各种地方看到的 corner case,和我学的时候想不明白题解却说显然的东西。

Splay 的实现方式多种多样,这里只讲我比较喜欢的写法。部分参考 Cx330 神仙的板子,拜谢。

不过基本原理上都是一样的,无需担心这个问题。学原理的建议就是多动笔自己画每棵树是怎么转。

这篇博客也画了很多图,算是给曾经对着网上几乎没图的博客画了一大堆东西也搞不明白 Splay 应该怎么转的自己一个交代?

二叉搜索树

在学习 Splay 之前,我们要先知道二叉搜索树的基本概念。

定义

  • 是一棵有根且点上带权值的二叉树
  • 空树是二叉搜索树
  • 若根节点左子树不为空,则左子树内点的权值均小于根节点的权值
  • 若根节点右子树不为空,则右子树内点的权值均大于根节点的权值

换句话说,中序遍历这棵二叉树,得到点的权值序列单调不降。比如这样:

这里要注意区分点的权值和编号,我们要求单调不降的是点的权值,不是编号。下文出现的所有图,点上的数字均表示权值。

用途

「二叉搜索树」,用来做的事情自然是搜索。具体地,它可以支持插入、删除、查询前驱后继、查询给定值在序列中的排名、查询特定排名的数字值等操作。

对于一个序列中多个权值相同的点,有两种处理方式:

  • 对树上的每个点记录 \(cnt\),表示该点的权值在序列中出现了几次;
  • 直接把多个权值相同的点都塞到树上。

第二种做法并不严格符合上文所述二叉搜索树的定义,但鉴于它能减少大量的特判,我们就先不计较这个问题。

这里樱雪喵擅自把定义改成「左子树权值不大于根节点,右子树权值不小于根节点」好了。

插入节点

我们考虑不改变树的原有结构,把新的点接在某个旧点下面。从根节点开始,我们依次考虑新加的这个点应该在哪个子树里:

  • 权值小于当前节点,则递归左子树;
  • 大于当前节点,则递归右子树;
  • 等于当前节点,理论上去哪边都行,就先钦定它往左边放吧。

走到叶子节点时,把这个新点接在下面就好了。时间复杂度是 \(O(h)\) 的,其中 \(h\) 表示树高。

删除节点

形如插入节点,我们先通过权值大小沿着树走,找到这个要被删除的点的位置。

这里可能要稍稍麻烦一点:

  • 如果这个点没有儿子,那直接删掉;
  • 只有一个儿子,我们直接把它的儿子和它的父亲连在一起。比如说删点 \(6\):

  • 它有两个儿子就比较难办。为了说得明白一点这里又重新画了一棵树,比如说我们要删点 \(4\)。



    先把 \(4\) 及和它相连的边删掉,再考虑怎么把剩下的点重新接成一棵树。

这种情况的处理方法一般是钦定一边的儿子来接替这个点的位置,这里我们钦定把左儿子接上来。

剩下的右子树怎么办呢?它肯定不能接在自己原来的位置上,因为接替 \(4\) 的 \(2\) 已经有右儿子了。根据二叉搜索树的性质,右子树的所有权值都大于左子树,所以我们把右子树整个接到(左子树里权值最大的那个点)的右儿子上。显然这个权值最大的点是没有右儿子的,因为不然它就不是最大的。

那这棵树长成了这样:

至此我们成功删除了 \(4\) 这个节点,并且依然满足二叉搜索树的性质。

至于查询操作各有不同,留到后面 Splay 的部分再逐一细说。

但根据上面两个操作也可以大致想象:朴素的二叉搜索树维护这些操作的时间复杂度都是 \(O(h)\) 的。

在正常情况下,树高不会太高,似乎并没有什么大问题;但我们可以构造一些数据让树变得很高。

考虑依次对树插入节点 \(1,2,3\dots\),根据上面插入操作的流程,这棵树就会变成这样:



可见它的树高变成了 \(O(n)\),插入的复杂度也变成了 \(O(n)\),并不比暴力做法高效。

对于同一个序列,能构成合法的二叉搜索树有很多种形态,我们要保证自己构造的这棵树不出现上面的情况。

平衡树就是解决这个问题的算法。顾名思义,找一种调整方法让这棵二叉搜索树平衡,保持它的高度为 \(O(\log n)\),从而保证各个操作的时间复杂度。

Splay

Splay 的核心是通过对一些节点进行旋转,改变这棵树的结构,让它趋于平衡。

接下来将对操作和模板代码进行分段讲解。(原来这里只有代码没有讲解,所以等同于完全重写

一些基础操作 & 准备

这里我们先不考虑怎么转,只把它当个普通的二叉搜索树,把节点维护的信息定义出来。

实现上,可以如下文写一个结构体,也可以直接开一堆数组。前者的逻辑更清晰,但考虑到后文的大量调用,开一堆数组写起来码量会少一些。

当然也可以跟樱雪喵一样写几个 define(?

struct tree
{
int s[2],siz,fa,key;
tree(){s[0]=s[1]=siz=fa=key=0;}
}tr[N];
#define ls(x) tr[(x)].s[0]
#define rs(x) tr[(x)].s[1]
#define fa(x) tr[(x)].fa

然后实现几个简单的函数。

il int newnode(int key) {tr[++idx].key=key,tr[idx].siz=1;return idx;} //新建一个权值为 key 的节点
il void maintain(int x) {tr[x].siz=tr[ls(x)].siz+tr[rs(x)].siz+1;} //更新子树 size
il void clear(int x) {ls(x)=rs(x)=fa(x)=tr[x].siz=tr[x].key=0;} //清空一个节点(用于删除)
il bool get(int x) {return x==rs(fa(x));} //求 x 是它父亲的哪个儿子

rotate 操作

rotate 操作的本质是把某个给定节点上移一个位置,并保证二叉搜索树的性质不改变。

在 Splay 中,旋转操作分为左旋(Zag) 和右旋(Zig)。(这张图是从 OI-wiki 贺的,他点上标的是编号而不是权值。)



发现旋转时,不只是简单地改变根节点,还改变了树的结构。或许看了上图令人迷惑,我们分步演示一个 Splay 的右旋操作步骤。

对于下面这棵树的点 \(2\) 执行 \(\text{rotate}\) 操作,可以想象最后 \(4\) 会变成 \(2\) 的右儿子,所以先把 \(2\) 现在的右儿子断开,连到 \(4\) 下面:



接下来,我们把 \(2\) 移到 \(4\) 上面,让它成为 \(4\) 的父亲:

这一步上,虽然树的形态(连边状态)不改变,但平衡树是有根树,我们改变的是儿子和父亲的关系。

最后,把 \(2\) 和原来 \(4\) 的父亲 \(6\) 连起来:

这时候我们就成功把点 \(2\) 上移了一个位置,而且保证了中序遍历没变。

代码实现时无需区分左旋和右旋,因为它们本质上都是根据 \(x\) 是父亲的哪个儿子进行的方向判断。操作完成后,要更新节点的 \(siz\) 信息。

il void rotate(int x)
{
int y=fa(x),z=fa(y); int c=get(x); // x 在父亲的哪个方向
if(tr[x].s[c^1]) fa(tr[x].s[c^1])=y; // 把 x 相反方向的儿子接在 y 上,可以对照上文的图理解一下
tr[y].s[c]=tr[x].s[c^1],tr[x].s[c^1]=y,fa(y)=x,fa(x)=z;
if(z) tr[z].s[y==tr[z].s[1]]=x; //这里千万不要想当然改成 get(y)!因为 fa(y) 已经不是 z 了。
maintain(y),maintain(x);
}

upd: 这只猫敲板子又把 if(z) tr[z].s[y==tr[z].s[1]]=x; 写错了。避雷避雷避雷!

Splay 操作

\(\text{Splay}(x)\) 的作用是把点 \(x\) 一路旋到根上。这里我们分为 \(6\) 种情况来讨论 \(x\) 应该怎么转。

Zig / Zag

最简单的情况是 \(x\) 现在的深度为 \(1\),那么我们直接执行 rotate(x) 即可。这张图应该不会造成啥理解障碍,直接贺过来了。

Zig-Zag / Zag-Zig

这种情况也比较直观。设 \(fa(x)=y,fa(y)=z\)。如果 \(x\) 和 \(y\) 相对于各自的父亲是不同方向的,我们就对它们执行 Zig-Zag 或 Zag-Zig 操作。

比如这棵树长这个样子。



我们先执行 rotate(x),变成这样:



然后再执行一次 rotate(x),就把 \(x\) 一共往上移了两个位置。

Zig-Zig / Zag-Zag

依然设 \(fa(x)=y,fa(y)=z\)。如果 \(x\) 和 \(y\) 相对于各自的父亲是同向的,我们就对它们执行 Zig-Zig 或 Zag-Zag 操作。

以 Zig-Zig 操作为例,树在转之前长这样:



我们规定,这种情况下先转 \(y\) 再转 \(x\),而不是上文那样转两次 \(x\)(这么做的原因真的显然吗?)。这是用于保证复杂度的,后文 Spaly 部分会对其进行分析。

rotate(y)



rotate(x):

至此讲完了 Splay 的 \(6\) 种操作,而 Splay 函数的代码实现实际上很短。

il void splay(int x)
{
for(int f=fa(x);f=fa(x),f;rotate(x)) // 不论哪个操作,最后一步都是 rotate(x)
if(fa(f)) rotate(get(f)==get(x)?f:x); // 判断转 y 还是转 x
rt=x;
}

这里放一段 Xu_brezza 学长写的注释。感觉有点乐的。原文链接

void rotate(int x){//旋转操作
int y = fa[x],z = fa[y],chk = get(x);//爹,爹的爹,是爹的哪个儿子
ch[y][chk] = ch[x][chk^1];//如果我是爹的左儿子,把我的右儿子给爹的左儿子//如果是右儿子,把我的左儿子给爹的右儿子
if(ch[x][chk^1])fa[ch[x][chk^1]] = y;//把这个儿子的爹改成我的爹
ch[x][chk^1] = y;//父子关系换了,哈哈!
fa[y] = x;fa[x] = z;//哈哈!哈哈!
if(z)ch[z][y == ch[z][1]] = x;//如果爹的爹存在,更新儿子,你滴儿子是我辣!
maintain(x);maintain(y);//pushup pushup
}
void splay(int x){
for(int f;f = fa[x];rotate(x))//我还有爹吗,有就旋
if(fa[f])rotate(get(x) == get(f) ? f : x);//如果有爹,相同的话要先旋爹
root = x;//我是根辣!
}

为保证 Splay 的复杂度,我们规定每次操作最后访问到的节点是 \(x\),都要把 \(x\) Splay 到根。

时间复杂度分析

比较复杂,需要用到势能分析。这里挂个 Link,神仙们有兴趣可以去看看。

这里就不证一遍了(让我证我也只会对着 OIwiki 贺),记下来它是 \(\log\) 的就可以。

单旋 Spaly

上面我们学的 Splay 是双旋的,也就是根据 \(x\) 和 \(y\) 是否同向分为两种不同的操作。我曾经很不理解为什么不一直只转 \(x\),不知道大家刚学的时候会不会这么想。

实际上确实有只转 \(x\) 的这个东西,它叫 Spaly,和 Splay 的区别就是单旋还是双旋。但这玩意的时间复杂度是假的,举个例子:



这棵树退化成了链,我们显然希望通过旋转操作让它不再是链。但是如果单旋,先 spaly(1),发现还是一条链;

我们再试试接下来 spaly(2),结果还是一条链。

可以自己画画图模拟转的过程,就会发现它完全没有起到平衡的作用。

而对原链进行 Splay(1),即先后 rotate 2,1,4,1,发现这棵树操作完改变了结构,不是一条链。这是我们希望看到的。

应用

学完了 Splay 的核心操作,插入删除查找什么的就和二叉搜索树没啥区别了。

这里一个一个操作说。

实现上,我的原则是在各个操作不互相依赖的情况下减少码长。虽然相互依赖能写出代码很短的板子(1.7k?),但如果题里只要求实现一部分操作,你还要把不用的也写上,就很不合算。

哦吐槽一句 OIwiki 的板子又互相依赖又写得很长。打算照着学的快跑,希望不要有人跟我一样费好大劲去背那个阴间板子。

插入

和前面二叉搜索树一样,唯一的区别是最后 Splay(x)。所以直接放代码:

il void ins(int key)
{
int now=rt,f=0;
while(now) f=now,now=tr[now].s[key>tr[now].key];
now=newnode(key),fa(now)=f,tr[f].s[key>tr[f].key]=now,splay(now);
}

删除

似乎这个东西大部分人的写法都依赖查询排名的函数,所以一般被放到最后讲。不过樱雪喵的板子貌似没有这个问题,于是直接按顺序放在了这里。

前面详细讲过了二叉搜索树怎么删点,依然直接给出代码:

别被吓跑,delete 是全文代码最长的操作了,剩下的都很短 QAQ。

upd: 直接 return 的复杂度假了,感谢评论区大佬指出。

il void del(int key)
{
int now=rt,p=0;
while(tr[now].key!=key&&now) p=now,now=tr[now].s[key>tr[now].key]; // 找到要删除的这个点
if(!now) {splay(p);return;}
splay(now); int cur=ls(now);
if(!cur) {rt=rs(now),fa(rs(now))=0,clear(now);return;} //没有左儿子,摆
while(rs(cur)) cur=rs(cur);
rs(cur)=rs(now),fa(rs(now))=cur,fa(ls(now))=0,clear(now); //把右儿子接在(左子树的最大权值)下面
maintain(cur),splay(cur);
}

查询 \(x\) 的排名

从根节点开始,根据左子树的 \(size\) 判断我们查询的 \(x\) 在哪边的子树里;因为一个平衡树里可能有一堆权值是 \(x\) 的点,这里我们本质上要找的是 严格小于 \(x\) 的点数 \(+1\)。

每次往右子树走,左边的子树就给答案贡献了 \(size_{ls(now)}+1\) 个比 \(x\) 小的数。

il int rnk(int key)
{
int res=1,now=rt,p;
while(now)
if(p=now,tr[now].key<key) res+=tr[ls(now)].siz+1,now=rs(now);
else now=ls(now);
return splay(p),res;
}

注意,这里虽然只是在树上跑点,没有改变平衡树的结构,但依然要进行 Splay。

考虑构造这样的数据:依次在树上插入 \(1,2,\dots,n\),画一下可以发现即使每次插入完都有在 Splay,它也还是一条链。当然这对插入的时间复杂度没有影响,因为每次根节点的右儿子都是空的,不会递归到链里。

但这对查询操作有影响啊。考虑插入完反复查询排名为 \(1\) 的数,单次复杂度就一直是 \(O(n)\)。于是就寄了!

而查询完 Splay 一下,这棵树就不再是一条链,保证了后续操作的均摊复杂度。

查询排名为 \(k\) 的数

同理,根据子树 \(size\) 直接判断排名为 \(k\) 的数走哪一边即可。

il int kth(int rk)
{
int now=rt;
while(now)
{
int sz=tr[ls(now)].siz+1;
if(sz>rk) now=ls(now);
else if(sz==rk) break;
else rk-=sz,now=rs(now);
}
return splay(now),tr[now].key;
}

查询 \(x\) 的前驱

前驱,定义为序列里最大的比 \(x\) 小的数。

一个写起来比较短的办法是,先插入一个 \(x\),这样 \(x\) 就是根了;那 \(x\) 的前驱,就是先走根的左儿子,然后再一直走右儿子走到底。最后再删掉插入的这个 \(x\)。

但是删除操作不好写,很多时候题面也不要求删除。我们换一种办法。

考虑从根往下走,如果当前点大于等于 \(x\),那前驱一定在左子树,我们往左走;否则,前驱可能在这个点,也可能在这个点的右子树里,总之不在左子树里。所以先用这个点更新答案,再进入它的右子树继续找。

也麻烦不了多少。

il int pre(int key)
{
int now=rt,ans=0,p;
while(now)
if(p=now,tr[now].key>=key) now=ls(now);
else ans=tr[now].key,now=rs(now);
return splay(p),ans;
}

查询 \(x\) 的后继

后继就是最小的比 \(x\) 大的数,把前驱的做法反过来即可,不再赘述。

il int nxt(int key)
{
int now=rt,ans=0,p;
while(now)
if(p=now,tr[now].key<=key) now=rs(now);
else ans=tr[now].key,now=ls(now);
return splay(p),ans;
}

到这里就足以过掉 lg P3369 【模板】普通平衡树 了。

完整板子如下:

#include<bits/stdc++.h>
#define il inline
using namespace std;
il int read()
{
int xr=0,F=1; char cr;
while(cr=getchar(),cr<'0'||cr>'9') if(cr=='-') F=-1;
while(cr>='0'&&cr<='9')
xr=(xr<<3)+(xr<<1)+(cr^48),cr=getchar();
return xr*F;
}
const int N=1e5+5;
struct tree
{
int s[2],siz,fa,key;
tree(){s[0]=s[1]=siz=fa=key=0;}
}tr[N];
#define ls(x) tr[(x)].s[0]
#define rs(x) tr[(x)].s[1]
#define fa(x) tr[(x)].fa
int rt,idx;
il int newnode(int key) {tr[++idx].key=key,tr[idx].siz=1;return idx;}
il void maintain(int x) {tr[x].siz=tr[ls(x)].siz+tr[rs(x)].siz+1;}
il void clear(int x) {ls(x)=rs(x)=fa(x)=tr[x].siz=tr[x].key=0;}
il bool get(int x) {return x==rs(fa(x));}
il void rotate(int x)
{
int y=fa(x),z=fa(y); int c=get(x);
if(tr[x].s[c^1]) fa(tr[x].s[c^1])=y;
tr[y].s[c]=tr[x].s[c^1],tr[x].s[c^1]=y,fa(y)=x,fa(x)=z;
if(z) tr[z].s[y==tr[z].s[1]]=x; //not get(y)!
maintain(y),maintain(x);
}
il void splay(int x)
{
for(int f=fa(x);f=fa(x),f;rotate(x))
if(fa(f)) rotate(get(f)==get(x)?f:x);
rt=x;
}
il void ins(int key)
{
int now=rt,f=0;
while(now) f=now,now=tr[now].s[key>tr[now].key];
now=newnode(key),fa(now)=f,tr[f].s[key>tr[f].key]=now,splay(now);
}
il void del(int key)
{
int now=rt,p=0;
while(tr[now].key!=key&&now) p=now,now=tr[now].s[key>tr[now].key];
if(!now) {splay(p);return;}
splay(now); int cur=ls(now);
if(!cur) {rt=rs(now),fa(rs(now))=0,clear(now);return;}
while(rs(cur)) cur=rs(cur);
rs(cur)=rs(now),fa(rs(now))=cur,fa(ls(now))=0,clear(now);
maintain(cur),splay(cur);
}
il int pre(int key)
{
int now=rt,ans=0,p;
while(now)
if(p=now,tr[now].key>=key) now=ls(now);
else ans=tr[now].key,now=rs(now);
return splay(p),ans;
}
il int nxt(int key)
{
int now=rt,ans=0,p;
while(now)
if(p=now,tr[now].key<=key) now=rs(now);
else ans=tr[now].key,now=ls(now);
return splay(p),ans;
}
il int rnk(int key)
{
int res=1,now=rt,p;
while(now)
if(p=now,tr[now].key<key) res+=tr[ls(now)].siz+1,now=rs(now);
else now=ls(now);
return splay(p),res;
}
il int kth(int rk)
{
int now=rt;
while(now)
{
int sz=tr[ls(now)].siz+1;
if(sz>rk) now=ls(now);
else if(sz==rk) break;
else rk-=sz,now=rs(now);
}
return splay(now),tr[now].key;
}
int main()
{
int T=read();
while(T--)
{
int op=read(),x=read();
if(op==1) ins(x);
if(op==2) del(x);
if(op==3) printf("%d\n",rnk(x));
if(op==4) printf("%d\n",kth(x));
if(op==5) printf("%d\n",pre(x));
if(op==6) printf("%d\n",nxt(x));
}
return 0;
}

附一个樱雪喵早年间写的 OIwiki 版本 Splay。可以在码风基本相同的情况下对比看看区别(?

点击查看代码
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
struct node{
int fa,s[2],siz,cnt,w;
}t[N];
int rt,tot;
void getsiz(int x) {t[x].siz=t[t[x].s[0]].siz+t[t[x].s[1]].siz+t[x].cnt;}
int gets(int x) {return x==t[t[x].fa].s[1];}
void clear(int x) {t[x].fa=t[x].s[0]=t[x].s[1]=t[x].siz=t[x].cnt=t[x].w=0;}
void turn(int x)
{
int y=t[x].fa,z=t[y].fa;bool chk=gets(x);
if(t[x].s[chk^1]) t[t[x].s[chk^1]].fa=y;
t[y].s[chk]=t[x].s[chk^1];
t[x].s[chk^1]=y;t[y].fa=x;t[x].fa=z;
if(z) t[z].s[y==t[z].s[1]]=x;
getsiz(y);getsiz(x);
rt=x;
}
void splay(int x)
{
for(int f=t[x].fa;f=t[x].fa,f;turn(x))
if(t[f].fa) turn(gets(x)==gets(f)?f:x);
rt=x;
}
void insert(int k)
{
if(!rt)
{
t[++tot].w=k;t[tot].cnt=1;getsiz(tot);
rt=tot;return;
}
int now=rt,f=0;
while(1)
{
//cout<<now<<" "<<t[now].w<<endl;
if(t[now].w==k) {t[now].cnt++;getsiz(now),getsiz(f);splay(now);return;}
f=now;now=t[f].s[k>t[f].w];
if(!now)
{
now=++tot;
t[now].w=k,t[now].fa=f,t[f].s[k>t[f].w]=now;
t[now].cnt=1;getsiz(now),getsiz(f);
splay(now);return;
}
}
}
int rnk(int k)
{
int now=rt,ans=0;
while(1)
{
if(k<t[now].w) {now=t[now].s[0];continue;}
ans+=t[t[now].s[0]].siz;
if(k==t[now].w) {splay(now);return ans+1;}
ans+=t[now].cnt;now=t[now].s[1];
}
}
int kth(int k)
{
int now=rt;
while(1)
{
if(t[now].s[0]&&k<=t[t[now].s[0]].siz) now=t[now].s[0];
else
{
k-=t[t[now].s[0]].siz+t[now].cnt;
if(k<=0) {splay(now);return t[now].w;}
now=t[now].s[1];
}
}
}
int pre()
{
int now=t[rt].s[0];
if(!now) return now;
while(t[now].s[1]) now=t[now].s[1];
splay(now);return t[now].w;
}
int nxt()
{
int now=t[rt].s[1];
if(!now) return now;
while(t[now].s[0]) now=t[now].s[0];
splay(now);return t[now].w;
}
void del(int k)
{
rnk(k);int now=rt;
if(t[now].cnt>1) {t[now].cnt--;getsiz(now);return;}
if(!t[now].s[0]&&!t[now].s[1]) {rt=0;clear(now);return;}
if(!t[now].s[0]) {rt=t[now].s[1];t[t[now].s[1]].fa=0;clear(now);return;}
if(!t[now].s[1]) {rt=t[now].s[0];t[t[now].s[0]].fa=0;clear(now);return;}
int x=pre();
t[t[now].s[1]].fa=rt;t[rt].s[1]=t[now].s[1];
clear(now);getsiz(rt);
}
int n;
int main()
{
scanf("%d",&n);
for(int i=1,op,x;i<=n;i++)
{
scanf("%d%d",&op,&x);
if(op==1) insert(x);
if(op==2) del(x);
if(op==3) cout<<rnk(x)<<endl;
if(op==4) cout<<kth(x)<<endl;
if(op==5) insert(x),cout<<pre()<<endl,del(x);
if(op==6) insert(x),cout<<nxt()<<endl,del(x);
}
return 0;
}

Splay 的序列操作

除了维护序列里有哪些值以外,平衡树另一个重要的用途是维护某些 只关心每个位置上的值,但是不关心大小关系 的序列。比如 lg P3391 【模板】文艺平衡树

题意简述:给定一个长度为 \(n\) 的序列,支持多次区间翻转,求最后的序列。

Splay 在维护序列操作时的定义

感觉并不太好理解,当然更可能是我脑袋不好用又没看到有人讲,一直在试图把它往之前的 Splay 上类比。

这里,我们不再关心不同点的点值大小之间的关系,只关心每个权值之间的位置关系。所以这棵 Splay 虽然依旧叫 Splay,但和上文的二叉搜索树并不一样。

也就是说它现在不用满足 左子树权值 比 \(x\) 小一类的限制,它就是一棵正常的二叉树,中序遍历这棵树得到的权值序列是题里要维护的序列。或者说,满足二叉搜索树性质的是点的下标,而不再是权值。

比如序列 \(1\ 4\ 3\ 5\ 2\),它对应的 Splay 就可以长成这样子:

Splay 函数的改进

虽然这棵 Splay 已经不满足二叉搜索树的性质,但 rotate 函数旋转后不改变树的中序遍历,这一点是没变的。所以还是可以用原来的 Splay 函数来操作这棵树。

那为什么要改进呢?要先知道,我们想怎么维护区间翻转。

考虑对上图的一整个序列都区间翻转,看看对应的 Splay 有什么变化:



发现其实本质是交换这棵树内每个节点的左右儿子。所以考虑使用类似线段树的 lazy 标记,在这个点打标记,表示 pushdown 时要交换它的两个子树。

也要注意,给 \(x\) 打标记的时候 \(x\) 这个点的左右儿子还没换,这与线段树的 lazytag 不同。线段树在 \(x\) 上打标记,表示 \(x\) 已经修改过了,将要修改儿子的贡献。

当然你把它定义成和线段树一样的也不是不行,我很长一段时间里都这么写。但这样写 corner case 巨大多,看题解代码又一头雾水。后来才知道是定义不同上出的锅,衷心祝愿大家不要再踩雷。

这么做的前提,是要修改的区间正好在同一个子树里。对于不在一个子树里的情况,我们想点办法把它们转到一起。

更改 splay 函数的定义。我们令 splay(x,y) 表示 把下标为 \(x\) 的点一路向上转,直到它成为 \(y\) 的某个儿子。那么,假设我们现在想让下标为 \([l,r]\) 的在一个子树里。

下文的图中,点的编号表示的是下标,不是权值。 显然,下标的中序遍历一定是 \(1,2,\dots,n\)。

我们先 splay(l-1,0),把 \(l-1\) 转到根上(图可能有点抽象,但是不赶工今天上午都写不完了):



那么 \(r+1\) 肯定在根的右子树里,再 splay(r+1,l-1),把 \(r+1\) 转到 \(l-1\) 的下面。



可以看出来,蓝色的那个子树就是区间 \([l,r]\)。

为了处理翻转 \([1,n]\) 找不到 \(l-1\) 和 \(r+1\) 的问题,我们在 \(1\) 号点前和 \(n\) 号点后各插入一个虚点,用于翻转区间。这两个点权值是啥不重要,但是要能根据权值区分出来谁是虚点(因为它们不能输出),这里就赋成 \(0\) 了。

改进后的 Splay 函数,其实就是把判断父亲是不是根改成判断是不是 \(y\)。

void splay(int x,int y)
{
for(int f=fa(x);f=fa(x),f!=y;rotate(x))
if(fa(f)!=y) rotate(get(f)==get(x)?f:x);
if(!y) rt=x;
}

其他函数

Find

使用 find 函数找到下标为 \(x\) 的点,原理和二叉搜索树的 kth 函数相同。中间要记得一边找一边 pushdown。同时因为有虚点,所以排名是实际的下标 \(+1\)。

il int find(int x)
{
int now=rt; x++;
while(now)
{
pushdown(now); int sz=tr[ls(now)].siz+1;
if(sz==x) break;
else if(sz>x) now=ls(now);
else now=rs(now),x-=sz;
}
return now;
}

reverse

根据上面的图,reverse 函数也不难写出:

il void reverse(int l,int r)
{
int x=find(l-1); splay(x,0);
int y=find(r+1); splay(y,x);
tr[ls(y)].lz^=1;
}

懒喵式建树!

建树的方法很多,比如像二叉搜索树一样写 insert 函数 / 类似于线段树的递归式建树。

但是樱雪喵比较懒,就直接一个循环把树建成一条链了,反正后面也要 splay 的。

il void build()
{
rt=newnode(0); int now=rt;
for(int i=1;i<=n+1;i++,now=rs(now)) tr[now].siz=n+3-i,rs(now)=newnode(a[i]),fa(rs(now))=now;
}

输出

中序遍历并输出就好了,别忘 pushdown,注意判一下虚点不输出。

void write(int now)
{
pushdown(now);
if(ls(now)) write(ls(now));
if(tr[now].key) printf("%d ",tr[now].key);
if(rs(now)) write(rs(now));
}

至此,用 Splay 维护序列的基本操作就完成了。贴一个本题的完整代码:

点击查看代码
#include<bits/stdc++.h>
#define il inline
using namespace std;
il int read()
{
int xr=0,F=1; char cr;
while(cr=getchar(),cr<'0'||cr>'9') if(cr=='-') F=-1;
while(cr>='0'&&cr<='9')
xr=(xr<<3)+(xr<<1)+(cr^48),cr=getchar();
return xr*F;
}
const int N=1e5+5,inf=2e9;
struct tree
{
int s[2],siz,fa,key,lz;
tree(){s[0]=s[1]=siz=fa=key=lz=0;}
}tr[N];
#define ls(x) tr[(x)].s[0]
#define rs(x) tr[(x)].s[1]
#define fa(x) tr[(x)].fa
int rt,idx,a[N];
il int newnode(int key) {tr[++idx].key=key,tr[idx].siz=1;return idx;}
il void maintain(int x) {tr[x].siz=tr[ls(x)].siz+tr[rs(x)].siz+1;}
il void clear(int x) {ls(x)=rs(x)=fa(x)=tr[x].siz=tr[x].key=0;}
il bool get(int x) {return x==rs(fa(x));}
il void rotate(int x)
{
int y=fa(x),z=fa(y); int c=get(x);
if(tr[x].s[c^1]) fa(tr[x].s[c^1])=y;
tr[y].s[c]=tr[x].s[c^1],tr[x].s[c^1]=y,fa(y)=x,fa(x)=z;
if(z) tr[z].s[y==tr[z].s[1]]=x; //not get(y)!
maintain(y),maintain(x);
}
il void splay(int x,int y)
{
for(int f=fa(x);f=fa(x),f!=y;rotate(x))
if(fa(f)!=y) rotate(get(f)==get(x)?f:x);
if(!y) rt=x;
}
il void pushdown(int x)
{
if(!tr[x].lz) return;
swap(ls(x),rs(x)),tr[ls(x)].lz^=1,tr[rs(x)].lz^=1;
tr[x].lz=0; return;
}
il int find(int x)
{
int now=rt; x++;
while(now)
{
pushdown(now); int sz=tr[ls(now)].siz+1;
if(sz==x) break;
else if(sz>x) now=ls(now);
else now=rs(now),x-=sz;
}
return now;
}
il void reverse(int l,int r)
{
int x=find(l-1); splay(x,0);
int y=find(r+1); splay(y,x);
tr[ls(y)].lz^=1;
}
void write(int now)
{
pushdown(now);
if(ls(now)) write(ls(now));
if(tr[now].key) printf("%d ",tr[now].key);
if(rs(now)) write(rs(now));
}
int n,m;
il void build()
{
rt=newnode(0); int now=rt;
for(int i=1;i<=n+1;i++)
tr[now].siz=n+3-i,rs(now)=newnode(a[i]),fa(rs(now))=now,now=rs(now);
}
int main()
{
n=read(),m=read();
for(int i=1;i<=n;i++) a[i]=i;
rt=newnode(0); int now=rt;
for(int i=1;i<=n+1;i++) tr[now].siz=n+3-i,rs(now)=newnode(a[i]),fa(rs(now))=now,now=rs(now);
while(m--)
{
int l=read(),r=read();
reverse(l,r);
}
write(rt);
return 0;
}

更复杂的序列操作

在节点里添加更多信息,就可以维护一些更复杂的东西。比如这个经典题 P2042 [NOI2005] 维护数列。这里不具体讲做法了,很阴间,建议写之前做好调一天的心理准备。


算是写完了吧?为了补 ybtoj 而学 LCT,但是用了一上午写了一篇 Splay,怎么回事呢。至少在打字速度上取得了进步,倒也不坏。

LCT 的学习笔记没准下午写?要是学不会就学会了再写(

那就完结撒花!ww

Splay 详细图解 & 轻量级代码实现的更多相关文章

  1. [转]超详细图解:自己架设NuGet服务器

    本文转自:http://diaosbook.com/Post/2012/12/15/setup-private-nuget-server 超详细图解:自己架设NuGet服务器 汪宇杰          ...

  2. 详细图解jQuery对象,以及如何扩展jQuery插件

    详细图解jQuery对象,以及如何扩展jQuery插件 早几年学习前端,大家都非常热衷于研究jQuery源码.我还记得当初从jQuery源码中学到一星半点应用技巧的时候常会有一种发自内心的惊叹,“原来 ...

  3. JS详细图解全方位解读this

    JS详细图解全方位解读this 对于this指向的理解中,有这样一种说法:谁调用它,this就指向谁.在我刚开始学习this的时候,我是非常相信这句话的.因为在一些情况下,这样理解也还算说得通.可是我 ...

  4. JS详细图解作用域链与闭包

    JS详细图解作用域链与闭包 攻克闭包难题 初学JavaScript的时候,我在学习闭包上,走了很多弯路.而这次重新回过头来对基础知识进行梳理,要讲清楚闭包,也是一个非常大的挑战. 闭包有多重要?如果你 ...

  5. JS执行上下文(执行环境)详细图解

    JS执行上下文(执行环境)详细图解 先随便放张图 我们在JS学习初期或者面试的时候常常会遇到考核变量提升的思考题.比如先来一个简单一点的. console.log(a); // 这里会打印出什么? v ...

  6. JS内存空间详细图解

    JS内存空间详细图解 变量对象与堆内存 var a = 20; var b = 'abc'; var c = true; var d = { m: 20 } 因为JavaScript具有自动垃圾回收机 ...

  7. maven3常用命令、java项目搭建、web项目搭建详细图解(转)

     转自:http://blog.csdn.net/edward0830ly/article/details/8748986 maven3常用命令.java项目搭建.web项目搭建详细图解 2013-0 ...

  8. 她娇羞道“不用这样细致认真的说啊~~”———详细图解在Linux环境中创建运行C程序

    她娇羞说,不用这样细致认真的说啊———详细图解在Linux环境中创建运行C程序“不,这是对学习的负责”我认真说到 叮叮叮,停车,让我们看看如何在Linux虚拟机环境中,创建运行C程序 详细图解在Lin ...

  9. CentOS 6.4 服务器版安装教程(超级详细图解)

    附:CentOS 6.4下载地址 32位:http://mirror.centos.org/centos/6.4/isos/i386/CentOS-6.4-i386-bin-DVD1to2.torre ...

  10. win8.1系统的安装方法详细图解教程

    win8.1系统的安装方法详细图解教程 关于win8.1系统的安装其实很简单 但是有的童鞋还不回 所以今天就抽空做了个详细的图解教程, 安装win8.1系统最好用U盘安装,这样最方便简单 而且系统安装 ...

随机推荐

  1. CDMP国际数据治理认证训练营来了(7-8月)

    大家好,我是独孤风,一位曾经的港口煤炭工人,目前在某国企任大数据负责人,公众号大数据流动主理人.在最近的两年的时间里,因为公司的需求,还有大数据的发展趋势所在,我开始学习数据治理的相关知识. 经过一段 ...

  2. JSGRID loaddata显示超级多空行

    这个逼问题困扰了我两天了 作为一个主后端的程序员 初体验前端技术栈真的麻之又麻 以防万一 请先确认 是不是和我一个情况 如果是 请往下看 首先 我们需要念一段咒语 json是json string是s ...

  3. Git子模块使用说明

    介绍 前端不同应用存在公共的脚本或样式代码,为了避免重复开发,将公共的代码抽取出来,形成一个公共的 git 子模块,方便调用和维护. 软件架构 本仓库代码将作为 git 子模块,被引用到其他仓库中,不 ...

  4. Python日志模块:实战应用与最佳实践

    本文详细解析了Python的logging模块,从基本介绍到实际应用和最佳实践.我们通过具体的代码示例解释了如何高效地使用这个模块进行日志记录,以及如何避免常见的陷阱,旨在帮助读者更好地掌握这个强大的 ...

  5. Centos7通过yum源安装Mysql

    1.下载并安装MySQL官方的Yum Repository 在CentOS中默认安装有MariaDB,这个是MySQL的分支,但为了需要,还是要在系统中安装MySQL,而且安装完成之后可以直接覆盖掉M ...

  6. 【MAUI Blazor踩坑日记】1.关于图标的处理

    前言 本系列文章,默认你已经踏上了MAUI Blazor的贼船,并且对MAUI Blazor有了一些了解,知道MAUI是什么,知道Blazor是什么. 不会教你怎么写MAUI Blazor的项目,只是 ...

  7. zabbix 主动模式下报文分析

    获取监控项列表 客户端发起请求 3次握手之后,请求监控项列表: {"request":"active checks","host":&quo ...

  8. 从 HTTP/1.1 到 HTTP/3

    从 HTTP/1.1 到 HTTP/3,解决了一些旧协议的问题,引入了好用的新功能. HTTP/1.1 HTTP/1.1 通过在传输层和应用层之间增加 SSL/TSL 解决数据不安全的问题,但它本身还 ...

  9. 从MybatisPlus回归Mybatis

    从MybatisPlus回归Mybatis 之前写项目一直习惯使用MyBatisPlus,单表查询很方便:两张表也很方便,直接业务层处理两张表的逻辑.但什么都图方便只会害了你. 但连接的表比较复杂的时 ...

  10. docker安装phpmyadmin

    下载docker镜像 docker pull phpmyadmin/phpmyadmin 创建容器 # 假设MySQL服务器的地址为:192.168.0.10,端口3306 # 通过3360端口访问p ...