堆和优先队列的应用
一、STL里的优先队列
优先队列(priority_queue)是STL自带的堆,支持插入、查询最大值、删除最大值,也包括查询容器size等常规操作。整体常数不算特别大,是算法竞赛中需要用到堆时的不二之选。它的基本操作如下:(T是一个可以比大小的类型)
priority_queue<T> q 声明一个变量类型为T的大根堆。
priority_queue<T,vector<T>,greater<T> >q 声明一个变量类型为T的小根堆。
q.push(x) 向优先队列q中插入一个T类型的变量x。
q.pop() 删除优先队列q中最大(如果是小根堆的话就是最小)的变量。
q.top() 返回优先队列q中最大(如果是小根堆的话就是最小)的变量值。
q.size() 查询优先队列q中的元素个数。
q.empty() 查询优先队列q是否为空,为空则返回1。
注意:优先队列没有q.clear()的用法。
与手写堆不同,STL的堆不支持删除任意一个数字,所以需要用打标记的办法懒惰删除。
二、堆的简单例题
【例1 合并果子】
有N堆大小不同的果子,每次合并消耗的体力为两堆大小之和,求所有果子合为一堆消耗的最小体力。
分析:最入门级的贪心,每次选择最小的两堆果子进行合并就行。原理也很简单:如果在任何一步选择了不是最小的两堆果子合并,那么当前这一步的花费更大,而且之后每一步的花费都不会更小,所以选择最小的两堆合并是最优的做法。那么只需要开一个小根堆,每次取出头两个,合并成1个之后再插入。直至堆中元素只有一个时,累加的答案就是最小花费。
【例2 最小函数值】
有 n 个函数,分别为 F1,F2,…,Fn。定义 Fi(x)=Aix2+Bix+Ci(x∈N∗)。给定这些 Ai、Bi和Ci,请求出所有函数的所有函数值中最小的m个(如有重复的要输出多个)。1≤n,m≤10000,1≤Ai≤10,Bi≤100,Ci≤10^4。
分析:由于A、B、C的范围给定,可以求出这些开口朝上的二次函数对称轴都在y轴左边,所以当i是正整数时,这些函数都是单增的。暴力做法是找到每个函数前m个值,再从这n*m个数字里找最小的m。但是这样速度太慢而且很多不必要的数字会被整出来。所以同样要维护一个小根堆,起初装有每个函数当x=1时的值。这时候你每取出当前最小值之后,把这个最小值对应的函数找到:例如当前的最小是Fi(j),就pop掉当前最小,把Fi(j+1)插入堆中。这样能保证每一次取出的值都是最小的,而且第m次取出的值就是第m小的。
【例3 序列合并】
有两个长度都是n的序列A和B,在A和B中各取一个数相加可以得到N2个和,求这N2个和中最小的N个。
分析:实际上就是例2的翻版。首先要把A和B都排一遍序,然后拿A序列里的所有数字去加上B序列的第一个数字,得到的N个答案存进堆里。每次从堆里取出最小值时,如果是A序列的第i个数字和B序列的第j个数字之和,那么pop掉当前最小值后就插入A的第i个数字和B的第(j+1)个数字之和。原理和例2一样,这里不详细说明。
三、对顶堆和其他堆的应用
对顶堆是用来解决一些情况特殊的求第k大的数据结构,当然也包括求中位数。以求第k大数为例,具体操作需要一个大根堆,一个小根堆。大根堆中存第k+1大到最小的数字,小根堆中存第一大到第k大数字。每次加入新数字,与小根堆的top比较,如若比top大,将小根堆的根加入大根堆中,再将小根堆的根pop出来,将要加入的新数字放入小跟堆;如若比小根堆top小,直接加入大根堆。
当然仔细思考也可以发现,加入一个数时,无论大小如何,都可以先加入小根堆,再把小根堆的根pop出来加入大根堆。除此之外,如果k值发生+1-1这种改变,对顶堆也可以处理(以k+1为例):即改变两个堆的size,小根堆里本来是第1~k大的,现在变成第1~(k+1)大的;大根堆里本来是第k+1~n的,现在变成k+2~n。只需要把大根堆的堆顶pop出来加入小根堆就可以了。
综合起来,对顶堆的操作有以下4个:
插入一个数x:先把x加入小根堆,再把小根堆的根pop出来加入大根堆。
K增加1:把大根堆的堆顶pop出来加入小根堆。
K减少1:把小根堆的堆顶pop出来加入大根堆。
查询第k大数:输出小根堆堆顶即可。
对顶堆不能解决的问题也有很多,例如它不能支持任何删除操作;不能让k一次增加或减少很多;不能解决一般的区间问题。但它仍然是解决第k大问题最简单最方便的数据结构。
【例1 黑匣子】
Black Box是一种原始的数据库。它可以储存一个整数数组,还有一个特别的变量i。最开始的时候Black Box是空的.而i等于0。这个Black Box要处理一串命令。
命令只有两种:
ADD(x):把x元素放进BlackBox;
GET:i加1,然后输出BlackBox中第i小的数。
对于100%的数据,命令总数≤200000。
分析:昭然若揭的对顶堆板题。与之前的讲解唯一不同之处在于这是求第k小。所以两个对顶堆是,一个大根堆保存第1~k小的数,一个小根堆保存第k+1~n小的数。我们分别观看两种操作:
ADD(x):相当于之前的“插入一个数x”,所以解决办法是先把x加入大根堆,再把大根堆的根pop出来加入小根堆。
GET:第一步是“k增加1”,第二步是“查询第k小数”。所以把两个操作合起来就是把小根堆的堆顶pop出来加入大根堆,再输出大根堆堆顶。
许多“进阶选手”在写这种对顶堆问题时总会联想到平衡树Treap,Splay,甚至是主席树划分树这些求第k大的“专门数据结构”,反而不容易想到对顶堆。
【例2 种树】
在n个数中选出至多k个数,且两两不相邻,并使所选数的和最大。0<k<n<300000
分析:首先很容易想到一个nk级别的dp。f[i][j]表示种到第i棵树且种了j棵的最大获利,则f[i][j]=max(f[i-1][j],f[i-2][j-1]+a[i]),注意边界、初始化即可,但显然空间时间都过不去。
我们先思考贪心思路,有一种比较简单的贪心,即我们使用堆来维护最大值,找到最大值了以后便将最大值两边的值从堆中删除,然后再寻找。这明显是一种错误的解法,我们可以举例:2 4 3这样的三个数据,如果按照我们刚才的思路就会选择4而删掉2、3,但显然2+3更优。不过这为我们提供了一个新思路,即后悔机制。
考虑刚才的情况,拿2+3和拿4相当于两个决策,贪心法只考虑了第一次取的最优,所做出的决策不一定正确,所以要给他反悔的机会。怎么反悔呢?如果要从拿4这个决策反悔变成拿2+3这个决策,那就需要再拿一个1。推而广之,如果要从拿a[i]这个决策反悔变成拿a[i-1]+a[i+1]这个决策,就需要再拿一个a[i-1]+a[i+1]-a[i]。
根据刚刚的公式,我们每次选择时,只需要向堆中重新加入一个a[i-1]+a[i+1]-a[i],来保证我们的最终利润最大。这一个我们可以建立一个双向链表来完成。也就是说,每次取出a[i]时,把a[i]、a[i-1]、a[i+1]从链表里删除,再把a[i-1]+a[i+1]-a[i]插入原先这三位所在的位置。
综上,我们的思路是,用堆维护当前数列,每次取出最大值,并删除左右两边的值,然后再将左右两边的点的值减去当前最大值的答案放入堆中。通过反复的取出最大答案并累加统计出答案即可。最后当堆中所有数字都小于0时,或者取出的数字个数等于k时就结束整个过程输出答案。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】