堆是一种支持查询最值、删除最值的树形数据结构。

二叉堆

二叉堆是最简单、最常用的堆之一。

其形态是一棵完全二叉树。

实现

众所周知,二叉树有一种独特的表示节点编号的方法。

每个左子节点编号都是其父节点的 \(2\) 倍,每个右子节点编号都是其父节点的 \(2\)\(+1\),根节点编号为 \(1\)

因此,我们可以用一个数组存储这个完全二叉树,并利用上述特征取出每个父节点与子节点的数值。

由于完全二叉树的性质,二叉堆的所有节点在数组中都是连续的,因此空间复杂度为 \(O(n)\)

为了防止越界的情况,还需要保存堆内的节点个数。

int heap[N],n;

不妨保证根节点永远是二叉堆内部最大的数(即此堆为大根堆):

  • 当插入新节点时更新上面的节点。

    • 观察新节点的父节点(编号 \(\lfloor \frac{x}{2}\rfloor\)

    • 若父节点小于子节点,则交换父节点与子节点的值:

void Up(int p){
    while(p>1) {
        if(heap[p]>heap[p>>1]) {
        	swap(heap[p],heap[p>>1]);
            p>>=1;
        }
        else break;
    }
}
void Insert(int x) {
    heap[++n]=x;
    Up(n);
}
  • 当排出根节点时更新下面的节点。

    • 观察左子节点与右子节点,取其较大者,与父节点比较,若父节点较小,则交换两数(左右子节点取较大值与父节点交换后必为三者中最大值)。

    • 继续下调:

void Down(int p) {
    int s=p<<1;
    while(s<=n) {
        if(s<n&&heap[s]<heap[s+1])s++;
        if(heap[s]>heap[p]) {
            swap(heap[s],heap[p]);
            p=s;s<<=1;
        }
        else break;
    }
}
void Pop(){
    heap[1]=heap[n--];
    Down(1);
}

通过上述操作维护堆,便可以使得根节点永远是堆中最大值。

int GetTop() {
    return heap[1];
}

删除堆中任意节点。

像普通的删除根节点一样,将此节点换到最后一个位置即可。

注意:此时可能既需要下调,也可能上调,都需要执行一次:

void Remove(int k){
    heap[k]=heap[n--];
    Up(k);Down(k);
}

当然,万能的 \(STL\) 也为我们提供了一个与堆用法基本相同的优先队列:

priority_queue<int,vector<int>,less<int> >q;//大根堆
priority_queue<int,vector<int>,greater<int> >q;//小根堆
priority_queue<int>q;//默认大根堆

但是相较于手写堆来说,优先队列无法支持删除任意一个数的操作:

经典问题

例1.二叉堆

题意:给定一个数列,初始为空,请支持下面三种操作:

  1. 给定一个整数?\(x\) ,请将?\(x\)?加入到数列中。
  2. 输出数列中最小的数。
  3. 删除数列中最小的数(如果有多个数最小,只删除?\(1\)?个)。

此题是堆的板子,直接模拟即可,单次 \(1,3\) 操作复杂度 \(O(\log n)\)\(2\) 操作复杂度 \(O(1)\)

int heap[1000010],n,m;
void Up(int p){
    while(p>1){
        if(heap[p]>heap[p>>=1]){
            swap(heap[p],heap[p>>=1]);
            p>>=1;
        }
        else break;
    }
}
int GetTop(){
    return heap[1];
}
void Insert(int x){
    heap[++n]=x;
    Up(n);
}
void Down(int p){
    int s=p<<=1;
    while(s<=n){
        if(s<n&&heap[s]<heap[s+1]) s++;
        if(heap[s]>heap[p]){
            swap(heap[s],heap[p]);
            p=s;
            s<<=1;
        }
        else break;
    }
}
void Pop(){
    heap[1]=heap[n--];
    Down(1);
}
int main(){
    scanf("%d",&m);
    while(m--){
        int op,x;
        scanf("%d",&op);
        if(op==1){
            scanf("%d",&x);
            Insert(x);
        }
        if(op==2) printf("%d\n",GetTop());
        if(op==3) Pop();
    }
    return 0;
}

左偏树

上述二叉堆,实现简单,用途广泛,但仅能支持一些基本操作。

当我们要合并两个堆时,二叉堆要枚举其中一个堆的所有值并插入另一个堆,时间复杂度高达惊人的 \(O(n\log n)\)

由于二叉堆维护的信息过少,导致合并时间复杂度过高。

此时,我们有两种解决方案:

  • 启发式合并,合并时间复杂度均摊 \(O(\log^2 n)\)

  • 可并堆。

这里主要介绍第二种方案。

左偏树,众多可并堆中的一种,仍然基于二叉树结构,但却不是完全二叉树。

相反的,左偏树,顾名思义,具有左倾性质,即对于任意节点 \(x\),有:左子树大小 \(\ge\) 右子树大小。

约定:

  • \(lc_x\),节点 \(x\) 左儿子。

  • \(rc_x\),节点 \(x\) 右儿子。

  • \(v_x\),节点 \(x\) 权值。

定义:

  • 外节点:左儿子右儿子为空的节点。

  • 距离 :该节点与最近的外节点经过的边数。外节点的距离为 \(0\) ,空节点的距离为 \(-1\) ,空节点指不存在的节点。

左偏树对每个节点维护一个 \(dis\) 值,表示该节点的距离。

通过维护上述性质,左偏树合并两个堆的复杂度可做到优秀的 \(O(\log n+\log m)\),其中 \(n,m\) 分别是两个被合并堆的大小。

性质:对于任意节点 \(x\) 有:

  • \(v_x\ge \max(v_{lc_x},v_{rc_x})\) (这里是大根堆,若是小根堆则交换大于号两边式子)。

  • \(dis_{lc_x}\ge dis_{rc_x}\)(左偏性质)。

  • \(dis_x=dis_{rc_x}+1\)

对于根节点,有 \(dis_{根}\le \log(n+1)-1\)

注意:\(dis\) 并不代表整棵树最大深度,左偏树最大深度为 \(O(n)\)

实现

合并

左偏树最重要的操作是 合并 ,即将两棵左偏树合并成一棵,基本上是左偏树以及大部分可并堆的灵魂所在。

合并流程大致如下:

  • 维护堆的性质,选取值较小的根作为合并后的堆顶,然后递归合并其右儿子与另一个堆,作为合并后的堆的右儿子。

  • 维护左偏性质,合并后若左儿子的 \(dis<\) 右儿子的 \(dis\) ,就交换两个儿子,并更新根的 \(dis\)

int Merge(int x,int y){
    if(!x||!y) return x+y;//其中一堆为空
    if(v[x]<v[y]) swap(x,y);//选择较大值作为堆顶
    rc[x]=Merge(rc[x],y);//递归合并右儿子
    if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);//交换两个儿子以维护左偏性质
    dis[x]=dis[rc[x]]+1;//更新当前根节点dis值
    return x;//返回根节点编号
}

