CC++ & Algorithm

对顶堆与堆的经典应用

极难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 核心思路

贪心策略:每次选择当前重量最小的两堆合并。因为每次合并都会增加一个“成本”,我们希望成本尽可能小。这就是哈夫曼编码的贪心思想——用一个小根堆来动态维护当前所有堆的重量。

过程

  1. 将所有重量放入小根堆(Python 的 heapq)。
  2. 重复以下步骤直到堆中只剩一堆:
    • 弹出两个最小的堆(a 和 b)。
    • 合并它们,消耗体力 cost = a + b,总体力 += cost。
    • 把新堆(a+b)重新放回堆中。
  3. 返回总体力。

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 大的数。

步骤

  1. 创建一个大小为 K 的小根堆(Python 直接用 heapq)。
  2. 遍历所有数:
    • 如果堆的大小小于 K,直接入堆。
    • 如果堆的大小等于 K,且当前数大于堆顶,则弹出堆顶,入堆当前数。
  3. 遍历结束,堆中就是前 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 最小生成树算法也用堆优化。
  • 数据结构进阶:可合并堆(左偏树、配对堆)、斐波那契堆等,在更复杂的问题中能进一步降低时间复杂度。

堆是一个看似简单、实则威力无穷的工具。下次遇到“动态取最值”或者“按优先级处理”的问题时,第一反应就是——上堆!

例题精讲

1单选题

对顶堆通常用于解决以下哪种经典问题?

A快速排序
B动态中位数
C二分查找
D最短路径
2判断题

使用对顶堆求动态中位数时,如果当前元素个数为奇数,则中位数通常存储在大根堆的堆顶。

3填空题
以下代码用对顶堆求数据流的中位数,请补充完整缺失的部分。
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;
        }
    }
};
4单选题

关于堆的经典应用,以下说法错误的是?

A堆排序的时间复杂度为O(n log n)
B求一个数组中的第K大元素可以使用最小堆
C优先队列底层通常用堆实现
D堆是一种完全二叉树结构,所有堆都是二叉堆
5填空题
以下代码使用堆实现求数组前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;
}