Lesson 0406 · 堆与优先队列

④ 数据结构与算法 — 第 6 课

上节课:BST —— 为了维护「全部有序」,代价是要小心平衡。
这节课:换个思路。很多时候我们不需要全部有序,只要随时能拿到最小(或最大)的那一个。为这一个需求,有一种更简单、更快、代码更短的结构:堆。
三句话速览
  1. 堆 = 完全二叉树 + 每个节点不大于它的孩子。
  2. 插入上浮、删堆顶下沉,都只走一条根到叶的路。
  3. 堆顶 O(1),增删 O(log n),其余无序。

1. 从你写过的题说起

P1090 合并果子:有 n 堆果子,每次把两堆合并成一堆,消耗体力 = 两堆重量之和,求合并成一堆的最小总体力。

贪心策略很直白:每次挑最轻的两堆合并。于是每一步都要做两件事——

如果用 sort 每轮重排一次:一轮 O(n log n),总共 O(n² log n),n 上万就超时。如果线性扫一遍找最小:一轮 O(n),总共 O(n²),也不够快。

方案取最小值动态插入/删除适合吗
vector + 线性查找O(n)O(1) 但会打乱顺序数据小才行
vector + sortO(1)(排好序后)O(n)(插入后要重排)数据不变时可以
setO(log n)O(log n)可以,但常数偏大
堆(priority_queue)O(1) 看堆顶O(log n)专门干这个的

堆就是为「动态地反复取极值」这件事设计的。它比 BST 简单得多,因为它只维护一小部分信息。

2. 堆是什么:完全二叉树 + 一条规矩

堆(heap)满足两个条件:

小根堆示例:              对应的数组(1-based):
        1                 h[1]=1
       / \                h[2]=3  h[3]=2
      3   2               h[4]=7  h[5]=5  h[6]=4
     / \  /
    7   5 4

注意两件事,这是初学者最容易绕不过去的弯:

  1. 堆顶一定是最小值,但堆里其他位置不是有序的。上面数组写成 1 3 2 7 5 4,看着乱,但 1 永远在顶上。
  2. 堆只保证父子之间的大小关系,兄弟之间没有任何约束。3 和 2 谁在左谁在右都行。

所以堆和 BST 是两种不同的取舍:

BST / set堆
维护什么全体有序只要极值在顶上
遍历能不能得到有序序列能(中序)不能
拿最小值O(log n) 一路向左O(1) 直接看堆顶
实现难度要处理平衡/删除两个动作就够

3. 用数组装堆

完全二叉树的形状规则,意味着它没有空洞,可以直接用数组紧凑地存,不需要指针。

约定从下标 1 开始存(根放在 h[1]),下标关系就非常干净:

节点 i 的左孩子: 2*i
节点 i 的右孩子: 2*i + 1
节点 i 的父亲:   i / 2      (整数除法,自动向下取整)
下标:  1   2   3   4   5   6
值:    1   3   2   7   5   4

h[2]=3 的孩子是 h[4]=7、h[5]=5
h[3]=2 的孩子是 h[6]=4(h[7] 不存在,说明没有右孩子)
为什么从下标 1 开始?
从 0 开始也能算:左孩子 2i+1、右孩子 2i+2、父亲 (i-1)/2。只是写起来更容易错。
竞赛里手写堆基本都用 1-based,记住这一套就行。

4. 两个动作就够了

4.1 插入:先放到末尾,再「上浮」

新元素先放在数组末尾(这样仍然是完全二叉树),然后不停和父亲比:比父亲小,就和父亲交换,直到不比父亲小为止。

const int N = 100005;
int h[N], hn = 0;                  // hn = 当前元素个数,堆顶是 h[1]

void push(int x) {
    h[++hn] = x;                   // 先放到末尾
    int i = hn;
    while (i > 1 && h[i] < h[i / 2]) {   // 比父亲小就往上换
        swap(h[i], h[i / 2]);
        i /= 2;                    // 继续和新的父亲比
    }
}

把原文那个例子逐轮拆开(往 1 3 2 7 5 4 里插 0):

