堆、优先队列与摊还分析 — 从零精通算法与数据结构——Google 面试系统备战 第6篇
第6章:堆、优先队列与摊还分析
本章目标
读完本章你会:
- 用数学严格证明建堆是 O(n) 而非 O(n log n)
- 熟练应用优先队列解决 Top-K 问题
- 掌握双堆模式(Two Heaps):从数据流中位数到滑动窗口中位数
- 深入理解三种摊还分析方法:聚合、记账、势能
- 用摊还分析解释动态数组扩容的 O(1) 摊销代价
知识讲解
从一个生活例子开始
急诊室不是按"先到先看"排队,而是按病情严重程度——这叫优先队列。有人比你晚到但心梗了,他插到你前面是合理的。
优先队列的底层正是堆——一个能快速找到"最紧急"病人的数据结构:
- 新病人来了 → 插入 O(log n)
- 最紧急的病人被叫走 → 取出最大/最小值 O(1) 查询 + O(log n) 删除
- 病人的病情变化了 → 更新优先级 O(log n)
如果没有堆,你只能给所有病人排序——排序是 O(n log n),而堆的每次操作只是 O(log n)。这就是堆存在的意义。
工作原理
6.1 堆的数组表示
堆 = 完全二叉树 + 堆性质(父 ≥ 子 → 最大堆, 或父 ≤ 子 → 最小堆)。
数组索引技巧:
16 (索引 0)
/ \
14 (1) 10 (2)
/ \ / \
8(3) 7(4) 9(5) 3(6)
/ \
2(7) 4(8)
规则:
- 父 → 左子: 2i + 1
- 父 → 右子: 2i + 2
- 子 → 父: (i - 1) / 2
不需要指针!数组就能完整表示堆——这是堆最大的工程优势。
6.2 建堆 O(n) 的严格证明
这是算法面试中最爱追问的理论点之一。直觉上逐个插入建堆是 O(n log n),但自底向上 heapify 是 O(n)。为什么?
自底向上建堆法:
BUILD-HEAP(A):
for i = ⌊n/2⌋ - 1 downto 0: // 从最后一个非叶节点开始
SIFT-DOWN(A, i) // 下沉修复以 i 为根的子树
关键洞察:下沉的代价与节点高度相关,而非深度。
树结构分析(n 个节点的完全二叉树):
● ← 高度 h = ⌊log₂ n⌋,1 个节点
/ \
● ● ← 高度 h-1,2 个节点
/ \ / \
● ● ● ● ← 高度 h-2,4 个节点
/ \ ...
● ● ... ← 高度 0 = 叶子,约 n/2 个节点(免费!)
每个节点下沉的代价 = O(该节点的高度)。
总代价 = Σ(各层节点数 × 该层高度)
高度 0: n/2 个节点 × 0 步 = 0
高度 1: n/4 个节点 × 1 步 = n/4
高度 2: n/8 个节点 × 2 步 = 2n/8
高度 3: n/16 个节点 × 3 步 = 3n/16
...
高度 h: 1 个节点 × h 步 = h
总和 = n · Σ_{k=0}^{h} (k / 2^{k+1})
< n · Σ_{k=0}^{∞} (k / 2^{k+1})
= n · 1
= O(n)
因为无穷级数 Σ k/2^{k+1} = 1(等比数列的变体——结果是个常数)。
这就是建堆 O(n) 的完整证明。 面试中能讲清楚这个结论 = 你对数据结构的理解已经达到 Google 面试的标准。
6.3 Top-K 问题
问题: 在 n 个元素中找最大(或最小)的 k 个。
方案对比:
| 方案 | 复杂度 | k 较小时 | k 接近 n 时 |
|---|---|---|---|
| 全排序 | O(n log n) | 浪费 | 刚好 |
| 大小为 k 的最小堆 | O(n log k) | 高效 | 退化为全排序 |
| QuickSelect | O(n) 期望 | 高效 | 高效 |
最小堆方案(找 Top-K 最大): 维护一个大小为 k 的最小堆。遍历所有元素,比堆顶大就替换堆顶并下沉。复杂度 O(n log k)。
// 找最大的 k 个元素
std::vector<int> TopKLargest(const std::vector<int>& data, int k) {
if (k <= 0) return {};
// 大小为 k 的最小堆
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;
for (int x : data) {
if (static_cast<int>(min_heap.size()) < k) {
min_heap.push(x);
} else if (x > min_heap.top()) {
min_heap.pop();
min_heap.push(x);
}
}
// 从堆中取出结果
std::vector<int> result;
while (!min_heap.empty()) {
result.push_back(min_heap.top());
min_heap.pop();
}
return result;
}
6.4 双堆模式:数据流中位数
问题: 数据源源不断到来,需要随时能查询当前所有数据的中位数。
双堆方案:
- 最大堆(左半):存储较小的一半元素
- 最小堆(右半):存储较大的一半元素
保持 |左堆| = |右堆| 或 |左堆| = |右堆| + 1。
中位数 = 左堆堆顶(奇数个)或 (左堆顶 + 右堆顶)/2(偶数个)。
插入逻辑:
插入 x:
if 左堆空 或 x ≤ 左堆顶:
左堆.push(x)
else:
右堆.push(x)
// 平衡:确保左堆不少于右堆,且两堆大小差距 ≤ 1
if 左堆.size() > 右堆.size() + 1:
右堆.push(左堆.pop())
else if 右堆.size() > 左堆.size():
左堆.push(右堆.pop())
每次插入 O(log n),查询中位数 O(1)。
6.5 摊还分析三方法
摊还分析回答:"一系列操作的总代价是多少?" 有三种经典方法:
1. 聚合分析: 直接计算序列总代价 ÷ 操作数。
例:动态数组 n 次 push_back。扩容只发生在元素数 = 2ᵏ 时。总拷贝次数 = 1 + 2 + 4 + ... + 2^{⌈log n⌉} < 2n。每次操作的摊还代价 = 2n / n = O(1)。
2. 记账法: 给每次操作预收"假想的费用",贵的操作使用之前攒的余额支付。
例:push_back 记账 3 元——1 元用于本次插入,1 元用于自己将来被拷贝,1 元用于提前帮另一个元素付拷贝费。每次扩容时,拷贝费已被之前存的余额覆盖。
3. 势能法: 定义一个"势能函数" Φ,使得:
- 廉价操作增加 Φ(存能量)
- 昂贵操作消耗 Φ(用能量)
摊还代价 = 实际代价 + ΔΦ。
代码实战
include/algo/heap.h(添加到 algo_toolkit)
#ifndef ALGO_HEAP_H_
#define ALGO_HEAP_H_
#include <cstddef>
#include <cstdint>
#include <functional>
#include <queue>
#include <stdexcept>
#include <vector>
namespace algo {
// ========== 动态中位数查找器(双堆模式) ==========
class MedianFinder {
public:
// 添加一个数 O(log n)
void AddNum(int num);
// 查询当前中位数 O(1)
double FindMedian() const;
std::size_t size() const { return max_heap_.size() + min_heap_.size(); }
private:
void Rebalance();
// 左半:最大堆(存较小的一半)
std::priority_queue<int> max_heap_;
// 右半:最小堆(存较大的一半)
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap_;
};
// ========== Top-K 工具 ==========
// 返回 data 中最大的 k 个元素(不保证有序)
std::vector<int> TopKLargest(const std::vector<int>& data, int k);
// 返回 data 中最小的 k 个元素(不保证有序)
std::vector<int> TopKSmallest(const std::vector<int>& data, int k);
} // namespace algo
#endif // ALGO_HEAP_H_
include/algo/heap_impl.h
#ifndef ALGO_HEAP_IMPL_H_
#define ALGO_HEAP_IMPL_H_
namespace algo {
inline void MedianFinder::AddNum(int num) {
if (max_heap_.empty() || num <= max_heap_.top()) {
max_heap_.push(num);
} else {
min_heap_.push(num);
}
Rebalance();
}
inline double MedianFinder::FindMedian() const {
if (max_heap_.empty() && min_heap_.empty()) {
throw std::runtime_error("MedianFinder is empty");
}
if (max_heap_.size() > min_heap_.size()) {
return max_heap_.top();
}
return (max_heap_.top() + min_heap_.top()) / 2.0;
}
inline void MedianFinder::Rebalance() {
if (max_heap_.size() > min_heap_.size() + 1) {
min_heap_.push(max_heap_.top());
max_heap_.pop();
} else if (min_heap_.size() > max_heap_.size()) {
max_heap_.push(min_heap_.top());
min_heap_.pop();
}
}
inline std::vector<int> TopKLargest(const std::vector<int>& data, int k) {
if (k <= 0 || data.empty()) return {};
std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;
for (int x : data) {
if (static_cast<int>(min_heap.size()) < k) {
min_heap.push(x);
} else if (x > min_heap.top()) {
min_heap.pop();
min_heap.push(x);
}
}
std::vector<int> result;
result.reserve(k);
while (!min_heap.empty()) {
result.push_back(min_heap.top());
min_heap.pop();
}
return result;
}
inline std::vector<int> TopKSmallest(const std::vector<int>& data, int k) {
if (k <= 0 || data.empty()) return {};
std::priority_queue<int> max_heap;
for (int x : data) {
if (static_cast<int>(max_heap.size()) < k) {
max_heap.push(x);
} else if (x < max_heap.top()) {
max_heap.pop();
max_heap.push(x);
}
}
std::vector<int> result;
result.reserve(k);
while (!max_heap.empty()) {
result.push_back(max_heap.top());
max_heap.pop();
}
return result;
}
} // namespace algo
#endif // ALGO_HEAP_IMPL_H_
本章小结
- 堆用数组表示完全二叉树,索引关系:左子 2i+1,右子 2i+2,父 (i-1)/2
- 自底向上建堆是 O(n),因为大部分节点(叶子)不需要下沉
- Top-K 用大小为 k 的最小堆 → O(n log k),远好于全排序 O(n log n)
- 双堆模式解决数据流中位数——左半最大堆 + 右半最小堆
- 摊还分析三种方法:聚合(算总账)、记账(预收费)、势能(能量函数)
关键术语
| 术语 | 释义 |
|---|---|
| 完全二叉树 | 除最后一层外每层都满,且最后一层左对齐 |
| 下沉(sift-down) | 将违反堆性质的节点向下调整,直到符合堆性质 |
| Top-K | 在海量数据中找到最大(或最小)的 k 个元素 |
| 双堆模式 | 用最大堆+最小堆分别存储数据的两半,实现快速中位数查询 |
| 摊还分析 | 分析一系列操作的平均代价,而非单次的最高代价 |

浙公网安备 33010602011771号