左偏树学习笔记

[算法总结] 左偏树学习笔记

0.前言

左偏树是一种高效的堆。之后可能要用做可持久化数据结构全家桶。

前置知识:

  • FHQ Treap 等一票平衡树(辅助理解 merge 操作)
  • 二叉堆
  • 并查集(虽然不一定用得上)

1.何为“左偏”

这个名字可不是白起的。

1.1 d d d 值的定义

一棵二叉树之中,有三类节点:两个子节点的点一个子节点的点叶子节点。其中比较影响树高的还是后面两类,后面两类节点占比越多,这棵树越不平衡。[1] 所以我们设想:能不能设计一个和后两类节点相关的值来减小二叉树的查询复杂度呢?

为了方便,后文把这两类节点叫做外结点

我们定义每个节点 i i i d d d 值为 i i i 子树内到距离最近的外节点总共经过的节点个数。即距离+1。如果 i i i 本身就是一个外节点的话,那么 d d d 值就是 1 1 1 ;否则从子树的 d d d 值转移上来。

转移和建造比较简单,这里就不贴代码了。

1.2 d d d 值有什么特别的?

首先, d d d 值最大的节点不一定是根节点。

但是,对于所有大小为 n n n 的二叉树来讲,根的 d d d 值都不会超过 log ⁡ 2 ( n ) \log_2(n) log2(n) 。为什么呢?

证明:如果根的 d d d 值为 x x x 的话,树的节点需要至少 2 x − 1 2^x-1 2x1 个才能做到根到无论哪个外节点都距离是 x x x 。所以,根的 d d d 值是 log ⁡ 2 ( n ) \log_2 (n) log2(n) 级别的。

1.3 左偏树是啥?

我们这样定义左偏树:根的 d d d 值为右儿子的 d d d + 1 +1 +1

为什么是右儿子的 d d d + 1 +1 +1 呢?显然左儿子的 d d d 值更大。

所以,左儿子的 d d d 要大于等于右儿子的 d d d ;所以我们就叫它左偏树。

1.4 哪几类二叉树是左偏树?

一条只有左儿子的链(这要注意,可能会被良心出题人卡);

一棵完全二叉树(前面几层是满二叉树,而最后一层的叶子是从左到右从属的);类似的,还有满二叉树。

还有特殊性质的二叉树,欢迎补充。

2.左偏树和堆

我们同时利用堆的性质和左偏树的性质来维护堆的操作。

也就是说,对于每个堆中的节点,维护左右儿子,和 d d d 值,还有这个节点本身的权值。

2.1 合并操作

有 FHQ Treap 内味了,但是不能分裂。

具体而言,先是谁小谁当爹 现在知道谁是老大了吧 ,然后递归下去合并。

因为左偏树的性质,我们考虑不动左子树,然后把稍微大一点的那另外一个根和当前根合并。

合并完之后,判断左子的 d d d 值和右子的关系,如果小于就交换,然后更新根节点的 d d d 值。

I merg(I x,I y){
	if(!x||!y)return x|y;
	if(a[y]<a[x])swap(x,y);
	R[x]=merg(R[x],y);
	if(d[L[x]]<d[R[x]])swap(L[x],R[x]);
	d[x]=d[R[x]]+1;
	return x;
}

能不能证明这个函数的时间复杂度呢?懒得证明了,直接贴图。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UHWOClVA-1660483238225)(C:\Users\Admin\AppData\Roaming\Typora\typora-user-images\image-20220714113240477.png)]

能不能不动右子树呢?我不会。

2.2 插入操作

把新的节点当作一个堆,直接插入即可。

2.3 删除堆顶操作

把堆顶的左右儿子合并,清空堆顶的 d d d 值、左右儿子、权值即可。

2.4 任意节点删除

和 [2.3] 不同,任意节点删除操作涉及到改动父节点的 d d d 值,所以这个可不只是合并它的左右儿子和清空,还需要向上循环以保证左偏树的正确性。具体而言,循环找父节点,然后更新它的 d d d 值;如果 d d d 值没有更新那么就退出循环。

