6.4 堆与优先队列

4384 字
22 分钟
6.4 堆与优先队列

6.4 堆与优先队列#

一句话理解:堆是一棵用数组存储的完全二叉树,满足”父节点 ≥ 子节点”(最大堆) 或”父节点 ≤ 子节点”(最小堆)。它能在 O(1) 取最值、O(log n) 插入/删除,是优先队列、堆排序、Top-K 问题的底层基石。


6.4.1 概念与性质#

完全二叉树 + 堆序性#

堆 = 两条性质的组合:

1. 结构性质(完全二叉树):
除最后一层外全满,最后一层从左到右连续填充
→ 可以用数组紧凑存储,无空洞
2. 堆序性质(Heap Property):
最大堆:parent.val ≥ children.val
最小堆:parent.val ≤ children.val
→ 根节点是全局最大/最小值
graph TD subgraph "最大堆" A1["90"] --> B1["80"] A1 --> C1["70"] B1 --> D1["50"] B1 --> E1["60"] C1 --> F1["30"] C1 --> G1["40"] end subgraph "最小堆" A2["10"] --> B2["20"] A2 --> C2["30"] B2 --> D2["50"] B2 --> E2["40"] C2 --> F2["60"] C2 --> G2["70"] end style A1 fill:#d00000,stroke:#e85d04,color:white style A2 fill:#2d6a4f,stroke:#40916c,color:white

数组存储 —— 堆的精髓#

完全二叉树可以完美映射到数组,不浪费任何空间:

最大堆: 90
/ \
80 70
/ \ / \
50 60 30 40
数组 (0-indexed): [90, 80, 70, 50, 60, 30, 40]
下标: 0 1 2 3 4 5 6

父子关系公式(0-indexed)

关系公式
父节点(i - 1) / 2
左子节点2 * i + 1
右子节点2 * i + 2
最后一个非叶节点(n / 2) - 1

💡 为什么用数组? 完全二叉树是唯一可以用数组零浪费存储的树结构。数组存储带来两个巨大优势:(1) cache 友好——连续内存,遍历时 cache 命中率极高;(2) 零指针开销——不需要 left/right 指针,节省 2 × 8 = 16 字节/节点。

堆 ≠ BST#

面试中常见的混淆:

对比BST
约束父 ≥ 子(或 ≤)左 < 根 < 右
能做什么O(1) 取最值O(log n) 查找任意值
不能做什么不支持精确查找不能 O(1) 取最值
存储数组链式(指针)
有序遍历✅ 中序遍历有序

6.4.2 核心操作#

sift-up(上浮)—— 插入时用#

插入 85 到最大堆 [90, 80, 70, 50, 60, 30, 40]:
Step 1: 追加到末尾
[90, 80, 70, 50, 60, 30, 40, 85]
↑ 新元素 (index=7)
Step 2: 上浮——与父节点比较,如果更大就交换
85 vs parent=50 (index=3) → 85 > 50 → swap!
[90, 80, 70, 85, 60, 30, 40, 50]
85 vs parent=80 (index=1) → 85 > 80 → swap!
[90, 85, 70, 80, 60, 30, 40, 50]
85 vs parent=90 (index=0) → 85 < 90 → 停!
最终: [90, 85, 70, 80, 60, 30, 40, 50]

sift-down(下沉)—— 删除最大值时用#

删除最大值(堆顶 90):
Step 1: 用末尾元素替换堆顶
[50, 85, 70, 80, 60, 30, 40]
↑ 50 不应该在这里
Step 2: 下沉——与较大的子节点比较,如果更小就交换
50 vs max(left=85, right=70) = 85 → 50 < 85 → swap!
[85, 50, 70, 80, 60, 30, 40]
50 vs max(left=80, right=60) = 80 → 50 < 80 → swap!
[85, 80, 70, 50, 60, 30, 40]
50 没有子节点了 → 停!
最终: [85, 80, 70, 50, 60, 30, 40]

💡 sift-down 的关键:必须和较大的子节点交换(最大堆)。如果和较小的交换,交换后的父节点可能比另一个子节点小,仍然违反堆序。

buildHeap —— O(n) 建堆#

从无序数组建堆有两种方法:

