FHQ Treap 树

FHQ Treap 也是维护 BST 的一种方式,它也运用了随机优先级的技术。而它的高明之处是所有的操作只用到了分裂和合并这两个操作。这两个操作的时间复杂度都为 \(\mathcal{O}(\log N)\)

1 常用操作与代码

  1. 分裂

这个函数可以将子树 \(u\) 分裂为两个子树,分别是权值都小于等于 \(k\) 的子树 \(l\) 和权值都大于 \(k\) 的子树 \(r\)。由于有返回值,所以一般使用传址调用。

代码如下。

//下标版
void split(int u,int k,int &l,int &r){
	/*将子树u分裂为小于等于k的部分l和大于k的部分r*/
	if(!u){
		l=r=0;return;
	}
	if(k<t[u].vl){
		r=u;
		split(t[u].ls,k,l,t[u].ls);
		/*向左递归*/
	}else{
		l=u;
		split(t[u].rs,k,t[u].rs,r);
		/*向右递归*/
	}
}
//指针版
void split(node *u,int k,node *&l,node *&r){
	/*将子树u分裂为小于等于k的部分l和大于k的部分r*/
	if(u==t){
		l=r=t;return;
	}
	if(k<u->vl){
		r=u;
		split(u->ls,k,l,u->ls);
		/*向左递归*/
	}else{
		l=u;
		split(u->rs,k,u->rs,r);
		/*向右递归*/
	}
}

如何理解呢?

在代码中,如果当前结点的权值大于 \(k\),那么继续向左子树递归,否则向右子树递归。我们可以在树上画一条 \(k\) 值所在的线,使得小于等于 \(k\) 的结点都在线的左边,其它的结点都在线的右边。那么递归的过程是先从根节点走到线的周围,再在线的两侧“反复横跳”。我们称这条线为 \(k\) 线。

下图是 \(k=3\) 的情况:

最后分裂结束后树的形态应为这样:

递归的过程是 \(2 \to 4 \to 3 \to 3\) 的右儿子。

先来证明一下返回的结点的正确性:

返回的两个正确结点中 \(l\) 应该是所有小于等于 \(k\) 的数中深度最小的那个结点,\(r\) 应该是所有大于 \(k\) 的数中深度最小的那个结点。

可以发现,在每次递归的时候,\(l,r\) 中一定有一个被赋值,另一个再去递归赋值。要么赋值 \(r\) 并向左递归,要么赋值 \(l\) 并向右递归。

在递归时,会有以下几种情况:

  • \(k\) 小于所有数

此时只会一直向左递归,\(r\) 被设为根,\(l\) 已知传递下去。最后到达空结点,\(l\) 被设为空。答案正确。

\(k\) 大于等于所有数的情况同理。

  • 否则

一定会出现最少一次的“拐弯”,即相邻两次递归的时候方向不同(要么上一次向左,下一次向右,要么反过来)。在拐弯的时候,设拐弯的前后三个结点为 \(g,f,u\),从 \(f\)\(u\) 的方向与从 \(g\)\(f\) 的方向不同。那么从 \(g\)\(f\) 时一定是越过了 \(k\) 线的。

这个不难证明。假设没有过线,那么 \(g\)\(f\) 要么都小于等于 \(k\),要么都大于 \(k\)。既然 \(g\)\(f\) 走了这个方向,那么 \(f\)\(u\) 也要走这个方向,则不应拐弯,但实际上出现了拐弯,假设不成立,因此一定过线。

我们只看第一次拐弯。拐弯前 \(l,r\) 中有一个已经被赋好值了,在拐弯后,另一个也将被赋值。赋值的这个点一定是深度最小的,因为深度比它小的都在 \(k\) 线的另一侧。

因此最后 \(l,r\) 一定是深度最小的两个结点。

接下来证明分裂后树的形态的正确性。

假设我们从根结点 \(u\) 开始,\(u\)\(k\) 线的左侧。第一次向右递归,\(l\) 被赋值,下一层递归传入的参数 \((l,r)\) 分别为 \((u_{rs},r)\)

