FHQ学习笔记

什么是FHQ

FHQ是一种特殊的Treap(树堆),其特色在于不需要进行节点旋转的操作,而
只依靠分裂和合并维护平衡。FHQ具有树堆的一切性质,包括本身权值满足
平衡树性质,并且堆权值(优先级)满足堆性质(小根堆或大根堆)

关于Treap和随机化堆权值

对于一颗朴素的二叉搜索树,每插入一个节点,都需要从根节点开始递归,通过大小比较判断某个数在左侧还是右侧,然后去对应的区块再次递归,知道找到目标节点或者找到合适的插入位置为止。
对于一个乱序序列,这种方式显然可以得到一个均摊\(O(\log(n))\)的复杂度。但如果序列有序,显然这颗搜索树会退化成链,复杂度也就退化到了\(O(n)\)。而Treap为了解决这个问题、达到一个较为平衡的状态,通过维护随机的优先级满足堆性质,打乱了节点的插入顺序,从而让二叉搜索树达到了理想的复杂度,避免了退化成链的问题。

证明

定义:
n代表节点数量。
key表示权值,满足搜索树性质;val表示优先级,满足性质。
\(x_{k}\)表示权值第k小的点
\(X_{i,j}\)表示集合\(\{x_{i},x_{i+1}...x_{j}\}\),也就是升序排列权值后第i小到第j小的节点的点集
\(dep(x)\)表示节点x的深度,规定根节点深度为0
\(Y_{i,j}\)是一个布尔型变量,如果\(x_{i}\)\(x_{j}\)的祖先,返回1;否则返回0.规定 \(Y_{i,i}=0\)
\(P(A)\)表示事件\(A\)发生的概率

对于树的高度,有:
由于结点\(x_{i}\)的深度等于它祖先的个数,因此有

\[\operatorname{dep}(x_i)=\sum\limits_{k=1}^nY_{k,i} \]

那么根据期望的线性性,有

\[E(\operatorname{dep}(x_i))=E\left(\sum\limits_{k=1}^nY_{k,i}\right)=\sum\limits_{k=1}^nE(Y_{k,i}) \]

由于\(Y_{k,i}\)是指示器随机变量,它的期望就等于它为1的概率,因此

\[E(\operatorname{dep}(x_i))=\sum_{k=1}^n\operatorname{P}(Y_{k,i}=1) \]

我们先证明引理:\(Y_{i,j}=1\)当且仅当\(x_{i}\)的优先级是\(X_{i,j}\)中最小的。

引理的证明

证明:考虑分类讨论\(x_{i}\)\(x_{j}\)的情况。
\(x_{i}\)是根节点:由于优先级满足小根堆性质,\(x_{i}\)的优先级最小,并且对于任意的\(x_{j}\)\(x_{i}\)都是\(x_{j}\)的祖先。
\(x_{j}\)是根节点:同理,\(x_{j}\)优先级最小,因此\(x_{i}\)不是\(X_{i,j}\)中优先级最小的;同时\(x_{i}\)也不是\(x_{j}\)的祖先。
\(x_{i}\)\(x_{j}\)在根节点的两个子树中(一左一右),那么根节点\(rt\in X_{i,j}\). 因此\(x_{i}\)的优先级不可能是\(X_{i,j}\)中最小的(因为根节点的比它小)。同时,由于\(x_{i}\)\(x_{j}\)分属两个子树,\(x_{i}\)也不是\(x_{j}\)的祖先。
\(x_{i}\)\(x_{j}\)在根节点的同一个子树中,此时可以将这个子树单独拿出来作为一棵新的Treap,递归进行上面的证明即可。
那么根据引理,深度的期望可以转化成

\[E(\operatorname{dep}(x_i))=\sum\limits_{k=1}^nP(x_k=\min X_{i,k}\land k\neq i) \]

又因为结点的优先级是随机的,我们假定集合\(X_{i,j}\)中任何一个结点的优先级最小的概率都相同,那么

