潜龙未见静水流,沉默深藏待时秋。一朝破空声势振,惊世骇俗展雄猷。
随笔 - 82, 文章 - 0, 评论 - 3, 阅读 - 2161

左偏树学习笔记

目录

一、左偏树简介
基本性质
合并操作
询问操作
插入操作
删除操作
可持久化
二、相关例题
例1、P3377 【模板】左偏树(可并堆)
例2、P1552 [APIO2012] 派遣
例3、P3261 [JLOI2015]城池攻占
例4、P4331 [BalticOI 2004]Sequence 数字序列
例5、P2483 【模板】k 短路 / [SDOI2010] 魔法猪学院

参考资料

一、左偏树简介

基本性质

左偏树可以在严格 O(logn) 的时间内合并两个堆,其他操作和普通的堆没有差别。

这也是左偏树的别名——可并堆的由来。

和普通的二叉堆类似,左偏树是一棵二叉树,每个节点需要维护 disval 两个值。

其中 val 不必多说,就是二叉堆维护的内容。

dis 表示往子树内走,走到空节点的最少步数。

接下来就可以给出左偏树的定义了,以小根堆为例:

  • 堆性质:每个节点 p 的 val 比左右儿子都小。
  • 左偏性质:每个节点 p 左儿子的 dis 一定大于等于右儿子。

根据定义可以得出一个直接推论:dis[p]=dis[rc[p]]+1

结论:对于一个点数为 n 的堆,disrtlog(n+1)1

证明:假设 disrt=k ,那么至少有一棵 k 层的满二叉树,即 n2k+11 ,证毕。


先讲一下左偏树的常见操作,假设我们维护的是小根堆。

合并操作

定义 merge(x,y) 为合并两棵左偏树的操作,返回合并后的根节点。

不妨 valxvaly ,否则可以交换 xy

保留 x 为根节点,将 yrcx 继续合并。

回溯时为了保证左偏树的左偏性质,如果 dislcx<disrcx ,我们需要交换 lcxrcx

时间复杂度 O(disx+disy)=O(logn)

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;
}

询问操作

堆能支持的询问操作也只有询问最小值了,返回堆顶即可,时间复杂度O(1)

int top(int p)
{///询问以p为堆顶的左偏树中的最小权值
    return val[p];
}

插入操作

新建一个节点 x 与左偏树合并,时间复杂度 O(logn)

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));
}

删除操作

p 的左右子树合并即可,时间复杂度 O(logn)

void pop(int &p)
{//将以p为根的左偏树的堆顶删除
    p=merge(lc[p],rc[p]);
}

可持久化

类比可持久化线段树合并,在合并过程中新建节点即可,时间复杂度 O(logn)

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、P3377 【模板】左偏树(可并堆)

题目描述

给定 n 个小根堆,初始第 i 个堆中仅有元素 ai ,接下来 m 次操作:

  • 1 x y :合并第 x 个数和第 y 个数所在堆。

  • 2 x :查询第 x 个数所在堆的最小元素,并将最小元素删除。

    如果有多个最小元素,删除编号较小的一个。

    如果第 x 个数已经被删除,输出 -1

数据范围

  • 1n,m105,1ai<231
  • 1x,yn

时间限制 1s ,空间限制 128MB

分析

基本操作上面都讲过了,不过本题还需要支持查询一个数所在堆的堆顶。

记录 fa[p] 表示 p 的父节点,特别地,对于堆顶有 fa[p]=p

注意左偏树的树高不一定logn (这也是和普通的二叉堆最大的区别),因此如果直接暴力跳 fa,单次时间复杂度可以退化到 O(n)

并查集优化即可。注意删除时需要将被删节点的 fa 指向新的根节点,从而保证子树内的所有点能访问到正确的 fa

时间复杂度 O((n+m)logn)

#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、P1552 [APIO2012] 派遣

题目描述

给定一棵 n 个节点的有根树,第 i 个节点的父节点为 fi ,权值为 (ci,li)

你需要选择一个点 u ,再从 u 子树(包括 u 自身)中选择一个点集 S ,要求 vScvm

lu|S| 的最大值。

数据范围

  • 1n105,1m109
  • 1fi<i,1cim,1li109

时间限制 500ms ,空间限制 125MB

分析

枚举 u ,显然只会选择子树内 cv 最小的若干个点。

大根堆维护 cv ,如果堆中权值和超过 m ,弹出堆顶即可。

需要额外维护堆中元素个数、元素和,时间复杂度 O(nlogn)

#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、P3261 [JLOI2015]城池攻占

题目描述

给定一棵 n 个节点的树, 1 号点为根,第 i 个点的父节点为 fi ,防御值为 hi

m 个骑士,第 i 个骑士初始在 ci 号点,战斗力为 si

  • 如果 sihj ,那么这个骑士可以占领 cj 号点。

    根据当前节点的参数 aj=0/1si 会加上或乘上 vj

    如果 j1 ,接下来骑士会继续尝试占领其父节点fj

  • 如果 si<hj ,骑士会在第 j 个节点牺牲。

对每个节点,输出有多少个骑士会在这里牺牲。

对每个骑士,输出他能占领的节点数。

数据范围

  • 1n,m3105,1018hi,vi,si1018
  • 1fi<i,1cin
  • ai{0,1} ,保证当 ai=1 时, vi>0 ,且任意时刻任意骑士的战斗力绝对值 1018