void pop(I&id){
    I fx=fa[id],u=merg(L[id],R[id]);
    fa[u]=fx;
    if(fx&&R[fx]==id)R[fx]=u;
    else if(fx&&L[fx]==id)L[fx]=u;
    num[id]=L[id]=R[id]=0;d[id]=1;
    while(fx){
        if(d[L[fx]]<d[R[fx]])swap(L[fx],R[fx]);
        if(d[R[fx]]+1==d[fx])break;
        d[fx]=d[R[fx]]+1;
        fx=fa[fx];
    }
    id=u;
}

时间复杂度的一点证明:

我的理解是跳到祖先之中的外节点或者根节点为止,因为我们循环到的每层都有两个儿子,所以运气不会太差以致到链的情况。

因为要保证循环下去,就必须得能被更新,而左子树显然还是得大于等于右子树的答案的。

假设当前右子树的答案为 k k k ,那么上面的答案需被更新为 k + 1 k+1 k+1 才能继续循环下去;显然左子树的答案要大于等于 k k k 才能有优势。

之前我们已经证明过, d d d = k =k =k 的树点数至少为 2 k − 1 2^k-1 2k1 。因为这个循环过程和 d d d 值的大小密切相关,所以这个过程是 O ( log ⁡ n ) O(\log n) O(logn) 的。

3.例题

这里介绍几个例题。

3.1 【模板】左偏树/可并堆

只需要使用 [2.1] [2.2] [2.3] 三个操作即可。

注意几个细节:(这是我被坑了几次的血泪教训)

  • 注意一下,WA 93 是因为需要先按照 a a a 、再来按照编号比较大小,所以需要使用结构体判断。(其实给 a a a 乘上一个 n n n 再加上编号也不是不行)

  • 因为是随便合并两个堆,所以记录一下一个元素所在堆的堆顶节点编号即可。

  • 上面这坨东西需要使用并查集维护,不然 TLE 。

  • 还有,如果一个点删除了,需要打上标记。碰到一个点,先别急着取祖先,先要判断它存不存在再说。

#include<bits/stdc++.h>
using namespace std;typedef int I;typedef long long LL;const I inf=1073741823;char CH;I FL;template<typename T>bool in(T&a){if(CH==EOF)return 0;FL=1;a=0;while(CH!=EOF&&!isdigit(CH))FL=(CH=='-')?-1:1,CH=getchar();while(CH!=EOF&&isdigit(CH))a=a*10+CH-'0',CH=getchar();return a*=FL,1;}template<typename T,typename...Args>I in(T&a,Args&...args){return in(a)+in(args...);}
I n,m;
const I maxn=1e5+10;
struct nd{
	I a,id;
	friend bool operator <(nd a,nd b){
		return a.a==b.a?a.id<b.id:a.a<b.a;
	}
}a[maxn];
I d[maxn],L[maxn],R[maxn],fo[maxn],de[maxn];
I gf(I x){
	return x==fo[x]?x:fo[x]=gf(fo[x]);
}
I merg(I x,I y){
	if(!x||!y)return x|y;
	if(a[y]<a[x])swap(x,y);
	R[x]=merg(R[x],y);
	if(d[L[x]]<d[R[x]])swap(L[x],R[x]);
	d[x]=d[R[x]]+1;
	return x;
}
I main(){
	in(n,m);
	for(I i=1;i<=n;++i){
		in(a[i].a);
		a[i].id=i;
		d[i]=1;
		fo[i]=i;
	}
	for(I i=1,op,x,y;i<=m;++i){
		in(op,x);
		if(op==1){
			in(y);
			if(de[x]||de[y])continue;
			x=gf(x);
			y=gf(y);
			if(x==y)continue;
			fo[x]=fo[y]=merg(x,y);
		}else{
			if(de[x]){printf("-1\n");continue;}
			x=gf(x);
			printf("%d\n",a[x]);
			de[x]=1;
			fo[L[x]]=fo[R[x]]=fo[x]=merg(L[x],R[x]);
			L[x]=R[x]=0;
			d[x]=1;
		}
	}
	return 0;
}

