左偏树学习笔记

搬运自我的洛谷同名文章

原因是 luogu.me 寄了,所以就搬过来了。

前言

左偏树是一个用于解决可并堆的数据结构。

本文参考 oi-wiki

算法流程

前置概念 / 结论:

  • 外结点:对于一棵二叉树,若某一结点其左儿子 右儿子为空,则称其为外结点。
  • \(dis_x\):我们称二叉树上的结点 \(x\),到其子树内和其最近的外结点的距离为 \(dis_x\)
  • 左偏树是一棵二叉树,既满足「堆」的性质,也有「左偏」的性质,即左儿子的 \(dis\) 不小于右儿子的 \(dis\),形式化地来说,有 \(dis_{lc}\geq dis_{rc}\)
  • 因此来说,在左偏树上,有 \(dis_x=dis_{rc}+1\)

核心操作——合并:

我们以小根堆举例如何合并两个左偏树。(解决可并堆问题。)

由于要满足堆性质,先取值较小的那个根作为合并后堆的根节点。考虑不断地合并较大的那个根和其儿子。如果其存在一个儿子为空,那么将较大的那个根合并上去;若其两个儿子均不为空,则将这个根的左儿子作为合并后堆的左儿子,递归地合并其右儿子与另一个堆,作为合并后的堆的右儿子。如果合并返回后发现左儿子的 \(dis\) 小于右儿子的 \(dis\) 则交换两儿子。

过程很简单。以下是截取的复杂度证明:

由于左偏性质,每递归一层,其中一个堆根节点的 \(dis\) 就会减小 \(1\),而一棵有 \(n\) 个节点的二叉树,根的 \(dis\) 不超过 \(\left\lceil\log (n+1)\right\rceil\),所以合并两个大小分别为 \(n\)\(m\) 的堆复杂度是 \(\mathcal{O}(\log n+\log m)\)

代码:

struct node{int val,lc,rc,dis;}tr[N];
int merge(int x,int y){
	if(!x||!y)return x+y;
	if(tr[y].val<tr[x].val)swap(x,y);
	tr[x].rc=merge(tr[x].rc,y);
	if(tr[tr[x].lc].dis<tr[tr[x].rc].dis)swap(tr[x].lc,tr[x].rc);
	tr[x].dis=tr[tr[x].rc].dis+1;
	return x;
}

同时还有插入删除等基础操作之类的。删除直接合并该结点的左右儿子即可,不再过多赘述。

pbds 可并堆

pbds 似乎可以替代部分左偏树(?)但是没有左偏树灵活性强。

具体来说,我们开设一个 pdbs 库:

#include<bits/extc++.h>
using namespace __gnu_pbds;

并定义优先队列(堆):

__gnu_pbds::priority_queue<int>q;

为了避免和 std 库的优先队列重复,在其前面加上前缀 __gnu_pbds::

这个堆相较于平常使用的来说,多了一个功能:join 操作。其实对应的就是左偏树的 merge 操作啦。我们有 q1.join(q2) 表示将堆 q1,q2 合并且并入 q1 中,同时将 q2 清空。复杂度是 \(\mathcal{O(\log)}\) 的。于是这在部分情况下可以代替左偏树作操作,会使代码简洁一些。但在另外部分情况下无法代替,下文会说到。

例题

P1552 [APIO2012] 派遣

忍者的上级关系构成了一棵树。考虑每个树上结点设一个堆,初始时放入该忍者的薪水。

对于每一个忍者 \(x\) 和其下属构成的子树单独考虑贡献。已经固定了贡献系数 \(L_x\)。考虑最大化派遣的忍者人数。显然,是要在子树中找到最多的人使得其总薪水 \(\leq M\)。那么一定要选最小的若干个。做法就显而易见了。考虑将 \(x\) 的堆与其所有处理好的儿子合并,再不断弹出里面的最大值薪水直到薪水和 \(\leq M\)

代码使用 pbds 实现。