轮次动作数组(下标 1 起)说明
初始—1 3 2 7 5 4hn = 6
1h[++hn] = 01 3 2 7 5 4 0放到末尾,i = 7
2和父亲 h[3] 交换1 3 0 7 5 4 20 比 2 小,i 变成 3
3和父亲 h[1] 交换0 3 1 7 5 4 20 比 1 小,i 变成 1,停

三轮之后 0 浮到了堆顶。上浮只走一条从叶到根的路径,长度是树高,所以是 O(log n)。

4.2 删除堆顶:把末尾搬上来,再「下沉」

删掉堆顶之后,让最后一个元素搬到堆顶(还是完全二叉树),然后不停和自己较小的那个孩子比:比孩子大就交换,直到不比孩子大。

int top() { return h[1]; }         // 看最小值:O(1)

void pop() {
    h[1] = h[hn--];                // 末尾元素搬到堆顶,个数减一
    int i = 1;
    while (true) {
        int l = i * 2, r = i * 2 + 1;
        int small = i;                     // 假设自己最小
        if (l <= hn && h[l] < h[small]) small = l;
        if (r <= hn && h[r] < h[small]) small = r;
        if (small == i) break;             // 两个孩子都不比我小,停
        swap(h[i], h[small]);
        i = small;                         // 继续往下沉
    }
}

还是用上面那个堆 1 3 2 7 5 4,删一次堆顶:

轮次动作数组(下标 1 起)说明
初始—1 3 2 7 5 4hn = 6,堆顶 1 要被删
1h[1] = h[6]; hn--4 3 2 7 5末尾的 4 搬到堆顶,hn = 5
2和较小的孩子 h[3]=2 交换2 3 4 7 5孩子是 3 和 2,跟小的那个换,i 变成 3
3i=3 没有孩子2 3 4 7 52i = 6 已经超过 hn = 5,停

一次上浮或下沉,最多走一条从根到叶的路径,长度是树高 log n,所以两种操作都是 O(log n)。top() 是 O(1)。

5. STL 里现成的优先队列

堆在 STL 里的名字叫 priority_queue(优先队列),头文件是 <queue>。

#include <queue>
using namespace std;

priority_queue<int> big;                                      // 大根堆(默认)
priority_queue<int, vector<int>, greater<int>> small;        // 小根堆

small.push(3);
small.push(1);
small.push(5);

cout << small.top() << endl;    // 1,堆顶是最小值
small.pop();                     // 删掉 1(注意 pop 不返回值)
cout << small.top() << endl;    // 3
cout << small.size() << endl;   // 1
cout << small.empty() << endl;  // 0
操作写法复杂度
插入push(x)O(log n)
看堆顶top()O(1)
删堆顶pop()O(log n)
大小 / 是否空size() / empty()O(1)
三个小坑
① pop() 不返回被删的元素,要「取值再删」得写 int x = q.top(); q.pop();
② 优先队列没有 clear(),想清空只能一个个 pop,或者重新定义一个(q = priority_queue<int>();)
③ 不能像 vector 那样用下标访问,堆里只有堆顶是给你看的

5.1 自定义类型:按分数取最高

struct Student {
    string name;
    int score;
};

struct Cmp {                              // 自定义「谁优先级低」
    bool operator()(const Student& a, const Student& b) {
        return a.score < b.score;
    }
};

priority_queue<Student, vector<Student>, Cmp> pq;
pq.push({"Alice", 92});
pq.push({"Bob",   78});
pq.push({"Cara",  99});

cout << pq.top().name;     // Cara(分数最高在堆顶)

更省事的写法是在结构体里直接重载 <:

struct Student {
    string name;
    int score;
    bool operator<(const Student& o) const {
        return score < o.score;           // 分数大的排前面
    }
};

priority_queue<Student> pq;               // 不用再写第三个参数
方向最容易搞反的地方
priority_queue 的第三个参数是「比较谁优先级低」:Cmp(a,b) == true 表示 a 排在 b 后面。
所以 a.score < b.score 的结果是分数高的在堆顶。
而 sort 里的 a.score < b.score 是升序(分数低的在前)。
同一个表达式,在两处的含义正好相反——记不住就写个小程序打印一下验证。

6. 什么时候用堆