3.2 【模板】单源最短路径(标准版)

普通的 dij 是这个样子的:

#define mp make_pair
priority_queue<pair<I,I> >pq;
I Ey[maxm],NX[maxm],Ez[maxm],Ec,HD[maxn],s,n,m,dis[maxn];
bool bz[maxn];
void dij(I s){
	pq.push(mp(0,s));
	for(I i=1;i<=n;++i)dis[i]=inf;
	dis[s]=0;
	while(pq.size()){
		I x=pq.top().second;
		pq.pop();
		if(bz[x])continue;
		bz[x]=1;
		for(I i=HD[x],y,z;i;i=NX[i]){
			y=Ey[i];
			z=Ez[i];
			if(dis[y]>dis[x]+z){
				dis[y]=dis[x]+z;
				pq.push(mp(-dis[y],y));
			}
		}
	}
	for(I i=1;i<=n;++i)printf("%d ",dis[i]);
}

让我们用左偏树实现优先队列。

template<typename T>struct piq{
	T num[maxm];I d[maxm],cnt,L[maxm],R[maxm],rt;
	I mer(I x,I y){if(!x||!y)return x|y;if(num[x]<num[y])swap(x,y);R[x]=mer(R[x],y);if(d[L[x]]<d[R[x]])swap(L[x],R[x]);d[x]=d[R[x]]+1;return x;}
	void push(T x){num[++cnt]=x;d[cnt]=1;rt=mer(rt,cnt);}
	T top(){return num[rt];}
	void pop(){d[rt]=1;rt=mer(L[rt],R[rt]);}
	bool size(){return rt;}
};

效果是明显的:运行效率提升了 50% ,妈妈再也不用担心我的优先队列被卡常了。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QIWcMuOF-1660483238226)(C:\Users\Admin\AppData\Roaming\Typora\typora-user-images\image-20220714150007351.png)]

类似地,我们可以 A 掉 【模板】堆

3.3 Monkey King

题意: n n n 个数,一开始各为一个堆,每次拿出 x , y x,y x,y 所在的堆顶,数值减半再把两个堆合并。

那么我们继续左偏树实现,注意几个坑点:

  • 如果把一个节点暂时删除,需要把左右儿子全部清空, d d d 值回归初始化。

  • 注意一下暂时删除时,左右儿子和这个删除节点本身的 r o o t root root 都要更新。

#include<bits/stdc++.h>
using namespace std;typedef int I;typedef long long LL;const I inf=1073741823;char CH;I FL;template<typename T>bool in(T&a){if(CH==EOF)return 0;FL=1;a=0;while(CH!=EOF&&!isdigit(CH))FL=(CH=='-')?-1:1,CH=getchar();while(CH!=EOF&&isdigit(CH))a=a*10+CH-'0',CH=getchar();return a*=FL,1;}template<typename T,typename...Args>I in(T&a,Args&...args){return in(a)+in(args...);}
const I maxn=1e5+10,maxm=1e5+10;
I a[maxn],L[maxm],R[maxm],rt[maxm],d[maxm],cnt,n,m;
I getf(I x){
	return x==rt[x]?x:rt[x]=getf(rt[x]);
}
I merg(I x,I y){
	if(!x||!y)return x|y;
	if(a[x]<a[y])swap(x,y);
	R[x]=merg(R[x],y);
	if(d[L[x]]<d[R[x]])swap(L[x],R[x]);
	d[x]=d[R[x]]+1;
	return x;
}
void mrgrt(I x,I y){
	x=getf(x);
	y=getf(y);
	if(x==y){
		printf("-1\n");
		return;
	}
	rt[x]=merg(L[x],R[x]);
	a[x]>>=1;L[x]=R[x]=0;d[x]=1;
	x=merg(rt[x],x);
	rt[y]=merg(L[y],R[y]);
	a[y]>>=1;L[y]=R[y]=0;d[y]=1;
	y=merg(rt[y],y);
	rt[x]=rt[y]=merg(x,y);
	printf("%d\n",a[rt[x]]);
}
I main(){
	while(scanf("%d",&n)!=-1){
		memset(L,0,sizeof(L));
		memset(R,0,sizeof(R));
		cnt=0;
		for(I i=1;i<=n;++i)in(a[i]),rt[i]=i,d[i]=1;
		in(m);
		for(I i=1,x,y;i<=m;++i){
			in(x,y);
			mrgrt(x,y);
		}
	}
	return 0;
}

