平衡树入门
平衡树入门
定义与性质
平衡树是二叉搜索树和堆合并构成的一种数据结构,所以它的名字是 \(tree(\)二叉搜索树\()+heap(\)堆\()\) 即 \(treap\) 。
事实上,堆和树的性质是冲突的,二叉搜索树要求满足左儿子小于根节点小于右儿子,而堆是满足根节点小于等于(或大于等于)左右儿子。因此在 \(treap\) 中,并不能以单一的键值作为节点的数据域。
\(treap\) 中的每个节点包含两个值,我们设其为 \(val\) 和 \(key\) 。
-
\(val\):满足二叉搜索树的性质。
-
\(key\):随机生成,满足堆的性质,即优先级。
简单理解的话,平衡树就是在二叉搜索树上增加了一个 \(key\) 值,我们需要维护它满足堆的性质。
如图,即一个标准的 \(treap\) :
\(fhq treap\)
ps:又称无旋 \(treap\) ,编程复杂度优于 \(splay\) 。
节点信息
struct node{
int l,r; //左右儿子
int val; //点权
int siz; //子树大小
int key; //treap随机值
}tr[N];
\(fhq\) 的节点信息和普通 \(treap\) 并无本质上的差别,但没有记录相同权值节点的个数,即不能把有相同权值的节点当成一个点来处理。这一个差异给 \(fhq\) 空间上的浪费,但却降低了编程的难度。
创建新节点
inline int build(int val)
{
tr[++tot].val=val,tr[tot].key=random(INF);
tr[tot].l=tr[tot].r=0,tr[tot].siz=1;
return tot;
}
其中 \(tot\) 记录节点的总量,\(val\) 是新节点的权值, \(rondom\) 函数返回一个随机值。
合并信息
inline void pushup(int k) { tr[k].siz=tr[tr[k].l].siz+tr[tr[k].r].siz+1; }
分裂
inline void split(int k,int val,int &x,int &y)
{
if(!k) { x=y=0; return; }
if(tr[k].val<=val) x=k,split(tr[k].r,val,tr[k].r,y);
else y=k,split(tr[k].l,val,x,tr[k].l);
pushup(k);
}
函数 \(split(k,val,x,y)\) 相当于在做一件这样的事情:
-
把以 \(k\) 为根的子树按照权值进行分裂:
-
权值小于等于 \(val\) 的会到以 \(x\) 为根的子树中。
-
权值大于 \(val\) 的回到以 \(y\) 为根的子树中。
-
接下来我们关注一下它是如何进行分裂的:
-
如果这个节点的权值是小于等于 \(val\) 的,说明节点 \(k\) 和节点 \(k\) 的左子树都会被划分到子树 \(x\) 上去,而 \(k\) 的右子树还没有被划分,那我们就需要再递归一下去划分 \(k\) 的右子树。注意此处我们是带引用的在进行递归,所以如果有要划分到 \(x\) 上的节点,直接把他挂上去即可。、
-
大于 \(val\) 的情况同理,此处不再赘述。
当然,我们仍然可以通过大小进行分裂,根据题目不同的要求,两种分裂方式都应该掌握,整体思路和按照权值分裂其实并无大的区别。
inline void split(int k,int s,int &x,int &y)
{
if(!k) { x=y=0; return; }
if(tr[tr[k].l].siz+1<=s) x=k,split(tr[k].r,s-tr[tr[k].l].siz-1,tr[x].r,y),pushup(x);
else y=k,split(tr[k].l,s,x,tr[y].l),pushup(y);
}
合并
inline int merge(int x,int y)
{
if(!x||!y) return x+y;
if(tr[x].key>tr[y].key){
tr[x].r=merge(tr[x].r,y),pushup(x);
return x;
}
else{
tr[y].l=merge(x,tr[y].l),pushup(y);
return y;
}
}
此段代码实现的操作是将以 \(x\) 为根的子树与以 \(y\) 为根的子树合并,需要注意的是我们这里保证了以 \(x\) 为根的子树的权值最大值小于以 \(y\) 为根的子树的权值最小值。同时我们需要不断维护优先级,因为有如上的性质,所以我们不用判断节点权值的大小而可以直接进行合并,最后这段代码返回的值是合并完两棵子树后的根节点。
考虑如何维护优先级:
-
如果 \(x\) 的优先级大于 \(y\) 的优先级,那么 \(x\) 和它的左子树我们就不需要动,需要处理的是 \(x\) 的右子树和 \(y\) 的合并问题,递归处理即可。
-
反之,\(y\) 的优先级大于 \(x\) 的优先级亦同理,我们仍然可以递归处理 \(y\) 的左子树和 \(x\) 。
按照这个过程一直递归,当有一棵子树为空,则返回 \(x+y\) ,显然,不失其正确性。
ps:分裂和合并是 \(fhq\) \(treap\) 的关键操作,其它操作的实现均基于此且相对简单。
插入
inline void insert(int val)
{
int x,y;
split(root,val-1,x,y);
root=merge(merge(x,build(val)),y);
}
向平衡树中插入一个权值为 \(val\) 的节点。
实现时,按照权值 \(val-1\) 进行分裂,分裂后,权值小于 \(val-1\) 的节点都在 \(x\) 子树中,其它节点在 \(y\) 子树中,先把 \(x\) 和新建的节点合并,再合并整棵树。
不难理解这个过程,实际上是为了保证我们合并时需要满足的大小性质。
删除
inline void delet(int val)
{
int x,y,z;
split(root,val,x,z),split(x,val-1,x,y);
if(y) y=merge(tr[y].l,tr[y].r);
root=merge(merge(x,y),z);
}
不难发现在分裂之后,以 \(y\) 为根的子树里只有权值等于 \(val\) 的节点,合并左右子树,并删除根即可。
删除完成后,将整棵树重新合并。
查询排名
inline int getrank(int val)
{
int x,y,ans;
split(root,val-1,x,y);
ans=tr[x].siz+1;
root=merge(x,y);
return ans;
}
某个数的排名实际上就是比他小的数的个数 \(+1\),分裂后直接查 \(x\) 子树的大小即可。
查询排名为 \(k\) 的数
inline int getval(int rank)
{
int k=root;
while(k){
if(tr[tr[k].l].siz+1==rank) break;
else if(tr[tr[k].l].siz>=rank) k=tr[k].l;
else rank-=tr[tr[k].l].siz+1,k=tr[k].r;
}
return !k?INF:tr[k].val;
}
由于我们的平衡树满足二叉搜索树的性质,我们可以在上面进行一个类似于二分的过程,基于此展开讨论即可。
前驱和后继
inline int getpre(int val)
{
int x,y,k,ans;
split(root,val-1,x,y);
k=x;
while(tr[k].r) k=tr[k].r;
ans=tr[k].val;
root=merge(x,y);
return ans;
}
inline int getnext(int val)
{
int x,y,k,ans;
split(root,val,x,y);
k=y;
while(tr[k].l) k=tr[k].l;
ans=tr[k].val;
root=merge(x,y);
return ans;
}
查询前驱就按照 \(val-1\) 分裂整棵树,然后取 \(x\) 子树最靠右的节点,后继同理,不再赘述。
优化
由于一定程度上 \(fhq\) 会浪费空间,基于此的一个优化是将被删除的节点用一个栈保存,新插入节点优先使用之前被删去的节点,示例代码中并没有这个操作,相信并不难实现。
CODE
#include
using namespace std;
const int N=5e5+10,INF=1e9;
inline int read()
{
int s=0,w=1;
char ch=getchar();
while(ch<'0'||ch>'9') { if(ch=='-') w*=-1; ch=getchar(); }
while(ch>='0'&&ch<='9') s=s*10+ch-'0',ch=getchar();
return s*w;
}
int n;
struct node{ int l,r,val,siz,key; }tr[N];
inline int random(int lim) { return rand()*rand()%lim+1; }
struct Treap{ //fhq平衡树
int tot,root;
inline void pushup(int k) { tr[k].siz=tr[tr[k].l].siz+tr[tr[k].r].siz+1; }
inline int build(int val){
tr[++tot].val=val,tr[tot].key=random(INF);
tr[tot].l=tr[tot].r=0,tr[tot].siz=1;
return tot;
}
inline void split(int k,int val,int &x,int &y){
if(!k) { x=y=0; return; }
if(tr[k].val<=val) x=k,split(tr[k].r,val,tr[k].r,y);
else y=k,split(tr[k].l,val,x,tr[k].l);
pushup(k);
}
inline int merge(int x,int y){
if(!x||!y) return x+y;
if(tr[x].key>tr[y].key){
tr[x].r=merge(tr[x].r,y),pushup(x);
return x;
}
else{
tr[y].l=merge(x,tr[y].l),pushup(y);
return y;
}
}
inline void insert(int val){
int x,y;
split(root,val-1,x,y);
root=merge(merge(x,build(val)),y);
}
inline void delet(int val){
int x,y,z;
split(root,val,x,z),split(x,val-1,x,y);
if(y) y=merge(tr[y].l,tr[y].r);
root=merge(merge(x,y),z);
}
inline int getrank(int val){
int x,y,ans;
split(root,val-1,x,y);
ans=tr[x].siz+1,root=merge(x,y);
return ans;
}
inline int getval(int rank){
int k=root;
while(k){
if(tr[tr[k].l].siz+1==rank) break;
else if(tr[tr[k].l].siz>=rank) k=tr[k].l;
else rank-=tr[tr[k].l].siz+1,k=tr[k].r;
}
return !k?INF:tr[k].val;
}
inline int getpre(int val){
int x,y,k,ans;
split(root,val-1,x,y),k=x;
while(tr[k].r) k=tr[k].r;
ans=tr[k].val,root=merge(x,y);
return ans;
}
inline int getnext(int val){
int x,y,k,ans;
split(root,val,x,y).k=y;
while(tr[k].l) k=tr[k].l;
ans=tr[k].val,root=merge(x,y);
return ans;
}
}treap;
int main()
{
n=read();
for(register int i=1;i<=n;i++){
int opt=read(),x=read();
if(opt==1) treap.insert(x);
else if(opt==2) treap.delet(x);
else if(opt==3) printf("%d\n",treap.getrank(x));
else if(opt==4) printf("%d\n",treap.getval(x));
else if(opt==5) printf("%d\n",treap.getpre(x));
else if(opt==6) printf("%d\n",treap.getnext(x));
}
return 0;
}
\(splay treap\)
ps:通过旋转维护的 \(treap\) ,一些操作与 \(LCT\) 有联系。
节点信息
struct node{
int fa; //父亲节点编号
int val; //节点权值
int siz; //子树大小
int cnt; //与该节点权值相同的数的个数
}tr[N];
int son[N][2]; //son[i][0/1]记录节点i的左右儿子
\(fhq\) 靠分裂和合并维护堆的性质避免二叉查找树退化为一条链,而 \(splay\) 不需要 \(key\) 值,它通过每次操作后的 \(splay\) 让树趋于平衡,以此避免复杂度的退化。
合并信息
inline void pushup(int k) { tr[k].siz=tr[k].cnt+tr[son[k][0]].siz+tr[son[k][1]].siz; }
创建新节点
inline void build()
{
++tot;
tr[tot].fa=son[tot][1]=son[tot][0]=tr[tot].cnt=tr[tot].siz=0;
return tot;
}
ps:以上两段代码过于简单,不加赘述。
左旋与右旋
正如分裂和合并是 \(fhq\) 最重要的操作, \(splay\) 的核心操作是两种旋转以及 \(splay\) 函数。
首先我们来看一个简单的例子:
如图所示的一个 \(treap\) 拥有三个节点,其中,根的右儿子是我们新插入的节点。
假设我们想要让 \(treap\) 满足大根堆的性质,那么我们需要在不改变 \(key\) 值顺序的情况下,对节点进行变形,使得 \(key\) 满足性质。
这一步即是我们的旋转,对于如上示例,旋转后的形态应该变为:
根据旋转的不同,我们将旋转分为两种:左旋和右旋。
在例子中,我们是将右儿子节点旋转至根,所以称为左旋。反之,将左儿子节点旋转至根,称为右旋。
这个旋转的具体过程,我们可以对应旋转前后的图进行分析,首先是左旋操作:
其过程具体如下:
-
获取根节点 \(A\) 的右儿子节点 \(B\)。
-
将节点 \(B\) 的父亲节点信息更新为 \(f\) ,并更新节点 \(f\) 的子节点信息为 \(B\) 。
-
将节点 \(A\) 的右儿子信息更新为 \(B\) 的左儿子 \(D\) ,同时将节点 \(D\) 的父亲节点信息更新为 \(A\) 。
-
将节点 \(B\) 的左儿子信息更改为节点 \(A\) ,同时将节点\(A\) 的父亲节点信息更改为 \(B\) 。
接下来是右旋操作,其过程和左旋操作互为镜像:
-
获取根节点 \(A\) 的左儿子节点 \(B\) 。
-
将节点 \(B\) 的父亲节点信息更新为 \(f\) ,并更新节点f的子节点信息为 \(B\) 。
-
将节点 \(A\) 的左儿子信息更新为节点B的右儿子 \(D\),同时将节点 \(D\) 的父亲节点信息更新为 \(A\) 。
-
将节点 \(B\) 的右儿子信息更改为节点 \(A\) ,同时将节点 \(A\) 的父亲节点信息更改为 \(B\) 。
我们考虑用一个函数实现这个过程:
inline void rotate(int k)
{
int f1=tr[k].fa,f2=tr[f1].fa,opt=get(k);
if(!f1) return;
if(f2) son[f2][get(f1)=u;
son[f1][opt]=son[k][!opt],tr[son[k][!opt]].fa=f1;
son[k][!opt]=f1;
tr[k].fa=f2;
tr[f1].fa=k;
pushup(f1),pushup(k);
}
其中, \(get(k)\) 返回值为 \(1\) 表示当前节点是父亲的右儿子,反之是左儿子,具体操作如下:
inline int get(int k) { return (son[tr[k].fa][1]==x); }
带入到上述过程,正确性显然。
\(splay\)
回看我们的旋转函数,每次进行这个操作,会让我们的左右子树中一棵高度 \(-1\) ,另一棵高度 \(+1\) 。
而我们的 \(splay\) 函数会通过一系列的调用,避免二叉查找树退化成链。
我们每次查询一个点,都将它 \(splay\) 到根,每次旋转前关注当前节点父亲节点的方向,如果方向一致就旋转父亲,这波操作的意义可以理解为尽量让树不规整,不然可能出现链的情况,否则旋转当前节点。
对于该函数的正确性,有详细的复杂度分析,但该博客的初衷在于学习平衡树的算法且并非深入研究,故此我们略过这一部分,总而言之,我们可以认为 \(splay\) 过后的树的高度期望在 \(logn\) 左右,该算法的时间复杂度均摊能够达到 \(log\) 级别。
inline void splay(int k)
{
while(tr[k].fa){
int opt=(get(tr[k].fa)==get(k)); //判断方向是否一样
if(opt) rotate(tr[k].fa);
else rotate(k);
rotate(k); //再转一下当前节点
}
root=k;
}
插入
inline void insert(int k,int val,int fa)
{
if(!k){
k=build(); //新建一个节点
if(fa) tr[k].fa=fa;
son[fa][tr[fa].val
插入操作即按部就班的写即可,注意我们维护的点权中包括与当前点点权相同的点的个数,所以如果在遍历的过程中成功碰到了点权与插入点权相同的节点,我们就不需要再新开节点,直接加在已有节点上并更新即可。
合并
inline void merge(int x,int y)
{
if(!x||!y) { root=x+y; return; }
int k=x;
while(k) x=k,k=son[k][1];
splay(x);
son[x][1]=y,tr[y].fa=x;
pushup(x);
}
\(merge(x,y)\) 表示合并以 \(x\) 为根和以 \(y\) 为根的两棵子树,其中保证子树 \(x\) 的最大值小于子树 \(y\) 的最小值。
其操作和 \(fhq\) 十分类似,找到子树 \(x\) 最右链的末端,然后将其 \(splay\) 至根,然后直接将子树 \(y\) 接在 \(x\) 的右子树下即可。
删除
inline void delet(int k,int val)
{
if(tr[k].val==val){
splay(k);
tr[k].cnt--,tr[k].siz--;
if(!tr[k].cnt){
tr[son[k][0]].fa=tr[son[k][1]].fa=0;
merge(son[k][0],son[k][1]); //合并两棵子树
}
return;
}
delet(son[k][tr[k].val
删除操作也是直接进行即可,但当当前节点被删空时,不要忘了合并当前节点的两个子树。
查询排名
inline int getrank(int k,int val)
{
if(tr[k].val==val){
splay(k);
return tr[son[k][0]].siz+1;
}
return getrank(son[k][tr[k].val
找到该点,将其 \(splay\) 至根,左子树的大小即比它小的数的个数,其排名比之多 \(1\) 。
查询排名为 \(k\) 的数
inline int getval(int k,int val)
{
if(tr[son[k][0]].siz=val){
splay(k);
return tr[k].val;
}
if(tr[son[k][0]].siz>=val) return getval(son[k][0],val);
return getval(son[k][1],x-tr[son[k][0]].siz=tr[k].cnt);
}
直接向下找即可,找到后别忘了 \(splay\) 。
前驱和后继
inline int getpre(int val)
{
getrank(root,val);
int k=son[root][0],x=root;
while(k) x=k,k=son[k][1];
splay(x);
return tr[x].val;
}
inline int getnext(int val)
{
getrank(root,val);
int k=son[root][1],x=root;
while(k) x=k,k=son[k][0];
splay(x);
return tr[x].val;
}
操作比较简单,把值为 \(val\) 的点 \(splay\) 到根后,暴力向下找即可。
细节
\(splay\) 在查询排名、前驱、后继时,需记得先插入查询的数,否则,如果查询的数载树中不存在,将会导致运行错误。
CODE
#include
using namespace std;
const int N=3e6;
inline int read()
{
int s=0,w=1;
char ch=getchar();
while(ch<'0'||ch>'9') { if(ch=='-') w*=-1; ch=getchar(); }
while(ch>='0'&&ch<='9') s=s*10+ch-'0',ch=getchar();
return s*w;
}
struct node{
int fa; //父亲节点编号
int val; //节点权值
int siz; //子树大小
int cnt; //与该节点权值相同的数的个数
}tr[N];
int n,tot,root;
int son[N][2]; //son[i][0/1]记录节点i的左右儿子
//判断是否为右儿子
inline int get(int k) { return (son[tr[k].fa][1]==k); }
inline void pushup(int k) { tr[k].siz=tr[k].cnt+tr[son[k][0]].siz+tr[son[k][1]].siz; }
inline int build()
{
++tot;
tr[tot].fa=son[tot][1]=son[tot][0]=tr[tot].cnt=tr[tot].siz=0;
return tot;
}
inline void rotate(int k)
{
int f1=tr[k].fa,f2=tr[f1].fa,opt=get(k);
if(!f1) return;
if(f2) son[f2][get(f1)]=k;
son[f1][opt]=son[k][!opt],tr[son[k][!opt]].fa=f1;
son[k][!opt]=f1;
tr[k].fa=f2;
tr[f1].fa=k;
pushup(f1),pushup(k);
}
inline void splay(int k)
{
while(tr[k].fa){
int opt=(get(tr[k].fa)==get(k)); //判断方向是否一样
if(opt) rotate(tr[k].fa);
else rotate(k);
rotate(k); //再转一下当前节点
}
root=k;
}
inline void insert(int k,int val,int fa)
{
if(!k){
k=build(); //新建一个节点
if(fa) tr[k].fa=fa;
son[fa][tr[fa].val=val){
splay(k);
return tr[k].val;
}
if(tr[son[k][0]].siz>=val) return getval(son[k][0],val);
return getval(son[k][1],val-tr[son[k][0]].siz-tr[k].cnt);
}
inline int getpre(int val)
{
getrank(root,val);
int k=son[root][0],x=root;
while(k) x=k,k=son[k][1];
splay(x);
return tr[x].val;
}
inline int getnext(int val)
{
getrank(root,val);
int k=son[root][1],x=root;
while(k) x=k,k=son[k][0];
splay(x);
return tr[x].val;
}
int main()
{
n=read();
for(register int i=1;i<=n;i++){
int opt=read(),x=read();
if(opt==1) insert(root,x,0);
else if(opt==2) delet(root,x);
else if(opt==3) insert(root,x,0),printf("%d\n",getrank(root,x)),delet(root,x);
else if(opt==4) printf("%d\n",getval(root,x));
else if(opt==5) insert(root,x,0),printf("%d\n",getpre(x)),delet(root,x);
else if(opt==6) insert(root,x,0),printf("%d\n",getnext(x)),delet(root,x);
}
return 0;
}
【模板】普通平衡树
模板题,给出的示例代码与讲解均围绕此题展开。