需求推荐
反复取最大/最小,数据还在动态增删priority_queue
要遍历出有序序列、要找前驱后继、要去重set / map
数据一次给定、不变,只要排个序vector + sort
要按下标随机访问第 i 个元素vector(堆做不到)
只要前 k 个最大/最小优先队列(见下)

7. 两个典型用法

7.1 合并果子(P1090 完整代码)

#include <iostream>
#include <queue>
#include <vector>
using namespace std;

int main() {
    int n;
    cin >> n;

    priority_queue<int, vector<int>, greater<int>> q;   // 小根堆
    for (int i = 0; i < n; i++) {
        int w;
        cin >> w;
        q.push(w);
    }

    long long ans = 0;                       // 体力总和可能超过 int
    while (q.size() > 1) {
        int a = q.top(); q.pop();            // 最轻的一堆
        int b = q.top(); q.pop();            // 第二轻的
        ans += a + b;
        q.push(a + b);                       // 合并后放回去
    }

    cout << ans << endl;
    return 0;
}

样例 3 / 1 2 9 的输出是 15(先合并 1+2=3 花 3 点体力,再合并 3+9=12 花 12 点,共 15)。

7.2 只要最大的 k 个

思路反着来:用一个小根堆只保留 k 个数,多出来的就把最小的扔出去,剩下的自然就是最大的 k 个。

int k = 3;
priority_queue<int, vector<int>, greater<int>> keep;

for (int x : v) {
    keep.push(x);
    if ((int)keep.size() > k) keep.pop();    // 超过 k 个就扔掉最小的
}

cout << keep.top() << endl;   // 堆顶是第 k 大的数

反过来求「最小的 k 个」就用大根堆,其它一字不改。

8. 坑点清单

9. 小测

Q1. 小根堆的堆顶是什么?
Q2. 用数组从下标 1 开始存堆,节点 i 的左孩子下标是?
Q3. priority_queue<int> 默认是?
Q4. 对堆做一次 push 或 pop,时间复杂度是?
Q5. 把一个堆从头到尾遍历打印,会得到什么?

10. 动手练习

题目1:手写堆(必做)

把第 4 节的两个函数抄进编辑器,写一个 main:
- 随机插入 8 个数
- 反复 cout << top(); pop(); 直到堆空
- 确认输出是升序
题目2:洛谷 P3378 【模板】堆(必做)

三种操作:1 x 插入 x;2 输出最小值;3 删除最小值。
建议写两遍:先用 priority_queue 过一遍,再把 STL 换成你手写的数组堆,两遍都提交。
题目3:洛谷 P1090 合并果子(重做)

你之前写过,这次注意两点:答案用 long long;循环条件用 while (q.size() > 1)。
题目4:洛谷 P1168 中位数(挑战,选做)

思路叫「对顶堆」:准备一个大根堆 A 和一个小根堆 B,让 A 存较小的一半、B 存较大的一半,并且 A.size() 始终等于 B.size() 或比它大 1。这样 A 的堆顶就是中位数。
每插入一个 x:先 push 进 A,把 A 的堆顶弹出来 push 进 B,如果此时 B 比 A 大就再把 B 的堆顶弹回 A。
写之前先把 P3378 刷熟。做不出来很正常,先跳过也行。
卡住时的自查顺序
  1. 先画数组和对应的完全二叉树,确认自己用的是 1-based 还是 0-based。
  2. push:先放末尾,再和父亲比;比较对象是 i/2。
  3. pop:先把末尾搬到堆顶、hn 减一,再和较小的孩子比。
  4. 检查循环边界:上浮 i > 1,下沉 l <= hn。
  5. 边界:空堆、只有一个元素、两个元素、连续 push 同一个值。
  6. STL 版确认第三个参数:要小根堆写 greater<int>。

11. 本课小结

下一课预告
0407 排序算法 —— 快排与归并。快排的 partition、归并的 merge,都是「把大问题拆成两半」;学完再回头看堆排序,会发现它只是「反复取堆顶」的另一种写法。

← 上一课:二叉搜索树 BST · 课程对照表

书籍 《算法 (第4版)》第 2.4 节(优先队列)