1. 优先级队列(堆)基础概念解析
优先级队列(Priority Queue)是一种特殊的队列数据结构,它不同于普通的先进先出(FIFO)队列,而是根据元素的优先级来决定出队顺序。在Java中,优先级队列通常通过堆(Heap)这种数据结构来实现。
堆是一种完全二叉树,满足以下性质:
- 大根堆:每个节点的值都大于或等于其子节点的值
- 小根堆:每个节点的值都小于或等于其子节点的值
Java中的PriorityQueue类默认实现的是小根堆,但可以通过自定义比较器来改变其行为。堆的主要操作及其时间复杂度如下:
- 插入元素(offer):O(log n)
- 删除堆顶元素(poll):O(log n)
- 获取堆顶元素(peek):O(1)
注意:虽然堆的插入和删除操作的时间复杂度都是O(log n),但构建一个包含n个元素的堆的时间复杂度是O(n),而不是O(n log n),这是因为构建堆的过程可以利用更高效的算法。
2. 经典堆应用问题解析
2.1 最后一块石头的重量(LeetCode 1046)
问题描述
有一堆石头,每块石头的重量都是正整数。每次选择最重的两块石头进行粉碎,如果重量相同则都消失,否则较轻的石头消失,较重的石头重量变为两者之差。重复这个过程直到只剩一块或没有石头,返回最后剩下的石头的重量。
算法实现
java复制class Solution {
public int lastStoneWeight(int[] stones) {
// 创建大根堆,通过自定义比较器实现
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
// 将所有石头放入堆中
for (int stone : stones) {
maxHeap.offer(stone);
}
// 模拟粉碎过程
while (maxHeap.size() > 1) {
int first = maxHeap.poll(); // 最重的石头
int second = maxHeap.poll(); // 第二重的石头
int diff = first - second;
if (diff > 0) {
maxHeap.offer(diff); // 将剩余部分放回堆中
}
// 如果diff == 0,两块石头都消失,无需处理
}
// 返回结果
return maxHeap.isEmpty() ? 0 : maxHeap.poll();
}
}
关键点分析
-
为什么使用大根堆?
- 因为每次需要取出当前最重的两块石头,大根堆可以高效地提供最大值
- Java的PriorityQueue默认是小根堆,需要通过比较器反转顺序
-
时间复杂度分析:
- 建堆:O(n)
- 每次粉碎操作:2次O(log n)的poll和最多1次O(log n)的offer
- 最坏情况下需要进行n-1次粉碎操作
- 总时间复杂度:O(n log n)
-
空间复杂度:O(n),用于存储堆
2.2 数据流中的第K大元素(LeetCode 703)
问题描述
设计一个类来找出数据流中第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<>();
for (int num : nums) {
add(num); // 使用add方法初始化,确保堆大小不超过k
}
}
public int add(int val) {
minHeap.offer(val);
// 如果堆大小超过k,移除最小的元素(堆顶)
if (minHeap.size() > k) {
minHeap.poll();
}
return minHeap.peek(); // 堆顶即为第K大元素
}
}
关键点分析
-
为什么使用小根堆而不是大根堆?
- 我们需要维护前K大的元素,小根堆的堆顶就是这K个元素中最小的,也就是第K大的元素
- 当新元素到来时,只需与堆顶比较,大于堆顶则替换,否则忽略
-
时间复杂度:
- 初始化:O(n log k),每个元素插入堆的时间是O(log k)
- 每次add操作:O(log k)
-
空间复杂度:O(k),只存储K个元素
实际应用:这种算法在实时数据处理系统中非常有用,比如监控系统需要实时显示当前流量最大的几个IP地址。
3. 前K个高频单词(LeetCode 692)
问题描述
给定一个单词列表,返回出现频率最高的前K个单词。返回的答案应该按频率从高到低排序。如果两个单词的频率相同,则按字典序排序。
算法实现
java复制class Solution {
public List<String> topKFrequent(String[] words, int k) {
// 1. 统计单词频率
Map<String, Integer> frequencyMap = new HashMap<>();
for (String word : words) {
frequencyMap.put(word, frequencyMap.getOrDefault(word, 0) + 1);
}
// 2. 创建小根堆,自定义比较器
PriorityQueue<Map.Entry<String, Integer>> minHeap = new PriorityQueue<>(
(a, b) -> {
// 频率相同,按字典序降序排列(因为后面要反转)
if (a.getValue().equals(b.getValue())) {
return b.getKey().compareTo(a.getKey());
}
// 否则按频率升序排列
return a.getValue() - b.getValue();
}
);
// 3. 维护大小为K的堆
for (Map.Entry<String, Integer> entry : frequencyMap.entrySet()) {
minHeap.offer(entry);
if (minHeap.size() > k) {
minHeap.poll();
}
}
// 4. 构建结果列表
List<String> result = new ArrayList<>();
while (!minHeap.isEmpty()) {
result.add(minHeap.poll().getKey());
}
// 5. 反转列表,因为堆顶是最小的
Collections.reverse(result);
return result;
}
}
关键点分析
-
比较器的设计:
- 主要按频率升序排列(小根堆)
- 频率相同时,按字典序降序排列(因为最后要反转结果)
-
为什么使用小根堆:
- 维护前K个高频元素,堆顶是这K个中频率最低的
- 当新元素的频率高于堆顶时,替换堆顶元素
-
时间复杂度:
- 统计频率:O(n)
- 建堆:O(n log k)
- 构建结果:O(k log k)
- 总时间复杂度:O(n log k)
-
空间复杂度:O(n)用于哈希表,O(k)用于堆
4. 数据流的中位数(LeetCode 295)
问题描述
设计一个数据结构,能够支持以下两种操作:
- addNum(int num):从数据流中添加一个整数到数据结构中
- findMedian():返回目前所有元素的中位数
双堆解法实现
java复制class MedianFinder {
// 大根堆存储较小的一半数字
private PriorityQueue<Integer> maxHeap;
// 小根堆存储较大的一半数字
private PriorityQueue<Integer> minHeap;
public MedianFinder() {
maxHeap = new PriorityQueue<>((a, b) -> b - a);
minHeap = new PriorityQueue<>();
}
public void addNum(int num) {
// 先加入大根堆
maxHeap.offer(num);
// 平衡两个堆
minHeap.offer(maxHeap.poll());
// 如果小根堆元素更多,移动一个回大根堆
if (minHeap.size() > maxHeap.size()) {
maxHeap.offer(minHeap.poll());
}
}
public double findMedian() {
if (maxHeap.size() > minHeap.size()) {
return maxHeap.peek();
} else {
return (maxHeap.peek() + minHeap.peek()) / 2.0;
}
}
}
关键点分析
-
双堆设计原理:
- 大根堆存储较小的一半数字,堆顶是这一半的最大值
- 小根堆存储较大的一半数字,堆顶是这一半的最小值
- 保持两个堆的大小平衡(相等或大根堆多一个)
-
添加元素的流程:
- 新元素先加入大根堆
- 将大根堆的最大值移动到小根堆
- 如果小根堆元素更多,移动一个回大根堆
-
时间复杂度:
- addNum:O(log n)
- findMedian:O(1)
-
空间复杂度:O(n)
实际应用:这种数据结构在实时统计系统中非常有用,比如实时计算用户访问时间的中位数,或者股票价格的中位数等。
5. 堆的应用技巧与注意事项
5.1 堆的选用原则
- 需要快速访问最大/最小元素时使用堆
- 大根堆用于需要频繁访问最大元素的场景
- 小根堆用于需要频繁访问最小元素的场景
- TopK问题通常使用堆来解决:
- 求前K大元素 → 维护大小为K的小根堆
- 求前K小元素 → 维护大小为K的大根堆
5.2 Java中PriorityQueue的使用技巧
-
默认是小根堆,可以通过自定义比较器改变顺序
-
比较器的写法:
java复制// 大根堆 PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a); // 按字符串长度排序 PriorityQueue<String> lengthHeap = new PriorityQueue<>((a, b) -> a.length() - b.length()); -
对于自定义对象,可以实现Comparable接口或提供Comparator
5.3 性能优化建议
-
如果知道元素的大致数量,可以在创建堆时指定初始容量:
java复制PriorityQueue<Integer> heap = new PriorityQueue<>(expectedSize); -
对于固定大小的堆(如TopK问题),及时移除不需要的元素
-
在数据量很大时,考虑使用更高效的数据结构或算法
5.4 常见问题排查
-
堆顺序错误:
- 检查比较器的实现是否正确
- 确认是需要大根堆还是小根堆
-
并发修改异常:
- PriorityQueue不是线程安全的
- 多线程环境下需要使用PriorityBlockingQueue
-
内存问题:
- 大量数据时,堆可能占用过多内存
- 考虑分批处理或使用磁盘-backed的数据结构
6. 堆的扩展应用场景
6.1 合并K个有序链表
使用小根堆来高效地合并多个已排序的链表,每次取出最小的元素。
6.2 任务调度
在操作系统中,堆常用于实现优先级调度算法,高优先级的任务先执行。
6.3 Dijkstra算法
在图的最短路径算法中,堆用于高效地选择下一个要处理的节点。
6.4 实时统计
如前所述,堆可以用于实时计算中位数、百分位数等统计量。
在实际开发中,理解堆的原理和掌握其实现方式,能够帮助我们高效解决许多看似复杂的问题。特别是在处理大数据量时,堆往往能提供比其他数据结构更好的时间复杂度。