• 左偏树小记


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

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

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

    一些 basic 的定义

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

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

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

    1. 它是一个堆,即 (forall x)(v_xle v_{lc_x})(v_xle 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_xle 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
    ",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 即可,时间复杂度 (nlog n)

    2. P1552 [APIO2012]派遣

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

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


    您看?确实很 trival 罢……

  • 相关阅读:
    111.浮动初识 Walker
    105.灰度和对比度 Walker
    102.表格属性 Walker
    POJ 1321 棋盘问题
    HDU 1106 排序 题解
    HDU 1240 Asteroids! 解题报告
    HDU 1372 Knight Moves
    HDU 1253 胜利大逃亡
    HDU 1231:最大连续子序列 解题报告
    POJ 2251 Dungeon Master
  • 原文地址:https://www.cnblogs.com/ET2006/p/leftist-tree.html
Copyright © 2020-2023  润新知