可以证明,由于右半部分节点深度最大为 \(O(\log n)\),所以单次合并时间复杂度为 \(O(\log n)\)

同时,由于我们在回溯时维护了左偏性质,故合并后的树仍然为左偏树。

插入:

将新插入节点当做单独的一个堆,合并即可。

删除:

将被删除节点的左、右儿子分别合并即可。

查询最值:

直接返回堆顶即可。

应用

例1.罗马游戏

显然是可并堆。

但是,由于左偏树深度最大 \(O(n)\)。暴力从 \(x\) 节点向上跳找根显然不是什么明智的选择。

可以使用并查集优化上述找根的过程。

const int N=1e6+10;
int a[N],n,q,fa[N],dis[N],ls[N],rs[N];
int Get(int x) {
	return x==fa[x]?x:fa[x]=Get(fa[x]);
}
int Merge(int x,int y) {
	if(!x||!y) return x+y;
	if(a[x]>a[y]) swap(x,y);
	rs[x]=Merge(rs[x],y);
	if(dis[ls[x]]<dis[rs[x]]) swap(ls[x],rs[x]);
	dis[x]=dis[rs[x]]+1;
	return x;
}
main() {
	cin>>n;dis[0]=-1;
	FOR(i,1,n) a[i]=read(),fa[i]=i;
	cin>>q;
	while(q--) {
		char c[2];scanf("%s",c);
		int x,y;
		if(c[0]=='M') {
			x=read(),y=read();
			if(!~a[x]||!~a[y]) continue;
			int fx=Get(x),fy=Get(y);
			if(fx==fy) continue;
			fa[fx]=fa[fy]=Merge(fx,fy);
		} else {
			x=read();
			if(!~a[x]) {
				puts("0");
				continue;
			}
			int fx=Get(x);
			write(a[fx]),putchar('\n');
			a[fx]=-1;
			fa[fx]=fa[ls[fx]]=fa[rs[fx]]=Merge(ls[fx],rs[fx]);
            //注意这里,如果不更新 fa[fx] 的值,由于并查集有路径压缩,所以一些点的父亲仍然是 fx,所以要让 fx 指向新根。
		}
	}
	return 0;
}