方法 1:逐个插入 → n 次 sift-up → O(n log n)
方法 2:自底向上 sift-down → O(n) ✅
方法 2 的过程:
从最后一个非叶节点开始,逐个向前做 sift-down
为什么是 O(n)?
- 叶节点(n/2 个)不需要下沉 → 0 次操作
- 倒数第二层(n/4 个)最多下沉 1 次
- 倒数第三层(n/8 个)最多下沉 2 次
- ...
- 根节点(1 个)最多下沉 log n 次
总操作 = n/4·1 + n/8·2 + n/16·3 + ... = O(n)(收敛的等比级数)

6.4.3 C++ 实现#

手写最大堆#

template <typename T, typename Compare = std::less<T>>
class BinaryHeap {
std::vector<T> _data;
Compare _cmp; // 默认 less → 最大堆(父 > 子)
// ===== 辅助 =====
int _parent(int i) const { return (i - 1) / 2; }
int _left(int i) const { return 2 * i + 1; }
int _right(int i) const { return 2 * i + 2; }
// ===== 上浮 =====
void _sift_up(int i) {
while (i > 0 && _cmp(_data[_parent(i)], _data[i])) {
// 父 < 子(违反堆序)→ 交换
std::swap(_data[_parent(i)], _data[i]);
i = _parent(i);
}
}
// ===== 下沉 =====
void _sift_down(int i) {
int n = _data.size();
while (true) {
int largest = i;
int l = _left(i);
int r = _right(i);
if (l < n && _cmp(_data[largest], _data[l]))
largest = l;
if (r < n && _cmp(_data[largest], _data[r]))
largest = r;
if (largest == i) break; // 已满足堆序
std::swap(_data[i], _data[largest]);
i = largest;
}
}
public:
BinaryHeap() = default;
// 从数组建堆 → O(n)
BinaryHeap(std::vector<T> data) : _data(std::move(data)) {
// 自底向上 sift-down
for (int i = static_cast<int>(_data.size()) / 2 - 1; i >= 0; --i) {
_sift_down(i);
}
}
// 插入 → O(log n)
void push(const T& val) {
_data.push_back(val);
_sift_up(_data.size() - 1);
}
// 取堆顶 → O(1)
const T& top() const { return _data.front(); }
// 弹出堆顶 → O(log n)
void pop() {
_data.front() = _data.back();
_data.pop_back();
if (!_data.empty()) _sift_down(0);
}
std::size_t size() const { return _data.size(); }
bool empty() const { return _data.empty(); }
};

std::priority_queue 接口回顾#

在第 4 章(队列)中已详细介绍过。这里回顾关键要点:

#include <queue>
// 最大堆(默认)
std::priority_queue<int> max_pq;
max_pq.push(3);
max_pq.push(1);
max_pq.push(4);
max_pq.top(); // 4
max_pq.pop(); // 弹出 4
// 最小堆(传 greater)
std::priority_queue<int, std::vector<int>, std::greater<int>> min_pq;
min_pq.push(3);
min_pq.push(1);
min_pq.push(4);
min_pq.top(); // 1
// 自定义比较
auto cmp = [](const auto& a, const auto& b) { return a.cost > b.cost; };
std::priority_queue<Edge, std::vector<Edge>, decltype(cmp)> pq(cmp);

std::priority_queue 的局限

不支持的操作说明解决方案
decrease_key修改已有元素的优先级索引堆 / lazy deletion
erase删除指定元素lazy deletion(标记删除)
遍历只能访问堆顶维护额外数据结构
合并两个堆直接 merge 再 buildHeap O(n)

STL 堆操作族#

<algorithm> 中有一组直接在数组上操作堆的函数:

#include <algorithm>
#include <vector>
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
// 建堆 → O(n)
std::make_heap(v.begin(), v.end());
// v = [9, 6, 4, 1, 5, 3, 2, 1]
// 入堆 → 先 push_back,再 push_heap → O(log n)
v.push_back(8);
std::push_heap(v.begin(), v.end());
// v = [9, 8, 4, 6, 5, 3, 2, 1, 1]
// 出堆 → 先 pop_heap(把最大值换到末尾),再 pop_back → O(log n)
std::pop_heap(v.begin(), v.end());
v.pop_back();
// v = [8, 6, 4, 1, 5, 3, 2, 1]
// 堆排序 → O(n log n)
std::sort_heap(v.begin(), v.end());
// v = [1, 1, 2, 3, 4, 5, 6, 8]
// 检查是否是堆
bool is_heap = std::is_heap(v.begin(), v.end());