这次如果还是向右递归,那么原来是 \(u_{rs}\),现在被赋值为 \(u_{rs}\),树的形态没有变化,继续传递 \((u_{rs_{rs}},r)\)

直到出现了向左递归,不妨继续设这三个结点为 \(g,f,u\),在将 \((g_{rs},r)\) 传递到 \(f\) 时拐弯了,那么 \(r\) 会被赋值,并将 \((g_{rs},f_{ls})\) 向下传。此时会有 \(g\) 在线的左侧,\(f\) 在线的右侧。

接下来如果一直向左递归,那么 \(g_{rs}\) 会一直向下传,到最后会设为空。这就相当于断开了 \(g\)\(g_{rs}\),也就是把线左边的点和线右边的点断开了。这是正确的。

如果后面出现了向右递归(即拐弯),设这次拐弯的结点为 \(g_1,f_1,u_1\),在 \(f_1\) 处拐弯,则 \(g_{rs}\) 会被设为 \(f_1\)。而从 \(g_1\)\(f_1\) 时过了线,也就是回到了线的左边。\(g\) 在线的左边,本来 \(g_{rs}\) 在线的右边,但递归找到了第一个在线左边的结点,并将 \(g_{rs}\) 改为它。

总结一下,就是如果一个点在线的一侧,递归到它的儿子时到了线的另一侧,那么到下一次回到线的这一侧时,会把它的儿子改为这个点。这样下去,\(l\) 子树就都是在线左侧的点,\(r\) 子树就都是在线右侧的点。

所以到最后,该在左边的在左边,该在右边的在右边,因此这样是正确的。

我们可以再借助上树来模拟一下。

先到 \(2\),此时 \(l\) 被设为点 \(2\),并向下传 \((2_{rs},r)\)

再到 \(3\),此时 \(r\) 被设为点 \(3\),并向下传 \((2_{rs},3_{ls})\)

再到 \(4\),此时 \(l(2_{rs})\) 被设为点 \(4\),并向下传 \((4_{rs},3_{ls})\)

到达空结点,将 \(4_{rs},3_{ls}\) 都设为空。分裂后的树是正确的。

在本树中,\(2 \to 4\) 越线了,递归找到了第一个回到线左侧的结点 \(3\),并将 \(2_{rs}\) 更改为 \(3\)\(4 \to 3\) 也越线了,但 \(3\) 的下面没有在线右侧的结点了,于是 \(4_{ls}\) 指向了空。

  1. 合并

这个函数可以将子树 \(l\) 与子树 \(r\) 合并为同一个子树并返回新树根。前提是 \(l\) 中结点的权值都不大于 \(r\) 中结点的权值,所以两个子树的顺序一定不能写错。为了使代码更加直观,本节中新树根也是用传址调用。

代码如下。

//下标版
void merge(int l,int r,int &u){
	/*将子树l和子树r合并到u上*/
	if(!l||!r){
		u=l+r;return;
		/*其中有一个空子树,直接合并*/
	}
	if(t[l].pri<t[r].pri){
		/*左子树优先级更高*/
		u=l;
		merge(t[l].rs,r,t[l].rs);
		/*将t[l].rs与r合并到子树t[l].rs上*/
	}else{
		/*右子树优先级更高*/
		u=r;
		merge(l,t[r].ls,t[r].ls);
		/*将l与t[r].ls合并到子树t[r].ls*/
	}
}
//指针版
void merge(node *l,node *r,node *&u){
	/*将子树l和子树r合并到u上*/
	if(l==t){
		u=r;return;
	}else if(r==t){
		u=l;return;
	}
	/*其中有一个空子树,直接合并*/
	if(l->pri<r->pri){
		/*左子树优先级更高*/
		u=l;
		merge(l->rs,r,l->rs);
		/*将l->rs与r合并到子树l->rs上*/
	}else{
		/*右子树优先级更高*/
		u=r;
		merge(l,r->ls,r->ls);
		/*将l与r->ls合并到子树r->ls上*/
	}
}

合并过程比较易懂,不再模拟。

于是我们便可以实现以下操作:

  1. 插入结点 \(k\)