\[\begin{aligned} E(\operatorname{dep}(x_i))&=\sum_{k=1}^nP(x_k=\min X_{i,k}\land k\neq i)\\ &=\sum_{k=1}^{n}P(x_k=\min X_{i,k})-1\\ &=\sum_{k=1}^n\dfrac{1}{|i-k|+1}-1\\ &=\sum_{k=1}^{i-1}\dfrac{1}{i-k+1}+\sum_{k=i+1}^n\dfrac{1}{k-i+1}\\ &=\sum_{j=2}^i\dfrac 1j+\sum_{j=2}^{n-i+1}\dfrac 1j\\ &\le 2\sum_{j=2}^n\dfrac 1j < 2\sum_{j=2}^n\int_{j-1}^j\dfrac 1x\mathrm dx\\ &=2\int_1^n\dfrac 1x\mathrm dx=2\ln n=O(\log n) \end{aligned}\]

因此每个结点的期望高度都是\(O(\log n)\)
而朴素的二叉搜索树的操作的复杂度均是 \(O(h)\),同时 treap 维护堆性质的复杂度也是 \(O(h)\)的,因此 treap 各种操作的期望复杂度都是\(O(\log n)\)
下面是一个Treap的例子,以小根堆为例:
image
我们发现,对于一个堆权值,满足堆的性质,也就是从上向下权值依次降低或升高;而对于一个树的权值,其满足二叉搜索树的性质,也就是对于一个父亲节点,其左儿子都比他小,右儿子都比他大,这也就是Treap的性质

分裂与合并操作

分裂(split)

分为按值分裂和按排名分裂两种
按值分裂
接受两个参数\(u\)\(x\),代表把整个Treap分成值小于\(x\)和大于\(x\)的两部分,\(u\)是当前所在的根节点。对于每次走到的\(u\),判断\(u\)的权值和\(x\)的大小关系。如果\(val_{u}\le x\),根据Treap维护的二叉搜索树的性质,\(u\)和它的整个左子树都小于\(x\),直接放到分裂后的左侧Treap里作为左侧Treap的左子树,然后继续向\(u\)的右子树递归。反之,如果\(val_{u}>x\)就把\(u\)及其右子树都放入分裂后的右侧Treap中作为右儿子,然后继续向左子树递归,过程如下:
image
按排名分裂
所谓排名,指对于一个点\(u\),Treap中所有小于它的点的数量+1就是这个点的排名。而按照排名分裂的操作接受一个参数\(x\),把整个树上前\(x\)小的数划分出来
我们发现,根据二叉搜索树的性质,对于一个节点\(u\),其排名\(rank_{u}\)实际上就是它的左子树的大小+1。那么每查询到一个根节点\(u\),就判断当前点的左儿子大小\(size_{ls}\)\(x\)的关系。如果\(size_{ls}+1\le x\),就继续向右子树查找(此时我们把所有排名相同为\(x\)的点都划分到左子树,所以可以取等),同时排除左子树和根节点已经包含的部分,也就是右子树上需要查找的排名变成\(x-size_{ls}-1\)。而对于\(size_{ls}+1<x\)的情况,直接递归左子树即可

合并操作

合并操作接收两个参数\(L\)\(R\),分别代表合并的两棵树的根节点。由于合并的两个Treap都已经有序,所以合并时只需要考虑把哪棵树作为根节点就行。对于Treap,我们需要维护出堆的性质。那么判断\(L\)\(R\)的堆权值,如果\(pri_{L}>pri_{R}\),就把\(L\)作为根。由于合并时我们钦定了\(L\)的所有权值都小于\(R\)的任意权值,所以此时继续把\(R\)\(L\)的右子树合并。反之,就让\(R\)成为根节点,把\(L\)\(R\)的左子树合并

其他操作

插入节点

根据题目需要,接受参数\(x\),把当前Treap按照\(x\)分裂成左右两个部分,具体按值分还是按排名分取决于给出的\(x\)。分裂完以后先把\(L\)\(x\)合并,再把合并后的新树和\(R\)合并

删除节点