#include<bits/stdc++.h>
#include<bits/extc++.h>
#define int long long
using namespace std;
using namespace __gnu_pbds;
const int N=1e5+5;
int n,m,ans,fa[N],v[N],w[N],sum[N];
__gnu_pbds::priority_queue<int>q[N];
vector<int>g[N];
void dfs(int x){
    for(int i=0;i<g[x].size();i++){
        int to=g[x][i];
        dfs(to),sum[x]+=sum[to],q[x].join(q[to]);
    }
    while(sum[x]>m)sum[x]-=q[x].top(),q[x].pop();
    ans=max(ans,w[x]*(int)q[x].size());
    return;
}
signed main(){
    cin>>n>>m;
    for(int i=1;i<=n;i++){
        cin>>fa[i]>>v[i]>>w[i];
        q[i].push(v[i]),sum[i]=v[i];
        g[fa[i]].push_back(i);
    }
    dfs(1),cout<<ans;
    return 0;
}

P4331 [BalticOI 2004] Sequence 数字序列

套路化地,我们将 \(a_i\leftarrow a_i-i,b_i\leftarrow b_i-i\),这样最后算得的答案不变但更易于我们分析。最后再加回去。

为什么会更易于我们分析呢?因为我们要求 \(b_1<b_2<\dots<b_n\),而减去 \(i\) 之后的 \(b_i\) 会有 \(b_1\leq b_2\leq \dots\leq b_n\),方便处理一些。

处理之后,我们考虑原问题的简化版:

  • \(a_1\leq a_2\leq \dots\leq a_n\)\(b_i\) 如何取?
    • 答案显然。\(a_i=b_i\) 即可。
  • \(a_1>a_2>\dots>a_n\)\(b_i\) 如何取?
    • 用到初中数学绝对值知识「奇点偶段」,\(b_1=b_2=\dots=b_n=val\),其中 \(val\)\(a_1,a_2,\dots,a_n\) 的中位数。

考虑将简化版拼凑成原问题。显然原问题可以拆分成若干段上升和下降的子段拼凑而成。我们将上升的子段也可以看作连续的长度为 1 的下降的子段拼凑,于是原序列可以看作若干段下降的子段的拼凑,而每段子段的答案是其中位数。

这样就可以了吗?

并非如此。构造一组反例 3 4 2 1。分段为 3 | 4 2 1,第一段答案为 3,第二段答案为 2,于是得到的 \(b\)3 2 2 2,显然是不合法的。这怎么办?继续不断合并!如果新合成的一段答案小于其前面一段,那么就不断合并其和其前面一段,找到该段的新中位数直到当前段的中位数大于等于其前面一段。

于是简化算法过程:

\(w_i\) 表示当前第 \(i\) 段的中位数,设 \(cnt\) 表示当前总段数。新加入一个数 \(x\) 时,开新段:\(w_{cnt+1}=x,cnt\leftarrow cnt+1\)。若当前段中位数小于前面段,即 \(w_{cnt}<w_{cnt-1}\),则不断往前合并找到新段的中位数 \(val\)\(w_{cnt-1}\leftarrow val,cnt\leftarrow cnt-1\)

现在最后一个问题:如何高效地找到中位数。这就要使用我们所讲解的「可并堆」了。对于每一段,开一个大根堆,设段长为 \(len\),则其中位数为其中第 \(\lceil \frac{len}{2}\rceil\) 小的。于是我们在大根堆里面保留 \(\lceil \frac{len}{2}\rceil\) 个元素,堆顶即为中位数。

合并两段时,我们将堆合并并保留新的元素个数,截取堆顶。这是好理解的。问题在于,是否存在一种情况,使得合并后的堆要求的中位数在之前被弹走?比如说 2 3 4 | 5 6,我们要求的中位数 42 3 4 段的时候就被弹走了啊?这忽略了合并的前提条件,即只有前一段中位数大于后一段时才进行合并。而显然,上述反例并不满足前提条件。

于是做完了。这个也可以使用 pbds 维护。

