左偏树学习笔记
目录
一、左偏树简介
基本性质
合并操作
询问操作
插入操作
删除操作
可持久化
二、相关例题
例1、
例2、
例3、
例4、
例5、
参考资料。
一、左偏树简介
基本性质
左偏树可以在严格 的时间内合并两个堆,其他操作和普通的堆没有差别。
这也是左偏树的别名——可并堆的由来。
和普通的二叉堆类似,左偏树是一棵二叉树,每个节点需要维护 dis
和 val
两个值。
其中 val
不必多说,就是二叉堆维护的内容。
而 dis
表示往子树内走,走到空节点的最少步数。
接下来就可以给出左偏树的定义了,以小根堆为例:
- 堆性质:每个节点
p
的val
比左右儿子都小。 - 左偏性质:每个节点
p
左儿子的dis
一定大于等于右儿子。
根据定义可以得出一个直接推论:dis[p]=dis[rc[p]]+1
。
结论:对于一个点数为 的堆,。
证明:假设 ,那么至少有一棵 层的满二叉树,即 ,证毕。
先讲一下左偏树的常见操作,假设我们维护的是小根堆。
合并操作
定义 merge(x,y)
为合并两棵左偏树的操作,返回合并后的根节点。
不妨 ,否则可以交换 和 。
保留 为根节点,将 和 继续合并。
回溯时为了保证左偏树的左偏性质,如果 ,我们需要交换 和 。
时间复杂度 。
int merge(int x,int y)
{///合并以x,y为根的左偏树,返回根节点
if(!x||!y) return x|y;
if(val[x]>val[y]) swap(x,y);
rc[x]=merge(rc[x],y);
if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);
return dis[x]=dis[rc[x]]+1,x;
}
询问操作
堆能支持的询问操作也只有询问最小值了,返回堆顶即可,时间复杂度。
int top(int p)
{///询问以p为堆顶的左偏树中的最小权值
return val[p];
}
插入操作
新建一个节点 与左偏树合并,时间复杂度 。
int newnode(int x)
{
dis[++tot]=1,val[tot]=x;
return tot;
}
void push(int &p,int x)
{///在以p为根的左偏树中插入一个权值为x的点
p=merge(p,newnode(x));
}
删除操作
将 的左右子树合并即可,时间复杂度 。
void pop(int &p)
{//将以p为根的左偏树的堆顶删除
p=merge(lc[p],rc[p]);
}
可持久化
类比可持久化线段树合并,在合并过程中新建节点即可,时间复杂度 。
int merge(int x,int y)
{
if(!x||!y) return x|y;
if(val[x]>val[y]) swap(x,y);
int p=++tot;
val[p]=val[x],lc[p]=lc[x],rc[p]=merge(rc[x],y);
if(dis[lc[p]]<dis[rc[p]]) swap(lc[p],rc[p]);
return dis[p]=dis[rc[p]]+1,p;
}
注意节点数需要开两倍。
二、相关例题
例1、
题目描述
给定 个小根堆,初始第 个堆中仅有元素 ,接下来 次操作:
-
1 x y
:合并第 个数和第 个数所在堆。 -
2 x
:查询第 个数所在堆的最小元素,并将最小元素删除。如果有多个最小元素,删除编号较小的一个。
如果第 个数已经被删除,输出
-1
。
数据范围
- 。
- 。
时间限制 ,空间限制 。
分析
基本操作上面都讲过了,不过本题还需要支持查询一个数所在堆的堆顶。
记录 fa[p]
表示 p
的父节点,特别地,对于堆顶有 fa[p]=p
。
注意左偏树的树高不一定是 (这也是和普通的二叉堆最大的区别),因此如果直接暴力跳 fa
,单次时间复杂度可以退化到 。
并查集优化即可。注意删除时需要将被删节点的 fa
指向新的根节点,从而保证子树内的所有点能访问到正确的 fa
。
时间复杂度 。
#include<bits/stdc++.h>
#define fi first
#define se second
#define mp make_pair
#define pii pair<int,int>
using namespace std;
const int maxn=1e5+5;
int m,n,x,y,op;
int f[maxn],lc[maxn],rc[maxn],dis[maxn];
pii val[maxn];
bool del[maxn];
int find(int x)
{
if(f[x]==x) return x;
return f[x]=find(f[x]);
}
int merge(int x,int y)
{
if(!x||!y) return x|y;
if(val[x]>val[y]) swap(x,y);
rc[x]=merge(rc[x],y);
if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);
return dis[x]=dis[rc[x]]+1,x;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&x),dis[i]=1,f[i]=i,val[i]=mp(x,i);
while(m--)
{
scanf("%d",&op);
if(op==1)
{
scanf("%d%d",&x,&y);
if(del[x]||del[y]||find(x)==find(y)) continue;
x=find(x),y=find(y);
if(val[x]>val[y]) swap(x,y);
f[y]=x,merge(x,y);
}
else
{
scanf("%d",&x);
if(del[x]) printf("-1\n");
else
{
x=find(x),del[x]=true;
printf("%d\n",val[x].fi);
int u=merge(lc[x],rc[x]);
f[x]=f[u]=u;
}
}
}
return 0;
}
例2、
题目描述
给定一棵 个节点的有根树,第 个节点的父节点为 ,权值为 。
你需要选择一个点 ,再从 子树(包括 自身)中选择一个点集 ,要求 。
求 的最大值。
数据范围
- 。
- 。
时间限制 ,空间限制 。
分析
枚举 ,显然只会选择子树内 最小的若干个点。
大根堆维护 ,如果堆中权值和超过 ,弹出堆顶即可。
需要额外维护堆中元素个数、元素和,时间复杂度 。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+5;
int m,n,x;
int l[maxn],rt[maxn];
int lc[maxn],rc[maxn],sz[maxn],dis[maxn],val[maxn];
long long res,sum[maxn];
vector<int> g[maxn];
int merge(int x,int y)
{
if(!x||!y) return x|y;
if(val[x]<val[y]) swap(x,y);
rc[x]=merge(rc[x],y);
if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);
sz[x]=sz[lc[x]]+sz[rc[x]]+1;
sum[x]=sum[lc[x]]+sum[rc[x]]+val[x];
return dis[x]=dis[rc[x]]+1,x;
}
void pop(int &p)
{
p=merge(lc[p],rc[p]);
}
void dfs(int u)
{
for(auto v:g[u]) dfs(v),rt[u]=merge(rt[u],rt[v]);
while(sum[rt[u]]>m) pop(rt[u]);
res=max(res,1ll*l[u]*sz[rt[u]]);
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
{
scanf("%d%d%d",&x,&val[i],&l[i]),g[x].push_back(i);
rt[i]=i,dis[i]=sz[i]=1,sum[i]=val[i];
}
dfs(1);
printf("%lld\n",res);
return 0;
}
例3、
题目描述
给定一棵 个节点的树, 号点为根,第 个点的父节点为 ,防御值为 。
有 个骑士,第 个骑士初始在 号点,战斗力为 。
-
如果 ,那么这个骑士可以占领 号点。
根据当前节点的参数 , 会加上或乘上 。
如果 ,接下来骑士会继续尝试占领其父节点。
-
如果 ,骑士会在第 个节点牺牲。
对每个节点,输出有多少个骑士会在这里牺牲。
对每个骑士,输出他能占领的节点数。
数据范围
- 。
- 。
- ,保证当 时, ,且任意时刻任意骑士的战斗力绝对值 。
时间限制 ,空间限制 。
分析
每个节点用可并堆维护骑士集合,先合并所有子树信息,再 pop
掉所有 的骑士即可。
维护乘法和加法标记的方法同线段树,进行 push/pop/merge
等操作前需 pushdown
。
时间复杂度 。
#include<bits/stdc++.h>
#define int long long
#define fi first
#define se second
#define mp make_pair
#define pii pair<int,int>
using namespace std;
const int maxn=3e5+5;
int m,n,x,tot;
int a[maxn],c[maxn],d[maxn],h[maxn],v[maxn],rt[maxn];
int lc[maxn],rc[maxn],dis[maxn],mul[maxn],add[maxn];
pii val[maxn];
int cnt[maxn],num[maxn];
vector<int> g[maxn];
void pushmul(int p,int v)
{
val[p].fi*=v,mul[p]*=v,add[p]*=v;
}
void pushadd(int p,int v)
{
val[p].fi+=v,add[p]+=v;
}
void pushdown(int p)
{
if(mul[p]!=1) pushmul(lc[p],mul[p]),pushmul(rc[p],mul[p]),mul[p]=1;
if(add[p]!=0) pushadd(lc[p],add[p]),pushadd(rc[p],add[p]),add[p]=0;
}
int merge(int x,int y)
{
if(!x||!y) return x|y;
if(val[x]>val[y]) swap(x,y);
pushdown(x),pushdown(y);
rc[x]=merge(rc[x],y);
if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);
return dis[x]=dis[rc[x]]+1,x;
}
int newnode(pii v)
{
val[++tot]=v,dis[tot]=mul[tot]=1;
return tot;
}
void pop(int &p)
{
pushdown(p),p=merge(lc[p],rc[p]);
}
void dfs(int u)
{
for(auto v:g[u]) d[v]=d[u]+1,dfs(v),rt[u]=merge(rt[u],rt[v]);
while(rt[u]&&val[rt[u]].fi<h[u])
{
int x=val[rt[u]].se;
cnt[u]++,num[x]=d[c[x]]-d[u],pop(rt[u]);
}
(a[u]?pushmul:pushadd)(rt[u],v[u]);
}
signed main()
{
scanf("%lld%lld",&n,&m);
for(int i=1;i<=n;i++) scanf("%lld",&h[i]);
for(int i=2;i<=n;i++) scanf("%lld%lld%lld",&x,&a[i],&v[i]),g[x].push_back(i);
for(int i=1;i<=m;i++) scanf("%lld%lld",&x,&c[i]),rt[c[i]]=merge(rt[c[i]],newnode(mp(x,i)));
d[1]=1,dfs(1);
while(rt[1])
{
int x=val[rt[1]].se;
num[x]=d[c[x]],pop(rt[1]);
}
for(int i=1;i<=n;i++) printf("%lld\n",cnt[i]);
for(int i=1;i<=m;i++) printf("%lld\n",num[i]);
return 0;
}
例4、
本题有更简单的slope trick做法。
题目描述
给定整数序列 ,求一个严格递增的整数序列 ,要求最小化 ,构造方案。
数据范围
- 。
时间限制 ,空间限制 。
分析
令 ,将严格递增转化为非严格递增。
观察发现有两个很显然的性质:
- 如果 (非严格)单调递增,显然 。
- 如果 (非严格)单调递减,显然 全部相等,进一步容易证明 取 的中位数最优。
一个很简单的想法是将原序列 划分为若干个单调递减的连续段,每一段取中位数。
但这并不足以保证 单调递增,如果相邻两段前面中位数比后面大就会出问题。
依次加入 ,用栈维护当前所有连续段。
如果出现上面这种情况,我们需要将两个连续段合并以后重新求中位数。
用大根堆维护区间内较小的 个元素,左偏树支持合并即可。
注意到 ,并且当且仅当 均为奇数时等号不成立,因此我们只需在两个大根堆 均为奇数时
pop
掉堆顶。:为何新的中位数一定在这两个大根堆的并集中,而不会出现 ,但 已经被删除的情况?
:记原本的连续段从右往左依次为 ,那么 。如果加入某个元素 后需要合并 和 ,显然 ,因此新的中位数要么为 ,要么在某个 的大根堆中,一定没有被删除。
时间复杂度 。
#include<bits/stdc++.h>
#define len(x) (r[x]-l[x]+1)
using namespace std;
const int maxn=1e6+5;
int n,top;
long long res;
int a[maxn],b[maxn];
int l[maxn],r[maxn],rt[maxn];
int lc[maxn],rc[maxn],dis[maxn],val[maxn];
int merge(int x,int y)
{
if(!x||!y) return x|y;
if(val[x]<val[y]) swap(x,y);
rc[x]=merge(rc[x],y);
if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);
return dis[x]=dis[rc[x]]+1,x;
}
void pop(int &p)
{
p=merge(lc[p],rc[p]);
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<=n;i++)
{
rt[++top]=i,dis[i]=1,val[i]=a[i]-i,l[top]=r[top]=i;
while(top>1&&val[rt[top-1]]>=val[rt[top]])
{
rt[top-1]=merge(rt[top-1],rt[top]);
if((len(top-1)&1)&&(len(top)&1)) pop(rt[top-1]);
r[--top]=i;
}
}
for(int i=1;i<=top;i++)
for(int j=l[i];j<=r[i];j++)
b[j]=val[rt[i]]+j,res+=abs(a[j]-b[j]);
printf("%lld\n",res);
for(int i=1;i<=n;i++) printf("%d ",b[i]);
putchar('\n');
return 0;
}
例5、
参考资料。
题目描述
给定一张 个点, 条边的有向图,边有边权 。
要求选择若干条不相同 的路径,使得边权总和不超过 ,求选择的路径数最大值。
对于每条路径, 号点仅允许经过一次,其余点、边允许经过多次。
两条路径不同,当且仅当存在一条边被经过的次数不同,或经过所有边的顺序不同。
数据范围
- 。
- 。
图中可能存在重边和自环,保证至少存在一条合法路径。
时间限制 ,空间限制 。
分析
算法的时间复杂度为 ,构造一个 元环加上一条指向 的边即可卡满。
本题等价于求 短路的边权,因为每条路径长度 ,所以 。
因为 号点仅允许经过一次,所以需要把以 为起点的边过滤掉。
接下来进入正题,先引入最短路径树的概念。
记终点为 ,最短路径树为一棵以 为根的内向树,满足对任意起点 ,树上 的路径是原图 的一条最短路。
构造方法很简单,反图上以 为起点跑一遍 dijkstra
即可。
对于一条 的路径 ,记 ,即 不在最短路径树中出现的边构成的有序可重集合。
最短路径树的性质:
-
对于 ,记 ,则 的路径长度 。
-
对于 中相邻两条边 ,满足 起点是 终点的祖先。
这是因为从 走到 需要经过若干(可以为零)条树边。
-
固定起点 ,对于任意合法的 ,恰有唯一的 与之对应。
至此, 短路问题被转化为求前 小的合法 。
容易发现刻画 只需要两个信息:路径上最后一条边和 的值。
对每个节点 维护一个可并堆 ,存储起点为 的祖先(包含 )的所有非树边 。
还要维护一个小根堆 ,存储所有候选路径 。
先考虑一种暴力生成 的方式:
对于当前堆顶路径 ,记 最后一条边终点为 。
- 将 中所有边拼在 后面并加入 。
这个做法的时间复杂度为 。
但注意到上述生成方式和下面等价:
对于当前堆顶路径 ,记 最后一条边终点为 ,倒数第二条边终点为 。
- 在 中选择 最小的 ,将其拼接在 后面。
- 将 的最后一条边替换为 中排在它后面的第一条边。
代码实现中,对于第二种生成方式,记录 在 中的节点编号,将其左右儿子加入 即可。
注:对于排在最后一条边 后面的第一条边,它在可并堆 中的位置并不一定是在 的子树中。但是这种生成方式可以保证不重不漏地遍历 中的每条边,并且遍历到边 时所有权值比 小的边都已经被遍历过。
因此每条路径至多只会生成 条候选路径,时间复杂度 。
#include<bits/stdc++.h>
#define fi first
#define se second
#define mp make_pair
#define pii pair<double,int>
using namespace std;
const int maxn=4e5+5;
const double eps=1e-9,inf=1e9;
int m,n,u,v,res,tot;
double e,w,d[maxn];
int lc[maxn],rc[maxn],dis[maxn];
pii val[maxn];///pair<路径长度,可并堆中节点编号>
int fa[maxn],rt[maxn];///fa[u]为u在最短路径树上的父节点
bool vis[maxn];
vector<pii> g[maxn],h[maxn];
priority_queue<pii,vector<pii>,greater<pii>> q;
void dijkstra()
{///在反图上跑 dijkstra
for(int i=1;i<=n;i++) d[i]=inf;
d[n]=0,q.push(mp(d[n],n));
while(!q.empty())
{
int u=q.top().se;
q.pop();
if(vis[u]) continue;
vis[u]=true;
for(auto p:h[u])
{
int v=p.se;
if(d[v]>d[u]+p.fi) d[v]=d[u]+p.fi,fa[v]=u,q.push(mp(d[v],v));
}
}
}
int merge(int x,int y,int op)///建堆不需要可持久化,但合并需要
{
if(!x||!y) return x|y;
if(val[x]>val[y]) swap(x,y);
int p=!op?x:++tot;
val[p]=val[x],lc[p]=lc[x],rc[p]=merge(rc[x],y,op);
if(dis[lc[p]]<dis[rc[p]]) swap(lc[p],rc[p]);
return dis[p]=dis[rc[p]]+1,p;
}
int newnode(pii v)
{
dis[++tot]=1,val[tot]=v;
return tot;
}
int main()
{
scanf("%d%d%lf",&n,&m,&e);
for(int i=1;i<=m;i++)
{
scanf("%d%d%lf",&u,&v,&w);
if(u==n) continue;///题目要求n号点只允许经过一次,删掉以n为起点的边
g[u].push_back(mp(w,v)),h[v].push_back(mp(w,u));
}
dijkstra();
for(int i=1;i<=n;i++) if(vis[i]) q.push(mp(d[i],i));
while(!q.empty())
{///d[u]升序就是最短路径树的拓扑序
int u=q.top().se,flg=1;
q.pop();
for(auto p:g[u])
{
int v=p.se;
if(flg&&v==fa[u]&&fabs(d[v]+p.fi-d[u])<eps) flg=0;///过滤掉树边
else rt[u]=merge(rt[u],newnode(mp(d[v]+p.fi-d[u],v)),0);
}
rt[u]=merge(rt[u],rt[fa[u]],1);
}
e-=d[1],res++;///题目保证至少存在一条合法路径
q.push(mp(d[1]+val[rt[1]].fi,rt[1]));
while(!q.empty())
{
pii cur=q.top();
q.pop();
if(cur.fi-e>eps) break;
e-=cur.fi,res++;
int p=cur.se,u=val[p].se;///p为最后一条边在小根堆中编号,u为当前路径终点
if(lc[p]) q.push(mp(cur.fi+val[lc[p]].fi-val[p].fi,lc[p]));
if(rc[p]) q.push(mp(cur.fi+val[rc[p]].fi-val[p].fi,rc[p]));
if(rt[u]) q.push(mp(cur.fi+val[rt[u]].fi,rt[u]));
}
printf("%d\n",res);
return 0;
}
本文来自博客园,作者:peiwenjun,转载请注明原文链接:https://www.cnblogs.com/peiwenjun/p/17305736.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现