平衡树入门


平衡树入门

定义与性质

平衡树是二叉搜索树和堆合并构成的一种数据结构,所以它的名字是 \(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;
}

【模板】普通平衡树

模板题,给出的示例代码与讲解均围绕此题展开。