FHQ Treap 树
FHQ Treap 也是维护 BST 的一种方式,它也运用了随机优先级的技术。而它的高明之处是所有的操作只用到了分裂和合并这两个操作。这两个操作的时间复杂度都为 \(\mathcal{O}(\log N)\)。
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}\) 指向了空。
- 合并
这个函数可以将子树 \(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上*/
}
}
合并过程比较易懂,不再模拟。
于是我们便可以实现以下操作:
- 插入结点 \(k\)
将树根 \(root\) 分裂为小于等于 \(k\) 的部分 \(l\) 和大于 \(k\) 的部分 \(r\)。新建节点 \(p\),其权值等于 \(k\)。合并子树 \((l,p)\) 到 \(root\)。合并子树 \((root,r)\) 到 \(root\)。
- 删除结点 \(k\)
将树根 \(root\) 分裂为小于等于 \(k\) 的部分 \(l\) 和大于 \(k\) 的部分 \(r\)。将子树 \(l\) 分裂为小于等于 \(k-1\) 的部分 \(l\) 和大于 \(k-1\) 即等于 \(k\) 的部分 \(p\)。合并 \(p\) 的左右子树到 \(root\)。合并子树 \((l,root)\) 到 \(root\)。合并子树 \((root,r)\) 到 \(root\)。
- 求 \(k\) 的排名
记录每个子树的结点个数 \(sz\)。此时在每次分裂和合并都要重新计算子树结点数。将树根 \(root\) 分裂为小于等于 \(k-1\) 的部分 \(l\) 和大于 \(k-1\) 的部分 \(r\),子树 \(l\) 的结点数加一即为 \(k\) 的排名。
- 求排名为 \(k\) 的数
代码与旋转法一样。
- 求 \(k\) 的前驱
将树根 \(root\) 分裂为小于等于 \(k-1\) 的部分 \(l\) 和大于 \(k-1\) 的部分 \(r\)。在 \(l\) 中查找最大的数(求排名为 t[l].sz
的数)。最后合并子树 \((l,r)\) 到 \(root\)。
- 求 \(k\) 的后继
将树根 \(root\) 分裂为小于等于 \(k\) 的部分 \(l\) 和大于 \(k\) 的部分 \(r\)。在 \(r\) 中查找排名为 \(1\) 的数。最后合并子树 \((l,r)\) 到 \(root\)。
FHQ Treap 通常要比旋转法慢,它的常数更大。
2 应用
FHQ Treap 还可应用于一些区间操作问题。下面给出一些例题和讲解。
1 文艺平衡树
我们以原序列建树。
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 线段树
和上题差不多,本题中需要维护区间和,在 push_up
中计算。