整体修改堆中权值

有时候我们需要对一个堆中的所有数字整体 \(+d,-d,\times d\dots\)

此时暴力地修改显然不是上策。

回想线段树区间修改的过程,我们使用懒标记,只有在查询用到该位置时才更新,利用懒标记快速维护区间信息,并在修改后将标记下传给儿子。

同样的,我们也可以在左偏树上运用懒标记思想,只有在查询时维护信息。

由于左偏树保存了左右儿子,所以下传标记是容易实现的。

//这里以全局加为例。
void push_down(int p) {
	if(!add[p]) return ;
	if(ls[p]) {
		a[ls[p]]+=add[p];
		add[ls[p]]+=add[p];
	}
	if(rs[p]) {
		a[rs[p]]+=add[p];
		add[rs[p]]+=add[p];
	}
	add[p]=0;
}
int Merge(int x,int y) {
	if(!x||!y) return x+y;
	if(!~x||!~y) return x+y+1;
	push_down(x),push_down(y);
	if(a[x]>a[y]) swap(x,y);
	rs[x]=Merge(rs[x],y);
	if(dis[ls[x]]<dis[rs[x]]) swap(ls[x],rs[x]);
	dis[x]=dis[rs[x]]+1;
	return x;
}

例2.P3261 [JLOI2015] 城池攻占

一个士兵攻占的城池事实上就是出生点的深度-死掉位置的深度(如果没死掉则减 \(0\))。

我们可以在树上进行 dfs,同时对每个节点维护一棵左偏树,用来统计到达该点的士兵。

可以通过不断弹出最小值的操作将死在该点的士兵清除并统计答案,所以要用小根堆。

这样就是一个支持整堆加、整堆乘的操作了。

我们可以内定运算优先级:先加再乘。这样就比较好维护了。