💡 priority_queue vs make_heappriority_queue 是封装好的容器适配器,更安全。make_heap 系列函数直接操作数组,更灵活(可以访问任意元素、自定义排列)。


6.4.4 堆排序 (Heap Sort)#

算法流程#

Step 1: 将数组原地建成最大堆 → O(n)
Step 2: 不断"取最大值放到末尾":
- 交换 heap[0](最大值)和 heap[n-1](末尾)
- 堆大小减 1
- 对堆顶做 sift-down
- 重复直到堆大小为 1
→ 总计 O(n log n)

C++ 实现#

void heapSort(std::vector<int>& arr) {
int n = arr.size();
// Step 1: 建堆(自底向上 sift-down)
for (int i = n / 2 - 1; i >= 0; --i) {
_siftDown(arr, n, i);
}
// Step 2: 逐个取出最大值
for (int i = n - 1; i > 0; --i) {
std::swap(arr[0], arr[i]); // 最大值放末尾
_siftDown(arr, i, 0); // 缩小堆范围,修复堆顶
}
}
void _siftDown(std::vector<int>& arr, int heap_size, int i) {
while (true) {
int largest = i;
int l = 2 * i + 1;
int r = 2 * i + 2;
if (l < heap_size && arr[l] > arr[largest]) largest = l;
if (r < heap_size && arr[r] > arr[largest]) largest = r;
if (largest == i) break;
std::swap(arr[i], arr[largest]);
i = largest;
}
}

堆排序 vs 其他排序#

排序算法平均最坏空间稳定特点
快速排序O(n log n)O(n²)O(log n)实际最快(cache 友好)
归并排序O(n log n)O(n log n)O(n)稳定,适合链表/外部排序
堆排序O(n log n)O(n log n)O(1)原地 + 最坏保证
std::sortO(n log n)O(n log n)O(log n)IntroSort(快排+堆排混合)

💡 堆排序的独特价值:它是唯一同时具有 O(n log n) 最坏保证O(1) 额外空间 的排序算法。实际中不如快排快(cache 不友好——sift-down 跳跃访问),但 std::sort(IntroSort)在快排退化时会切换到堆排序作为保底。


6.4.5 索引堆 (Indexed Heap)#

标准堆不支持”修改某个元素的优先级”(decrease_key)。索引堆通过维护一个位置映射表解决这个问题——Dijkstra 最短路算法就需要它。

// 索引最小堆:支持 decrease_key
class IndexedMinHeap {
int _capacity;
int _size = 0;
std::vector<int> _heap; // heap[i] = 原始数组中的下标
std::vector<int> _pos; // pos[id] = id 在堆中的位置(-1 = 不在堆中)
std::vector<int> _keys; // keys[id] = id 对应的优先级
void _swap_heap(int i, int j) {
_pos[_heap[i]] = j;
_pos[_heap[j]] = i;
std::swap(_heap[i], _heap[j]);
}
void _sift_up(int i) {
while (i > 0) {
int parent = (i - 1) / 2;
if (_keys[_heap[i]] < _keys[_heap[parent]]) {
_swap_heap(i, parent);
i = parent;
} else break;
}
}
void _sift_down(int i) {
while (2 * i + 1 < _size) {
int child = 2 * i + 1;
if (child + 1 < _size && _keys[_heap[child + 1]] < _keys[_heap[child]])
++child;
if (_keys[_heap[i]] <= _keys[_heap[child]]) break;
_swap_heap(i, child);
i = child;
}
}
public:
IndexedMinHeap(int capacity)
: _capacity(capacity), _heap(capacity), _pos(capacity, -1),
_keys(capacity, INT_MAX) {}
bool contains(int id) const { return _pos[id] != -1; }
bool empty() const { return _size == 0; }
void push(int id, int key) {
_keys[id] = key;
_heap[_size] = id;
_pos[id] = _size;
_sift_up(_size++);
}
std::pair<int, int> top() const {
return {_heap[0], _keys[_heap[0]]};
}
std::pair<int, int> pop() {
int id = _heap[0];
int key = _keys[id];
_swap_heap(0, --_size);
_pos[id] = -1;
_sift_down(0);
return {id, key};
}
// 关键操作:修改优先级 → O(log n)
void decrease_key(int id, int new_key) {
_keys[id] = new_key;
_sift_up(_pos[id]); // 优先级变小(更优先)→ 上浮
}
};

