堆与优先队列
环境:Windows 11 x64,GCC 13.1.0,
g++ -std=c++17 -O2。本节所有实现都做了交叉验证,包括我自己写错的两处。
一、手写二叉堆:30 行,但两个方向都容易反
template <class T>
struct min_heap {
std::vector<T> a;
void push(T v) { /* 上浮 */
a.push_back(std::move(v));
size_t i = a.size() - 1;
while (i > 0) {
size_t p = (i - 1) / 2;
if (!(a[i] < a[p])) break; /* 孩子不小于父亲 → 停 */
std::swap(a[i], a[p]); i = p;
}
}
T pop() { /* 下沉 */
T top = a[0];
a[0] = std::move(a.back()); a.pop_back();
size_t i = 0, n = a.size();
while (true) {
size_t l = 2*i+1, r = 2*i+2, b = i;
if (l < n && a[l] < a[b]) b = l; /* 选更小的孩子 */
if (r < n && a[r] < a[b]) b = r;
if (b == i) break;
std::swap(a[i], a[b]); i = b;
}
return top;
}
bool valid() const { for (size_t i = 1; i < a.size(); i++) if (a[(i-1)/2] > a[i]) return false; return true; }
};
我把它写错了两次,小样本还测不出来
第一版我用模板比较器 Cmp(默认 std::greater<int>),结果:
- 上浮写成
!Cmp()(a[i], a[p])→ 实际是”孩子 > 父亲才交换”,方向反了。 - 下沉写成
Cmp()(a[l], a[best])→ 变成”选较大的孩子”,也反了。
两处都反,在小规模测试里互相抵消——我拿 {5,3,8,1,9,2,7} 手工跑 4 次弹出,结果和 std::priority_queue 完全一致,valid() 也全过。看起来”没问题”。
真正的对拍才抓出来:
第 6 次弹出就不一致:手写 884431548 vs 标准 -1198440400,弹出前手写堆大小 4
修正两处方向后:
手写小顶堆 vs std::priority_queue(30 万次随机操作)
弹出序列与不变式全部一致=1,剩余元素数 100132 vs 100132
教训:堆的正确性不能靠”几个数手工验证”。 必须做两件事:① 每次操作后断言不变式 valid();② 与权威实现(std::priority_queue)在随机操作序列上比对弹出序列。少了任何一条,两个反向 bug 就能藏住。
二、std::priority_queue 的三个陷阱
陷阱 1:默认是大顶堆
std::priority_queue<int> a; /* 默认 less → 堆顶最大 */
std::priority_queue<int, std::vector<int>, std::greater<int>> b; /* 小顶堆 */
比较器的语义是”父节点不应排在子节点之后”,less 表示”父 < 子 违规”→ 大顶。这个反直觉命名坑了几乎所有人。要小顶堆就写 std::greater<>,或者干脆自己封装一个 using MinHeap = priority_queue<T, vector<T>, greater<T>>。
陷阱 2:没有 decrease_key
Dijkstra、Prim、A* 都需要”降低某个元素的优先级”,priority_queue 不提供。两种标准对策:
/* 方案 A:惰性删除 —— 直接 push 新值,弹出时跳过过期条目 */
if (du > d[u]) continue;
/* 方案 B:用 std::set,先 erase 旧值再 insert 新值 */
s.erase({ d[v], v }); d[v] = nd; s.insert({ d[v], v });
方案 A 更常用(代码短、常数小),代价是堆里会有重复条目,内存上界 O(边数)。
陷阱 3:top() 返回常量引用,pop() 不返回元素
auto &x = pq.top(); // 拿到的是 const T&
pq.pop(); // x 现在悬空了!pop 会销毁那个元素
必须先拷贝再 pop:auto x = pq.top(); pq.pop();。写成引用是真实的 use-after-free(std::vector 尾部元素被析构)。
三、make_heap 是 O(n),但只比逐个插入快 1.2 倍
上一节实测:300 万个数,make_heap 37.7ms vs 逐个 push_heap 45.1ms。
理论上 O(n) vs O(n log n),为什么只差 1.2 倍?因为 O(n log n) 是上界,实际增量插入的 sift-up 平均距离是常数:新元素落在数组末尾,一半情况下它比父亲大(不用动),四分之三情况下最多动一层……期望上浮层数收敛到常数。
make_heap 的向下 sift-down 同理:sum(⌈log₂k⌉/k) 收敛到 O(n)。
结论:两者都是线性时间,只差常数。别为了”O(n) 建堆”去改写代码,除非你的建堆真的在热点上。
四、双堆求数据流中位数:实测快 3862 倍
维护一个大顶堆装较小一半、一个小顶堆装较大一半,保持大小差不超过 1,中位数就在两个堆顶:
priority_queue<int> lo; /* 较小一半,大顶 */
priority_queue<int, vector<int>, greater<int>> hi; /* 较大一半,小顶 */
if (lo.empty() || x <= lo.top()) lo.push(x); else hi.push(x);
if (lo.size() > hi.size() + 1) { hi.push(lo.top()); lo.pop(); }
if (hi.size() > lo.size()) { lo.push(hi.top()); hi.pop(); }
double med = lo.size() > hi.size() ? lo.top() : (lo.top() + hi.top()) / 2.0;
20 万个数逐个插入并求中位数,对照”每次用 nth_element 现算”:
双堆 10.1 ms | 每次 nth_element 39011.5 ms(中位数之和 100125037334 vs 100125037334)
3862 倍,且两万个中位数之和逐位相同。 这个”和相同”是关键——它证明两种实现算的是同一个东西(我第一版把朴素实现写成了”取下中位数”,两边定义不同,和自然对不上,改齐定义后才一致)。
复杂度:双堆每次 O(log n)、总 O(n log n);朴素每次 O(n)、总 O(n²)。
五、Dijkstra:三种实现的耗时与一致性
1200 个节点、每点 12 条出边的随机图:
优先队列+惰性删除 0.4 ms | std::set 做 decrease-key 0.8 ms | O(V^2) 线性扫描 1.1 ms
三者距离完全一致=1(源点到各点距离之和 782951)
- 稀疏图(E ≈ 12V)用堆:O(E log V)。
- 稠密图用 O(V²) 线性扫描反而更简单,且当 E ≈ V² 时两者同阶、扫描常数更小。
- 三种实现结果必须一致——这是唯一能证明你写对了的方式(
O(V²)版几乎不可能写错方向,可以当参照实现)。
判据:E > V²/log V 就用扫描版,否则用堆版。
六、k 路归并:理论更优,实测更慢
合并 500 个已排序的 2000 元素序列(共 100 万个):
k 路归并(堆)66.7 ms | 拼接后整体排序 46.5 ms | 结果一致=1(1000000 个元素)
堆做 k 路归并是 O(N log k) = 100万 × 9,整体排序是 O(N log N) = 100万 × 20——理论上一半的比较次数,实测却慢 43%。
原因:
- 堆的每次操作都是随机访问 + 分支不可预测,而
std::sort是顺序访问、可向量化。 - 堆里存的是
tuple<value, list_idx, elem_idx>,比单个int大,缓存效率差。 - 拼接后的数组虽然”看起来无序”,但它是 500 段各自有序的片段,
introsort的分区阶段仍比堆的串行弹出友好。
什么时候 k 路归并才是对的:① 数据是流式的,装不进内存(外部排序的多路归并阶段);② 各序列来自不同来源(多路数据库游标、多文件合并),必须惰性产出;③ 只要前 M 个结果(M ≪ N),堆可以提前停止。
能全量装进内存时,还有更好的选择:std::inplace_merge 两两合并、或 C++23 的 std::ranges::fold_left/第三方 flat_merge。
七、这一节的自查清单
- 手写堆必须做随机操作对拍 + 每步断言不变式,手工小样本会骗人
- 小顶堆写
std::greater<>,或者封装成具名类型避免记反 -
top()不要存引用,先拷贝再pop() - 需要 decrease-key 就用惰性删除(记得
if (du > d[u]) continue;) - 稠密图 Dijkstra 用 O(V²) 扫描版,稀疏图用堆版
- 多种实现之间必须比对结果,不能只测一个
- 别为了
make_heap的 O(n) 改写代码(实测只差 1.2 倍) - 流式/内存受限才用 k 路归并,能全量载入就直接排序
- 比较”理论更优”的实现时,一定要实测——缓存和分支预测经常翻盘
下一节讲图的存储与遍历:邻接矩阵/邻接表/前向星的空间与速度实测、BFS 与 DFS 的非递归写法、拓扑排序与环检测。