1. 优先级队列与堆的基础原理
1.1 优先级队列的本质特性
优先级队列(Priority Queue)是一种特殊的抽象数据类型,它不同于普通的先进先出队列。每次从队列中取出的元素都是当前队列中优先级最高的元素。这种数据结构在操作系统任务调度、网络流量管理、图算法等领域有广泛应用。
优先级队列通常通过堆(Heap)来实现,这是因为它能高效地支持以下核心操作:
- 插入(offer/put):时间复杂度O(logN)
- 查看最高优先级元素(peek):时间复杂度O(1)
- 移除最高优先级元素(poll/take):时间复杂度O(logN)
注意:虽然优先级队列常被称为"堆",但严格来说堆只是优先级队列的一种实现方式。理论上也可以用其他数据结构如有序数组实现,但效率会显著降低。
1.2 堆的结构与分类
堆是一种特殊的完全二叉树,具有以下关键性质:
- 结构性质:除最后一层外,其他层节点都完全填满,且最后一层节点尽可能向左靠齐
- 堆序性质:每个节点的值与其子节点满足特定的大小关系
根据堆序性质的不同,堆分为两类:
- 小根堆(Min-Heap):父节点值 ≤ 子节点值
- 大根堆(Max-Heap):父节点值 ≥ 子节点值
示例存储结构(数组表示):
code复制 小根堆示例 大根堆示例
1 (0) 8 (0)
/ \ / \
3 (1) 2 (2) 4 (1) 6 (2)
/ /
5 (3) 2 (3)
数组表示:[1,3,2,5] [8,4,6,2]
1.3 Java中的PriorityQueue实现细节
Java标准库中的PriorityQueue类是基于堆实现的优先级队列,有几个关键实现细节值得注意:
-
扩容机制:初始默认容量为11,当队列满时会按当前容量的1.5倍扩容(实际是 oldCapacity + (oldCapacity >> 1))
-
比较规则:
- 默认使用自然排序(小根堆)
- 可通过Comparator自定义排序规则
- 大根堆常用写法:
(a, b) -> b.compareTo(a)
-
线程安全性:
- PriorityQueue不是线程安全的
- 多线程环境应使用PriorityBlockingQueue
-
特殊限制:
- 不允许插入null元素
- 元素必须实现Comparable或提供Comparator
2. 堆的核心操作与算法
2.1 堆的调整算法
堆的核心在于维护堆序性质的两个基本操作:
向上调整(siftUp)
当新元素插入堆末尾时,需要向上调整:
java复制void siftUp(int k, E x) {
while (k > 0) {
int parent = (k - 1) >>> 1; // 计算父节点位置
Object e = queue[parent];
if (comparator.compare(x, (E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = x;
}
向下调整(siftDown)
当堆顶元素被移除时,需要将末尾元素移到堆顶并向下调整:
java复制void siftDown(int k, 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 &&
comparator.compare((E) c, (E) queue[right]) > 0)
c = queue[child = right];
if (comparator.compare(x, (E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = x;
}
2.2 时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 插入(offer) | O(logN) | 最坏情况需要调整整条路径 |
| 查看(peek) | O(1) | 直接返回数组第一个元素 |
| 移除(poll) | O(logN) | 需要将末尾元素移动到根并调整 |
| 建堆 | O(N) | 从最后一个非叶子节点开始调整 |
提示:虽然单个插入操作是O(logN),但连续插入N个元素的总时间复杂度是O(N)而不是O(NlogN),这是因为建堆过程可以利用已有部分有序性。
3. 堆的典型应用场景
3.1 最后一块石头的重量问题
问题重述:有一堆石头,每次选择最重的两块相撞,若重量相同则都消失,不同则留下差值。重复直到最多剩一块,返回剩余重量。
算法选择:
- 大根堆是自然选择,因为每次需要取最大的两个元素
- 每次操作后可能产生的新石头需要重新插入堆中
优化技巧:
- 预处理时直接将所有石头放入堆中
- 循环条件设为堆大小>1
- 处理碰撞结果时只需将差值重新入堆
边界情况:
- 输入为空数组时应返回0
- 所有石头都完全碰撞消失时返回0
3.2 数据流中的第K大元素
问题特点:
- 数据是动态流入的,无法预先知道全部数据
- 需要实时维护当前的前K大元素
堆的选择:
- 使用小根堆而不是大根堆
- 保持堆的大小为K,堆顶即为第K大元素
- 新元素比堆顶大时,替换堆顶并调整
实现细节:
java复制class KthLargest {
private final PriorityQueue<Integer> minHeap;
private final int k;
public KthLargest(int k, int[] nums) {
this.k = k;
this.minHeap = new PriorityQueue<>(k);
for (int num : nums) {
add(num);
}
}
public int add(int val) {
if (minHeap.size() < k) {
minHeap.offer(val);
} else if (val > minHeap.peek()) {
minHeap.poll();
minHeap.offer(val);
}
return minHeap.peek();
}
}
复杂度分析:
- 初始化:O(NlogK)
- 每次add操作:O(logK)
- 空间复杂度:O(K)
3.3 前K个高频单词
特殊要求:
- 按频率从高到低排序
- 频率相同时按字典序排序
解决方案:
- 先用哈希表统计词频:O(N)
- 使用大小为K的堆进行筛选:
- 堆排序规则:先比较频率,频率相同则比较字典序
- 小根堆维护当前的前K高频词
- 最后逆序输出结果
关键比较器实现:
java复制PriorityQueue<Map.Entry<String, Integer>> heap = new PriorityQueue<>(
(a, b) -> a.getValue().equals(b.getValue()) ?
b.getKey().compareTo(a.getKey()) :
a.getValue() - b.getValue()
);
注意事项:
- 字符串比较使用compareTo而不是==
- 最终结果需要反转顺序
- 处理大文本时要注意内存消耗
3.4 数据流的中位数
问题难点:
- 数据是动态流入的
- 需要高效地维护当前数据的中位数
双堆解法:
- 大根堆保存较小的一半数字
- 小根堆保存较大的一半数字
- 保持两个堆的大小平衡(差值不超过1)
操作规则:
- 新元素先根据当前堆顶决定放入哪个堆
- 放入后检查堆大小差,必要时转移元素
- 取中位数时:
- 如果总数为奇数,取元素多的堆顶
- 如果总数为偶数,取两个堆顶的平均值
代码关键点:
java复制public void addNum(int num) {
if (leftHeap.size() == rightHeap.size()) {
rightHeap.offer(num);
leftHeap.offer(rightHeap.poll());
} else {
leftHeap.offer(num);
rightHeap.offer(leftHeap.poll());
}
}
复杂度保证:
- 每次插入操作:O(logN)
- 查询中位数:O(1)
- 空间复杂度:O(N)
4. 堆的高级应用与优化
4.1 多路归并问题
堆可以高效解决多路归并问题,如合并K个有序链表。基本思路:
- 将每个链表的头节点放入最小堆
- 每次取出堆顶节点加入结果链表
- 将该节点的下一个节点放入堆中
- 重复直到堆为空
时间复杂度:O(NlogK),其中N是总节点数,K是链表数
4.2 堆的替代方案
在某些特定场景下,可以考虑以下优化:
- 固定大小的堆:如果K很小(如10以内),可以用数组+插入排序替代
- 快速选择算法:对于静态数据的TopK问题,可以用快速选择达到O(N)时间复杂度
- 桶排序:如果数据范围有限,可以用桶统计+排序
4.3 堆的常见陷阱
-
对象可变性问题:如果堆中对象的排序字段被修改,会破坏堆性质
- 解决方案:使用不可变对象或修改后重新建堆
-
内存消耗:Java的PriorityQueue基于数组,会有扩容和空闲内存
- 对于超大堆,考虑使用更紧凑的表示方法
-
比较器实现错误:
- 未处理相等情况可能导致不稳定排序
- 比较逻辑不符合传递律会导致不可预测结果
5. 性能优化实战技巧
5.1 避免频繁的堆调整
当需要批量插入大量元素时,先收集所有元素再一次性建堆(O(N))比逐个插入(O(NlogN))更高效。
5.2 自定义堆实现优化
对于性能关键场景,可以考虑:
- 使用基本类型特化实现(如IntHeap)避免装箱开销
- 实现基于磁盘的外部排序堆
- 使用更紧凑的数据表示方法
5.3 并行化处理
对于大规模数据:
- 可以分片统计后再合并结果
- 使用并发安全堆实现多线程处理
- 考虑Map-Reduce等分布式计算模型
在实际工程实践中,堆的选择和优化需要根据具体场景权衡。对于面试和算法竞赛,掌握标准库实现通常足够;而在高性能计算场景,可能需要深入定制堆实现。