用于 Dijkstra

std::vector<int> dijkstra(const Graph& g, int src) {
int n = g.size();
std::vector<int> dist(n, INT_MAX);
dist[src] = 0;
IndexedMinHeap pq(n);
pq.push(src, 0);
while (!pq.empty()) {
auto [u, d] = pq.pop();
if (d > dist[u]) continue;
for (auto [v, w] : g[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (pq.contains(v))
pq.decrease_key(v, dist[v]); // ← 关键!
else
pq.push(v, dist[v]);
}
}
}
return dist;
}

💡 没有索引堆怎么办? 实际竞赛和面试中,常用 lazy deletion(懒删除)代替:直接往 priority_queue 里重复推入 {new_dist, v},弹出时检查是否过时。简单但空间 O(m)。


6.4.6 面试高频题#

第 K 大元素 (LeetCode 215)#

在未排序的数组中找到第 k 大的元素。

方法一:最小堆(大小为 K)

int findKthLargest(std::vector<int>& nums, int k) {
// 维护大小为 k 的最小堆
// 堆顶就是第 k 大
std::priority_queue<int, std::vector<int>, std::greater<int>> min_pq;
for (int num : nums) {
min_pq.push(num);
if (static_cast<int>(min_pq.size()) > k) {
min_pq.pop(); // 弹出最小的,保持堆大小 = k
}
}
return min_pq.top();
}
// 时间 O(n log k), 空间 O(k)

方法二:快速选择(QuickSelect)

int findKthLargest_quick(std::vector<int>& nums, int k) {
// 第 k 大 = 第 (n-k) 小 (0-indexed)
int target = nums.size() - k;
return _quickSelect(nums, 0, nums.size() - 1, target);
}
int _quickSelect(std::vector<int>& nums, int lo, int hi, int target) {
int pivot = nums[lo + rand() % (hi - lo + 1)];
int i = lo, j = lo, k = hi;
// 三路划分:[< pivot | == pivot | > pivot]
while (j <= k) {
if (nums[j] < pivot) std::swap(nums[i++], nums[j++]);
else if (nums[j] > pivot) std::swap(nums[j], nums[k--]);
else ++j;
}
if (target < i) return _quickSelect(nums, lo, i - 1, target);
if (target > k) return _quickSelect(nums, k + 1, hi, target);
return nums[target];
}
// 时间 O(n) 平均, O(n²) 最坏; 空间 O(1)
方法时间空间特点
排序O(n log n)O(1)最简单
最小堆O(n log k)O(k)适合数据流
快速选择O(n) 平均O(1)最快但最坏 O(n²)
std::nth_elementO(n) 平均O(1)STL 版快速选择

数据流的中位数 (LeetCode 295) —— 双堆经典#

设计一个数据结构,支持 addNum(int num)findMedian() 两个操作。

🧠 思路推导(面试时怎么想到的)

Step 1: 暴力想法
每次 addNum 插入排序数组, findMedian 取中间 → addNum O(n), findMedian O(1)
或者: addNum O(1) 直接追加, findMedian 时排序取中间 → findMedian O(n log n)
都太慢。
Step 2: 目标复杂度
addNum O(log n), findMedian O(1) → 需要始终维护"有序性的部分信息"
Step 3: 关键洞察
中位数把数据分成"较小的一半"和"较大的一半"。
我不需要两半各自完全有序, 只需要知道:
- 较小半的最大值 (小半里最接近中间的)
- 较大半的最小值 (大半里最接近中间的)
"最大值" → 最大堆! "最小值" → 最小堆!
Step 4: 双堆方案
最大堆 lo: 存较小的一半 (堆顶 = 小半的最大值)
最小堆 hi: 存较大的一半 (堆顶 = 大半的最小值)
保持: lo.size() == hi.size() 或 lo.size() == hi.size() + 1
中位数: lo.size() > hi.size() → lo.top()
否则 → (lo.top() + hi.top()) / 2
Step 5: 插入逻辑
新数先入 lo → lo 的最大值转入 hi → 如果 hi 多了再转回 lo
这 3 步保证: lo 的每个元素 ≤ hi 的每个元素, 且大小平衡