仍然接受一个\(x\),根据给定的\(x\)把Treap分裂,然后把排除\(x\)的余下部分重新合并。具体实现参照代码

获取排名

接受参数\(x\),代表要查值为\(x\)的点的排名。则按照值为\(x-1\)分裂Treap,此时得到的\(L\)一定能保证包含了所有小于\(x\)的点,那么值为\(x\)的点的排名就是\(size_{L}+1\)

获取第k大数

接收参数\(k\),对于每个走到的节点\(u\),判断\(u\)的排名和\(k\)的关系。如果\(rank_{u}=k\),返回\(u\),如果\(rank_{u}<k\),向右子树递归;反之向左子树递归。最后的答案就是整个操作返回的\(u\)的值\(val_{u}\)

求前驱

把树按值为\(x-1\)分裂成\(L\)\(R\),在\(L\)中找到最大的数(排名为\(L\)的数),这就是前驱。找完以后,把\(L\)\(R\)合并恢复成原来的树

求后缀

同理,按照值为\(x+1\)分裂,在\(R\)中找到排名最小的树(排名为1),输出答案,然后合并恢复

区间翻转

翻转\([l,r]\)这个区间时,基本思路是将树分裂成\([1,l-1],\ [l, r],\ [r + 1, n]\)三个区间,再对中间的\([l, r]\)进行翻转
翻转的具体操作是把区间内的子树的每一个左,右子节点交换位置。如下图就展示了翻转上图中 treap的\([3, 4]\)\([3, 5]\)区间后的treap
image
为了保证时间复杂度,可以使用线段树中常用的懒标记(lazy_tag)来优化复杂度。交换时,只需要在父节点打上标记,代表这个子树下的每个左右子节点都需要交换就行了
对于FHQ_Treap,需要在分裂与合并的操作中均下传lazy_tag

建树

对于建树操作,我们新建一个节点,然后把它和原树合并就行了

下面是板子

点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 500005 
std::mt19937 rnd(233);//随机数生成器 

/*
FHQ是一种特殊的Treap(树堆),其特色在于不需要进行节点旋转的操作,而
只依靠分裂和合并维护平衡。FHQ具有树堆的一切性质,包括本身权值满足
平衡树性质,并且堆权值(优先级)满足堆性质(小根堆或大根堆)
详见博客 
注意按照值分裂的相关操作和按照排名分裂的相关操作是完全不同的,需要
根据题目要求进行判断。 
*/ 

struct tree{
	int l,r;//左右儿子
	int val;//权值
	int pri;//堆权值(随机的优先级)
	int siz;//子树大小
	bool tag;//懒标记,判断是否被翻转 
}t[N];
int tot;//节点数 
int root;//根 

//更新子树大小
void push_up(int p){t[p].siz=t[t[p].l].siz+t[t[p].r].siz+1;}

//新建节点(建立只有一个点的FHQ) 
int build(int u){
	t[++tot].val=u;
	t[tot].pri=rnd();//赋予随机堆权值 
	t[tot].siz=1;
	return tot;
}

//分裂(按权值)
/*以x为阈值,按照权值分裂成两颗合法的搜索树,同时返回两棵树的根
保证左树L上所有节点的权值都不大于x,右树R所有节点权值都大于x*/
void split_key(int u,int x,int &L,int &R){
	if(!u){//没有儿子,即到达叶子,递归返回 
		L=R=0;
		return;
	}
	if(t[u].val<=x){//本节点权值比x小,到右子树上找x 
		L=u;//左树的根是本结点
		split_key(t[u].r,x,t[u].r,R);//通过t[u].r传回新节点 
	}else{
		R=u;//右树的根是本结点
		split_key(t[u].l,x,t[u].l,L);//通过t[u].l传回新节点 
	}
	push_up(u);//更新siz 
}
 