#include<bits/stdc++.h>
#include<bits/extc++.h>
#define int long long
using namespace std;
using namespace __gnu_pbds;
const int N=1e6+5,inf=1e18;
int n,cnt,now,res,ans[N],a[N],w[N],L[N],R[N];
__gnu_pbds::priority_queue<int>q[N];
signed main(){
    cin>>n,w[0]=-inf;
    for(int i=1;i<=n;i++)cin>>a[i],a[i]-=i;
    for(int i=1;i<=n;i++){
        w[++cnt]=a[i],q[cnt].push(a[i]),L[cnt]=R[cnt]=i;
        while(w[cnt]<w[cnt-1]){
            q[cnt-1].join(q[cnt]),w[cnt]=0,R[cnt-1]=R[cnt];
            int siz=q[cnt-1].size();
            while(q[cnt-1].size()>(R[cnt-1]-L[cnt-1]+2)/2)q[cnt-1].pop();
            w[cnt-1]=q[cnt-1].top(),cnt--;
        }
    }
    for(int i=1;i<=cnt;i++)for(int j=L[i];j<=R[i];j++)now++,ans[now]=w[i]+now;
    for(int i=1;i<=n;i++)res+=abs(ans[i]-(a[i]+i));
    cout<<res<<"\n";
    for(int i=1;i<=n;i++)cout<<ans[i]<<" ";
    return 0;
}

P3261 [JLOI2015] 城池攻占

和上一题处理方式差不多。

对于每一个树上结点建一个堆,初始时存储初始点在该点上的骑士。考虑不断从儿子节点向父亲节点合并。对于当前堆,不断弹出生命值 \(<h_x\) 的骑士并记录死亡信息。关键在于如何处理经过一个城池的生命变化。这就是左偏树相对于 pbds 的优越之处了。对于每个点建立两个标记,分别是乘法标记和加法标记,记录附加在当前点上的生命变化。对于堆进行弹出标记时,将堆顶的标记往其左儿子右儿子下传(树的特性得以发挥,类似于线段树)。同时,合并 \(x,y\) 时,也要把 \(x,y\) 的标记下传,以免将标记打到本不应该打上标记的地方。

可能不太清楚。看代码:

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=3e5+5;
int n,m,a[N],op[N],w[N],rt[N],tg1[N],tg2[N],dep[N],ori[N],ans1[N],ans2[N];
vector<int>v[N];
struct node{int val,lc,rc,dis;}tr[N];
void ad(int cur,int t1,int t2){
	tr[cur].val*=t1,tr[cur].val+=t2;
	tg1[cur]*=t1,tg2[cur]*=t1,tg2[cur]+=t2;
	return;
}
void pd(int cur){
	int x=tg1[cur],y=tg2[cur];
	ad(tr[cur].lc,x,y),ad(tr[cur].rc,x,y);
	tg1[cur]=1,tg2[cur]=0;
	return;
}
int merge(int x,int y){
	if(!x||!y)return x+y;
	pd(x),pd(y);
	if(tr[y].val<tr[x].val)swap(x,y);
	tr[x].rc=merge(tr[x].rc,y);
	if(tr[tr[x].lc].dis<tr[tr[x].rc].dis)swap(tr[x].lc,tr[x].rc);
	tr[x].dis=tr[tr[x].rc].dis+1;
	return x;
}
void dfs(int x,int fa){
	dep[x]=dep[fa]+1;
	for(int i=0;i<v[x].size();i++){
		int to=v[x][i];
		dfs(to,x);
		rt[x]=merge(rt[x],rt[to]);
	}
	while(rt[x]&&tr[rt[x]].val<a[x]){
		pd(rt[x]);
		ans1[x]++,ans2[rt[x]]=dep[ori[rt[x]]]-dep[x];
		rt[x]=merge(tr[rt[x]].lc,tr[rt[x]].rc);
	}
	if(op[x])ad(rt[x],w[x],0);
	else ad(rt[x],1,w[x]);
	return;
}
signed main(){
	cin>>n>>m,tr[0].dis=-1;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=2,x;i<=n;i++)cin>>x>>op[i]>>w[i],v[x].push_back(i);
	for(int i=1,x,y;i<=m;i++){
		cin>>tr[i].val>>ori[i],tg1[i]=1;
		rt[ori[i]]=merge(rt[ori[i]],i);
	}
	dfs(1,0);
	while(rt[1])pd(rt[1]),ans2[rt[1]]=dep[ori[rt[1]]],rt[1]=merge(tr[rt[1]].lc,tr[rt[1]].rc);
	for(int i=1;i<=n;i++)cout<<ans1[i]<<"\n";
	for(int i=1;i<=m;i++)cout<<ans2[i]<<"\n";
	return 0;
}

完结撒花!

posted @ 2025-08-21 14:11  xuchuhan  阅读(10)  评论(0)    收藏  举报