3.4 罗马游戏

题意:【模板】可并堆。代码不贴了。

3.5 【堆专题】小明的账单

题意:大概是 n n n 次操作,每次操作在集合中插入 k k k 个不同的数,最后求出并删除最大值、最小值。 n ≤ 15000 , k ≤ 100 n\le 15000,k\le 100 n15000,k100

奶奶滴,我平衡树过不了,气死了

正解是对顶堆,建立两个堆,如果删除了一个堆中的堆顶,对应的另外一个堆中的元素也要删掉。

实现技巧上,其实可以建立两个小根堆,然后其中一个存另一个堆元素的相反数。因为是同时加入的,两个堆中的节点编号都一致,所以就可以追溯到编号直接删除。然后就是 [2.4] 的操作了。

#include<bits/stdc++.h>
using namespace std;typedef int I;typedef long long LL;const I inf=1073741823;char CH;I FL;template<typename T>bool in(T&a){if(CH==EOF)return 0;FL=1;a=0;while(CH!=EOF&&!isdigit(CH))FL=(CH=='-')?-1:1,CH=getchar();while(CH!=EOF&&isdigit(CH))a=a*10+CH-'0',CH=getchar();return a*=FL,1;}template<typename T,typename...Args>I in(T&a,Args&...args){return in(a)+in(args...);}
const I maxn=1500010;
I n;
struct heap{
	I num[maxn],d[maxn],fa[maxn],L[maxn],R[maxn],cnt,rt;
	I merg(I x,I y){
		if(!x||!y)return x|y;
		if(num[x]>num[y])swap(x,y);
		if(d[L[x]]<d[R[x]])swap(L[x],R[x]);
		R[x]=merg(R[x],y);
		fa[R[x]]=x;
		d[x]=d[R[x]]+1;
		return x;
	}
	void push(I x){
		num[++cnt]=x;
		d[cnt]=1;
		rt=merg(rt,cnt);
	}
	void pop(I&id){
		I fx=fa[id],u=merg(L[id],R[id]);
		fa[u]=fx;
		if(fx&&R[fx]==id)R[fx]=u;
		else if(fx&&L[fx]==id)L[fx]=u;
		num[id]=L[id]=R[id]=0;d[id]=1;
		while(fx){
			if(d[L[fx]]<d[R[fx]])swap(L[fx],R[fx]);
			if(d[R[fx]]+1==d[fx])break;
			d[fx]=d[R[fx]]+1;
			fx=fa[fx];
		}
		id=u;
	}
}H1,H2;
void push(I x){
	H1.push(x);
	H2.push(-x);
}
void pop2(){
	I rt1=H1.rt,rt2=H2.rt;
	printf("%d ",H1.num[H1.rt]);
	H1.pop(H1.rt);
	H2.pop(rt1);
	printf("%d\n",-H2.num[H2.rt]);
	H2.pop(H2.rt);
	H1.pop(rt2);
}
I main(){
	in(n);
	for(I i=1,k,a;i<=n;++i){
		in(k);
		while(k--){
			in(a);
			push(a);
		}
		pop2();
	}
	return 0;
}

3.6 [APIO2012]派遣

题意:有 n n n 个点,成本 a i a_i ai ,权值 l i l_i li ,构成一棵树。每个点有权“派遣”其自身和其下级。“管理者”是“派遣”子树节点的老大,但不一定要被“派遣”。现在要求“派遣”一些点,成本不超过 m m m ,价值是 节点个数 × \times × “管理者”的权值。求最大价值。