//分裂(按排名)
/*把树u分裂成包含前x个数的树L和另一半R*/ 
void split_rank(int u,int x,int &L,int &R){
	if(!u){
		L=R=0;
		return;//和上面一样 
	} 
	if(t[t[u].l].siz+1<=x){//第x个数在u的右子树上 
		L=u;
		split_rank(t[L].r,x-t[t[u].l].siz-1,t[L].r,R);
	}else{
		R=u;
		split_rank(t[R].l,x,L,t[R].l);
	}
	push_up(u);
}

//合并
/*合并以L,R为根的两棵子树,返回合并后的根。考虑到L上所有节点的val都
小于R的节点的val,合并时可以只考虑优先级pri,新树的根就是L,R中优先
较大的一个*/
int merge(int L,int R){
	if(!L||!R) return L+R;//到达叶子,如果L=0则返回R
	if(t[L].pri>t[R].pri){//L是新树的根 
		t[L].r=merge(t[L].r,R);//合并R和L的右儿子,更新L
		push_up(L);
		return L; 
	}else{//合并后R是新树的根 
		t[R].l=merge(t[R].l,L);//合并L和R的左儿子,更新R
		push_up(R);
		return R; 
	}
} 

//插入
/*插入节点,先按照即将插入的节点x将树分裂成L和R两棵树,新建
节点x,合并L和x,再继续和R合并*/
void insert(int x){
	int L,R;
	split_key(root,x,L,R);
	split_rank(root,x,L,R);
	root=merge(merge(L,x),R);//更新根 
} 

//删除(权值分裂)
/*把树 u按x分裂为根小于或等于x的树L和大于x的树R,再把L分裂为根小于x
的树L和根等于x的树p,合并p的左右儿子,即删除了x,最后合并L,p,R(这里
是指删除一个x=p的数,若要全部删除就无须合并x=p的左右子树)。*/ 
void del_key(int x){
	int L,R,p;
	split_key(root,x,L,R);//<=x的树和>x的树
	split_key(L,x-1,L,p);//<x的树和>=x的树
	p=merge(t[p].l,t[p].r);//合并x=p的左右子树,即删除了x
	root=merge(merge(L,p),R);
}

//删除(按排名分裂)
/*把树u分裂成包含前x个数的树L和包含其他数的树R,不合并p的左右子树*/
void del_rnk(int x){
	int L,R,p;
	split_rank(root,x,L,R);
	split_rank(L,x-1,L,p);
	root=merge(L,R);
} 

//获取排名
/*把树u按照x-1分裂成L和R,L中包含了所有小于x的树,则排名为sizL+1
排完以后需要合并L和R恢复原来的树*/
int rnk(int x){
	int L,R;
	split_key(root,x-1,L,R);//<x 的树和 >=x 的树
	int ans=t[L].siz+1;
	root=merge(L,R);//恢复
	return ans;
}

//获取第k大数(排名为k的数) 
/*根据节点的siz不断递归寻找整棵树,求到第k大数。在主函数中输出时
取t[kth(root,k)].val*/
int getk(int u,int k){
	if(k==t[t[u].l].siz+1) return u;//这个数为根
	if(k<=t[t[u].l].siz) return getk(t[u].l,k);//在左子树
	if(k>t[t[u].l].siz) return getk(t[u].r,k-t[t[u].l].siz-1);//在右边 
} 

//求前驱
/*把树按x-1分裂成L和R,在L中找到最大的数(排名L),找到以后输出,把
L和R合并恢复成原来的树*/
int pre(int x){
	int L,R;
	split_key(root,x-1,L,R);
	int ans=t[getk(L,t[L].siz)].val;
	root=merge(L,R);//恢复
	return ans;
}

//求后缀
/*把树按x+1分裂成L和R,在R中找到最小的数(排名1),找到以后输出,把
L和R合并恢复成原来的树*/ 
int nxt(int x){
	int L,R;
	split_key(root,x,L,R);
	int ans=t[getk(R,1)].val;
	root=merge(L,R);//恢复
	return ans;
}

