对顶堆与堆的经典应用
极难4好的,这是一篇为你扩充和润色后的参考文章,保留了原内容的所有知识点,并增加了更多生活例子、常见错误解析和完整代码注释。
堆的妙用:从动态中位数到贪心算法,堆能帮你做什么?
大家平时有没有遇到过这样的场景:老师一边念全班同学的成绩,每念一个分数,你就想知道目前全班的中位数是多少?如果每次都用排序,成绩一多就慢得让人受不了。有没有一种方法能边听边算,快速得到答案?答案是有的——用两个堆(对顶堆)就能搞定。
堆(Heap)是一种能快速取出最大值或最小值的数据结构,很多时候我们不光要取最值,还要在数据不断变化的过程中维护一些统计量(比如中位数、前K大的数等)。这篇我们会重点讲几个堆的经典应用:对顶堆求中位数、合并果子(哈夫曼编码思想)、TopK 问题、Dijkstra 最短路径,以及事件模拟。学会它们,你会发现很多实际难题都能用堆轻松解决。
1. 对顶堆(双堆)——动态中位数
1.1 为什么需要两个堆?
想象一下,你手头有一堆分数,你想知道中间的那个数(中位数)。如果数据是静态的,排好序直接取中间就行。但如果是动态插入的,每插入一个数都重新排序就太慢了。对顶堆的思路是:把数据分成“较小的一半”和“较大的一半”,分别用一个大根堆和一个小根堆来存。这样,两个堆的堆顶就能决定当前中位数。
- 大根堆(left):存较小的一半数,堆顶是这部分中最大的(也就是所有数的“左半部分最大值”)。
- 小根堆(right):存较大的一半数,堆顶是这部分中最小的(也就是所有数的“右半部分最小值”)。
1.2 如何维护平衡?
规则很简单:
- 大根堆的大小要么等于小根堆,要么比小根堆多 1(这样中位数就总是大根堆的堆顶)。
- 插入新数时,先判断它应该放到左堆还是右堆。
- 如果放完后两个堆的大小差超过 1,就把堆顶移动到另一个堆去。
举个例子:
假设已有分数:[1, 2, 3, 4, 5, 6, 7](共 7 个)。
- 大根堆 left:
[4, 3, 2, 1](堆顶 4) - 小根堆 right:
[5, 6, 7](堆顶 5) - 中位数 = left.top() = 4。
插入 8:8 比 right 堆顶 5 大,放入 right。right 变成 [5,6,7,8],size=4;left size=4,平衡。中位数 = (4+5)/2 = 4.5。
插入 -1:-1 比 left 堆顶 4 小,放入 left。left 变成 [4,3,2,1,-1],size=5;right size=3,left 多了 2 个,需要把 left 堆顶移到 right。调整后 left:[3,2,1,-1](堆顶 3),right:[4,5,6,7](堆顶 4)。中位数 = (3+4)/2 = 3.5。
1.3 常见错误
- 忘记处理平衡条件:只插入不调整,导致两个堆大小不平衡,中位数取错。
- 偶数个元素时计算错误:偶数个元素时中位数是 (left.top + right.top) / 2.0,很多人忘了转成浮点数。
- 堆类型选反:大根堆存较小的一半,小根堆存较大的一半。如果搞反了,堆顶就不是你要的中位数了。
1.4 完整代码示例(C++ & Python)
C++ 代码
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
class MedianFinder {
private:
priority_queue<int> left; // 大根堆,存较小的一半(默认大根堆)
priority_queue<int, vector<int>, greater<int>> right; // 小根堆,存较大的一半
public:
void addNum(int num) {
// 先决定放哪个堆
if (left.empty() || num <= left.top()) {
left.push(num); // 比左堆顶小,放左堆
} else {
right.push(num); // 否则放右堆
}
// 调整平衡:left 可以比 right 多一个,但不能少
if (left.size() > right.size() + 1) {
// left 太大,移一个到 right
right.push(left.top());
left.pop();
} else if (right.size() > left.size()) {
// right 太大,移一个到 left
left.push(right.top());
right.pop();
}
}
double findMedian() {
if (left.size() > right.size()) {
return left.top(); // 奇数个,中位数在左堆顶
} else {
return (left.top() + right.top()) / 2.0; // 偶数个,两堆顶平均值
}
}
};
int main() {
MedianFinder mf;
vector<int> nums = {5, 3, 8, 1, 9, 2, 7, 4, 6};
for (int num : nums) {
mf.addNum(num);
cout << "加入 " << num << " 后中位数: " << mf.findMedian() << endl;
}
return 0;
}
Python 代码
import heapq
class MedianFinder:
def __init__(self):
self.left = [] # 大根堆,Python用负数模拟(存 -num)
self.right = [] # 小根堆,正常存 num
def addNum(self, num):
# 先决定放左还是右
if not self.left or num <= -self.left[0]:
heapq.heappush(self.left, -num) # 存入负数,让堆顶是最大的数(负值最小)
else:
heapq.heappush(self.right, num)
# 平衡
if len(self.left) > len(self.right) + 1:
# left 多了,移一个到 right
heapq.heappush(self.right, -heapq.heappop(self.left))
elif len(self.right) > len(self.left):
# right 多了,移一个到 left
heapq.heappush(self.left, -heapq.heappop(self.right))
def findMedian(self):
if len(self.left) > len(self.right):
return -self.left[0] # 取出时取负
else:
return (-self.left[0] + self.right[0]) / 2.0
# 测试
mf = MedianFinder()
nums = [5, 3, 8, 1, 9, 2, 7, 4, 6]
for num in nums:
mf.addNum(num)
print(f"加入 {num} 后中位数: {mf.findMedian()}")
2. 堆在贪心问题中的应用:合并果子(霍夫曼编码思想)
2.1 问题描述
假设你在果园里收果子,有 n 堆果子,每堆重量不同。每次你可以把任意两堆果子合并成一堆,合并消耗的体力等于两堆重量之和。你想把所有果子合并成一堆,问最少需要消耗多少体力?
(这个场景也可以替换成:合并文件、合并账本、合并巧克力棒……总之是不断合并最小值的问题。)
2.2 核心思路
贪心策略:每次选择当前重量最小的两堆合并。因为每次合并都会增加一个“成本”,我们希望成本尽可能小。这就是哈夫曼编码的贪心思想——用一个小根堆来动态维护当前所有堆的重量。
过程:
- 将所有重量放入小根堆(Python 的
heapq)。 - 重复以下步骤直到堆中只剩一堆:
- 弹出两个最小的堆(a 和 b)。
- 合并它们,消耗体力 cost = a + b,总体力 += cost。
- 把新堆(a+b)重新放回堆中。
- 返回总体力。
2.3 常见错误
- 合并后忘了把新堆放回堆:这样堆会越来越少,程序提前结束。
- 没有用堆,而是手动找最小值:每次找最小值如果用排序,复杂度会变成 O(n² log n),而用堆只用 O(n log n)。
- 总体力初始化为 0,但忘记累加:新手容易在循环中只合并但不加总。
2.4 Python 完整实现
import heapq
def merge_fruits(weights):
"""
:param weights: list[int] 每堆果子的重量
:return: int 最小总体力
"""
heapq.heapify(weights) # 将列表转为小根堆,O(n)
total_cost = 0 # 总消耗体力
while len(weights) > 1: # 只要还有多于一堆
a = heapq.heappop(weights) # 最轻的第一堆
b = heapq.heappop(weights) # 第二轻的堆
cost = a + b # 合并消耗
total_cost += cost # 累加
heapq.heappush(weights, cost) # 新堆放回
return total_cost
# 测试
weights = [1, 2, 3, 4, 5]
print("最小总体力:", merge_fruits(weights)) # 输出 33
手动演算:
- 初始:[1,2,3,4,5]
- 合并1+2=3,堆变为[3,3,4,5],体力=3
- 合并3+3=6,堆变为[4,5,6],体力=3+6=9
- 合并4+5=9,堆变为[6,9],体力=9+9=18
- 合并6+9=15,堆变为[15],体力=18+15=33
3. 堆在 TopK 问题中的应用
3.1 场景
你想从全班 1000 人的考试成绩中找出前 10 名(最大的 10 个数)。如果数据量极大(比如 1 亿个数字),没法全部排序,怎么办?
3.2 核心思路
用一个小根堆来维护当前最大的 K 个数。为什么是小根堆?因为堆顶是当前 K 个数中最小的那个,如果新来的数比堆顶大,说明它应该进入前 K,那就把堆顶弹出,把新数放进去,再调整堆。这样堆里始终保存着最大的 K 个数,而堆顶就是第 K 大的数。
步骤:
- 创建一个大小为 K 的小根堆(Python 直接用
heapq)。 - 遍历所有数:
- 如果堆的大小小于 K,直接入堆。
- 如果堆的大小等于 K,且当前数大于堆顶,则弹出堆顶,入堆当前数。
- 遍历结束,堆中就是前 K 大的数。
3.3 举个例子
假设数组 [3, 7, 2, 9, 5],K=3。
- 前三个数 [3,7,2] 直接入堆,堆为 [2,3,7](小根堆,堆顶 2)。
- 遇到 9,9>2,弹出 2,入堆 9,堆变为 [3,7,9]。
- 遇到 5,5<3,不处理。结果 [3,7,9] 就是最大的 3 个数。
3.4 常见错误
- 用了大根堆:如果取最大 K 个数用大根堆,堆顶是最大值,但我们需要“挤掉”最小的,大根堆做不到。
- 忘记处理堆满的情况:如果不判断堆大小,会一直往里加,最后变成全排序。
- 数据流式输入时没有实时更新:如果需要实时查询当前 TopK,可以用同样的方法维护一个大小为 K 的堆。
3.5 代码示例(Python)
import heapq
def topK(nums, k):
"""
从 nums 中找出最大的 k 个数
:param nums: list[int]
:param k: int
:return: list[int]
"""
if k <= 0 or not nums:
return []
min_heap = []
for num in nums:
if len(min_heap) < k:
heapq.heappush(min_heap, num) # 堆没满,直接加入
else:
if num > min_heap[0]: # 比堆顶大,替换
heapq.heapreplace(min_heap, num) # 相当于 pop + push
return min_heap
# 测试
nums = [10, 3, 7, 2, 9, 5, 8, 1, 4, 6]
print("最大的 3 个数:", topK(nums, 3)) # 输出可能是 [8, 9, 10](顺序不保证)
4. 堆在图论算法中的应用:Dijkstra 最短路径
4.1 问题概述
你从家(起点)到学校(终点),地图上有很多岔路,每条路都有长度。你想找到最短的路线。Dijkstra 算法就是干这个的——它每次从未访问的节点中选一个距离最短的,然后更新它的邻居。传统做法用数组遍历找最小,复杂度 O(V²),用优先队列(小根堆)可以优化到 O((V+E) log V)。
4.2 堆的作用
堆里存的是“节点”和“当前从起点到该节点的最短距离”。每次弹出堆顶(当前距离最小的节点),如果该节点已访问则跳过,否则“松弛”它的邻居:如果经过当前节点到邻居的距离比之前记录的短,就更新邻居的距离并把它压入堆。
4.3 代码骨架(Python)
import heapq
def dijkstra(graph, start):
"""
graph: 邻接表,graph[u] = [(v, w)] 表示 u->v 权重 w
start: 起点
return: dist 数组,dist[i] 为起点到 i 的最短距离
"""
n = len(graph)
INF = 10**9
dist = [INF] * n
dist[start] = 0
heap = [(0, start)] # (距离, 节点)
visited = [False] * n
while heap:
d, u = heapq.heappop(heap)
if visited[u]:
continue
visited[u] = True
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(heap, (dist[v], v))
return dist
常见错误:忘记用 visited 标记,导致重复弹出同一个节点(虽然也能算出结果,但效率降低)。
5. 堆与事件模拟
5.1 医院急诊模拟
假设医院急诊室有多个病人,每个病人有到达时间、治疗时长和病情等级(数字越小越紧急)。医生一次只能治疗一个病人,且总是选择当前病情等级最高的病人(优先级最高)。用 优先队列(堆)可以轻松实现:
- 把病人在到达时间发生的事件放入队列,按照病情等级排序(大根堆,等级数字小表示更紧急?可以自行定义)。
- 每当医生空闲,就从堆顶取出最紧急的病人治疗,同时设定下一个事件(治疗结束时间)。
- 治疗结束后,医生再次从堆里取下一个最紧急的病人。
这种“事件驱动”的模拟在操作系统(调度算法)、游戏(AI行为)中都非常常见。
5.2 简单例子:按优先级处理任务
你有几个任务,每个任务有一个“优先级”(数字越大越优先)和一个“处理时长”。每次从堆中取出优先级最高的任务执行,直到全部完成。用堆实现只需每次弹出堆顶,记录当前时间,然后继续。
import heapq
def process_tasks(tasks):
"""
tasks: list of (priority, duration)
优先级数字越大越重要
返回完成所有任务需要的时间(假设串行执行)
"""
# 用负号实现大根堆,因为 Python 默认是小根堆
heap = [(-p, d) for p, d in tasks]
heapq.heapify(heap)
time = 0
while heap:
neg_p, duration = heapq.heappop(heap)
# 模拟执行该任务
time += duration
print(f"处理优先级为 {-neg_p} 的任务,耗时 {duration},当前时间 {time}")
return time
# 测试
tasks = [(3, 5), (1, 2), (5, 3), (2, 4)]
print("总耗时:", process_tasks(tasks))
6. 常见错误总结
| 错误类型 | 说明 | 如何避免 |
|---|---|---|
| 对顶堆平衡条件搞反 | left 应比 right 多一个或相等,若反过来则中位数取错 | 画图或手动模拟插入过程 |
| 合并果子时忘了放回新堆 | 堆元素越来越少,计算错误 | 每次合并后 heappush |
| TopK 用错了堆的类型 | 取最大 K 要用小根堆,取最小 K 要用大根堆 | 记住“堆顶是要被淘汰的” |
Dijkstra 中忘记 visited | 造成重复入堆和冗余计算 | 标记已访问节点,跳过 |
| 堆中元素类型不匹配 | 比如 Python 中用 (-x, y) 但比较时可能出错 | 确认自定义比较方式 |
| 优先队列默认是小根堆 | 在需要大根堆时,用负数或自定义 __lt__ | 熟练使用 heapq 的负数技巧 |
7. 相关指引
学完了堆的这些应用,接下来你可以探索:
- 堆排序:利用堆不断取出最小值实现排序,时间复杂度 O(n log n),空间 O(1)(原地建堆)。
- 优先队列的实现:了解如何从零实现一个优先队列(使用二叉堆)。
- 图论中的堆优化:除了 Dijkstra,Prim 最小生成树算法也用堆优化。
- 数据结构进阶:可合并堆(左偏树、配对堆)、斐波那契堆等,在更复杂的问题中能进一步降低时间复杂度。
堆是一个看似简单、实则威力无穷的工具。下次遇到“动态取最值”或者“按优先级处理”的问题时,第一反应就是——上堆!
例题精讲
对顶堆通常用于解决以下哪种经典问题?
使用对顶堆求动态中位数时,如果当前元素个数为奇数,则中位数通常存储在大根堆的堆顶。
以下代码用对顶堆求数据流的中位数,请补充完整缺失的部分。
class MedianFinder {
private:
priority_queue<int> maxHeap; // 大根堆
priority_queue<int, vector<int>, greater<int>> minHeap; // 小根堆
public:
void addNum(int num) {
if (maxHeap.empty() || num <= maxHeap.top()) {
maxHeap.push(num);
} else {
minHeap.push(num);
}
// 调整使两个堆大小平衡
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.push(maxHeap.top());
maxHeap.pop();
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.push(minHeap.top());
minHeap.pop();
}
}
double findMedian() {
if (maxHeap.size() > minHeap.size()) {
return ___;
} else {
return (maxHeap.top() + minHeap.top()) / 2.0;
}
}
};关于堆的经典应用,以下说法错误的是?
以下代码使用堆实现求数组前K个最小元素,请填空。
vector<int> findKSmallest(vector<int>& nums, int k) {
// 使用最大堆保存最小的k个元素
priority_queue<int> maxHeap;
for (int num : nums) {
maxHeap.push(num);
if (maxHeap.size() > k) {
___;
}
}
vector<int> result;
while (!maxHeap.empty()) {
result.push_back(maxHeap.top());
maxHeap.pop();
}
// 由于栈后进先出,需要反转得到升序
reverse(result.begin(), result.end());
return result;
}