左偏树小记

也是一个非常 trival 的知识点,不过学过了就不要忘掉了哦(

upd on 2021.6.3:终于来填坑了,之前不知道鸽了多少东西啊……

一言以蔽之,左偏树是一种特殊的堆,它支持在 \(\mathcal O(n\log n)\) 的时间内合并两个堆。

一些 basic 的定义

定义一个节点为外节点当且仅当它的左儿子或右儿子为空节点。

定义一个节点 \(x\)距离为其子树内深度最浅的外节点与其的距离,记作 \(d_x\)

那么左偏树满足以下性质:

  1. 它是一个堆,即 \(\forall x\)\(v_x\le v_{lc_x}\)\(v_x\le v_{rc_x}\) 均成立(当然如果是大根堆符号就换个方向)
  2. \(\forall x,d_{lc_x}\ge d_{rc_x}\),也就是说对于任意节点,左儿子的距离不小于右儿子的距离。画个图就可以发现这个堆是呈左边下垂的状态,“左偏树”这个名字就是 name after this characteristic 的(

根据这些基本性质也可以引申出一些别的推论:

  1. 一个高度为 \(n\) 的左偏树至少有 \(2^{n+1}-1\) 个节点,当堆为一棵满二叉树时取到等号。

  2. 一个由 \(n\) 个节点组成的左偏树的高度为 \(\log n\) 级别的,证明参见推论 1

  3. \(d_x=d_{rc_x}+1\),由性质 2 可直接推出。

合并两个左偏树

到了左偏树所有操作的核心了(

首先考虑怎样朴素地合并以 \(x,y\) 为根的两个堆。

  • 如果 \(x,y\) 有一个为空,那直接返回另一个即可。
  • 如果 \(x,y\) 均非空,不妨假设 \(v_x\le v_y\),那么合并后显然以 \(x\) 为根,而 \(y\) 可以与 \(x\) 的任意一个儿子合并,如此递归下去即可。

但这样复杂度显然是有问题的,因为在最极端的情况下有可能会出现一条链的情况,此时复杂度就会达到 \(\mathcal O(n)\)

此时就有两种优化的思路,一是像 fhq-treap 一样赋一个随机权值并按照随机权值大小进行合并,这里又不赘述了,另一种是像左偏树一样限定死左右儿子的关系。我们既然知道对于一个 \(n\) 个点的左偏树而言,其距离是 \(\log n\) 级别的,因此我们这里可以考虑按照启发式合并的思想,自动选择距离小的一边,即右儿子合并。显然这样合并次数最多是 \(\mathcal O(\log n)\) 的,因此总复杂度是 \(\mathcal O(\log n)\)

还有一个注意点,就是合并完之后不一定满足左儿子距离大于等于右儿子,如果出现这种情况直接 swap 即可,这样可以保证合并完之后还是左偏树。

左偏树其他操作

核心操作 over 了,其他就都很 naive 了……

插入一个新节点

直接将新节点当作一个左偏树与原来合并即可

删除根节点

直接合并根节点两个儿子即可,我并不相信学过 fhq-treap 的人不会这个……

找一个节点所在的根节点

一个非常 naive 的想法是暴力跳父亲,不过这样复杂度是错误的,因为左偏树并不能保证树高是对数级别的,比方说一条只有左儿子组成的链也是左偏树。

不过注意到这个操作与并查集找根本质上是相同的,因此我们同样可以像并查集一样通过路径压缩的方式优化这个过程。复杂度就降了下来。

模板题代码:

const int MAXN=1e5;
int n,qu,rt[MAXN+5];
struct node{int ch[2],val,id,dis;} s[MAXN+5];
int merge(int x,int y){
	if(!x||!y) return x+y;
	if(s[x].val>s[y].val||(s[x].val==s[y].val&&s[x].id>s[y].id)) swap(x,y);
	s[x].ch[1]=merge(s[x].ch[1],y);
	if(s[s[x].ch[1]].dis>s[s[x].ch[0]].dis) swap(s[x].ch[0],s[x].ch[1]);
	s[x].dis=s[s[x].ch[1]].dis+1;
	return x;
}
int find(int x){return (rt[x]==x)?x:rt[x]=find(rt[x]);}
bool del[MAXN+5];
int main(){
	scanf("%d%d",&n,&qu);s[0].dis=-1;
	for(int i=1;i<=n;i++) scanf("%d",&s[i].val),rt[i]=i,s[i].id=i;
	while(qu--){
		int opt;scanf("%d",&opt);
		if(opt==1){
			int x,y;scanf("%d%d",&x,&y);
			if(del[x]||del[y]) continue;
			x=find(x);y=find(y);
			if(x^y) rt[x]=rt[y]=merge(x,y);
		} else {
			int x;scanf("%d",&x);
			if(del[x]){puts("-1");continue;}
			x=find(x);printf("%d\n",s[x].val);del[x]=1;
			rt[s[x].ch[0]]=rt[s[x].ch[1]]=rt[x]=merge(s[x].ch[0],s[x].ch[1]);
		}
	}
	return 0;
}

例题:

1. P3261 [JLOI2015]城池攻占

非常一眼的题,然鹅死活调不对……

考虑在每个节点处建一个小根堆保存当前节点的战士的血量。然后对树进行一遍 DFS,DFS 到一个节点时就将它的儿子节点上的堆与当前的堆合并,然后每次取出血量最小的 pop 掉直到大于当前节点的防御值即可,最后奖励操作就打个 tag 即可,时间复杂度 \(n\log n\)

2. P1552 [APIO2012]派遣

显然我们可以枚举领导,然后再从小到大贪心地选出其子树内的忍者直到它们的 \(c_i\) 之和 \(>m\),但这样显然会炸。

考虑使用左偏树优化这个过程,还是考虑一遍 DFS,注意到对于 \(u\) 子树内某个节点 \(v\),如果在 \(u\) 当领导时,\(v\) 没有被选,那么在 \(fa_u\) 当领导时 \(v\) 也不可能被选,也就是说我们可以直接 pop 掉。因此我们在每个节点处建一个堆表示到当前节点时还剩哪些忍者,每次将当前节点与儿子节点的堆合并,然后不断 pop 直到和 \(<m\) 即可,由于每个节点最多被插入、删除各一次,因此总复杂度 \(n\log n\)


您看?确实很 trival 罢……

posted @ 2021-06-03 20:14  tzc_wk  阅读(67)  评论(0编辑  收藏  举报