const int N=3e5+10;
int n,m,h[N],a[N],c[N],v[N],dep[N],die[N],ans[N],dis[N],ls[N],rs[N],rt[N],vis[N];
int add[N],mul[N];
vector<int>e[N];
void push_down(int p) {
	if(mul[p]==1&&!add[p]) return ;
	if(ls[p]) {
		a[ls[p]]*=mul[p];
		a[ls[p]]+=add[p];
		mul[ls[p]]*=mul[p];
		add[ls[p]]*=mul[p];
		add[ls[p]]+=add[p];
	}
	if(rs[p]) {
		a[rs[p]]*=mul[p];
		a[rs[p]]+=add[p];
		mul[rs[p]]*=mul[p];
		add[rs[p]]*=mul[p];
		add[rs[p]]+=add[p];
	}
	mul[p]=1,add[p]=0;
}
int Merge(int x,int y) {
	if(!x||!y) return x+y;
	if(!~x||!~y) return x+y+1;
	push_down(x),push_down(y);
	if(a[x]>a[y]) swap(x,y);
	rs[x]=Merge(rs[x],y);
	if(dis[ls[x]]<dis[rs[x]]) swap(ls[x],rs[x]);
	dis[x]=dis[rs[x]]+1;
	return x;
}
void dfs(int x,int fa) {
	dep[x]=dep[fa]+1;
	for(int y:e[x]) {
		dfs(y,x);
		if(!~rt[x]) rt[x]=rt[y];
		else rt[x]=Merge(rt[x],rt[y]);
	}
	while(~rt[x]&&a[rt[x]]<h[x]) {
		die[rt[x]]=x;
		push_down(rt[x]);
		if(!ls[rt[x]]) rt[x]=-1;
		else rt[x]=Merge(ls[rt[x]],rs[rt[x]]);
	}
	if(!~rt[x]) return ;
	if(x==1) return ;
	if(vis[x]) mul[rt[x]]*=v[x],add[rt[x]]*=v[x],a[rt[x]]*=v[x];
	else a[rt[x]]+=v[x],add[rt[x]]+=v[x];
	push_down(rt[x]);
	return ;
}
main() {
	cin>>n>>m;dis[0]=-1;
	fill(mul+1,mul+m+1,1);
	FOR(i,1,n) h[i]=read();
	rt[1]=-1;
	FOR(i,2,n) {
		int fa=read();vis[i]=read(),v[i]=read();
		e[fa].pb(i);rt[i]=-1;
	}
	FOR(i,1,m) {
		a[i]=read();c[i]=read();
		if(!~rt[c[i]]) rt[c[i]]=i;
		else rt[c[i]]=Merge(rt[c[i]],i);
	}
	dfs(1,0);
	FOR(i,1,m) ans[die[i]]++;
	FOR(i,1,n) cout<<ans[i]<<"\n";
	FOR(i,1,m) cout<<dep[c[i]]-dep[die[i]]<<"\n";
	return 0;
}

例3. [APIO2012] 派遣

原问题等价于:记一个节点的价值为从该节点子树中选出使得 \(b_i\) 之和不超过 \(m\) 的最大节点数量乘以该节点 \(c_i\),求 \(n\) 个节点中的最大价值。

有一个显然的贪心:一定是从小往大选,证明显然。

所以我们对每个节点维护一个左偏树(大根堆),并从叶子开始自底而上合并,若堆中节点 \(b_i\) 之和大于 \(m\) 则不断弹出堆顶。更新答案。

复杂度 \(O(n\log n)\)

const int N=1e5+10;
int n,m,rt[N];
int ls[N],rs[N],dis[N],siz[N];
LL ans,sum[N];
struct Node {
	int ld,cst;
}a[N];
int Merge(int l,int r) {
	if(!l||!r) return l+r;
	if(a[l].cst<a[r].cst) swap(l,r);
	rs[l]=Merge(rs[l],r);
	if(dis[ls[l]]<dis[rs[l]]) swap(ls[l],rs[l]);
	dis[l]=dis[rs[l]]+1;
	return l;
}
vector<int>e[N];
void dfs(int x) {
	for(int y:e[x]) {
		dfs(y);
		sum[x]+=sum[y];siz[x]+=siz[y];
		rt[x]=Merge(rt[x],rt[y]);
	}
	while(sum[x]>m) {
		int nw=rt[x];sum[x]-=a[nw].cst;
		rt[x]=Merge(ls[rt[x]],rs[rt[x]]);
		siz[x]--;
	}
	cmax(ans,(LL)siz[x]*a[x].ld);
}
main() {
	cin>>n>>m;dis[0]=-1;
	int root=0;
	FOR(i,1,n) {
		int x=read();a[i].cst=read();a[i].ld=read();
		if(x) e[x].eb(i);
		else root=i;
		sum[i]=a[i].cst;rt[i]=i;siz[i]=1;
	}
	dfs(root);
	cout<<ans<<"\n";
	return 0;
}
posted @ 2025-08-09 11:39  cannotmath  阅读(7)  评论(0)    收藏  举报