④ 数据结构与算法 — 第 6 课
P1090 合并果子:有 n 堆果子,每次把两堆合并成一堆,消耗体力 = 两堆重量之和,求合并成一堆的最小总体力。
贪心策略很直白:每次挑最轻的两堆合并。于是每一步都要做两件事——
如果用 sort 每轮重排一次:一轮 O(n log n),总共 O(n² log n),n 上万就超时。如果线性扫一遍找最小:一轮 O(n),总共 O(n²),也不够快。
| 方案 | 取最小值 | 动态插入/删除 | 适合吗 |
|---|---|---|---|
| vector + 线性查找 | O(n) | O(1) 但会打乱顺序 | 数据小才行 |
| vector + sort | O(1)(排好序后) | O(n)(插入后要重排) | 数据不变时可以 |
| set | O(log n) | O(log n) | 可以,但常数偏大 |
| 堆(priority_queue) | O(1) 看堆顶 | O(log n) | 专门干这个的 |
堆就是为「动态地反复取极值」这件事设计的。它比 BST 简单得多,因为它只维护一小部分信息。
堆(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 3 2 7 5 4,看着乱,但 1 永远在顶上。所以堆和 BST 是两种不同的取舍:
| BST / set | 堆 | |
|---|---|---|
| 维护什么 | 全体有序 | 只要极值在顶上 |
| 遍历能不能得到有序序列 | 能(中序) | 不能 |
| 拿最小值 | O(log n) 一路向左 | O(1) 直接看堆顶 |
| 实现难度 | 要处理平衡/删除 | 两个动作就够 |
完全二叉树的形状规则,意味着它没有空洞,可以直接用数组紧凑地存,不需要指针。
约定从下标 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] 不存在,说明没有右孩子)
2i+1、右孩子 2i+2、父亲 (i-1)/2。只是写起来更容易错。新元素先放在数组末尾(这样仍然是完全二叉树),然后不停和父亲比:比父亲小,就和父亲交换,直到不比父亲小为止。
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 4 | hn = 6 |
| 1 | h[++hn] = 0 | 1 3 2 7 5 4 0 | 放到末尾,i = 7 |
| 2 | 和父亲 h[3] 交换 | 1 3 0 7 5 4 2 | 0 比 2 小,i 变成 3 |
| 3 | 和父亲 h[1] 交换 | 0 3 1 7 5 4 2 | 0 比 1 小,i 变成 1,停 |
三轮之后 0 浮到了堆顶。上浮只走一条从叶到根的路径,长度是树高,所以是 O(log n)。
删掉堆顶之后,让最后一个元素搬到堆顶(还是完全二叉树),然后不停和自己较小的那个孩子比:比孩子大就交换,直到不比孩子大。
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 4 | hn = 6,堆顶 1 要被删 |
| 1 | h[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 |
| 3 | i=3 没有孩子 | 2 3 4 7 5 | 2i = 6 已经超过 hn = 5,停 |
一次上浮或下沉,最多走一条从根到叶的路径,长度是树高 log n,所以两种操作都是 O(log n)。top() 是 O(1)。
堆在 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();q = priority_queue<int>();)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 是升序(分数低的在前)。| 需求 | 推荐 |
|---|---|
| 反复取最大/最小,数据还在动态增删 | priority_queue |
| 要遍历出有序序列、要找前驱后继、要去重 | set / map |
| 数据一次给定、不变,只要排个序 | vector + sort |
| 要按下标随机访问第 i 个元素 | vector(堆做不到) |
| 只要前 k 个最大/最小 | 优先队列(见下) |
#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)。
思路反着来:用一个小根堆只保留 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 个」就用大根堆,其它一字不改。
2*i、i/2 是 1-based 的公式;从 0 开始要换成 2i+1、2i+2、(i-1)/2,别只改一半。pop():h[1] = h[hn--] 在 hn = 0 时读到的是 h[0] 的垃圾值,取堆顶前先判空。hn:push 后不加、pop 后不减,后面所有边界判断全错位。i > 1,下沉是 l <= hn;差一个等号就会越界或提前停。pop() 不返回值;自定义比较器和 sort 的 cmp 方向正好相反。priority_queue<int> 默认是?cout << top(); pop(); 直到堆空1 x 插入 x;2 输出最小值;3 删除最小值。priority_queue 过一遍,再把 STL 换成你手写的数组堆,两遍都提交。
long long;循环条件用 while (q.size() > 1)。
A.size() 始终等于 B.size() 或比它大 1。这样 A 的堆顶就是中位数。push:先放末尾,再和父亲比;比较对象是 i/2。pop:先把末尾搬到堆顶、hn 减一,再和较小的孩子比。i > 1,下沉 l <= hn。greater<int>。2i、右孩子 2i+1、父亲 i/2。priority_queue<int> 大根堆,priority_queue<int, vector<int>, greater<int>> 小根堆。sort 的 cmp 相反,容易搞反。