核心思路:用一个最大堆存较小的一半,一个最小堆存较大的一半。中位数从两个堆顶取。

class MedianFinder {
// 最大堆:存较小的一半(堆顶 = 较小半的最大值)
std::priority_queue<int> lo;
// 最小堆:存较大的一半(堆顶 = 较大半的最小值)
std::priority_queue<int, std::vector<int>, std::greater<int>> hi;
public:
void addNum(int num) {
lo.push(num); // 先放入较小半
hi.push(lo.top()); // 把最大值转到较大半
lo.pop();
// 保持 lo.size() >= hi.size()(lo 可以多一个)
if (lo.size() < hi.size()) {
lo.push(hi.top());
hi.pop();
}
}
double findMedian() {
if (lo.size() > hi.size()) {
return lo.top(); // 奇数个 → lo 堆顶
}
return (lo.top() + hi.top()) / 2.0; // 偶数个 → 两堆顶的均值
}
};
// addNum: O(log n), findMedian: O(1)

图解(依次插入 [5, 3, 8, 1, 4]):

add(5): lo=[5], hi=[] median=5
add(3): lo=[3], hi=[5] median=(3+5)/2=4
add(8): lo=[5,3], hi=[8] median=5
add(1): lo=[3,1], hi=[5,8] median=(3+5)/2=4
add(4): lo=[4,3,1], hi=[5,8] median=4

💡 面试金句:「双堆维护有序性——最大堆的堆顶 ≤ 最小堆的堆顶。这样中位数就在两个堆顶之间。每次插入 O(log n),查询中位数 O(1)。」

合并 K 个有序链表 (LeetCode 23)#

合并 k 个排序链表,返回合并后的排序链表。

ListNode* mergeKLists(std::vector<ListNode*>& lists) {
auto cmp = [](ListNode* a, ListNode* b) { return a->val > b->val; };
std::priority_queue<ListNode*, std::vector<ListNode*>, decltype(cmp)> pq(cmp);
// 把每个链表的头节点放入最小堆
for (auto* head : lists) {
if (head) pq.push(head);
}
ListNode dummy(0);
ListNode* tail = &dummy;
while (!pq.empty()) {
ListNode* node = pq.top();
pq.pop();
tail->next = node;
tail = node;
if (node->next) pq.push(node->next);
}
return dummy.next;
}
// 时间 O(N log k), N = 总节点数, k = 链表数
// 空间 O(k)

这道题在第 2 章(链表)中用分治做过。这里用堆来做——核心思想:始终从 k 个链表的当前头节点中取最小的。堆维护这 k 个候选节点。

前 K 个高频元素 (LeetCode 347)#

给定非空整数数组,返回其中出现频率前 k 高的元素。

std::vector<int> topKFrequent(std::vector<int>& nums, int k) {
// 1. 统计频率
std::unordered_map<int, int> freq;
for (int n : nums) ++freq[n];
// 2. 最小堆维护 top-k
auto cmp = [](const auto& a, const auto& b) { return a.second > b.second; };
std::priority_queue<std::pair<int,int>, std::vector<std::pair<int,int>>,
decltype(cmp)> pq(cmp);
for (auto& [num, count] : freq) {
pq.push({num, count});
if (static_cast<int>(pq.size()) > k) pq.pop();
}
// 3. 提取结果
std::vector<int> result;
while (!pq.empty()) {
result.push_back(pq.top().first);
pq.pop();
}
return result;
}
// 时间 O(n log k), 空间 O(n)

最接近原点的 K 个点 (LeetCode 973)#

给定平面上 n 个点,找出最接近原点的 k 个。

