FHQ Treap 学习笔记

FHQ Treap

这是一个很好理解、码量短、应用多变、容易可持久化的平衡树。我认为这是最实用的平衡树。

我们结合例题讲解:【模板】普通平衡树。

主要通过 split 和 merge 来维护。

首先平衡树有三个性质:

  • 堆性质,这里我们将维护一个小根堆(事实上维护大根堆也没有关系),即是一棵二叉树,树中每个节点的优先级值都会大于它的左儿子和右儿子。

  • 搜索树性质,也就是每个节点的权值大于等于它左儿子的权值(如果有的话),并且小于等于它右儿子的权值(如果有的话)。更进一步,每个节点的权值大于等于它左子树所有节点的最大权值,小于等于它右子树所有节点的最小权值。

  • 深度为 \(\log n\) 左右。

那么我们怎么维护它呢?

我们用一个结构体来维护平衡树每个节点的信息。

struct bst{
	int l,r;//左右儿子
	int pri,val;//优先级和权值
	int sz;//以它为根的子树大小
}t[100010];

当然不同题目不同对待。

然后就是当前树根的编号 \(rt\),以及当前新节点的编号 \(cnt\),设为全局变量。

首先我们要知道如何新增节点。

inline newnode(int x){//新增一个权值为 x 的节点
	t[++cnt]={0,0,rand(),x,1};
//新增节点编号为 cnt,左右儿子为空、优先级随机(保证了平衡树的深度)、权值为 x、子树大小为 1(它自己一个点)
}

然后是维护大小,每个节点的子树大小是它左儿子的大小加上右儿子的大小加一(它自己也是一个点)。

inline update(int x)//更新编号为 x 的节点的子树大小
	t[x].sz=t[t[x].l].sz+t[t[x].r].sz+1;
}

接下来是分裂,假设我们要将以节点 \(x\) 为根的平衡树按照权值分为 2 棵平衡树,一棵所有权值 \(\le k\),一棵所有权值 \(>k\),它们的根分别为 \(l\)\(r\)

那我们就要依据权值的大小,看一下根节点属于 \(l\) 还是 \(r\),然后再递归地考虑它的儿子就可以了。

void split(int x,int k,int &l,int &r){
	if(!x){l=r=0;return;}//空节点,直接返回两个空节点
	if(t[x].val<=k){//x 权值小于等于 k,说明整个左子树都是 l 的
		l=x;
		split(t[x].r,k,t[x].r,r);
	}else{
		r=x;
		split(t[x].l,k,l,t[x].l);
	}
}

还有另外一种分裂,就是按照权值的排名分裂,我们要将以节点 \(x\) 为根的子树按照排名分为 2 棵,一棵所有排名 \(\le k\),一棵所有排名 \(>k\),它们的根分别为 \(l\)\(r\)

void split(int x,int k,int &l,int &r){
	if(!x){l=r=0;return;}
	if(t[t[x].l].sz+1<=k){
		l=x;
		split(t[x].r,k-t[t[x].l].sz-1,t[x].r,r);
	}else{
		r=x;
		split(t[x].l,k,l,t[x].l);
	}
}

接下来是更简单的合并,传入两棵平衡树的根 \(l\)\(r\),合并这两棵平衡树,返回合并后的根节点编号。

由于 \(l\)\(r\) 已经满足平衡树性质,并且 \(l\) 中最大权值小于等于 \(r\) 中最小权值,所以直接考虑照堆的性质合并就可以了。

注意如果不满足以上任意两点中的一点就会出错。

我们直接依据优先级的大小,看一下根节点属于 \(l\) 还是 \(r\),然后再递归地考虑它们的左右儿子就可以了。

具体来说:

如果 \(l\) 的优先级更小,那么 \(l\) 是根节点,将 \(l\) 的右子树与 \(r\) 合并并且更新 \(l\)、返回 \(l\)

如果 \(r\) 的优先级更小,那么 \(r\) 是根节点,将 \(l\)\(r\) 的左子树合并并且更新 \(r\)、返回 \(r\)

与左偏树很像,但是不用维护左偏性质,不难。

