C++ priority_queue 底层:手撕二叉堆与 Top K 问题
摘要 :
priority_queue(优先队列)是算法题和工程开发中极高频的容器。但当你被问到"它的底层是什么?为什么默认是大堆?为什么底层容器默认是 vector 而不是 deque?"时,很多人只能背出"堆"这个字。本文用一张图拆解二叉堆的上浮与下沉,并手写核心骨架代码,最后用 Top K 问题实战演练。
一、一个反直觉的默认行为
先看一段代码:
cpp
#include <queue>
#include <vector>
using namespace std;
int main() {
priority_queue<int> pq;
pq.push(3);
pq.push(1);
pq.push(4);
pq.push(2);
cout << pq.top() << endl; // 输出 4,而不是 3
}
为什么 top() 不是最先插入的 3,而是最大的 4?
因为 priority_queue 不是普通的先进先出队列,它是容器适配器 ,底层默认维护一个大顶堆(Max-Heap) 。每次 top() 返回的都是当前堆中优先级最高的元素(默认数值最大的)。
二、一张图看懂二叉堆:数组里的完全二叉树
priority_queue 的底层是一个完全二叉树 ,并且它被完美地映射到了数组(vector)中。
大顶堆的物理存储(vector):
下标: 0 1 2 3 4 5
数值: [9, 7, 8, 2, 5, 6]
对应的完全二叉树:
9 (0)
/ \
7 (1) 8 (2)
/ \ /
2 (3) 5(4) 6(5)
核心规则:
- 任意节点的值 ≥ 其左右子节点的值(大顶堆)。
- 对于下标为
i的节点:- 左孩子下标:
2 * i + 1 - 右孩子下标:
2 * i + 2 - 父节点下标:
(i - 1) / 2
- 左孩子下标:
📌 核心结论 :堆的根节点(下标 0)永远是整个数组中的最大值 。这就是
top()的 O(1) 复杂度的来源。
三、堆的两大核心操作:上浮与下沉
3.1 插入元素:向上调整(AdjustUp)
当新元素插入到数组尾部时,它可能破坏大顶堆的性质。此时需要向上调整:不断与父节点比较,如果比父节点大,就交换,直到满足条件或到达根节点。
cpp
// 向上调整(大顶堆)
void AdjustUp(vector<int>& heap, int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (heap[parent] < heap[child]) {
swap(heap[parent], heap[child]);
child = parent;
parent = (child - 1) / 2;
} else {
break; // 已经满足堆性质
}
}
}
3.2 删除堆顶:向下调整(AdjustDown)
pop() 操作会移除堆顶(最大值),然后把数组最后一个元素放到堆顶,再进行向下调整:不断与较大的子节点比较,如果比子节点小,就交换,直到满足条件或到达叶子节点。
cpp
// 向下调整(大顶堆)
void AdjustDown(vector<int>& heap, int parent, int size) {
int child = 2 * parent + 1; // 左孩子
while (child < size) {
// 选出左右孩子中较大的一个
if (child + 1 < size && heap[child] < heap[child + 1]) {
++child;
}
if (heap[parent] < heap[child]) {
swap(heap[parent], heap[child]);
parent = child;
child = 2 * parent + 1;
} else {
break;
}
}
}
💡 面试金句 :
push是上浮 ,pop是下沉。上浮和下沉的路径长度最多为树高 O(log n)。
四、为什么底层默认是 vector,而不是 deque?
priority_queue 的模板声明如下:
cpp
template <
class T,
class Container = std::vector<T>, // 默认底层容器
class Compare = std::less<typename Container::value_type> // 默认大堆
> class priority_queue;
为什么必须是 vector(或 deque),不能用 list?
因为堆的 AdjustUp 和 AdjustDown 需要随机访问 (通过下标 i 直接访问 heap[i])。list 不支持 O(1) 随机访问,因此被排除。
为什么默认选 vector 而不是 deque?
虽然 deque 也支持随机访问,但 vector 的内存连续性更好,CPU 缓存命中率更高 。堆操作需要频繁地上下跳跃访问,连续内存的局部性优势非常明显。此外,vector 的迭代器是原生指针,实现更简单。
五、实战:用 priority_queue 解决 Top K 问题
面试高频题:"从 100 万个数据中找出最大的 K 个。"
关键思路 :维护一个大小为 K 的小顶堆(注意:找最大 K 个,反而用小顶堆)。
cpp
#include <queue>
#include <vector>
#include <iostream>
using namespace std;
vector<int> topK(vector<int>& nums, int k) {
// 小顶堆:堆顶是当前 K 个元素中最小的
priority_queue<int, vector<int>, greater<int>> minHeap;
for (int x : nums) {
if (minHeap.size() < k) {
minHeap.push(x);
} else if (x > minHeap.top()) {
minHeap.pop(); // 淘汰最小的
minHeap.push(x); // 加入更大的
}
}
// 堆中剩下的 K 个就是最大的 K 个
vector<int> result;
while (!minHeap.empty()) {
result.push_back(minHeap.top());
minHeap.pop();
}
return result;
}
复杂度:时间 O(n log K),空间 O(K)。如果 K 很小(比如 10),性能远超全量排序的 O(n log n)。
⚠️ 避坑 :找"最大 K 个"必须用小顶堆 ,找"最小 K 个"用大顶堆。面试时先想清楚再写代码。
六、面试高频 FAQ
Q1:priority_queue 是容器还是容器适配器?
是容器适配器 。它本身不维护数据结构,而是包装了 vector(默认),并调用 push_heap / pop_heap 等算法来维护堆性质。
Q2:如何让 priority_queue 变成小顶堆?
两种方式:
priority_queue<int, vector<int>, greater<int>> pq;(推荐)- 自定义仿函数
struct Compare { bool operator()(int a, int b) { return a > b; } };
Q3:priority_queue 的 push 和 pop 的时间复杂度是多少?
push 需要上浮,O(log n);pop 需要下沉,O(log n);top 是 O(1)。
Q4:priority_queue 可以遍历吗?
不可以 。它只暴露 top(),不提供迭代器。因为堆的内部顺序并不等同于优先级顺序,遍历没有意义。如需遍历,应使用 vector 加 make_heap 手动管理。
七、总结与挑战
| 核心点 | 结论 |
|---|---|
| 底层结构 | 完全二叉树 → 数组存储 |
| 默认行为 | 大顶堆,top() 返回最大值 |
| 核心操作 | push 上浮,pop 下沉,均 O(log n) |
| 底层容器 | 默认 vector(随机访问 + 缓存友好) |
| Top K 口诀 | 最大 K 个用小顶堆,最小 K 个用大顶堆 |
核心结论 :priority_queue 是"用数组存树"的经典范例。理解上浮与下沉,就理解了堆的核心;理解 Top K 的对偶关系,就掌握了这道面试题的灵魂。
评论区互动 :
你在算法题或项目中用过 priority_queue 吗?
扣1 :用过,主要用来做 Top K。
扣2 :用过,用来做任务调度/合并 K 个链表。
扣3 :还没用过,今天第一次学。
评论区见!
🔥 系列爆款推荐 :
👉 [C++ unordered_map 底层:一张图看懂哈希冲突与扩容](#C++ unordered_map 底层:一张图看懂哈希冲突与扩容)
👉 [C++ vector 迭代器失效:一张图+3句话,面试直接背](#C++ vector 迭代器失效:一张图+3句话,面试直接背)