看懂了题目,就好说了。

因为每个节点有权“派遣”其子树节点去做任务,所以不妨把每个点当作“管理者”。

这类题目的套路就是维护一个堆,然后把成本大的一直踢出去直到成本之和足够小为止。

#include<bits/stdc++.h>
using namespace std;typedef int I;typedef long long LL;const I inf=1073741823;char CH;I FL;template<typename T>bool in(T&a){if(CH==EOF)return 0;FL=1;a=0;while(CH!=EOF&&!isdigit(CH))FL=(CH=='-')?-1:1,CH=getchar();while(CH!=EOF&&isdigit(CH))a=a*10+CH-'0',CH=getchar();return a*=FL,1;}template<typename T,typename...Args>I in(T&a,Args&...args){return in(a)+in(args...);}
const I maxn=1e5+10;
I d[maxn],L[maxn],R[maxn],a[maxn],siz[maxn],fa[maxn],b[maxn],n,m,rrt;
I Ey[maxn],NX[maxn],HD[maxn],Ec;
void conn(I x,I y){
	Ey[++Ec]=y;NX[Ec]=HD[x];HD[x]=Ec;
}
LL ss[maxn];
I gf(I x){
	return x==fa[x]?x:fa[x]=gf(fa[x]);
}
I mer(I x,I y){
	if(!x||!y)return x|y;
	if(a[x]<a[y])swap(x,y);
	R[x]=mer(R[x],y);
	if(d[L[x]]<d[R[x]])swap(L[x],R[x]);
	return d[x]=d[R[x]]+1,x;
}I pop(I rt){
	I res=a[rt];
	fa[rt]=fa[L[rt]]=fa[R[rt]]=mer(L[rt],R[rt]);
	L[rt]=R[rt]=a[rt]=0;d[rt]=0;
	return res;
}void travel(I p){
	if(!p)return;
	printf("%d ",a[p]);
	travel(L[p]);
	travel(R[p]);
}
LL ans=0;
void calc(I x){
	siz[x]=1;
	ss[x]=a[x];
	for(I i=HD[x],y;i;i=NX[i]){
		y=Ey[i];
		calc(y);
		siz[x]+=siz[y];
		ss[x]+=ss[y];
		fa[x]=mer(fa[x],gf(y));
	}
	I rt=fa[x];
	while(ss[x]>m){
		ss[x]-=pop(rt);
		--siz[x];
		rt=gf(rt);
	}
	ans=max(ans,1ll*siz[x]*b[x]);
}
I main(){
	in(n,m);
	for(I i=1,ff;i<=n;++i){
		in(ff,a[i],b[i]);
		fa[i]=i;d[i]=1;
		if(ff==0)rrt=i;
		else conn(ff,i);
	}
	calc(rrt);
	printf("%lld\n",ans);
	return 0;
}

终于一遍过,不容易

3.7 6370.基础 fake 练习题

和 3.6 难度差不多。

题意:树上有一些祖孙链,要求选最多的链出来覆盖,使得每个点覆盖次数不超过 c i c_i ci n ≤ 3 × 1 0 5 n\le 3\times 10^5 n3×105

思路:不用考虑树链剖分,因为深层的只可能有一条祖孙链。

所以先来考虑序列上面的情况,这时候没有辈分的影响,所以转化成为了每个点最多被覆盖 c i c_i ci 次,然后求最多能选多少链。

假设从左往右覆盖,那么我们观察到另外一个性质,如果覆盖了 x x x 的所有方案,很明显后边多出来的东西越少越好。所以可以维护右端点最大的区间,丢进一个堆里面处理,每次当前指针往右移动,就可以计算出堆中能覆盖 x x x 的区间个数。

但是暴力去算还是太慢,所以直接差分,然后右扫指针的过程中再做前缀和即可。如果这个点被覆盖的次数太多,就删堆顶并且修改差分的贡献,直到等于 c i c_i ci 为止。