inline int merge(int l,int r){
	if(!l||!r)return l+r;
	if(t[l].pri<=t[r].pri){
		t[l].r=merge(t[l].r,r);
		update(l);
		return l;
	}else{
		t[r].l=merge(l,t[r].l);
		update(r);
		return r;
	}
}

接下来是求以 \(x\) 为根的平衡树里第 \(k\) 小值。递归地求就可以。

具体来说,求到了一个节点,看一下 \(k\)\(x\) 左子树大小的关系:

  • 如果 \(k\) 小于等于 \(x\) 左子树的大小,那么就返回 \(x\) 的左子树的第 \(k\) 小值。

  • 如果 \(k\) 刚好等于 \(x\) 左子树的大小加一(指 \(x\)),那么就返回 \(x\) 这个位置对应的值。

  • 如果 \(k\) 大于 \(x\) 左子树的大小加一,那么就返回 \(x\) 的右子树的第 \(k-左子树大小-1\) 小值。

inline int kth(int x,int k){
	if(t[t[x].l].sz>=k)return kth(t[x].l,k);
	else if(t[x].sz==k-1)return b[t[x].val];//因为离散化过
	else return kth(t[x].r,k-t[t[x].l].sz-1);
}

然后就是其它正常操作了。

  1. 要往平衡树里增加一个值为 \(x\) 的元素。

先把 \(x\) 看做一棵以 \(cnt\) 为根的平衡树。

那我们可以将权值 \(\le x\) 的与权值 \(>x\) 的分开,变成平衡树 \(l\)\(r\),然后再将 \(l\)\(cnt\) 合并,再与 \(r\) 合并。

inline void insert(int x){
	int l,r;
	split(rt,x,l,r);
	newnode(x);
	rt=merge(merge(l,cnt),r);
}
  1. 要删掉一个值为 \(x\) 的元素,而且只删掉 1 个。

那我们可以把值为 \(x\) 的所有节点单独分离出来。怎么做呢?

我们先把平衡树 \(rt\) 分为值 \(\le x\) 的平衡树 \(l\) 和值 \(>x\) 的平衡树 \(r\)

然后再把平衡树 \(l\) 分为值 \(<x\) 的平衡树 \(l\) 和值 \(=x\) 的平衡树 \(p\)

那我们要在 \(p\) 中删掉一个节点,方便起见删掉根节点,就是将 \(p\) 的左右子树合并,再与 \(l\) 合并,再与 \(r\) 合并即可。

inline void delet(int x){
	int l,r,p;
	split(rt,x,l,r);
	split(l,x-1,l,p);
	rt=merge(merge(l,merge(t[p].l,t[p].r)),r);
}
  1. 如果要查询数字 \(x\) 的排名,那就是要输出 \(\le x-1\) 的数的个数加一。

把平衡树 \(rt\) 分裂成值 \(\le x-1\) 的平衡树 \(l\) 和值 \(\ge x\) 的平衡树 \(r\)

输出 \(l\) 的大小加一后,再合并回去就行。

inline void getrank(int x){
	int l,r;
	split(rt,x-1,l,r);
	cout<<t[l].sz+1<<'\n';
	rt=merge(l,r);
}
  1. 要查询排名为 \(x\) 的数的值。

直接调用 \(kth(rt,x)\) 即可。

  1. 求数值 \(x\) 的前驱,那就是要输出小于 \(x\) 的最大数。

把平衡树 \(rt\) 分裂成值 \(\le x-1\) 的平衡树 \(l\) 和值 \(\ge x\) 的平衡树 \(r\)

输出 \(l\) 的最大数,也就是 \(l\) 的第 \(t[l].sz\) 个数后,再合并回去就行。

inline void getpre(int x){
	int l,r;
	split(rt,x-1,l,r);
	cout<<kth(l,t[l].sz)<<'\n';
	rt=merge(l,r);
}
  1. 求数值 \(x\) 的后继,那就是要输出大于 \(x\) 的最小数。

把平衡树 \(rt\) 分裂成值 \(\le x\) 的平衡树 \(l\) 和值 \(\ge x+1\) 的平衡树 \(r\)

