文章目录
笔者在刷题的过程中,经常碰到优先队列相关的题目,因此本文对其知识体系进行汇总,供大家参考学习。
本文参考:
基础知识
优先队列是一种抽象的数据结构,不同于普通的队列FIFO,其优先级最高的元素最先出队。堆是实现优先队列的一种高效的数据结构,因此网上常常将「堆」和「优先队列」等价。堆有两个性质:
- 堆是一棵完全二叉树
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
因此就可以将其分为两类,对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做「大顶堆」。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做「小顶堆」。
堆帮我们省去了手动维护队列有序性的工作,常用于求最值,或者在que.size()==K
用于求固定数量的最值问题。
Java中和优先队列有关的常用api如下:
//优先队列声明
//默认升序排列,小顶堆
PriorityQueue<Integer> pq = new PriorityQueue<>();
//自定义降序,大顶堆
PriorityQueue<Integer> pq = new PriorityQueue<>((a,b)->b-a);
//返回队首元素
pq.peek();
//返回队首元素,队首元素出队列
pq.poll();
//添加元素
pq.add();
pq.offer();
//返回队列元素个数
pq.size();
//判断队列是否为空,为空返回true,不空返回false
pq.isEmpty();
时间复杂度:插入和删除都是O(logK)
。
更优的改进是建立「索引堆」。当我们的原数据可以通过索引index
访问的话,我们可以在队列中,比较的是 data
的数据,交换的是 index
的位置。既能避免将原数据(比如字符串)直接放入堆的性能消耗,又可以通过索引非常高效地索引到原数据。比如下面的题857.
原理实现
听说有些大厂的面试官会让自己手写建堆,因此有必要了解一些建堆的知识。这块知识需要后面补上。
相关题目
心得体会:
- 需要弄清楚入堆的处理和建立什么堆,如何利用堆顶的信息。
857.雇佣K名工人的最低成本
最大索引堆+时薪升序入堆。为了在K个人中,既能按比例支付,同时比例支付的工资又不能低于期望工资,我们算出每个人单位quality的wage(时薪r)
,在K个人中选择时薪最大的maxr
,就也能满足其他人的需求。以上K个人的总薪资为sumQ*maxr
,为了能让总薪资最低,我们让maxr
尽可能小,同时要让总时间sumQ
也尽可能小。从小到达枚举r,当前的时薪就是最大时薪,用最大堆维护的是时间Quality,当堆中有k个元素时,如果当前Quality比堆顶小,则可以弹出堆顶,将Quality入堆,产生一个更小的sumQ
,这有可能「折衷」产生一个更小的总薪资的结果。
class Solution {
public double mincostToHireWorkers(int[] quality, int[] wage, int k) {
int len=quality.length;
//建立索引
Integer[]idx=new Integer[len];
for(int i=0;i<len;i++){
idx[i]=i;
}
//将时薪从小到大排序
Arrays.sort(idx,(i,j)->wage[i]*quality[j]-wage[j]*quality[i]);
//建立有k个元素的大根堆,维护quality
PriorityQueue<Integer> pq=new PriorityQueue<>((a,b)->b-a);
int sum=0;
for(int i=0;i<k;i++){
pq.offer(quality[idx[i]]);
sum+=quality[idx[i]];
}
double ans= (double)wage[idx[k-1]]/quality[idx[k-1]]*sum;
for(int i=k;i<len;i++){
if(quality[idx[i]]<pq.peek()){
int outq=pq.poll();
pq.offer(quality[idx[i]]);
sum=sum-outq+quality[idx[i]];
}
double temp= (double)wage[idx[i]]/quality[idx[i]]*sum;
ans=Math.min(ans,temp);
}
return ans;
}
}
6178.将区间分为最少组数
最小堆+左边界升序入堆+贪心。看灵神的题解说是用到堆,没想到这也可以,一步步分析来。首先看到答案/划分与输入顺序无关,应该可以想到「排序」,给我们一些利于解题的性质。然后就是如何贪心地将区间进行组合,尤其是可能会存在多组的情况,可以看到在划分过程中,如果某个划分的组中区间出现重叠,那我们就应该新开一个划分组,但是在没有出现重叠的情况下,我们尽可能将当前数据放到之前划分的右边界最小的组中,并且更新其右边界。这种找最小同时更新最小再放入下次比较,我们可以采用堆实现,如果当前元素左边界比最小还小的话,只能入堆,相当于新开了一组,最终堆中的元素就是总组数。
class Solution {
public int minGroups(int[][] intervals) {
Arrays.sort(intervals,(a,b)->(a[0]-b[0]));
PriorityQueue<Integer> pq=new PriorityQueue<>();
for(int[]i:intervals){
if(!pq.isEmpty()&&i[0]>pq.peek()){
pq.poll();
pq.offer(i[1]);
}else{
pq.offer(i[1]);
}
}
return pq.size();
}
}
双堆模拟问题
通常是放在服务器调度等题目上,需要根据各自的规则建立合适的优先队列比较规则。
2402.会议室III
双堆模拟。“当会议室处于未占用状态时,将会优先提供给原开始时间更早的会议。”从这句话就可以知道,会议的安排只和会议的开始时间顺序相关,因此可以按照开始时间对会议升序处理。用一个小顶堆维护空闲会议室编号,这是为了每次能够取用最小编号的空闲会议室;用另一个小顶堆维护(已占用会议室的结束时间,已占用编号),在遍历所有会议的时候,每次将遍历的meetings[0]前结束的会议弹出,放入空闲会议室。接下来在判断当前会议加入哪个会议室的时候,如果有空会议室,则占用;没有可用的会议室,则等待,编程上反映就是弹出已占有会议室中结束时间最早的会议室(但仍然比当前meetings[0]要大),占用它并更新已占用会议室的结束时间的差值(相当于当前会议室延后了我等待的时间),将其加入占用会议室堆。
时间复杂度:O(MlogM+MlogN+N)
。分别为会议排序时间,遍历会议调整堆中会议室,最后选出答案。
class Solution {
public int mostBooked(int n, int[][] meetings) {
Arrays.sort(meetings,(a,b)->a[0]-b[0]);
int[]ans=new int[n];
PriorityQueue<Integer> pqidle=new PriorityQueue<>();
//存放(会议室结束时间,编号),优先选择结束时间最早的会议室,当结束时间相同,优先弹出编号小的,后面if(pqidle.size()==0) poll需要注意
PriorityQueue<Pair<Long,Integer>> pqusing=new PriorityQueue<Pair<Long,Integer>>((a,b)->Objects.equals(a.getKey(),b.getKey())?Integer.compare(a.getValue(),b.getValue()):Long.compare(a.getKey(),b.getKey()));
for(int i=0;i<n;i++) pqidle.offer(i);
int len=meetings.length;
for(int i=0;i<len;i++){
long start=meetings[i][0];
long end=meetings[i][1];
//将start以前结束的会议室放出
while(!pqusing.isEmpty()&&start>=pqusing.peek().getKey()){
//这里只是在满足时间的情况下弹出,然后放入的时候需要满足pq两个条件构造优先队列方便后面的pqusing.poll()操作
Pair<Long,Integer> top=pqusing.poll();
pqidle.offer(top.getValue());
}
int id;
//如果没有空闲会议室
if(pqidle.size()==0){
//前面pq条件两个写好就是在poll的时候,结束时间相同,弹出编号最小的会议室
//等待top.getKey()-start的时间空出来并占用
Pair<Long,Integer>top=pqusing.poll();
end+=top.getKey()-start;
id=top.getValue();
pqusing.offer(new Pair<Long,Integer>(end,id));
}else{
int idletop=pqidle.poll();
id=idletop;
pqusing.offer(new Pair<Long,Integer>(end,idletop));
}
ans[id]+=1;
}
int res=0;
for(int i=0;i<ans.length;i++){
if(ans[i]>ans[res]) res=i;
}
return res;
}
}
1606.找到处理最多请求的服务器
双堆模拟。一个最小堆用于维护当前可用服务器编号,一个最小堆用于维护当前正在使用的服务器的结束时间及编号。在维护使用服务器的时候,和2402.会议室III不一样的是PriorityQueue排序条件的书写,这里在有空闲服务器的时候才让出,不然就丢弃,只和空闲服务器的编号有关;而上一题不仅需要等待时间差值,还和会议室编号有关,因此会在时间相同的时候,优先让出编号最小的会议室。
class Solution {
public List<Integer> busiestServers(int k, int[] arrival, int[] load) {
PriorityQueue<Integer> idle= new PriorityQueue<>((a,b)->a-b);
//这里只需要满足每次把时间最小的放前面就行了,后面没有额外的poll()
PriorityQueue<Pair<Long,Integer>> used= new PriorityQueue<>((a,b)->Long.compare(a.getKey(),b.getKey()));
int len=arrival.length;
int[]ans=new int[k];
for(int i=0;i<k;i++){
idle.offer(i);
}
for(int i=0;i<len;i++){
long arrivaltime=arrival[i],loadtemp=load[i];
while(!used.isEmpty()&&arrivaltime>=used.peek().getKey()){
Pair<Long,Integer> top=used.poll();
//考虑到编号从i%k开始往后考虑,因此放入的时候需要考虑从i开始向后进行合理偏移,也能够让小顶堆很好地维护
//如果是负数或者正数都是向后进行偏移,方便从i%k开始考虑,取余是避免了负数太大,+k没法变正,最后无法偏移的情况
int index=i+((top.getValue()-i)%k+k)%k;
idle.offer(index);
}
if(!idle.isEmpty()){
int top=idle.poll()%k;
ans[top]++;
used.offer(new Pair<Long,Integer>(arrivaltime+loadtemp,top));
}
}
//一次遍历找到所有最大值序号
ArrayList<Integer> res=new ArrayList<>();
int m=0;
for(int i=0;i<k;i++){
if(ans[i]>m){
res.clear();
res.add(i);
m=ans[i];
}else if(ans[i]==m){
res.add(i);
}
}
return res;
}
}
1882.使用服务器处理任务
双堆模拟,和2402.会议室III很像,同样需要等待,但这一题是在有空闲服务器的时候,优先选择权重最小然后才是标号最小,因此需要多存储一个权重的信息。
class Solution {
public int[] assignTasks(int[] servers, int[] tasks) {
//idle有[权重,服务器标号],先按照权重小的来,然后是下标小的
PriorityQueue<long[]> idleServer=new PriorityQueue<>((a,b)->a[0]==b[0]?Long.compare(a[1],b[1]):Long.compare(a[0],b[0]));
//used有[权重,服务器标号,服务器结束时间],先按照结束时间早空闲的来,在结束时间相同时优先选择权重最小,下标最小的
PriorityQueue<long[]> used=new PriorityQueue<>((a,b)->a[2]==b[2]?(a[0]==b[0]?Long.compare(a[1],b[1]):Long.compare(a[0],b[0])):Long.compare(a[2],b[2]));
int n=servers.length;
int m=tasks.length;
int[]ans=new int[m];
long curtime=-1;
for(int i=0;i<n;i++){
idleServer.offer(new long[]{servers[i],i});
}
for(int j=0;j<m;j++){
while(!used.isEmpty()&&j>=used.peek()[2]){
long[] usedtop=used.poll();
idleServer.offer(new long[]{usedtop[0],usedtop[1]});
}
if(idleServer.size()==0){
long[] usedtop=used.poll();
long endtime=usedtop[2]+tasks[j];
ans[j]=(int)usedtop[1];
used.offer(new long[]{usedtop[0],usedtop[1],endtime});
}else{
long[] idletop=idleServer.poll();
long endtime=j+tasks[j];
ans[j]=(int)idletop[1];
used.offer(new long[]{idletop[0],idletop[1],endtime});
}
}
return ans;
}
}
Top K问题
最小的K个数
第一直觉是建立小根堆,这里学习建立大根堆的做法,维护一个大小为K的最大堆, 遍历N个元素,通过和堆头的最大值比较,若碰到比堆头小的数,则堆头必不可能属于最后找到的那K个最小值,将头弹出,当前元素入堆进行平衡性调整,时间复杂度为O(NlogK)
。
相比于建立小根堆,若要弹出堆头的K个最小元素,需要遍历N个元素,同时维护一个大小为N的堆,这是因为你无法通过当前的最小值去判断它是否为最后的K个的最小值,比如原序列有大于K个元素是从大到小排列的,插入前K个元素显然不是最终答案,维护整个堆的时间复杂度为O(NlogN)
。
class Solution {
//建立大根堆,时间复杂度为O(NlogK)
public int[] smallestK(int[] arr, int k) {
if(k==0) return new int[0];
PriorityQueue<Integer> pq=new PriorityQueue<>((a,b)->b-a);
for(int i:arr){
//堆达到k了以后,将有可能会是最小值的元素放入堆中
if(pq.size()==k&&i<pq.peek()){
pq.poll();
}
// 比当前堆顶还大就不可能了
if(pq.size()==k&&i>=pq.peek()){
continue;
}
// 在还没达到k的时候就直接加入
pq.offer(i);
}
int[]ans=new int[k];
for(int i=0;i<k;i++){
ans[i]=pq.poll();
}
return ans;
}
}
59.数据流的第K大数值
维护一个K大小的小顶堆,在往里面add的时候不断进行检查维护,最后堆顶元素就是答案。
class KthLargest {
PriorityQueue<Integer> q=new PriorityQueue<>();
int k;
public KthLargest(int _k, int[] nums) {
k=_k;
for(int i:nums){
q.offer(i);
}
}
public int add(int val) {
q.offer(val);
// 维护一个K大小的小顶堆
while(q.size()>k){
q.poll();
}
// 堆顶元素就是第K大元素
return q.peek();
}
}
/**
* Your KthLargest object will be instantiated and called as such:
* KthLargest obj = new KthLargest(k, nums);
* int param_1 = obj.add(val);
*/
60.出现频率最高的k个数字
哈希表+最小堆。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 哈希表存频率
HashMap<Integer,Integer> mp=new HashMap<>();
for(int i:nums){
mp.put(i,mp.getOrDefault(i,0)+1);
}
// 最小堆存频率最高的K个数
PriorityQueue<int[]> pq=new PriorityQueue<>((a,b)->(a[1]-b[1]));
for(Map.Entry<Integer,Integer> m:mp.entrySet()){
if(pq.size()==k){
// 唯一数字,> = 也没关系
if(m.getValue()>pq.peek()[1]){
pq.poll();
pq.offer(new int[]{m.getKey(),m.getValue()});
}
}else{
pq.offer(new int[]{m.getKey(),m.getValue()});
}
}
int[]ans=new int[k];
for(int i=0;i<k;++i){
ans[i]=pq.poll()[0];
}
return ans;
}
}
61.和最小的k个数对
最大堆,根据数对和进行排序。
class Solution {
public List<List<Integer>> kSmallestPairs(int[] nums1, int[] nums2, int k) {
PriorityQueue<int[]> pq=new PriorityQueue<>((a,b)->(b[0]+b[1])-(a[0]+a[1]));
int len1=Math.min(nums1.length,k);
int len2=Math.min(nums2.length,k);
for(int i=0;i<len1;i++){
for(int j=0;j<len2;j++){
if(pq.size()==k){
if(nums1[i]+nums2[j]<pq.peek()[0]+pq.peek()[1]){
pq.poll();
pq.offer(new int[]{nums1[i],nums2[j]});
}
}else{
pq.offer(new int[]{nums1[i],nums2[j]});
}
}
}
List<List<Integer>> res=new ArrayList<>();
while(!pq.isEmpty()){
int[] top=pq.poll();
res.add(Arrays.asList(top[0],top[1]));
}
return res;
}
}