文章目录
-
二、C++手写堆:面试简化版(小根堆+大根堆)
-
三、C++ STL优先队列(priority_queue):堆的实战封装
-
四、堆的面试高频考点与实战场景
-
五、面试避坑指南与学习建议
-
总结
前言
在C++数据结构进阶学习中,堆(Heap)是贯穿笔试面试的核心知识点,也是解决"高效获取最值"问题的最优工具。它不像二叉搜索树那样侧重有序遍历,也不像哈希表那样侧重快速查询,而是以"O(1)获取最值、O(log n)插入删除"的独特优势,成为优先队列、Top K、堆排序等高频场景的核心依赖。
本文专为C++学习者打造,全程贴合面试场景,从堆的核心原理、手写实现(小根堆/大根堆)、STL优先队列应用,到面试高频考点、避坑指南,层层拆解,让你不仅"会用堆",更能"吃透原理、手写代码、应对面试追问",真正掌握堆的进阶用法。
适合人群:已掌握C++基础语法、了解基本数据结构(数组、链表),想要进阶面试必备知识点的学习者;需要补充堆的实战代码、应对笔试手写题的求职者。
一、堆的核心认知:不是"堆内存",是"完全二叉树"
很多初学者会把"堆结构"和"堆内存"混淆,其实二者毫无关联:堆内存是程序内存分配的区域(如C++中的new/delete申请的内存),而堆结构是一种基于完全二叉树的非线性数据结构,核心价值是"快速获取最值"。
1. 堆的本质与核心特性
堆的本质是"完全二叉树"(除了最后一层,每一层的节点数都达到最大值,最后一层的节点从左到右连续排列),但它通常用**数组(顺序存储)**实现,无需构建二叉树节点,通过索引关系就能表示父子节点,极大节省空间。
堆分为两种核心类型,规则严格且易记:
-
小根堆(Min-Heap):每个父节点的值 ≤ 其左右子节点的值,堆顶(数组第一个元素)是整个堆的最小值;
-
大根堆(Max-Heap):每个父节点的值 ≥ 其左右子节点的值,堆顶是整个堆的最大值。
补充:堆的核心价值的是"最值优先",比如Top K问题、任务调度(优先执行优先级高的任务),本质都是"快速获取并操作最值",这是数组、链表等基础结构无法高效实现的。
2. 数组实现堆的索引规律(面试必记)
由于堆是完全二叉树,用数组存储时,父子节点的索引存在固定规律(假设父节点索引为i,子节点索引为j),无需遍历就能快速定位:
-
父节点i → 左子节点:2i + 1,右子节点:2i + 2;
-
子节点j → 父节点:(j - 1) / 2(整数除法,忽略小数部分);
-
示例:索引0(堆顶)的左子节点是1,右子节点是2;索引3的父节点是(3-1)/2 = 1。
这个规律是手写堆的核心,所有插入、删除操作都依赖它实现,一定要牢记!
3. 堆的核心操作:上浮(Push)与下沉(Heapify)
堆的所有操作(插入、删除堆顶),本质都是通过"调整堆结构"维持其核心规则,而调整的核心就是两个操作:上浮 和下沉,二者互为补充,面试手写堆时必须掌握。
-
上浮(Push操作核心):插入元素时,将元素放到数组末尾,然后逐步与父节点比较,若不满足堆规则(小根堆:子节点<父节点;大根堆:子节点>父节点),则交换父子节点,直到满足规则或到达堆顶。
-
下沉(Pop操作核心):删除堆顶元素时,将数组末尾元素放到堆顶(覆盖堆顶),然后逐步与左右子节点比较,若不满足堆规则,则与最值子节点(小根堆找最小子节点,大根堆找最大子节点)交换,直到满足规则或到达叶子节点。
提示:上浮和下沉的时间复杂度都是O(log n),因为堆的高度是log n(完全二叉树的高度=⌊log₂n⌋),最多需要调整log n次。
二、C++手写堆:面试简化版(小根堆+大根堆)
面试中,手写堆是高频考点,通常要求实现"插入、删除堆顶、获取堆顶、判空"四个核心接口,无需过度复杂的封装,重点是代码简洁、逻辑清晰,能直接默写。
下面分别实现小根堆和大根堆,基于vector存储(C++中最常用的方式),注释详细,可直接用于面试手写。
1. 手写小根堆(MinHeap)
小根堆是面试中考察最多的堆类型,常用于Top K(求前K个最大元素)、数据流中的中位数等场景,核心是"堆顶最小"。
cpp
// C++ 手写小根堆(面试简化版,可直接默写)
#include <iostream>
#include <vector>
using namespace std;
class MinHeap {
private:
vector<int> heap; // 用vector存储堆,节省空间且支持动态扩容
// 下沉操作:调整堆结构,维持小根堆规则(核心函数)
void heapify(int index) {
int n = heap.size();
while (true) {
int minIndex = index; // 当前节点作为最小值候选
int left = 2 * index + 1; // 左子节点索引
int right = 2 * index + 2; // 右子节点索引
// 找到当前节点、左子节点、右子节点中的最小值
if (left < n && heap[left] < heap[minIndex]) {
minIndex = left;
}
if (right < n && heap[right] < heap[minIndex]) {
minIndex = right;
}
// 若当前节点已是最小值,无需继续调整,退出循环
if (minIndex == index) {
break;
}
// 交换当前节点与最小值节点,继续下沉
swap(heap[index], heap[minIndex]);
index = minIndex;
}
}
public:
// 1. 插入元素(上浮操作)
void push(int val) {
heap.push_back(val); // 新元素放到数组末尾
int index = heap.size() - 1; // 新元素的索引
// 上浮:逐步与父节点比较,小于父节点则交换
while (index > 0) {
int parent = (index - 1) / 2; // 父节点索引
if (heap[index] < heap[parent]) {
swap(heap[index], heap[parent]);
index = parent; // 继续向上调整
} else {
break; // 满足小根堆规则,退出
}
}
}
// 2. 删除堆顶元素(下沉操作)
void pop() {
if (heap.empty()) {
return; // 堆空,无需删除
}
// 用数组末尾元素覆盖堆顶,然后删除末尾元素
heap[0] = heap.back();
heap.pop_back();
// 从堆顶开始下沉调整
heapify(0);
}
// 3. 获取堆顶元素(最小值)
int top() {
if (heap.empty()) {
throw runtime_error("Heap is empty!"); // 异常处理,面试可简化为return -1
}
return heap[0];
}
// 4. 判断堆是否为空
bool empty() {
return heap.empty();
}
// 可选:打印堆(用于测试,面试可省略)
void printHeap() {
for (int num : heap) {
cout << num << " ";
}
cout << endl;
}
};
// 测试代码(面试手写可简化,核心是接口实现)
int main() {
MinHeap minHeap;
minHeap.push(5);
minHeap.push(3);
minHeap.push(7);
minHeap.push(2);
minHeap.push(4);
cout << "小根堆元素:";
minHeap.printHeap(); // 输出:2 3 7 5 4(堆结构,非严格升序)
cout << "堆顶最小值:" << minHeap.top() << endl; // 输出:2
minHeap.pop(); // 删除堆顶(2)
cout << "删除堆顶后,新堆顶:" << minHeap.top() << endl; // 输出:3
return 0;
}
2. 手写大根堆(MaxHeap)
大根堆与小根堆逻辑完全一致,仅需修改"比较规则"(父节点>子节点),代码可基于小根堆快速修改,面试中若要求大根堆,可直接调整比较条件。
cpp
// C++ 手写大根堆(面试简化版,基于小根堆修改)
#include <iostream>
#include <vector>
using namespace std;
class MaxHeap {
private:
vector<int> heap;
// 下沉操作:维持大根堆规则(仅修改比较条件)
void heapify(int index) {
int n = heap.size();
while (true) {
int maxIndex = index;
int left = 2 * index + 1;
int right = 2 * index + 2;
// 找最大值节点(修改比较符号:>)
if (left < n && heap[left] > heap[maxIndex]) {
maxIndex = left;
}
if (right < n && heap[right] > heap[maxIndex]) {
maxIndex = right;
}
if (maxIndex == index) break;
swap(heap[index], heap[maxIndex]);
index = maxIndex;
}
}
public:
// 插入元素(上浮,修改比较符号:>)
void push(int val) {
heap.push_back(val);
int index = heap.size() - 1;
while (index > 0) {
int parent = (index - 1) / 2;
if (heap[index] > heap[parent]) { // 大根堆:子节点>父节点则交换
swap(heap[index], heap[parent]);
index = parent;
} else {
break;
}
}
}
// 删除堆顶(与小根堆完全一致)
void pop() {
if (heap.empty()) return;
heap[0] = heap.back();
heap.pop_back();
heapify(0);
}
// 获取堆顶(最大值)
int top() {
if (heap.empty()) throw runtime_error("Heap is empty!");
return heap[0];
}
bool empty() {
return heap.empty();
}
};
// 测试代码
int main() {
MaxHeap maxHeap;
maxHeap.push(5);
maxHeap.push(3);
maxHeap.push(7);
maxHeap.push(2);
maxHeap.push(4);
cout << "大根堆顶最大值:" << maxHeap.top() << endl; // 输出:7
maxHeap.pop();
cout << "删除堆顶后,新堆顶:" << maxHeap.top() << endl; // 输出:5
return 0;
}
手写堆面试注意事项
-
不要遗漏边界条件:堆空时的pop()、top()操作,面试中忽略边界会丢分;
-
简化代码:面试手写无需封装printHeap()等测试接口,重点实现push、pop、top、empty四个核心接口;
-
记住比较规则:小根堆用"<",大根堆用">",仅需修改比较符号,无需修改整体逻辑。
三、C++ STL优先队列(priority_queue):堆的实战封装
实际开发中,我们很少手写堆,而是直接使用C++ STL中的priority_queue(优先队列)------它底层就是用堆实现的,封装了堆的所有核心操作,无需关心底层实现,直接调用接口即可,面试中也常考察其使用方法。
1. priority_queue的核心用法(面试必记)
priority_queue的默认实现是大根堆,若要实现小根堆,需指定模板参数,核心接口与手写堆一致:push()、pop()、top()、empty()、size()。
cpp
// C++ STL priority_queue 用法(面试高频)
#include <iostream>
#include <queue> // 必须包含头文件
using namespace std;
int main() {
// 1. 默认大根堆(priority_queue<数据类型> 队列名)
priority_queue<int> maxQ;
maxQ.push(5);
maxQ.push(3);
maxQ.push(7);
cout << "大根堆顶:" << maxQ.top() << endl; // 输出:7
maxQ.pop();
cout << "删除堆顶后,新堆顶:" << maxQ.top() << endl; // 输出:5
// 2. 小根堆(三种实现方式,推荐第一种)
// 方式1:priority_queue<数据类型, 容器类型, 比较函数>
priority_queue<int, vector<int>, greater<int>> minQ;
// 方式2:用大根堆存储负数(不推荐,易出错)
// priority_queue<int> minQ; push(-val); top(-minQ.top());
// 方式3:自定义比较函数(复杂场景用)
minQ.push(5);
minQ.push(3);
minQ.push(7);
cout << "小根堆顶:" << minQ.top() << endl; // 输出:3
minQ.pop();
cout << "删除堆顶后,新堆顶:" << minQ.top() << endl; // 输出:5
// 3. 常用接口
cout << "小根堆大小:" << minQ.size() << endl; // 输出:2
cout << "小根堆是否为空:" << (minQ.empty() ? "是" : "否") << endl; // 输出:否
return 0;
}
2. 面试高频:priority_queue与手写堆的区别
面试中常追问:"为什么有时候要手写堆,而不用STL的priority_queue?",核心区别如下,记准即可应对:
-
priority_queue是封装好的容器,无法直接访问堆的中间元素,也无法自定义调整堆的规则(如批量插入、堆的合并);
-
手写堆可以灵活扩展,比如实现"堆排序""批量插入元素""自定义比较规则",更适合面试中的手写题场景;
-
实际开发中优先用priority_queue(高效、简洁),面试中若要求"手写堆的核心操作",则必须手写,不能用STL替代。
四、堆的面试高频考点与实战场景
堆的面试考察分为"基础考点"和"实战应用",基础考点侧重原理和手写代码,实战应用侧重场景选型和算法优化,二者都要掌握。
1. 基础高频考点(必背)
-
堆的定义和特性:完全二叉树、小根堆/大根堆的规则,数组存储的索引规律;
-
核心操作:手写push(上浮)、pop(下沉)操作,能脱稿写出小根堆/大根堆代码;
-
时间复杂度:插入、删除O(log n),获取堆顶O(1),堆排序O(n log n);
-
STL相关:priority_queue的默认类型(大根堆)、小根堆的实现方式,与vector、queue的区别。
2. 实战场景(面试高频题)
堆的应用场景集中在"最值相关",以下3个场景是面试必考,结合代码示例,一看就会。
场景1:Top K问题(求数组中前K个最大/最小元素)
Top K是堆的最经典应用,核心思路:求前K个最大元素用小根堆 ,求前K个最小元素用大根堆,时间复杂度O(n log K),比排序(O(n log n))更高效。
cpp
// 示例:求数组中前3个最大元素(用小根堆实现)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
vector<int> topK(vector<int>& nums, int k) {
vector<int> res;
if (k <= 0 || nums.empty()) return res;
// 建立一个大小为k的小根堆
priority_queue<int, vector<int>, greater<int>> minQ;
for (int num : nums) {
if (minQ.size() < k) {
minQ.push(num); // 堆大小不足k,直接插入
} else {
// 堆大小为k,若当前元素>堆顶,替换堆顶(保证堆内是前k大元素)
if (num > minQ.top()) {
minQ.pop();
minQ.push(num);
}
}
}
// 将堆内元素存入结果(堆顶是第k大元素,结果需逆序)
while (!minQ.empty()) {
res.push_back(minQ.top());
minQ.pop();
}
reverse(res.begin(), res.end()); // 逆序后得到前k大元素(从大到小)
return res;
}
int main() {
vector<int> nums = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
int k = 3;
vector<int> res = topK(nums, k);
cout << "前3个最大元素:";
for (int num : res) {
cout << num << " "; // 输出:9 6 5
}
return 0;
}
场景2:数据流中的中位数
数据流的特点是"元素动态插入,随时需要获取中位数",核心思路:用两个堆(大根堆存左半部分元素,小根堆存右半部分元素),维持两个堆的大小差≤1,中位数可通过堆顶直接获取。
cpp
// 示例:数据流中的中位数
#include <iostream>
#include <queue>
using namespace std;
class MedianFinder {
private:
priority_queue<int> maxHeap; // 大根堆:存左半部分元素(小于等于中位数)
priority_queue<int, vector<int>, greater<int>> minHeap; // 小根堆:存右半部分元素(大于中位数)
public:
// 插入元素,维持两个堆的平衡
void addNum(int num) {
// 先插入大根堆,再调整到小根堆(保证大根堆≤小根堆)
maxHeap.push(num);
minHeap.push(maxHeap.top());
maxHeap.pop();
// 维持两个堆的大小差≤1(大根堆可以比小根堆多1个元素)
if (maxHeap.size() < minHeap.size()) {
maxHeap.push(minHeap.top());
minHeap.pop();
}
}
// 获取中位数
double findMedian() {
// 元素个数为奇数:大根堆顶是中位数
if (maxHeap.size() > minHeap.size()) {
return maxHeap.top();
}
// 元素个数为偶数:两个堆顶的平均值是中位数
return (maxHeap.top() + minHeap.top()) / 2.0;
}
};
int main() {
MedianFinder mf;
mf.addNum(1);
mf.addNum(2);
cout << "中位数:" << mf.findMedian() << endl; // 输出:1.5
mf.addNum(3);
cout << "中位数:" << mf.findMedian() << endl; // 输出:2.0
return 0;
}
场景3:堆排序
堆排序是基于堆的排序算法,时间复杂度O(n log n),原地排序(空间复杂度O(1)),不稳定排序,面试中常考察其核心思路(建堆→调整堆→排序)。
cpp
// 示例:用大根堆实现堆排序(升序排列)
#include <iostream>
#include <vector>
using namespace std;
// 下沉操作(大根堆)
void heapify(vector<int>& nums, int n, int index) {
int maxIndex = index;
int left = 2 * index + 1;
int right = 2 * index + 2;
if (left < n && nums[left] > nums[maxIndex]) maxIndex = left;
if (right < n && nums[right] > nums[maxIndex]) maxIndex = right;
if (maxIndex != index) {
swap(nums[index], nums[maxIndex]);
heapify(nums, n, maxIndex);
}
}
// 堆排序(升序)
void heapSort(vector<int>& nums) {
int n = nums.size();
// 1. 建堆(从最后一个非叶子节点开始下沉)
for (int i = (n - 2) / 2; i >= 0; i--) {
heapify(nums, n, i);
}
// 2. 排序(每次将堆顶(最大值)放到末尾,再调整堆)
for (int i = n - 1; i > 0; i--) {
swap(nums[0], nums[i]); // 堆顶与末尾元素交换
heapify(nums, i, 0); // 调整剩余元素为大根堆
}
}
int main() {
vector<int> nums = {3, 1, 4, 1, 5, 9, 2, 6};
heapSort(nums);
cout << "堆排序结果(升序):";
for (int num : nums) {
cout << num << " "; // 输出:1 1 2 3 4 5 6 9
}
return 0;
}
五、面试避坑指南与学习建议
1. 常见避坑点(面试丢分重灾区)
-
坑1:混淆堆的类型------把priority_queue默认当成小根堆,记住:默认是大根堆,小根堆需加greater<int>;
-
坑2:手写堆时,下沉操作找错最值子节点------小根堆找最小子节点,大根堆找最大子节点,否则堆结构会错乱;
-
坑3:Top K问题选型错误------求前K个最大元素用小根堆(不是大根堆),大根堆会导致时间复杂度升高;
-
坑4:忽略堆的时间复杂度------认为堆的插入、删除是O(1),实际是O(log n),只有获取堆顶是O(1)。
2. 学习建议(高效掌握,应对面试)
-
先理解原理,再手写代码:不要死记硬背代码,先搞懂"上浮、下沉"的逻辑,再动手写小根堆、大根堆,每天默写1遍,直到脱稿;
-
重点突破实战场景:Top K、数据流中位数是必考题型,把示例代码看懂、默写,掌握核心思路,能灵活应对变体题;
-
对比记忆:把手写堆和STL priority_queue的用法对比,明确二者的适用场景,避免面试中混淆;
-
多刷真题:LeetCode上堆的高频题(215. 数组中的第K个最大元素、295. 数据流的中位数),练熟实战能力。
总结
堆作为C++数据结构进阶的核心知识点,核心不在于"复杂的代码",而在于"理解最值优先的设计思想"和"掌握核心操作的逻辑"。面试中,无论是手写堆的代码,还是用STL优先队列解决实战问题,只要吃透"上浮、下沉"两个核心操作,牢记索引规律和场景选型,就能轻松应对。
建议大家先手写小根堆、大根堆,再练习Top K、数据流中位数等实战场景,把代码练熟、原理吃透,堆的相关面试题就能迎刃而解。
小练习:用堆实现"合并K个有序链表"(LeetCode 23),试试能不能写出完整代码?欢迎在评论区交流你的思路~