回到树上,那么发现可以算出子树之中所有堆,因为本题的性质没有跨根节点分别在两个子树之内的点对(有的话就是点分治的事了),所以只涉及到祖孙关系,我们观察到其实就是根节点到它这条链情况的求解。

但是这是棵树,所以会分支出来很多树枝666,所以就使用可并堆合并那些子解即可。差分的过程也是一样,使用可并堆合并。

代码极其丑陋(((

#include<bits/stdc++.h>
#define mm(a,b) memset((a),(b),sizeof(a))
using namespace std;typedef int I;typedef long long LL;const I inf=1073741823;I CH,FL;template<typename T>bool in(T&a){for(FL=1;CH!=EOF&&!isdigit(CH);CH=getchar())if(CH=='-')FL=-1;for(a=0;CH!=EOF&&isdigit(CH);CH=getchar())a=a*10+CH-'0';return a*=FL,CH!=EOF;}template<typename T,typename...Args>bool in(T&a,Args&...args){return in(a)+in(args...);}
const I maxn=6e5+10;
I rt[maxn],d[maxn],c[maxn],fa[maxn],cf[maxn],cnt,Ey[maxn<<1],NX[maxn<<1],HD[maxn],Ec,n,m;
void conn(I x,I y){
	Ey[++Ec]=y;NX[Ec]=HD[x];HD[x]=Ec;
}
I L[maxn],R[maxn],di[maxn],si[maxn];
struct path{I x,y;friend bool operator <(path a,path b){return d[a.x]<d[b.x];}}A[maxn];//x浅y深
I nwpath(I x,I y){//x:浅 y:深
	A[++cnt]={x,y};di[cnt]=1;si[cnt]=1;return cnt;
}
I merg(I p,I q){
	if(!p||!q)return p|q;
	if(A[q]<A[p])swap(p,q);
	R[p]=merg(R[p],q);
	if(di[R[p]]>di[L[p]])swap(L[p],R[p]);
	di[p]=di[R[p]]+1;
	si[p]=si[L[p]]+si[R[p]]+1;
	return p;
}void pop(I&rot){
	di[rot]=0;si[rot]=0;I LP=L[rot],RP=R[rot];L[rot]=R[rot]=0;
	rot=merg(LP,RP);
}
void dfs1(I x,I fa){
	::fa[x]=fa;
	for(I i=HD[x],y;i;i=NX[i]){
		y=Ey[i];
		if(y==fa)continue;
		d[y]=d[x]+1;
		dfs1(y,x);
	}
}I ans[maxn];
I B[maxn];
I blen;
void dfs2(I x,I fa){
	for(I i=HD[x],y;i;i=NX[i]){
		y=Ey[i];
		if(y==fa)continue;
		dfs2(y,x);
	}
	for(I i=HD[x],y;i;i=NX[i]){
		y=Ey[i];
		if(y==fa)continue;
		rt[x]=merg(rt[x],rt[y]);
		cf[x]+=cf[y];
	}
	blen=0;
	while(si[rt[x]]&&d[A[rt[x]].x]<=d[x]&&cf[x]>c[x]){
		--cf[x];
		++cf[::fa[A[rt[x]].x]];
		pop(rt[x]);
	}
	ans[x]=si[rt[x]];
}
I main(){
	freopen("fake.in","r",stdin);
	freopen("fake.out","w",stdout);
	in(n,m);
	for(I i=1;i<=n;++i)in(c[i]);
	for(I i=1,x,y;i<n;++i){
		in(x,y);
		conn(x,y);
		conn(y,x);
	}dfs1(1,0);
	for(I i=1,x,y;i<=m;++i){
		in(x,y);
		if(d[x]>d[y])swap(x,y);
		--cf[fa[x]];
		++cf[y];
		rt[y]=merg(rt[y],nwpath(x,y));
	}dfs2(1,0);
	printf("%d\n",ans[1]);
	return 0;
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值