将树根 \(root\) 分裂为小于等于 \(k\) 的部分 \(l\) 和大于 \(k\) 的部分 \(r\)。新建节点 \(p\),其权值等于 \(k\)。合并子树 \((l,p)\)\(root\)。合并子树 \((root,r)\)\(root\)

  1. 删除结点 \(k\)

将树根 \(root\) 分裂为小于等于 \(k\) 的部分 \(l\) 和大于 \(k\) 的部分 \(r\)。将子树 \(l\) 分裂为小于等于 \(k-1\) 的部分 \(l\) 和大于 \(k-1\) 即等于 \(k\) 的部分 \(p\)。合并 \(p\) 的左右子树到 \(root\)。合并子树 \((l,root)\)\(root\)。合并子树 \((root,r)\)\(root\)

  1. \(k\) 的排名

记录每个子树的结点个数 \(sz\)。此时在每次分裂和合并都要重新计算子树结点数。将树根 \(root\) 分裂为小于等于 \(k-1\) 的部分 \(l\) 和大于 \(k-1\) 的部分 \(r\),子树 \(l\) 的结点数加一即为 \(k\) 的排名。

  1. 求排名为 \(k\) 的数

代码与旋转法一样。

  1. \(k\) 的前驱

将树根 \(root\) 分裂为小于等于 \(k-1\) 的部分 \(l\) 和大于 \(k-1\) 的部分 \(r\)。在 \(l\) 中查找最大的数(求排名为 t[l].sz 的数)。最后合并子树 \((l,r)\)\(root\)

  1. \(k\) 的后继

将树根 \(root\) 分裂为小于等于 \(k\) 的部分 \(l\) 和大于 \(k\) 的部分 \(r\)。在 \(r\) 中查找排名为 \(1\) 的数。最后合并子树 \((l,r)\)\(root\)

FHQ Treap 通常要比旋转法慢,它的常数更大。

例1 洛谷-P3369

下标版代码

指针版代码

2 应用

FHQ Treap 还可应用于一些区间操作问题。下面给出一些例题和讲解。

1 文艺平衡树

例题 洛谷-P3391

我们以原序列建树。

FHQ Treap 还有一种分裂方法是按排名分裂。假如按 \(k\) 分裂,则分裂好后,\(l\) 子树存前 \(k\) 个结点,\(r\) 子树存其它节点。

按权值分裂时,如果 \(k<u_{vl}\),则要找的点在左子树。按排名分裂时,如果 \(k<u_{ls_{sz}}+1\),则要找的点在左子树;在向右子树递归时,还要让 \(k\) 减去 \(u_{ls_{sz}}+1\)

代码如下。

void split(node *u,int k,node *&l,node *&r){
	if(u==t){
		l=r=t;return;
	}
	push_down(u);
	if(k<u->ls->sz+1){
		/*要找的点在左子树*/
		r=u;
		split(u->ls,k,l,u->ls);
	}else{
		l=u;
		split(u->rs,k-u->ls->sz-1,u->rs,r);
		/*不要忘记减去(u左子树大小+1)*/
	}
	push_up(u);
}

在区间翻转时,我们可以将需要翻转的区间分裂出来。那怎么实现翻转呢?

这时我们需要借助 Lazy-Tag 技术。它本来是应用于线段树的,但平衡树上也可以使用。

每个结点再维护一个 \(lz\) 值表示是否有标记,如果有则代表要把这个子树翻转。

那么在打标记的时候,我们应该先标记该结点的 \(lz\) 值,然后交换它的左右儿子。后面在下传标记时,将它的左右儿子分别打上标记并清空它的标记。这样就能实现区间翻转了。

由于元素个数不会改变,因此我们可以像替罪羊树那样建一棵平衡的树,优先级按建树的顺序依次递增。这样能更好的保持树的平衡。

代码

2 线段树

例题 洛谷-P3372

和上题差不多,本题中需要维护区间和,在 push_up 中计算。

代码

posted @ 2024-02-08 20:02  lrx139  阅读(7)  评论(0编辑  收藏  举报