左偏树学习笔记
搬运自我的洛谷同名文章。
原因是 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)}\) 的。于是这在部分情况下可以代替左偏树作操作,会使代码简洁一些。但在另外部分情况下无法代替,下文会说到。
例题
忍者的上级关系构成了一棵树。考虑每个树上结点设一个堆,初始时放入该忍者的薪水。
对于每一个忍者 \(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,我们要求的中位数 4 在 2 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;
}
和上一题处理方式差不多。
对于每一个树上结点建一个堆,初始时存储初始点在该点上的骑士。考虑不断从儿子节点向父亲节点合并。对于当前堆,不断弹出生命值 \(<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;
}
完结撒花!

浙公网安备 33010602011771号