平衡树之Splay树详解
认识
Splay树,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是新的根。
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^]/* }
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析