平衡树 - 分治与随机化思想的融合

I.综述&解释

平衡树,是非常平衡的树吗?

啊对对对。

平衡,就是指对于每一个节点:它的左右子树大小差不超过1。

当然,我们后来发现除了某些平衡树(e.g. 替罪羊树)之外,其他的都只是做到大致平衡。

II.真·综述

这是一种用来处理元素的前驱、后继,以及处理一些可以自底向上传递数据的结构(FHQ-Treap可以完成,普通Treap因为ZigZag改变了结构所以不能分出区间)

其实,本文中探讨的都不是最严格的平衡树,只能说是比较平衡的(利用随机化平均特性的Treap和FHQ-Treap)。

让我们从平衡树的基本性质——“BST性质”谈起。

这个性质是指,如果一棵二叉树满足:

  • 如果有左子树,其每个节点的权值均不大于其根节点
  • 如果有右子树,其每个节点的权值均不小于其根节点
  • 这个节点的左右子树也满足以上两条性质

那么我们就称这棵二叉树满足“BST性质”。

所以这个有啥用?其实这就是二叉查找树的基本工作原理。

因为我们只要知道我们要找什么,我们就一定可以排除半边子树。所以就可以提升速度。

但是,如果一棵二叉树只满足以上性质,那么我们完全可以通过特殊的节点插入顺序(例如有序的插入序列)来将这棵树变成链。

这个时候我们就想起并查集的处理方式——我们不关心树的结构,只要满足BST性质即可。那么我们就可以按照某种方式对结构进行维护。

所以Treap的解决方案就是,为每个点添加随机权值,并在满足BST性质的前提下再像二叉堆一样满足“堆性质”,即每个点都比其左右子树的权值大/小。

那么,要怎么维护呢。旋转。可以发现,旋转并不会损伤BST性质。所以,维护“堆性质”不会损伤BST性质。

那么为什么要随机地维护“堆性质”呢?会发现,实际上随机如果作用在二叉堆上,那么它的高度就趋近O(logN)\operatorname O(\log N),不要问我怎么证明,大概就是因为随机数分布随机吧。这个多半玄学。

好,那么FHQ-Treap就是不同的维护方式。

它取消了旋转这个步骤,直接变成分裂和合并,按这样的方式分裂出仍然满足BST性质的树。

合并时,由于BST性质只是在意左右顺序,这个时候再来维护堆性质也是可以。

不过这么做,常数确实会大,但是好处就是,可以分出操作区间,就能像线段树一样维护区间信息了,Yeah!

本质上,这就是分治和随机化思想。所以我更改了标题

III.基操

  • 初始化(init)

就是不断插入元素。

不过还可以用笛卡尔树的建树方法将时间复杂度优化到O(N)

  • 插入(insert)

递归地,像字典树一样,找到val应该在的位置,插入一个新节点。

  • 删除(delete)

将这个节点旋转到最底下,直接删除

  • 旋转(zig/zag)

Link

不好说,大概就是,转化一下子节点和父节点的关系。

例如右旋,就是直接把父节点变成右儿子,然后把自己原来的右儿子传给父亲的左儿子。(这一子树都比原来父亲的key要小,这么转化显然合法)

  • 分裂(split)

更难描述,大概就是递归下去,将节点分为两类,直接连接到上面传下来的“接口”上。(这里应用用的妙)

  • 合并(merge)

建立两个指针,指示两棵树上正在决定的节点,这个节点由于传参就是按顺序传的,所以只要关系随机权值的大小,左树大就把它变成父亲,右树变成右儿子,然后再往下决定。

放个板子。

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
struct FHQ_Treap{
	int l,r;
	int dat,val,siz;
	bool resv;
}t[N];
#define l(p) t[p].l
#define r(p) t[p].r
#define dat(p) t[p].dat
#define val(p) t[p].val
#define siz(p) t[p].siz
#define resv(p) t[p].resv
int tot;
int n,m;
int New(int val){
	int np=++tot;
	val(np)=val;
	dat(np)=rand();
	siz(np)=1;
	return np;
}
void Pushup(int p){
	siz(p)=siz(l(p))+siz(r(p))+1;
}
int root;const int INF=INT_MAX;
void SplitByVal(int p,int &x,int &y,int val)
{
	if(!p){
		x=y=0; 
		return;
	}
	if(val(p)<=val){
		x=p,SplitByVal(r(p),r(p),y,val);
		//左子树整个都要被分到x,而右子树里有一些要被分到x,有一些要被分到y 
	}else{
		y=p,SplitByVal(l(p),x,l(p),val);
		//右子树整个都要被分到y,而左子树里有一些要被分到x,有一些要被分到y 
	}
	Pushup(p);//分裂后,p的左或右子树已经改变 
}
void Spread(int p){
	if(resv(p)){
		resv(p)=false;
		swap(l(p),r(p));
		resv(l(p))^=1,resv(r(p))^=1;
	}
}
void SplitBySize(int p,int &x,int &y,int val){
	if(!p){
		x=y=0;
		return;
	}
	Spread(p);
	if(siz(l(p))+1<=val){
		x=p,SplitBySize(r(p),r(p),y,val-siz(l(p))-1);
		//              |这里传下x树的最右边方便链接 
		//分界点在右边,并把左边完全包括进x,并继续寻找右边分界点 
	}else{
		y=p,SplitBySize(l(p),x,l(p),val);
		//                |这里传下y树的最左边方便链接 
		//分界点在左边,把右边完全包括进y,并继续寻找左边分界点 
	}
	Pushup(p);
}
//以上Split函数中,引用的意义是,修改从上面传下来的l,r,以链接一块块树(或者是最上面的答案)
//Split过程示意图: http://www.yhzq-blog.cc/wp-content/uploads/2021/10/merge.gif(是SplitByVal)
int Merge(int x,int y){//返回合并后的root 
	if(!x||!y) return x+y;//有一子树不存在,直接合并
	//为啥不检查val呢?因为x是原来左子树来的,y是右边子树来的,val问题不用考虑 
	if(dat(x)<dat(y)){//按照大根堆性质合并 (确定上下关系)
		Spread(y);
		l(y)=Merge(x,l(y));//注意我们不能把参数传反了 
		//注意val大小关系 
		Pushup(y);
		return y; 
	}else{
		Spread(x);
		r(x)=Merge(r(x),y);
		Pushup(x);
		return x;
		//同上 
	}
	// x是原来左树,y是右树,合并时左右关系不能乱 
}
void Insert(int val){
	int lt=0,rt=0;
	SplitByVal(root,lt,rt,val);
	root=Merge(Merge(lt,New(val)),rt);
}
void Resv(int l,int r){
	int lt=0,mt=0,rt=0;
	SplitBySize(root,lt,mt,l-1);
	SplitBySize(mt,mt,rt,r-l+1);
	resv(mt)^=1;
	root=Merge(Merge(lt,mt),rt);
}
void Output(int p){
	if(!p) return;
	Spread(p);
	Output(l(p));
	cout<<val(p)<<" ";
	Output(r(p));
	return;
}
int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		Insert(i);
	}
	for(int i=1;i<=m;i++){
		int l,r;cin>>l>>r;
		Resv(l,r);
	}
	Output(root);
	return 0;
}
posted @ 2023-07-09 13:55  haozexu  阅读(16)  评论(0)    收藏  举报  来源