//区间翻转
/*用类似线段树的思路,打上lazy_tag以后下传。由于反转不会破坏优先级
pri,所以直接操作就行。如果翻转,需要在split和merge时处理懒标记,
详见文艺平衡树代码*/ 
void push_down(int u){
	if(!t[u].tag) return;
	swap(t[u].l,t[u].r);//翻转 u 的左右部分,翻转不会破坏优先级 pri
	t[t[u].l].tag^=1;
	t[t[u].r].tag^=1;
	t[u].tag=0;//与线段树类似
}

//输出
void output(int u){//中序遍历,打印结果
	if(!u) return;
	push_down(u);
	output(t[u].l);
	cout<<t[u].val<<"\n";
	output(t[u].r);
}

signed main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	return 0;
}

例题 P3391 【模板】文艺平衡树

实际上可以用FHQ直接维护,题目所要求的输出序列直接中序遍历输出就行。直接看代码实现

点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 1000005 
std::mt19937 rnd(233);//随机数生成器 

/*注意,本题为按照排名分裂*/ 

int n,m,x,y,L,R,p;
struct tree{
	int l,r;//左右儿子
	int val;//权值
	int pri;//堆权值(随机的优先级)
	int siz;//子树大小
	int tag;//懒标记,判断是否被翻转 
}t[N];
int tot;//节点数 
int root=0;//根 

//更新子树大小
void push_up(int p){t[p].siz=t[t[p].l].siz+t[t[p].r].siz+1;}

//新建节点(建立只有一个点的FHQ) 
void build(int u){
	t[u].val=u;
	t[u].pri=rand();//赋予随机堆权值 
	t[u].siz=1;
	t[u].l=t[u].r=0;
	return;
}

//区间翻转
/*用类似线段树的思路,打上lazy_tag以后下传。由于反转不会破坏优先级
pri,所以直接操作就行。*/ 
void push_down(int u){
	if(!t[u].tag) return;
	swap(t[u].l,t[u].r);//翻转 u 的左右部分,翻转不会破坏优先级 pri
	t[t[u].l].tag^=1;
	t[t[u].r].tag^=1;
	t[u].tag=0;//与线段树类似,清空懒标记 
}

//分裂(按排名)
/*把树u分裂成包含前x个数的树L和另一半R*/ 
void split(int u,int x,int &L,int &R){
	if(!u){
		L=R=0;
		return;//和上面一样 
	} 
	push_down(u);//处理懒标记 
	if(t[t[u].l].siz+1<=x){//第x个数在u的右子树上 
		L=u;
		split(t[u].r,x-t[t[u].l].siz-1,t[u].r,R);
	}else{
		R=u;
		split(t[u].l,x,L,t[u].l);
	}
	push_up(u);
}

//合并
/*合并以L,R为根的两棵子树,返回合并后的根。考虑到L上所有节点的val都
小于R的节点的val,合并时可以只考虑优先级pri,新树的根就是L,R中优先
较大的一个*/
int merge(int L,int R){
	if(!L||!R) return L+R;//某一侧子树为空,直接返回另一侧
	//维护小根堆,保证树的高度
	if(t[L].pri<t[R].pri){//L是新树的根 
		push_down(L);//处理懒标记 
		t[L].r=merge(t[L].r,R);//合并R和L的右儿子,更新L
		push_up(L);
		return L; 
	}else{//合并后R是新树的根 
		push_down(R);//处理懒标记 
		t[R].l=merge(L,t[R].l);//合并L和R的左儿子,更新R
		push_up(R);
		return R; 
	}
} 

//输出
void output(int u){//中序遍历,打印结果
	push_down(u);
	if(t[u].l) output(t[u].l);
	cout<<t[u].val<<" ";
	if(t[u].r) output(t[u].r);
}

signed main(){
	ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		build(i);//建立新节点
		root=merge(root,i);//更新根 
	}
	while(m--){
		cin>>x>>y;
		split(root,y,L,R);
		split(L,x-1,L,p);
		t[p].tag^=1;
		root=merge(merge(L,p),R);
	}
	output(root);
	return 0;
}

posted @ 2025-05-08 10:54  Yun_Mo_s5_013  阅读(21)  评论(0)    收藏  举报