输出 \(r\) 的最小数,也就是 \(r\) 的第一个数后,再合并回去就行。

inline void getback(int x){
	int l,r;
	split(rt,x,l,r);
	cout<<kth(r,1)<<'\n';
	rt=merge(l,r);
}

把以上几个综合起来就是代码了。理解之后其实很好写。

事实上,除了 split 都很好理解。由于 split、merge、kth 都是 \(O(h=\log n)\) 的,所以总复杂度 \(O(n\log n)\)

然后就是 【模板】文艺平衡树。

这个也不难,就是要处理区间翻转,那我们要像线段树一样,给平衡树打上懒标记。

我们可以发现,如果我们搞一个中序遍历,那么平衡树的权值就是单调的。

然后,翻转一个区间 \([l,r]\),对于任意 \(p∈[l,r]\),可以看作:

  1. 翻转 \([l,p-1]\)

  2. 翻转 \([p+1,r]\)

  3. 交换 \([l,p-1]\)\([p+1,r]\)

那么对应在平衡树上,就是找到区间 \([l,r]\) 的根节点 \(x\),然后交换它子树内所有节点的左、右儿子。

之所以可以这么操作,是因为这题不用排大小,所以权值不用满足二叉搜索树的性质。

我们只需要用二叉搜索树维护当前的序列,也就是保证它的所有懒标记下传后,中序遍历是当前序列就好了。

那么可以考虑用懒标记,为 1 代表要翻转,为 0 代表不翻转。

由于 FHQ Treap 的父子关系总是变来变去,为了防止下传到错误的节点上,每一次 split 或者 merge 之前都要下传懒标记。

下传的方式是异或,因为翻转两次代表不翻转。

考虑 split 操作,对于每一次操作,我们是要对整个 \([l,r]\) 打懒标记,但是不能对其余的任何值打标记。

那我们可以每一次把整棵树都 split 2 次,变成 3 棵,分别代表了区间 \([1,l-1]\)\([l,r]\)\([r+1,n]\),对于 \([l,r]\) 打标记即可。

我们是要对区间进行 split,那就是依据排名分裂。

然后这道题就做完了。时间复杂度 \(O(n\log n)\)

inline void newnode(int x){t[++cnt]={0,0,x,rand(),1,0};}
inline void update(int x){t[x].sz=t[t[x].l].sz+t[t[x].r].sz+1;}
inline void pushdown(int x){
	swap(t[x].l,t[x].r);
	if(t[x].l)t[t[x].l].t^=1;
	if(t[x].r)t[t[x].r].t^=1;
	t[x].t=0;
}
inline int merge(int l,int r){
	if(!l||!r)return l+r;
	if(t[l].pri<=t[r].pri){
		if(t[l].t)pushdown(l);
		t[l].r=merge(t[l].r,r);
		update(l);
		return l;
	}else{
		if(t[r].t)pushdown(r);
		t[r].l=merge(l,t[r].l);
		update(r);
		return r;
	}
}
inline void split(int x,int k,int &l,int &r){
	if(!x){l=r=0;return;}
	if(t[x].t)pushdown(x);
	if(t[t[x].l].sz+1<=k){
		l=x;
		split(t[x].r,k-t[t[x].l].sz-1,t[x].r,r);
	}else{
		r=x;
		split(t[x].l,k,l,t[x].l);
	}
	update(x);
}
inline void dfs(int x){
	if(t[x].t)pushdown(x);
	if(t[x].l)dfs(t[x].l);
	write(t[x].val);
	putchar(32);
	if(t[x].r)dfs(t[x].r); 
}
int main(){
	srand(time(0));n=read();m=read();
	for(int i=1;i<=n;i++){
		newnode(i);
		rt=merge(rt,cnt);
	}
	for(int i=1,l,r,p,x,y;i<=m;i++){
		x=read();y=read();
		split(rt,y,l,r);
		split(l,x-1,l,p);
		t[p].t^=1;
		rt=merge(merge(l,p),r);
	}
	dfs(rt);
	return 0;
}
posted @ 2023-04-26 08:15  lrxQwQ  阅读(25)  评论(0编辑  收藏  举报