平衡树之Splay树详解
认识
Splay树,BST(二叉搜索树)的一种,整体效率很高,平摊操作次数为\(O(log_2n)\),也就是说,在一棵有n个节点的BST上做M次Splay操作,时间复杂度为\(O(Mlog_2n)\)(曾经是使用最多的BST,但现在多了一个更好码的FHQ Treap),其基本操作,是把节点旋转到BST的根部,其旋转操作能很好地改善树的平衡性。
如何设计把一个节点旋转到根的方法?需要考虑以下两个目的:
(1) 每次旋转,节点x就上升一层,从而能在有限次操作后到达根部。
(2) 旋转能改善BST的平衡性(尽量使BST层数减少)。
显然,如果只考虑(1),那么使用Treap树的旋转法即可,每次x与x的父亲交换位置(x上升一层)。可Treap树的这种“单旋”并不能减少BST的层数。
于是我们要请出它的升级版:双旋。单旋不是爸爸和儿子互换吗?双旋就是把爸爸的爸爸也加进来,让儿子,爸爸,祖父三个点转着圈儿的换。一番操作下来我们就能惊奇地发现,BST的平衡性被改善了。
Splay旋转(双旋)
接下来为了方便,我们将左旋称为zig,右旋称为zag。
Splay树的旋转分为两种,一字旋和之字旋:
(1) 一字旋:分为zig-zig和zag-zag。当x,f,g在一条直线上时,如果是向左的一条链,则做zig-zig,反之则做zag-zag。注意:应该先旋转父亲和祖父。
(2) 之字旋:也就是zig-zag,不同于一字旋,zig-zag不用先旋转父亲和祖父,可以直接旋转x,否则将不能达到减少层数的效果。
Splay树常用操作
Splay树常用于处理区间分裂和合并问题,旋转到根的功能使分裂和合并很容易实现。(作为对比,可以回顾一下FHQ Treap树的分裂与合并。)例如:一个常见的区间操作,修改或查询区间[L,R],用Splay树就很容易实现:先把L-1旋转到根,然后把节点R+1旋转到L-1的右子树上,此时,L+1的左子树就是区间[L,R]。
接下来咱们以洛谷 P4008 [NOI2003] 文本编辑器为例,说一下Splay树的常用操作。
Splay常用操作如下:
-
旋转
rotate(int x),对节点x做一次单旋,若x是一个右儿子,左旋,反之,右旋。
void rotate(int x){//单旋一次
int f=t[x].fa;//f:父亲
int g=t[f].fa;//g:祖父
int son=get(x);
if(son==1){//x是左儿子,右旋
t[f].rs=t[x].ls;
if(t[f].rs){
t[t[f].rs].fa=f;
}
}
else{//x是右儿子,左旋
t[f].ls=t[x].rs;
if(t[f].ls){
t[t[f].ls].fa=f;
}
}
t[f].fa=x;//x旋为f的父节点
if(son==1){//左旋,f变为x的左儿子
t[x].ls=f;
}
else{//右旋,f变为x的右儿子
t[x].rs=f;
}
t[x].fa=g;//x现在是祖父的儿子
if(g){//更新祖父的儿子
if(t[g].rs==f){
t[g].rs=x;
}
else{
t[g].ls=x;
}
}
Update(f);
Update(x);
}
Splay(int x,int goal),把节点x旋转到goal位置。goal=0表示把x旋转到根,x是新的根。\(goal\not=0\)表示把x旋转为goal的儿子。
void Splay(int x,int goal){
if(goal==0){
root=x;
}
while(1){
int f=t[x].fa;//一次处理x,f,g三个节点
int g=t[f].fa;
if(f==goal){
break;
}
if(g!=goal){//有祖父,分为一字旋和之字旋两种情况
if(get(x)==get(f)){一字旋,先旋转f,g
rotate(f);
}
else{//之字旋,直接旋转x
rotate(x);
}
}
rotate(x);
}
Update(x);
}
-
分裂与合并
Insert()、Del()函数中包含了分裂与合并,详情见代码注释。利用Splay函数实现分裂与合并,编码很简单。
void Insert(int L,int len){//插入一段区间
int x=kth(root,L);//x为第L个数的位置,y为第L+1个数的位置
int y=kth(root,L+1);
Splay(x,0);//分裂
Splay(y,x);
//先把x旋转到根,然后把y旋转到x的儿子,且y的儿子为空
t[y].ls=build(1,len,y);//合并:建一棵树,挂到y的左儿子上
Update(y);
Update(x);
}
void Del(int L,int R){//删除区间[L+1,R]
int x=kth(root,L);
int y=kth(root,R+1);
Splay(x,0);//y是x的右儿子,y的左儿子是待删除的区间
Splay(y,x);
t[y].ls=0;//剪短左子树,等于直接删除,这里为了简单,没有释放空间
Update(y);
Update(x);
}
-
块操作
每读入一个字符串,先用Build()函数把它建成一棵平衡树,然后再挂到Splay树上。而FHQ Treap树,只能一个一个地把字符添加到Treap树上,因为在FHQ Treap树中,每个节点都有一个自己的优先级,需要单独处理,不能像Splay一样对字符串做整体处理。
int build(int L,int R,int f){//把字符串建成平衡树
if(L>R){
return 0;
}
int mid=(L+R)>>1;
int cur=++cnt;
t[cur].fa=f;
t[cur].key=str[mid];
t[cur].ls=build(L,mid-1,cur);
t[cur].rs=build(mid+1,R,cur);
Update(cur);
return cur;//返回新树的根
}
Splay树能完成的操作当然远不止这些,这里只是列举了几种最常见的操作。下面就说说代码实现,还是以洛谷 P4008 [NOI2003] 文本编辑器为例。
代码实现
#include<bits/stdc++.h>//万能头文件大法好
using namespace std;
const int M=2e6+10;
int cnt=0,root=0;
struct Node{//结构体存树
int fa,ls,rs,size;//爸爸,左儿子,右儿子和大小
char key;//存的值
}t[M];
void Update(int u){//用于排名
t[u].size=t[t[u].ls].size+t[t[u].rs].size+1;
}
char str[M]={0};//输入的字符串
int build(int L,int R,int f){//把字符串建成平衡树
if(L>R){
return 0;
}
int mid=(L+R)>>1;
int cur=++cnt;
t[cur].fa=f;
t[cur].key=str[mid];
t[cur].ls=build(L,mid-1,cur);
t[cur].rs=build(mid+1,R,cur);
Update(cur);
return cur;//返回新树的根
}
int get(int x){
return t[t[x].fa].rs==x;//如果x是右儿子,返回一,反之,返回0
}
void rotate(int x){//单旋一次
int f=t[x].fa;//f:父亲
int g=t[f].fa;//g:祖父
int son=get(x);
if(son==1){//x是左儿子,右旋
t[f].rs=t[x].ls;
if(t[f].rs){
t[t[f].rs].fa=f;
}
}
else{//x是右儿子,左旋
t[f].ls=t[x].rs;
if(t[f].ls){
t[t[f].ls].fa=f;
}
}
t[f].fa=x;//x旋为f的父节点
if(son==1){//左旋,f变为x的左儿子
t[x].ls=f;
}
else{//右旋,f变为x的右儿子
t[x].rs=f;
}
t[x].fa=g;//x现在是祖父的儿子
if(g){//更新祖父的儿子
if(t[g].rs==f){
t[g].rs=x;
}
else{
t[g].ls=x;
}
}
Update(f);
Update(x);
}
void Splay(int x,int goal){
if(goal==0){
root=x;
}
while(1){
int f=t[x].fa;//一次处理x,f,g三个节点
int g=t[f].fa;
if(f==goal){
break;
}
if(g!=goal){//有祖父,分为一字旋和之字旋两种情况
if(get(x)==get(f)){一字旋,先旋转f,g
rotate(f);
}
else{//之字旋,直接旋转x
rotate(x);
}
}
rotate(x);
}
Update(x);
}
int kth(int u,int k){//第k大树的位置
if(k==t[t[u].ls].size+1){
return u;
}
if(k<=t[t[u].ls].size){
return kth(t[u].ls,k);
}
if(k>=t[t[u].ls].size+1){
return kth(t[u].rs,k-t[t[u].ls].size-1);
}
}
void Insert(int L,int len){//插入一段区间
int x=kth(root,L);//x为第L个数的位置,y为第L+1个数的位置
int y=kth(root,L+1);
Splay(x,0);//分裂
Splay(y,x);
//先把x旋转到根,然后把y旋转到x的儿子,且y的儿子为空
t[y].ls=build(1,len,y);//合并:建一棵树,挂到y的左儿子上
Update(y);
Update(x);
}
void Del(int L,int R){//删除区间[L+1,R]
int x=kth(root,L);
int y=kth(root,R+1);
Splay(x,0);//y是x的右儿子,y的左儿子是待删除的区间
Splay(y,x);
t[y].ls=0;//剪短左子树,等于直接删除,这里为了简单,没有释放空间
Update(y);
Update(x);
}
void Inorder(int u){//中序遍历
if(u==0){
return;
}
Inorder(t[u].ls);
cout<<t[u].key;
Inorder(t[u].rs);
}
int main(){
t[1].size=2;//小技巧:虚拟祖父,防止旋转时越界而出错
t[1].ls=2;
t[2].size=1;//小技巧:虚拟父亲
t[2].fa=1;
root=1,cnt=2;//在操作过程中,root将指向字符串的根
int pos=1;//光标位置
int n;
cin>>n;
while(n--){
int len;
char opt[10];
cin>>opt;
if(opt[0]=='I'){
cin>>len;
for(int i=1;i<=len;i++){
char ch=getchar();
while(ch<32||ch>126){
ch=getchar();
}
str[i]=ch;
}
Insert(pos,len);
}
if(opt[0]=='D'){
cin>>len;
Del(pos,pos+len);
}
if(opt[0]=='G'){
cin>>len;
int x=kth(root,pos);
int y=kth(root,pos+len+1);
Splay(x,0);
Splay(y,x);
Inorder(t[y].ls);
cout<<"\n";
}
if(opt[0]=='M'){
cin>>len;
pos=len+1;
}
if(opt[0]=='P'){
pos--;
}
if(opt[0]=='N'){
pos++;
}
}
return 0;//完结撒花 *\[^W^]/*
}