std::vector<std::vector<int>> kClosest(
std::vector<std::vector<int>>& points, int k)
{
// 最大堆维护最近 k 个(堆顶 = 第 k 近 = k 个中最远的)
auto dist = [](const std::vector<int>& p) {
return p[0] * p[0] + p[1] * p[1]; // 不用开方,比较平方即可
};
auto cmp = [&](const std::vector<int>& a, const std::vector<int>& b) {
return dist(a) < dist(b); // 最大堆
};
std::priority_queue<std::vector<int>, std::vector<std::vector<int>>,
decltype(cmp)> pq(cmp);
for (auto& p : points) {
pq.push(p);
if (static_cast<int>(pq.size()) > k) pq.pop();
}
std::vector<std::vector<int>> result;
while (!pq.empty()) {
result.push_back(pq.top());
pq.pop();
}
return result;
}
// 时间 O(n log k), 空间 O(k)

💡 Top-K 问题的通用模板:用大小为 K 的堆。找最大 K 个用最小堆(淘汰最小的),找最小 K 个用最大堆(淘汰最大的)。理由:堆顶是”最危险的”——第 K+1 个元素来了,和堆顶比,如果更优就替换堆顶。


6.4.7 面试题速查表#

题号题目核心技巧难度
LC 215第 K 大元素最小堆 / 快速选择Medium
LC 295数据流中位数双堆(大顶 + 小顶)Hard
LC 23合并 K 个有序链表最小堆 k 路归并Hard
LC 347前 K 个高频元素哈希 + 最小堆Medium
LC 973最接近原点的 K 个点最大堆 维护 K 个Medium
LC 703数据流中第 K 大元素最小堆 size=kEasy
LC 378有序矩阵中第 K 小最小堆 / 二分Medium
LC 767重组字符串最大堆贪心Medium
LC 1046最后一块石头的重量最大堆模拟Easy
LC 355设计推特哈希 + 最小堆归并Medium
堆排序buildHeap + 逐个 pop手写
索引堆decrease_key + Dijkstra进阶

6.4.8 本节小结#

核心要点#

概念要点
完全二叉树 + 堆序性,用数组存储
sift-up插入时用——新元素追加到末尾后上浮
sift-down删除堆顶时用——末尾元素放顶部后下沉
buildHeap自底向上 sift-down → O(n)
堆排序原地 O(n log n),最坏也是 O(n log n),std::sort 的保底方案
Top-K大小为 K 的堆。找最大 K 个用最小堆,找最小 K 个用最大堆
双堆数据流中位数——最大堆存小半,最小堆存大半
索引堆支持 decrease_key,用于 Dijkstra

面试 30 秒速答#

Q:堆的插入和删除是怎么做的?

A:插入:新元素追加到数组末尾,然后 sift-up(和父节点比较,更大就向上交换),O(log n)。删除堆顶:用末尾元素替换堆顶,然后 sift-down(和较大的子节点比较,更小就向下交换),O(log n)。

Q:如何从 n 个元素中找第 K 大?

A:三种方法:(1) 排序 O(n log n);(2) 大小为 K 的最小堆 O(n log k)——遍历数组,每个元素入堆,堆大小超过 K 就 pop;(3) 快速选择 O(n) 平均。面试中优先写堆解法(稳定、好写),再提快速选择作为优化。

Q:堆排序为什么实际比快排慢?

A:虽然堆排序最坏也是 O(n log n),但它的 cache 命中率差——sift-down 跳跃式访问数组(从 i 到 2i+1),不像快排那样顺序扫描。std::sort 用的 IntroSort = 快排 + 堆排切换:正常用快排(快),快排退化时切到堆排(保证最坏 O(n log n))。


📖 上一节:6.3 平衡树:AVL 与红黑树

📖 下一节:6.5 线段树 & 树状数组 —— 区间查询与修改的利器,懒传播与树状数组的 lowbit 技巧。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

6.4 堆与优先队列
https://firefly-7a0.pages.dev/posts/data_structure/tree/06_04_heap/
作者
lonelystar
发布于
2026-04-07
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
LonelyStar
Hello, I'm LonelyStar.
公告
欢迎来到我的博客!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
119
分类
11
标签
346
总字数
226,548
运行时长
0
最后活动
0 天前

目录