平衡树——treap
Treap 简介
Treap 是一种二叉查找树。它的结构同时满足二叉查找树(Tree)与堆(Heap)的性质,因此得名。Treap的原理是为每一个节点赋一个随机值使其满足堆的性质,保证了树高期望 O(log2n) ,从而保证了时间复杂度。
Treap 是一种高效的平衡树算法,在常数大小与代码复杂度上好于 Splay。
Treap 的基本操作
现在以 BZOJ 3224 普通平衡树为模板题,详细讨论 Treap 的基本操作。
1.基本结构
在一般情况下,Treap 的节点需要存储它的左右儿子,子树大小,节点中相同元素的数量(如果没有可以默认为1),自身信息及随机数的值
1 struct sd{ 2 int l,r,sz,key,rd,re; 3 }t[100005];
其中 l
为左儿子节点编号, r
为右儿子节点编号, key
为节点数值, sz
为子树大小, rd
为节点的随机值, re
为该节点数值的出现次数(目的为将所有数值相同的点合为一个)。
2.关于随机值
随机值由 rand()
函数生成, 考虑到 <cstdlib>
库中的 rand()
速度较慢,所以在卡常数的时候建议手写 rand()
函数。
1 inline int rand(){ 2 static int seed = 2333; 3 return seed = (int)((((seed ^ 998244353) + 19260817ll) * 19890604ll) % 1000000007); 4 }
其中 seed
为随机种子,可以随便填写。
3.节点信息更新
节点信息更新由 update()
函数实现。在每次产生节点关系的修改后,需要更新节点信息(最基本的子树大小,以及你要维护的其他内容)。
时间复杂度 O(1) 。
1 inline void update(int k){ 2 t[k].sz = t[l].sz + t[r].sz + t[k].re; 3 }
4.「重要」左旋与右旋
左旋与右旋是 Treap 的核心操作,也是 Treap 动态保持树的深度的关键,其目的为维护 Treap 堆的性质。
下面的图片可以让你更好的理解左旋与右旋:
下面具体介绍左旋与右旋操作。左旋与右旋均为变更操作节点与其两个儿子的相对位置的操作。
「左旋」为将作儿子节点代替根节点的位置, 根节点相应的成为左儿子节点的右儿子(满足二叉搜索树的性质)。相应的,之前左儿子节点的右儿子应转移至之前根节点的左儿子。此时,只有之前的根节点与左儿子节点的 sz
发生了变化。所以要 update()
这两个节点。
「右旋」类似于「左旋」,将左右关系相反即可。
时间复杂度 O(1) 。
1 void right(int &k) 2 { 3 int y=t[k].l;t[k].l=t[y].r;t[y].r=k; 4 t[y].sz=t[k].sz; 5 update(k);k=y; 6 } 7 void left(int &k) 8 { 9 int y=t[k].r;t[k].r=t[y].l;t[y].l=k; 10 t[y].sz=t[k].sz; 11 update(k);k=y; 12 }
5.节点的插入与删除
节点的插入与删除是 Treap 的基本功能之一。
「节点的插入」是一个递归的过程,我们从根节点开始,逐个判断当前节点的值与插入值的大小关系。如果插入值小于当前节点值,则递归至左儿子;大于则递归至右儿子;
相等则直接在把当前节点数值的出现次数 +1 ,跳出循环即可。如果当前访问到了一个空节点,则初始化新节点,将其加入到 Treap 的当前位置。
「节点的删除」同样是一个递归的过程,不过需要讨论多种情况:
如果插入值小于当前节点值,则递归至左儿子;大于则递归至右儿子。
如果插入值等于当前节点值:
若当前节点数值的出现次数大于 1 ,则减一;
若当前节点数值的出现次数等于于 1 :
若当前节点没有左儿子与右儿子,则直接删除该节点(置 0);
若当前节点没有左儿子或右儿子,则将左儿子或右儿子替代该节点;
若当前节点有左儿子与右儿子,则不断旋转 当前节点,并走到当前节点新的对应位置,直到没有左儿子或右儿子为止。
时间复杂度均为 O(log2n) 。
具体实现代码如下:
1 void inin(int &k,int x) 2 { 3 if(k==0) 4 { 5 size++; 6 k=size;t[k].sz=1; 7 t[k].re=1; 8 t[k].key=x; 9 t[k].rd=rand(); 10 return; 11 } 12 t[k].sz++; 13 if(t[k].key==x) 14 t[k].re++; 15 else 16 { 17 if(x>t[k].key) 18 { 19 inin(t[k].r,x); 20 if(t[t[k].r].rd<t[k].rd) 21 left(k); 22 } 23 else 24 { 25 inin(t[k].l,x); 26 if(t[t[k].l].rd<t[k].rd) 27 right(k); 28 } 29 } 30 } 31 void del(int &k,int x) 32 { 33 if(k==0) 34 return; 35 if(t[k].key==x) 36 { 37 if(t[k].re>1) 38 { 39 t[k].re--; 40 t[k].sz--; 41 return; 42 } 43 if(t[k].l*t[k].r==0) 44 k=t[k].l+t[k].r; 45 else 46 { 47 if(t[t[k].l].rd<t[t[k].r].rd) 48 right(k),del(k,x); 49 else 50 left(k),del(k,x); 51 } 52 } 53 else 54 { 55 if(x>t[k].key) 56 { 57 t[k].sz--; 58 del(t[k].r,x); 59 } 60 else 61 { 62 t[k].sz--; 63 del(t[k].l,x); 64 } 65 } 66 }
接下来来一道treap模板题,具体其它操作可在代码中学习,有较详细注释
新手代码可能有点冗长,作为蒟蒻,希望大佬勿喷。
https://www.luogu.org/problemnew/show/P3369
#include<cstdio> #include<cstring> #include<cstdlib> #include<ctime> using namespace std; struct sd{ int l,r,sz,key,rd,re;//树的左,右,大小,关键值,随机权值,重复次数 //我这里建立的是小根堆,即随机权值小的在上方 }t[100005]; int size,ans,root; void update(int k)//每次上浮都要更新树的大小 { t[k].sz=t[t[k].l].sz+t[t[k].r].sz+t[k].re; } void right(int &k)//向右旋转,是左子树就右旋 { int y=t[k].l;t[k].l=t[y].r;t[y].r=k; t[y].sz=t[k].sz; update(k);k=y; } void left(int &k)//向左旋转 ,是右子树就左旋 { int y=t[k].r;t[k].r=t[y].l;t[y].l=k; t[y].sz=t[k].sz; update(k);k=y; } void inin(int &k,int x)//插入x { if(k==0)//判断是否到了叶节点,如果是就开始插入X { size++; k=size;t[k].sz=1; t[k].re=1; t[k].key=x; t[k].rd=rand();//随机权值,保证平衡树的随机性与唯一性,让出题人卡不了 return; } t[k].sz++;//每次向下插入时都要在子树大小加一 if(t[k].key==x)//如果要插入的数原本就存在,那就直接在这个结点数的重复次数+1. t[k].re++; else { if(x>t[k].key) { inin(t[k].r,x);//到右子树中去找 if(t[t[k].r].rd<t[k].rd)//每次插入后判断是否改变了平衡树堆的性质 left(k); } else { inin(t[k].l,x);//在左子树中找 if(t[t[k].l].rd<t[k].rd) right(k); } } } void del(int &k,int x)//删除x { if(k==0) return; if(t[k].key==x)//找到了目标x就将其下沉 { if(t[k].re>1)//如果x重复多次出现,只用删除一个,那就不用下沉了,直接将重复次数-1 { t[k].re--; t[k].sz--; return; } if(t[k].l*t[k].r==0)//如果某个子树为空,那就直接将那个子树接到原树上,然后就把原树挤掉了 k=t[k].l+t[k].r; else { if(t[t[k].l].rd<t[t[k].r].rd)//为了维持平衡树堆的性质,每次下沉都与随机权值小的交换 right(k),del(k,x); else left(k),del(k,x); } } else//如果还没找到要删除的数,那就继续找呗 { if(x>t[k].key) { t[k].sz--; del(t[k].r,x); } else { t[k].sz--; del(t[k].l,x); } } } int rank1(int k,int x)//查找数x的排名 { if(k==0)return 0; if(t[k].key==x)return t[t[k].l].sz+1;//找到目标数,加上自己与比自己小的(即左子树)的数的个数 else if(x>t[k].key) return t[t[k].l].sz+t[k].re+rank1(t[k].r,x);//一旦在右子树寻找就要,递归回来时就要加上左子树大小 else return rank1(t[k].l,x);//如果在左子树找的话就不用加了 } int rank2(int k,int x)//查找排名为x的数 { if(k==0)return 0; if(x<=t[t[k].l].sz)//在左子树中找 return rank2(t[k].l,x); else if(x>(t[t[k].l].sz+t[k].re)) return rank2(t[k].r,x-t[t[k].l].sz-t[k].re);//在右子树中找 else return t[k].key;//如果既不在左子树,也不在右子树,那就在这个结点上了,就是这个结点的数 } void pre(int k,int x)//找前缀 { if(k==0)return; if(t[k].key<x) { ans=k;//每次都更新ans的值,直到找到最值 pre(t[k].r,x); } else pre(t[k].l,x);//显然没找到符合要求的,那就继续找 } void next(int k,int x)//找后缀 { if(k==0)return; if(t[k].key>x) { ans=k;//与找前缀同理 next(t[k].l,x); } else next(t[k].r,x); } int main() { srand(time(0));//好像这行代码可加可不加,本来就是只为了使随机数每次不同,至于为何删掉后没影响,我就不知道了 int n; scanf("%d",&n); for(int i=1;i<=n;i++) { int op,x; scanf("%d%d",&op,&x); if(op==1) inin(root,x); if(op==2) del(root,x); if(op==3) { int res=rank1(root,x); printf("%d\n",res); } if(op==4) { int res=rank2(root,x); printf("%d\n",res); } if(op==5) { pre(root,x); printf("%d\n",t[ans].key); } if(op==6) { next(root,x); printf("%d\n",t[ans].key); } } return 0; }