时间限制 1s ,空间限制 256MB

分析

每个节点用可并堆维护骑士集合,先合并所有子树信息,再 pop 掉所有 si<hj 的骑士即可。

维护乘法和加法标记的方法同线段树,进行 push/pop/merge 等操作前需 pushdown

时间复杂度 O((n+m)logn)

#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、P4331 [BalticOI 2004]Sequence 数字序列

本题有更简单的slope trick做法。

题目描述

给定整数序列 a1,,an ,求一个严格递增的整数序列 b ,要求最小化 i=1n|aibi| ,构造方案。

数据范围

  • 1n106,0ai2109

时间限制 1s ,空间限制 125MB

分析

aiaii ,将严格递增转化为非严格递增。

观察发现有两个很显然的性质:

  • 如果 ai (非严格)单调递增,显然 bi=ai
  • 如果 ai (非严格)单调递减,显然 bi 全部相等,进一步容易证明 bia 的中位数最优。

一个很简单的想法是将原序列 ai 划分为若干个单调递减的连续段,每一段取中位数。

但这并不足以保证 bi 单调递增,如果相邻两段前面中位数比后面大就会出问题。

依次加入 a1,,an ,用栈维护当前所有连续段。

如果出现上面这种情况,我们需要将两个连续段合并以后重新求中位数。

用大根堆维护区间内较小的 sz2 个元素,左偏树支持合并即可。

注意到 x2+y2x+y2 ,并且当且仅当 x,y 均为奇数时等号不成立,因此我们只需在两个大根堆 sz 均为奇数时 pop 掉堆顶。

Q:为何新的中位数一定在这两个大根堆的并集中,而不会出现 mid({4,5},{1,2,3})=3 ,但 3 已经被删除的情况?

A:记原本的连续段从右往左依次为 x1,x2, ,那么 mid(x1)mid(xi) 。如果加入某个元素 a 后需要合并 xix1xi1a ,显然 amid(xi) ,因此新的中位数要么为 a ,要么在某个 xj 的大根堆中,一定没有被删除。

时间复杂度 O(nlogn)

#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、P2483 【模板】k 短路 / [SDOI2010] 魔法猪学院

参考资料

题目描述

给定一张 n 个点, m 条边的有向图,边有边权 wi

要求选择若干条不相同 1n 的路径,使得边权总和不超过 e ,求选择的路径数最大值。

对于每条路径, n 号点仅允许经过一次,其余点、边允许经过多次。

两条路径不同,当且仅当存在一条边被经过的次数不同,或经过所有边的顺序不同。

数据范围

  • 2n5103,1m2105
  • 1wie107,wi,eR

图中可能存在重边和自环,保证至少存在一条合法路径。

时间限制 1s ,空间限制 128MB

分析

A-star 算法的时间复杂度为 O(nklogn) ,构造一个 n1 元环加上一条指向 n 的边即可卡满。

本题等价于求 k 短路的边权,因为每条路径长度 1 ,所以 k107

因为 n 号点仅允许经过一次,所以需要把以 n 为起点的边过滤掉。


接下来进入正题,先引入最短路径树的概念。

记终点为 t ,最短路径树为一棵以 t 为根的内向树,满足对任意起点 s ,树上 st 的路径是原图 st一条最短路。

构造方法很简单,反图上以 t 为起点跑一遍 dijkstra 即可。


对于一条 st 的路径 P ,记 P=P/T ,即 P 不在最短路径树中出现的边构成的有序可重集合。

最短路径树的性质:

  • 对于 eT ,记 Δe=disv+wdisu ,则 P 的路径长度 Lp=diss+ePΔe

  • 对于 P 中相邻两条边 e1,e2 ,满足 e2 起点是 e1 终点的祖先。

    这是因为从 e1 走到 e2 需要经过若干(可以为零)条树边。

  • 固定起点 s ,对于任意合法的 P ,恰有唯一的 P 与之对应。

至此, k 短路问题被转化为求前 k 小的合法 P


容易发现刻画 P 只需要两个信息:路径上最后一条边LP 的值

对每个节点 u 维护一个可并堆 gu ,存储起点为 u 的祖先(包含 u )的所有非树边 e

还要维护一个小根堆 q ,存储所有候选路径 P

先考虑一种暴力生成 P 的方式:

对于当前堆顶路径 P ,记 P 最后一条边终点为 v

  • gv 中所有边拼在 P 后面并加入 q

这个做法的时间复杂度为 O(kmlogn)

但注意到上述生成方式和下面等价:

对于当前堆顶路径 P ,记 P 最后一条边终点为 v ,倒数第二条边终点为 u

  • gv 中选择 Δe 最小的 e ,将其拼接在 P 后面。
  • P 的最后一条边替换为 gu 中排在它后面的第一条边。

代码实现中,对于第二种生成方式,记录 egu 中的节点编号,将其左右儿子加入 q 即可。

注:对于排在最后一条边 e 后面的第一条边,它在可并堆 gu 中的位置并不一定是在 e 的子树中。但是这种生成方式可以保证不重不漏地遍历 gu 中的每条边,并且遍历到边 e 时所有权值比 e 小的边都已经被遍历过。

因此每条路径至多只会生成 3 条候选路径,时间复杂度 O((n+m)logn+klogk)

#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;
}

posted on   peiwenjun  阅读(4)  评论(0编辑  收藏  举报

相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现

导航

< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5
点击右上角即可分享
微信分享提示