平衡树之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。注意:应该先旋转父亲和祖父。
zig-zig 旋转过程
(2) 之字旋:也就是zig-zag,不同于一字旋,zig-zag不用先旋转父亲和祖父,可以直接旋转x,否则将不能达到减少层数的效果。
zig-zag 旋转过程

Splay树常用操作

Splay树常用于处理区间分裂和合并问题,旋转到根的功能使分裂和合并很容易实现。(作为对比,可以回顾一下FHQ Treap树的分裂与合并。)例如:一个常见的区间操作,修改或查询区间[L,R],用Splay树就很容易实现:先把L-1旋转到根,然后把节点R+1旋转到L-1的右子树上,此时,L+1的左子树就是区间[L,R]。
接下来咱们以洛谷 P4008 [NOI2003] 文本编辑器为例,说一下Splay树的常用操作。
Splay常用操作如下:

  1. 旋转

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);
} 
  1. 分裂与合并

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);
}
  1. 块操作

每读入一个字符串,先用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^]/*
} 
posted @ 2024-02-23 23:15  Murder!sans  阅读(22)  评论(0编辑  收藏  举报