一、引言:为什么需要优先队列?
在日常生活和算法竞赛中,我们经常遇到这样的场景:
-
医院急诊室根据病情严重程度决定就诊顺序,而不是先来后到
-
操作系统调度任务时,高优先级的进程先获得CPU
-
海量数据中找出最大的K个数
这些问题的共同特点是:我们需要一种能够动态维护元素集合,并快速取出"最值"的数据结构。普通队列FIFO无法满足,普通数组查找最值需要O(n),而优先队列能在O(log n)时间内完成插入和删除最值操作。
Java中的PriorityQueue正是为此而生,它基于二叉堆实现,是面试和算法题中的常客。
二、优先队列的核心原理
2.1 二叉堆:优先队列的骨架
优先队列的底层通常采用二叉堆数据结构。二叉堆是一棵完全二叉树,满足以下性质:
-
堆序性:任意节点的值不大于(或不小于)其子节点的值
-
小顶堆:父节点 ≤ 子节点,堆顶为最小值
-
大顶堆:父节点 ≥ 子节点,堆顶为最大值
为什么用完全二叉树?因为可以用数组紧凑存储,不需要指针,且父子节点索引关系简单:
对于节点 i(从0开始):
左子节点 = 2*i + 1
右子节点 = 2*i + 2
父节点 = (i - 1) / 2
2.2 核心操作详解
插入:
将新元素放在数组末尾,然后上浮 :不断与父节点比较,如果违背堆序则交换,直到满足堆序。
最坏情况:从叶子到根,比较次数 = 树高 = O(log n)
删除堆顶 :
将堆顶元素移除,把数组末尾元素放到堆顶,然后下沉 :不断与较小的子节点比较,如果大于子节点则交换,直到满足堆序。
最坏情况:从根到叶子,比较次数 = 树高 = O(log n)
查看堆顶 :
直接返回数组第一个元素,O(1)。
2.3 时间复杂度总结
| 操作 | 时间复杂度 |
|---|---|
| 插入(offer) | O(log n) |
| 删除堆顶(poll) | O(log n) |
| 查看堆顶(peek) | O(1) |
| 删除任意元素(remove) | O(n) + O(log n) |
| 构建堆(heapify) | O(n) |
| 堆排序 | O(n log n) |
注意:删除任意元素需要先遍历查找(O(n)),然后上浮或下沉(O(log n)),实际使用较少。
三、Java PriorityQueue 源码深度剖析
3.1 类结构概述
java
public class PriorityQueue<E> extends AbstractQueue<E>
implements Serializable {
// 底层数组,默认初始容量11
transient Object[] queue;
// 当前元素个数
private int size;
// 比较器,决定堆序
private final Comparator<? super E> comparator;
}
PriorityQueue是无界队列(基于数组动态扩容),不允许null元素。它默认是小顶堆,可以通过Comparator定制大顶堆或其他排序规则。
3.2 核心方法源码分析
java
public boolean offer(E e) {
if (e == null) throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length)
grow(i + 1); // 扩容:通常翻倍
size = i + 1;
if (i == 0)
queue[0] = e;
else
siftUp(i, e);
return true;
}
关键的siftUp实现:
java
private void siftUp(int k, E x) {
if (comparator != null)
siftUpUsingComparator(k, x);
else
siftUpComparable(k, x);
}
private void siftUpComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
int parent = (k - 1) >>> 1; // 父节点索引
Object e = queue[parent];
if (key.compareTo((E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = key;
}
逻辑非常清晰:从插入位置开始,不断与父节点比较,如果当前元素更小则上移父节点,直到找到合适位置。
poll() - 删除堆顶:
java
public E poll() {
if (size == 0) return null;
int s = --size;
modCount++;
E result = (E) queue[0];
E x = (E) queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);
return result;
}
siftDown实现(以无比较器为例):
java
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
int half = size >>> 1; // 最后一个非叶子节点索引
while (k < half) {
int child = (k << 1) + 1; // 左子节点
Object c = queue[child];
int right = child + 1;
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
child = right; // 选择较小的子节点
if (key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
这里的关键优化:不需要比较到叶子节点,只要下沉到half位置(最后一个非叶子节点)即可,因为叶子节点没有子节点。
3.3 扩容机制
当size >= queue.length时触发扩容。新容量计算规则:
-
如果旧容量 < 64,新容量 = 旧容量 * 2 + 2
-
否则新容量 = 旧容量 * 1.5
3.4 迭代器与有序性
PriorityQueue的迭代器不保证有序 ,因为迭代器按数组顺序遍历,而非堆序。要获取有序序列,需要不断调用poll(),但会破坏原队列。
四、优先队列在力扣中的经典应用
我们将通过5道精心挑选的力扣题,展示优先队列在不同场景下的妙用。
题目1:【215】数组中的第K个最大元素
题目描述 :
给定整数数组nums和整数k,请返回数组中第k个最大的元素。
示例:[3,2,1,5,6,4],k=2 → 5
思路分析 :
解法很多,但优先队列提供优雅的O(n log k)方案。维护一个大小为k的小顶堆,堆顶就是当前k个最大元素中的最小值,遍历完整个数组后堆顶即为第k大。
代码实现:
java
public int findKthLargest(int[] nums, int k) {
// 小顶堆,保留最大的k个元素
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int num : nums) {
heap.offer(num);
if (heap.size() > k) {
heap.poll(); // 移除最小的,保持堆大小为k
}
}
return heap.peek();
}
复杂度分析:
-
时间:O(n log k),n为数组长度
-
空间:O(k),堆的大小
延伸思考 :
如果k接近n,可用快速选择(O(n))更优;如果k较小(如k=1),优先队列简单直接。这道题也常考手写堆排序变种。
题目2:【347】前 K 个高频元素
题目描述 :
给定整数数组nums和一个整数k,返回出现频率前k高的元素。
示例:[1,1,1,2,2,3],k=2 → [1,2]
思路分析 :
先统计频率(HashMap),再按频率排序。使用小顶堆维护频率最高的k个元素,堆顶是这k个中频率最低的。
代码实现:
java
public int[] topKFrequent(int[] nums, int k) {
// 1. 统计频率
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.put(num, freq.getOrDefault(num, 0) + 1);
}
// 2. 小顶堆,按频率比较
PriorityQueue<Map.Entry<Integer, Integer>> heap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
heap.offer(entry);
if (heap.size() > k) {
heap.poll(); // 移除频率最低的
}
}
// 3. 提取结果
int[] res = new int[k];
for (int i = 0; i < k; i++) {
res[i] = heap.poll().getKey();
}
return res;
}
复杂度分析:
-
时间:O(n log k),统计频率O(n),堆操作O(n log k)
-
空间:O(n),用于哈希表和堆
优化技巧 :
如果要求按频率从高到低输出,可以将堆中元素倒序取出,或者使用大顶堆(需自定义比较器)。
题目3:【23】合并K个升序链表
题目描述 :
给你一个链表数组,每个链表都已按升序排列,将所有链表合并成一个升序链表。
示例:[[1,4,5],[1,3,4],[2,6]] → [1,1,2,3,4,4,5,6]
思路分析 :
将每个链表的头节点放入小顶堆,每次取出最小节点,将其下一个节点入堆。重复直到堆空。
代码实现:
java
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) return null;
// 小顶堆,按节点值比较
PriorityQueue<ListNode> heap = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
// 所有链表的头节点入堆
for (ListNode node : lists) {
if (node != null) {
heap.offer(node);
}
}
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
while (!heap.isEmpty()) {
ListNode node = heap.poll();
curr.next = node;
curr = curr.next;
if (node.next != null) {
heap.offer(node.next);
}
}
return dummy.next;
}
复杂度分析:
-
时间:O(N log k),N为总节点数,k为链表个数
-
空间:O(k),堆的大小
对比其他解法:
-
分治法:O(N log k) + O(log k)递归栈
-
暴力两两合并:O(kN),效率较低
优先队列在此题中代码最简洁,且时间复杂度最优。
题目4:【264】丑数 II
题目描述 :
编写程序找出第n个丑数。丑数是指只包含质因数2, 3, 5的正整数。
示例:n=10 → 12(前10个丑数:1,2,3,4,5,6,8,9,10,12)
思路分析 :
使用小顶堆,初始放入1。每次取出最小丑数,分别乘以2、3、5后入堆,注意去重(用Set防止重复)。重复n次。
代码实现:
java
public int nthUglyNumber(int n) {
PriorityQueue<Long> heap = new PriorityQueue<>();
Set<Long> visited = new HashSet<>();
heap.offer(1L);
visited.add(1L);
long ugly = 1;
int[] factors = {2, 3, 5};
for (int i = 0; i < n; i++) {
ugly = heap.poll();
for (int factor : factors) {
long next = ugly * factor;
if (visited.add(next)) {
heap.offer(next);
}
}
}
return (int) ugly;
}
复杂度分析:
-
时间:O(n log n),每次堆操作O(log n)
-
空间:O(n),堆和Set的大小
优化 :
使用动态规划(三指针法)可将时间复杂度优化到O(n),空间O(1)。但优先队列解法思路更通用,尤其适用于因数个数不固定的场景。
题目5:【973】最接近原点的 K 个点
题目描述 :
给定平面上的点列表points和整数k,返回离原点欧几里得距离最近的k个点。
示例:[[1,3],[-2,2]],k=1 → [[-2,2]]
思路分析 :
维护一个大顶堆(容量k),存储当前距离最大的k个点。当新点距离小于堆顶距离时,替换堆顶并下沉。最终堆中就是最近的k个点。
代码实现:
java
public int[][] kClosest(int[][] points, int k) {
// 大顶堆,按距离平方从大到小排序
PriorityQueue<int[]> heap = new PriorityQueue<>(
(a, b) -> (b[0]*b[0] + b[1]*b[1]) - (a[0]*a[0] + a[1]*a[1])
);
for (int[] point : points) {
heap.offer(point);
if (heap.size() > k) {
heap.poll(); // 移除距离最大的
}
}
int[][] res = new int[k][2];
for (int i = 0; i < k; i++) {
res[i] = heap.poll();
}
return res;
}
复杂度分析:
-
时间:O(n log k)
-
空间:O(k)
关键点:
-
这里的比较器要特别注意,大顶堆是
(b距离 - a距离) -
不需要计算平方根,比较平方即可,避免了浮点误差
五、优先队列实战技巧与陷阱
5.1 自定义比较器写法
Java中的PriorityQueue默认小顶堆,但我们可以通过Comparator轻松改造:
java
// 大顶堆(整数)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
// 按字符串长度排序
PriorityQueue<String> lenHeap = new PriorityQueue<>((a, b) -> a.length() - b.length());
// 多级排序(先按值,再按索引)
PriorityQueue<int[]> multiHeap = new PriorityQueue<>((a, b) -> {
if (a[0] != b[0]) return a[0] - b[0];
return a[1] - b[1];
});
注意:比较器返回负数表示a排在b前面(小顶堆语义),返回正数表示a在后面。
5.2 常见坑点
坑1:null值不允许
PriorityQueue不接受null,否则会抛出NPE。
坑2:迭代器无序
不要依赖迭代器顺序,即使你连续调用peek后poll也是不行的。
坑3:自定义对象需要实现Comparable或提供Comparator
否则运行时抛出ClassCastException。
坑4:扩容影响性能
如果预先知道元素个数,建议在构造时指定初始容量,避免多次扩容。
java
PriorityQueue<Integer> pq = new PriorityQueue<>(1000); // 指定初始容量
5.3 与TreeSet对比
| 特性 | PriorityQueue | TreeSet |
|---|---|---|
| 底层 | 数组堆 | 红黑树 |
| 允许重复 | ✅ | ❌(去重) |
| 查找任意元素 | O(n) | O(log n) |
| 有序遍历 | 需要不断poll | 迭代器有序 |
| 内存 | 紧凑 | 节点开销大 |
如果只需要频繁取最值且允许重复,选PriorityQueue;如果需要有序集合且去重,选TreeSet。
六、总结与核心心得
经过深入分析,我们可以总结出优先队列的核心思维模型:
-
动态维护最值:当我们需要不断从动态变化的集合中取最大/最小元素时,优先队列是首选
-
TopK问题:维护大小为k的堆,能高效解决各种TopK变种
-
多路归并:多个有序流合并,优先队列是天然的工具
-
替代排序:当只需要部分有序结果(如前k个)时,堆比全排序更高效