Splay
- 引入
- Splay
- 旋转操作
- splay操作
- 插入操作
- 查询x排名
- 查询排名为x
- 删除操作
- 查询前驱/后继
- 模板
- Splay时间复杂度分析
- 进阶操作
- 截取区间
- 区间加,区间赋值,区间查询,区间最值
- 区间翻转
- 原序列整体插入
- 指定位置插入
- 整体插入末尾
- 区间最大子段和
- 一些好题
- 参考文献
引入
首先我们要知道一个东西叫二叉搜索树。
其定义如下:
- 空树是二叉搜索树。
- 若二叉搜索树的左子树不为空,则其左子树上所有点的附加权值均小于其根节点的值。
- 若二叉搜索树的右子树不为空,则其右子树上所有点的附加权值均大于其根节点的值。
- 二叉搜索树的左右子树均为二叉搜索树。
二叉搜索树上的基本操作所花费的时间与这棵树的高度成正比。对于一个有 n n n 个结点的二叉搜索树中,这些操作的最优时间复杂度为 O ( log n ) O(\log n) O(logn)
如图就是一颗典型的 BST(二叉查找树)
可是我们发现,如果树退化成一条链,那么时间复杂度将退化为
O
(
n
)
O(n)
O(n),这是我们不能接受的,于是平衡树孕育而生,其核心就是维护一颗相对平衡的 BST。
本文将介绍Splay,虽然它并不能保证树一直是"平衡"的,但对于Splay的一系列操作,我们可以证明其每步操作的平摊复杂度都是
O
(
log
n
)
O(\log n)
O(logn)。所以从某种意义上说,Splay也是一种平衡的二叉查找树。
Splay
旋转操作
下面参考 OI-WIKI的介绍。
注意,左右旋指的是向左或右旋转。
左旋为ZAG,右旋为ZIG
以下是一次标准旋转操作:
我们可以知道,旋转流程如下:
于是我们便可以写出 ZIG和ZAG函数,参考下列代码:
不过有时候为了方便表示,我们可以把两个旋转操作合并起来。
就成了 rotate(旋转)函数,以下是参考代码:
void rotate(int x){
int y=fa[x],z=fa[y],id=son_(x);
ch[y][id]=ch[x][id^1];
if(ch[x][id^1])
fa[ch[x][id^1]]=y;
ch[x][id^1]=y;
fa[y]=x;
fa[x]=z;
if(z)
ch[z][y==ch[z][1]]=x;
pushup(y);
pushup(x);
}
其中 son_( x x x)是判断 x x x 为父节点的左儿子还是右儿子,pushup为由下往上更新。
splay操作
这个操作可以说是Splay的核心操作之一,可以理解为把某个点通过旋转操作旋转到根节点。
那么如何将一个节点旋转到根节点呢?
首先有
6
6
6 种基本情况,见下图:
那么我们只需要不断重复执行旋转操作,即可旋转到根节点。
以下是参考代码:
void splay(int x) {
for (int f = fa[x]; f = fa[x], f; rotate(x))
if (fa[f])
rotate(get(x) == get(f) ? f : x);
rt = x;
}
一些进阶:
由于后面某些操作需要用到,所以我们对splay函数进行一些修改。
具体而言,我们引入一个参数
y
y
y,让splay把
x
x
x 旋转到
y
y
y 的儿子上。(当
y
=
0
y=0
y=0 时将
x
x
x 旋转到根节点)
其实也没什么改动,见参考代码:
void splay(int x,int y){
while(fa[x]!=y){
if(fa[fa[x]]!=y){
if(son_(fa[x])==son_(x))
rotate(fa[x]);
else
rotate(x);
}
rotate(x);
}
if(!y)
rt=x;
}
插入操作
解释一下:
二叉树的性质使得插入操作变得非常简易,具体而言,只要值比当前节点大,就往右子树找,小就往左子树找,一样就让计数器+1,如果找不到匹配的值就直接新建一个节点。
参考代码:
void add(int k){
if(!rt){
rt=++idx;
cnt[rt]++,val[rt]=k;
pushup(rt);
return ;
}
int x=rt,y=0;
while(1){
if(val[x]==k){
cnt[x]++;
pushup(x),pushup(y);
splay(x,0);
break;
}
y=x;
x=ch[x][val[x]<k];
if(!x){
cnt[++idx]++,val[idx]=k;
fa[idx]=y;
ch[y][val[y]<k]=idx;
pushup(idx);
pushup(y);
splay(idx,0);
break;
}
}
}
查询x排名
这个跟插入差不多,从根节点不断往下找,每次向右子树找时加上左子树的size+1,因为左子树和根的值一定比查询值小(BST的性质)。
具体详见代码:
int x_rank(int k){
int rk=0,x=rt;
while(1){
if(k<val[x])
x=ch[x][0];
else{
rk+=sz[ch[x][0]];
if(!x)
return rk+1;
if(k==val[x]){
splay(x,0);
return rk+1;
}
rk+=cnt[x];
x=ch[x][1];
}
}
}
查询排名为x
这个跟上面两个操作都差不多,不断往下找就行了。
看着代码,画画图也就能理解了。
int kth(int k){
int x=rt;
while(1){
if(ch[x][0]&&k<=sz[ch[x][0]])
x=ch[x][0];
else{
k-=sz[ch[x][0]];
if(k<=cnt[x]){
splay(x,0);
return val[x];
}
k-=cnt[x];
x=ch[x][1];
}
}
}
删除操作
这个就感性理解一下。
参考代码:
void del(int k){
x_rank(k);
int x=rt,y=0;
if(cnt[rt]>1)
cnt[rt]--,pushup(rt);
else if(!ch[rt][0]&&!ch[rt][1])
clean(rt),rt=0;
else if(!ch[rt][0]){
rt=ch[rt][1];
fa[rt]=0;
clean(x);
}
else if(!ch[rt][1]){
rt=ch[rt][0];
fa[rt]=0;
clean(x);
}
else{
pre();
fa[ch[x][1]]=rt;
ch[rt][1]=ch[x][1];
clean(x),pushup(rt);
}
}
或者还有一种方式,我们把
x
x
x 的前驱旋转到根节点,再把
x
x
x 的后继旋转到根节点的右子树上,这样根节点的右子树的左儿子即为目标节点,直接断开联系即为删除。
参考代码:
void del(int x){
int l=kth(x-1),r=kth(r+1);
splay(l,0),splay(r,l);
fa[ch[r][0]]=0,ch[r][0]=0;
pushup(r);
pushup(l);
}
查询前驱/后继
这个可以先将这个节点插入,此时它在根节点,那么前驱就是它左子树中最右的点,后继就是它右子树中最左的点。
查询完我们在删除这个点即可。
参考代码:
int pre(){
int z=ch[rt][0];
while(ch[z][1])
z=ch[z][1];
splay(z,0);
return z;
}
int nxt(){
int z=ch[rt][1];
while(ch[z][0])
z=ch[z][0];
splay(z,0);
return z;
}
模板
综合上述操作,我们即可A掉洛谷模版题。
P3369 【模板】普通平衡树
题目概述:
参考代码:
struct Tr_splay{
int fa[N],ch[N][2],sz[N],val[N],cnt[N];
void pushup(int x){
sz[x]=sz[ch[x][0]]+sz[ch[x][1]]+cnt[x];
}
void clean(int x){
fa[x]=sz[x]=cnt[x]=val[x]=ch[x][0]=ch[x][1]=0;
}
bool son_(int x){
return x==ch[fa[x]][1];
}
void rotate(int x){
int y=fa[x],z=fa[y],id=son_(x);
ch[y][id]=ch[x][id^1];
if(ch[x][id^1])
fa[ch[x][id^1]]=y;
ch[x][id^1]=y;
fa[y]=x;
fa[x]=z;
if(z)
ch[z][y==ch[z][1]]=x;
pushup(y);
pushup(x);
}
void splay(int x,int y){
while(fa[x]!=y){
if(fa[fa[x]]!=y){
if(son_(fa[x])==son_(x))
rotate(fa[x]);
else
rotate(x);
}
rotate(x);
}
if(!y)
rt=x;
}
int pre(){
int z=ch[rt][0];
while(ch[z][1])
z=ch[z][1];
splay(z,0);
return z;
}
int nxt(){
int z=ch[rt][1];
while(ch[z][0])
z=ch[z][0];
splay(z,0);
return z;
}
void add(int k){
if(!rt){
rt=++idx;
cnt[rt]++,val[rt]=k;
pushup(rt);
return ;
}
int x=rt,y=0;
while(1){
if(val[x]==k){
cnt[x]++;
pushup(x),pushup(y);
splay(x,0);
break;
}
y=x;
x=ch[x][val[x]<k];
if(!x){
cnt[++idx]++,val[idx]=k;
fa[idx]=y;
ch[y][val[y]<k]=idx;
pushup(idx);
pushup(y);
splay(idx,0);
break;
}
}
}
int x_rank(int k){
int rk=0,x=rt;
while(1){
if(k<val[x])
x=ch[x][0];
else{
rk+=sz[ch[x][0]];
if(!x)
return rk+1;
if(k==val[x]){
splay(x,0);
return rk+1;
}
rk+=cnt[x];
x=ch[x][1];
}
}
}
int kth(int k){
int x=rt;
while(1){
if(ch[x][0]&&k<=sz[ch[x][0]])
x=ch[x][0];
else{
k-=sz[ch[x][0]];
if(k<=cnt[x]){
splay(x,0);
return val[x];
}
k-=cnt[x];
x=ch[x][1];
}
}
}
void del(int k){
x_rank(k);
int x=rt,y=0;
if(cnt[rt]>1)
cnt[rt]--,pushup(rt);
else if(!ch[rt][0]&&!ch[rt][1])
clean(rt),rt=0;
else if(!ch[rt][0]){
rt=ch[rt][1];
fa[rt]=0;
clean(x);
}
else if(!ch[rt][1]){
rt=ch[rt][0];
fa[rt]=0;
clean(x);
}
else{
pre();
fa[ch[x][1]]=rt;
ch[rt][1]=ch[x][1];
clean(x),pushup(rt);
}
}
}tree;
signed main(){
IOS;
cin>>m;
while(m--){
int x,y;
cin>>x>>y;
if(x==1)tree.add(y);
if(x==2)tree.del(y);
if(x==3)tree.add(y),cout<<tree.x_rank(y)<<"\n",tree.del(y);
if(x==4)cout<<tree.kth(y)<<"\n";
if(x==5)tree.add(y),cout<<tree.val[tree.pre()]<<"\n",tree.del(y);
if(x==6)tree.add(y),cout<<tree.val[tree.nxt()]<<"\n",tree.del(y);
}
return 0;
}
Splay时间复杂度分析
这个蒟蒻不会,但可以参考 OI-WIKI的证明:
证明
进阶操作
截取区间
Splay还可应用到序列操作中,具体而言,如果我们需要对区间
[
l
,
r
]
[l,r]
[l,r]进行操作,我们只需要先将
l
−
1
l-1
l−1 弄到根节点,再把
r
+
1
r+1
r+1 弄到根节点的右儿子上,那么它的左子树就是区间
[
l
,
r
]
[l,r]
[l,r]了。
参考代码:
int split(int l,int r){
l=kth(l-1),r=kth(r+1);
splay(l,0);
splay(r,l);
return ch[r][0];
}
//返回区间[l,r]对应的子树的根节点
区间加,区间赋值,区间查询,区间最值
这个类似线段树,我们相应的维护标记,并写好pushdown即可。
区间加参考:
void pushadd(int x,int k){
val[x]+=k;
sum[x]+=k*sz[x];
add[x]+=k;
}
void modify1(int l,int r,int k){
int _=split(l,r);
pushadd(_,0,k);
pushup(r);
pushup(l);
}
区间赋值参考:
void pushcov(int x,int k){
val[x]=k;
sum[x]=sz[x]*k;
add[x]=0;
cov[x]=1;
}
void modify(int l,int r,int k){
int _=split(l,r);
pushcov(_,k);
pushup(r);
pushup(l);
}
区间查询参考:
void ask_sum(int l,int r){
int _=split(l,r);
cout<<sum[_]<<"\n";
}
区间翻转
这个呢我们还是搞一个懒标记然后下传,注意各个标记之间的先后顺序。
参考代码:
void change(int x){
swap(ch[x][0],ch[x][1]);
lazy[x]^=1;
}
void reverse(int l,int r){
l=kth(l),r=kth(r+2);
splay(l,0);
splay(r,l);
change(ch[ch[l][1]][0]);
}
原序列整体插入
有时候题目会直接给我们一个初始序列,一个个插入过于麻烦,于是我们可以类似线段树直接建树。
参考代码:
int create(int k){
int x=top?rb[top--]:++ID;
ch[x][0]=ch[x][1]=fa[x]=rev[x]=cov[x]=0;
sz[x]=1;
val[x]=mx[x]=sum[x]=k;
lx[x]=rx[x]=max(0ll,k);
return x;
}
一些毒瘤题卡空间,这样回收可以节省空间。
int build(int l,int r,int *a){
if(l>r)
return 0;
if(l==r)
return create(a[l]);
int mid=(l+r)>>1,x=create(a[mid]);
ch[x][0]=build(l,mid-1,a);
ch[x][1]=build(mid+1,r,a);
fa[ch[x][0]]=fa[ch[x][1]]=x;
pushup(x);
return x;
}
rt=build(1,n,a);
指定位置插入
这个可以参考查询排名为x的操作。
能看到这里说明你已经是大佬了,看着代码画画图即可理解吧。
void add(int pos,int k){
kth(pos);
pushdown(rt);
fa[ch[rt][0]]=++ID,ch[ID][0]=ch[rt][0];
ch[rt][0]=ID,fa[ID]=rt;
sz[ID]=1;
val[ID]=sum[ID]=k;
pushup(ID);
pushup(rt);
}
整体插入末尾
这个也比较抽象,类似于建一棵新的splay,然后合并。
void insert(int pos,int len,int *a){
int _=build(1,len,a);
int y=kth(pos),x=kth(pos+1);
splay(y,0);
splay(x,y);
ch[x][0]=_,fa[_]=x;
pushup(x);
pushup(y);
}
区间最大子段和
参考线段树,我们维护3个标记:
lx:从左起的最大子段和
mx:整个区间的最大子段和
rx:从右起的最大子段和
参考代码:(由于同时维护区间赋值和区间翻转,代码比较抽象)
void pushup(int x){
sz[x]=sz[ch[x][0]]+sz[ch[x][1]]+1;
sum[x]=sum[ch[x][0]]+sum[ch[x][1]]+val[x];
lx[x]=max(lx[ch[x][0]],sum[ch[x][0]]+val[x]+lx[ch[x][1]]);
rx[x]=max(rx[ch[x][1]],sum[ch[x][1]]+val[x]+rx[ch[x][0]]);
mx[x]=max(max(mx[ch[x][0]],mx[ch[x][1]]),rx[ch[x][0]]+val[x]+lx[ch[x][1]]);
}
void pushdown(int x){
if(cov[x]){
if(ch[x][0])val[ch[x][0]]=val[x],cov[ch[x][0]]=1,sum[ch[x][0]]=val[x]*sz[ch[x][0]];
if(ch[x][1])val[ch[x][1]]=val[x],cov[ch[x][1]]=1,sum[ch[x][1]]=val[x]*sz[ch[x][1]];
if(val[x]>0){
if(ch[x][0])lx[ch[x][0]]=rx[ch[x][0]]=mx[ch[x][0]]=sum[ch[x][0]];
if(ch[x][1])lx[ch[x][1]]=rx[ch[x][1]]=mx[ch[x][1]]=sum[ch[x][1]];
}
else{
if(ch[x][0])lx[ch[x][0]]=rx[ch[x][0]]=0,mx[ch[x][0]]=val[x];
if(ch[x][1])lx[ch[x][1]]=rx[ch[x][1]]=0,mx[ch[x][1]]=val[x];
}
cov[x]=0;
}
if(rev[x]){
if(ch[x][0])
rev[ch[x][0]]^=1,swap(ch[ch[x][0]][0],ch[ch[x][0]][1]),swap(lx[ch[x][0]],rx[ch[x][0]]);
if(ch[x][1])
rev[ch[x][1]]^=1,swap(ch[ch[x][1]][0],ch[ch[x][1]][1]),swap(lx[ch[x][1]],rx[ch[x][1]]);
rev[x]=0;
}
}
void ask_max_sum(){
cout<<mx[rt]<<"\n";
}
一些好题
P2042
P4008
P6707
参考文献
- OI-WIKI
- 伸展树的基本操作和应用——杨思雨
- 各位大佬的博客和题解