从 "Top-K 问题" 入门二叉堆:C 语言从零实现与经典应用
一、引言:一道题搞懂二叉堆的核心逻辑
你是否在刷题时遇到过「Top-K 筛选」「优先队列实现」「原地堆排序」这类问题?比如海量数据找最大的 K 个值、按优先级调度任务、O (n log n) 级别的原地排序,不知道该用什么数据结构解决?其实这类问题天生就是为 ** 二叉堆(Binary Heap)** 量身定做的。
今天我们就以 LeetCode 高频面试题「Top-K 问题」为核心,从零开始学会二叉堆的核心思路和 C 语言代码实现,即使是算法新手也能轻松掌握。
二、先搞懂:什么是二叉堆?
二叉堆的核心是一种基于完全二叉树实现、满足堆序性质的高效数据结构,它的核心逻辑就像一个层级分明的金字塔:塔顶永远是整个结构里的最值(最大值或最小值),每一层的节点都必须满足和下一层节点的大小规则,保证每一步操作都能快速维护这个金字塔的秩序。
和有序数组、单链表这类结构不同,二叉堆完美兼顾了「快速获取最值」和「高效动态修改」的能力:获取堆顶最值仅需O(1) 时间,插入、删除最值操作也仅需O(log n) 时间,远优于有序数组 O (n) 的插入删除开销,天生适配需要动态维护极值的场景。
二叉堆的实现核心是数组存储完全二叉树,无需复杂的链式指针,通过固定公式就能计算父子节点的索引,主要分为两种标准类型:
- 小顶堆:堆顶是全局最小值,任意父节点的值 ≤ 左右子节点的值
- 大顶堆:堆顶是全局最大值,任意父节点的值 ≥ 左右子节点的值
它的实现通常有两个核心基础动作:上浮(sift up) 和 下沉(sift down),所有复杂操作都是这两个动作的组合,就像 DFS 的核心是递归遍历一样,掌握了这两个动作,就掌握了二叉堆的精髓。
三、核心分析:二叉堆到底要我们做什么?
我们先拆解二叉堆两个不可动摇的核心规则,再通过示例直观理解,就像先搞懂岛屿数量的题目规则一样,只有吃透规则,才能写对代码。
1. 两个核心规则
规则 1:完全二叉树结构
除最后一层外,所有层的节点全满;最后一层的节点严格靠左排列,没有空缺。
这个规则是二叉堆能用数组高效存储的核心前提 ------ 没有空节点浪费空间,父子节点的位置可以通过固定公式计算,无需指针寻址。
规则 2:堆序性质
- 小顶堆:任意父节点的值 ≤ 左右子节点的值,堆顶(根节点)永远是全局最小值
- 大顶堆:任意父节点的值 ≥ 左右子节点的值,堆顶(根节点)永远是全局最大值
划重点:堆序性质只约束父子节点,左右兄弟节点之间没有任何大小关系,这是很多新手容易混淆的点。
2. 数组存储与索引映射(C 语言适配,下标从 0 开始)
完全二叉树的结构天然适配数组存储,我们可以用一个一维数组存放所有节点,通过固定公式计算父子节点的下标:
对于数组中任意下标为 i 的节点:
- 父节点下标:
(i - 1) / 2(C 语言 int 除法自动向下取整,完美适配) - 左孩子下标:
2 * i + 1 - 右孩子下标:
2 * i + 2
举两个直观的示例,对应岛屿数量的示例输入:
示例 1 小顶堆
输入数组:
[1,3,8,4,5]
对应的完全二叉树结构:
1 ← 下标0(堆顶,全局最小值)
/ \
3 8 ← 下标1、2
/ \
4 5 ← 下标3、4
验证:下标 1 的父节点是 (1-1)/2=0,左孩子是 2*1+1=3,完全符合公式,且所有父节点都小于等于子节点,满足小顶堆规则。
示例 2 大顶堆
输入数组:
[9,7,6,5,3]
对应的完全二叉树结构:
9 ← 下标0(堆顶,全局最大值)
/ \
7 6 ← 下标1、2
/ \
5 3 ← 下标3、4
所有父节点都大于等于子节点,满足大顶堆规则。
四、二叉堆解法思路:两个动作搞定所有操作
我们可以把二叉堆的所有复杂操作,转化为两个核心基础动作的组合,就像岛屿数量问题用 DFS 染色解决一样,只要掌握了这两个动作,所有场景都能迎刃而解。
核心动作 1:上浮(sift up)
适用场景:插入新元素时,恢复堆序性质
核心逻辑:
- 新元素默认放到数组的末尾(完全二叉树的最后一个节点)
- 将该节点与其父节点比较,若不满足堆序性质(小顶堆:当前节点 < 父节点),则交换两者
- 重复步骤 2,直到当前节点到达堆顶,或满足堆序性质为止
就像新人进入金字塔,从最底层往上走,直到找到符合自己层级的位置。
核心动作 2:下沉(sift down)
适用场景:删除堆顶元素、批量建堆时,恢复堆序性质
核心逻辑:
- 从待调整的节点出发,找到其左右孩子中符合堆序要求的极值节点(小顶堆找最小的孩子,大顶堆找最大的孩子)
- 若当前节点不满足堆序性质,则与极值孩子节点交换
- 重复步骤 1-2,直到当前节点没有孩子节点,或满足堆序性质为止
就像金字塔塔顶的节点换了人,从塔顶往下走,直到找到符合自己层级的位置。
基于两个动作的核心操作流程
- 插入元素:数组末尾添加新元素 → 对新元素执行上浮操作
- 取出堆顶最值:堆顶与数组最后一个元素交换 → 删除末尾元素 → 对新堆顶执行下沉操作
- 批量建堆:从最后一个非叶子节点开始 → 从下往上、从右往左对每个节点执行下沉操作
- Top-K 问题:用前 K 个元素建小顶堆 → 遍历剩余元素,大于堆顶则替换堆顶 → 对新堆顶执行下沉操作 → 遍历完成后堆内就是最大的 K 个元素
五、代码实现与逐行详解(C 语言版)
我们以面试最常用的小顶堆为例,实现完整的二叉堆,每一行都加详细注释,确保新手也能看懂,代码可直接用于 LeetCode 相关题目。
1. 结构体定义与基础宏
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 堆的初始容量
#define INIT_CAPACITY 10
// 小顶堆结构体定义
typedef struct MinHeap {
int* data; // 存储堆元素的动态数组
int size; // 当前堆中有效元素的个数
int capacity; // 数组的最大容量
} MinHeap;
2. 核心动作 1:上浮操作
// 上浮函数:调整下标为index的节点,使其满足小顶堆的堆序性质
static void siftUp(MinHeap* heap, int index) {
// 只要没到堆顶,就持续和父节点比较
while (index > 0) {
// 计算父节点下标
int parent = (index - 1) / 2;
// 小顶堆规则:父节点 <= 当前节点,满足堆序,直接退出循环
if (heap->data[parent] <= heap->data[index]) {
break;
}
// 不满足堆序,交换父子节点的值
int temp = heap->data[parent];
heap->data[parent] = heap->data[index];
heap->data[index] = temp;
// 下标上移到父节点,继续向上检查
index = parent;
}
}
3. 核心动作 2:下沉操作
// 下沉函数:调整下标为index的节点,使其满足小顶堆的堆序性质
static void siftDown(MinHeap* heap, int index) {
// 获取堆的有效元素个数,作为下标边界
int n = heap->size;
while (1) {
int smallest = index; // 记录当前节点、左右孩子中最小值的下标
int left = 2 * index + 1; // 左孩子下标
int right = 2 * index + 2; // 右孩子下标
// 左孩子存在,且值比当前最小值更小,更新最小值下标
if (left < n && heap->data[left] < heap->data[smallest]) {
smallest = left;
}
// 右孩子存在,且值比当前最小值更小,更新最小值下标
if (right < n && heap->data[right] < heap->data[smallest]) {
smallest = right;
}
// 最小值就是当前节点,满足堆序,退出循环
if (smallest == index) {
break;
}
// 不满足堆序,交换当前节点和最小孩子节点的值
int temp = heap->data[index];
heap->data[index] = heap->data[smallest];
heap->data[smallest] = temp;
// 下标下移到孩子节点,继续向下检查
index = smallest;
}
}
4. 堆的初始化与销毁
// 初始化一个空的小顶堆
MinHeap* heapCreate() {
// 申请堆结构体的内存
MinHeap* heap = (MinHeap*)malloc(sizeof(MinHeap));
if (heap == NULL) {
perror("malloc heap failed");
return NULL;
}
// 申请存储元素的数组内存
heap->data = (int*)malloc(sizeof(int) * INIT_CAPACITY);
if (heap->data == NULL) {
perror("malloc heap data failed");
free(heap); // 申请失败,释放已申请的内存,避免泄漏
return NULL;
}
// 初始化大小和容量
heap->size = 0;
heap->capacity = INIT_CAPACITY;
return heap;
}
// 销毁堆,释放所有动态申请的内存
void heapFree(MinHeap* heap) {
if (heap == NULL) return;
free(heap->data); // 先释放数组内存
free(heap); // 再释放结构体内存
}
5. 插入元素与取出堆顶元素
// 向堆中插入一个新元素
int heapInsert(MinHeap* heap, int val) {
if (heap == NULL) return -1;
// 堆已满,触发扩容,扩容为原容量的2倍
if (heap->size == heap->capacity) {
int newCapacity = heap->capacity * 2;
int* newData = (int*)realloc(heap->data, sizeof(int) * newCapacity);
if (newData == NULL) {
perror("realloc heap data failed");
return -1;
}
heap->data = newData;
heap->capacity = newCapacity;
}
// 新元素放到数组末尾
heap->data[heap->size] = val;
heap->size++;
// 对新元素执行上浮操作,恢复堆序
siftUp(heap, heap->size - 1);
return 0;
}
// 取出堆顶的最小值(删除并返回)
int heapExtractMin(MinHeap* heap) {
// 堆为空,返回错误值
if (heap == NULL || heap->size == 0) {
printf("heap is empty\n");
return -1;
}
// 保存堆顶的最小值
int minVal = heap->data[0];
// 把数组最后一个元素移到堆顶
heap->data[0] = heap->data[heap->size - 1];
heap->size--;
// 对新堆顶执行下沉操作,恢复堆序
siftDown(heap, 0);
return minVal;
}
6. 批量建堆(Heapify)
// 基于无序数组,批量构建一个小顶堆
MinHeap* heapBuildFromArray(int* arr, int arrSize) {
if (arr == NULL || arrSize <= 0) return NULL;
// 创建空堆
MinHeap* heap = heapCreate();
if (heap == NULL) return NULL;
// 如果数组大小超过初始容量,先扩容
if (heap->capacity < arrSize) {
int* newData = (int*)realloc(heap->data, sizeof(int) * arrSize);
if (newData == NULL) {
heapFree(heap);
return NULL;
}
heap->data = newData;
heap->capacity = arrSize;
}
// 把数组元素拷贝到堆中
memcpy(heap->data, arr, sizeof(int) * arrSize);
heap->size = arrSize;
// 核心:从最后一个非叶子节点开始,从下往上执行下沉操作
for (int i = arrSize / 2 - 1; i >= 0; i--) {
siftDown(heap, i);
}
return heap;
}
7. 经典应用:LeetCode Top-K 问题实现
// 找到数组中最大的K个元素,结果存入res数组
void findTopK(int* arr, int arrSize, int K, int* res) {
// 入参合法性校验
if (arr == NULL || arrSize <= 0 || K <= 0 || K > arrSize || res == NULL) {
return;
}
// 用前K个元素构建小顶堆
MinHeap* heap = heapBuildFromArray(arr, K);
if (heap == NULL) return;
// 遍历数组剩余元素
for (int i = K; i < arrSize; i++) {
// 当前元素大于堆顶最小值,替换堆顶
if (arr[i] > heap->data[0]) {
heap->data[0] = arr[i];
// 下沉调整,恢复堆序
siftDown(heap, 0);
}
}
// 堆中的K个元素就是最大的K个值,拷贝到结果数组
for (int i = 0; i < K; i++) {
res[i] = heapExtractMin(heap);
}
// 释放堆内存
heapFree(heap);
}
8. 辅助工具函数
// 获取堆顶元素(不删除)
int heapGetTop(MinHeap* heap) {
if (heap == NULL || heap->size == 0) {
return -1;
}
return heap->data[0];
}
// 判断堆是否为空
int heapIsEmpty(MinHeap* heap) {
return heap == NULL || heap->size == 0;
}
// 获取堆中有效元素的个数
int heapGetSize(MinHeap* heap) {
return heap == NULL ? 0 : heap->size;
}
9. 主函数测试
int main() {
// 1. 基础堆操作测试
printf("===== 基础堆操作测试 =====\n");
MinHeap* heap = heapCreate();
heapInsert(heap, 5);
heapInsert(heap, 3);
heapInsert(heap, 8);
heapInsert(heap, 1);
heapInsert(heap, 4);
printf("堆顶元素:%d\n", heapGetTop(heap)); // 输出1
printf("堆元素个数:%d\n", heapGetSize(heap)); // 输出5
printf("依次取出堆顶最小值:");
while (!heapIsEmpty(heap)) {
printf("%d ", heapExtractMin(heap)); // 输出1 3 4 5 8
}
printf("\n");
heapFree(heap);
// 2. Top-K问题测试
printf("\n===== Top-K问题测试 =====\n");
int topKArr[] = {3, 2, 3, 1, 2, 4, 5, 5, 6};
int topKSize = sizeof(topKArr) / sizeof(topKArr[0]);
int K = 4;
int* res = (int*)malloc(sizeof(int) * K);
findTopK(topKArr, topKSize, K, res);
printf("数组中最大的%d个元素:", K);
for (int i = 0; i < K; i++) {
printf("%d ", res[i]); // 输出3 4 5 6
}
printf("\n");
free(res);
return 0;
}
六、关键细节:新手容易踩的坑
- 数组下标越界 :一定要在下沉操作的开头判断孩子节点的下标是否小于堆的有效大小
size,否则会访问到非法内存,导致程序崩溃,这是 C 语言实现二叉堆最常见的致命错误。 - 忘记恢复堆序:插入元素后必须执行上浮操作,替换堆顶后必须执行下沉操作,就像岛屿数量问题中必须把遍历过的陆地改为 '0',否则会重复统计、结果错误,堆结构也会完全失效。
- 批量建堆的起始下标错误 :从 0 开始的数组,最后一个非叶子节点的下标是
size/2 - 1,不是size/2,如果起始下标写错,会导致堆化不完整,堆结构不符合规则。 - 内存泄漏 :C 语言手动管理内存,动态申请的堆结构体和数组,使用完必须调用
heapFree释放;realloc扩容时,要用临时变量接收返回值,避免扩容失败丢失原指针。 - 堆序性质搞反:小顶堆和大顶堆的核心区别就是比较符号,上浮和下沉的比较逻辑必须统一,否则会把小顶堆写成大顶堆,导致 Top-K 等场景的结果完全错误。
七、总结:二叉堆的适用场景
通过 "Top-K 问题" 这个经典例子,我们可以总结出二叉堆的核心适用场景:
- 极值维护问题:比如 LeetCode 高频 Top-K 问题、数据流中的中位数、动态维护最大值 / 最小值,无需全量排序,就能高效获取极值。
- 优先队列实现:比如操作系统进程调度、事件驱动系统、网络优先级数据包处理,优先队列的标准底层实现就是二叉堆。
- 排序算法:堆排序,原地、O (n log n) 时间复杂度的排序算法,无需额外的数组空间,适合内存受限的场景。
- 图算法优化:Dijkstra 最短路径、Prim 最小生成树,用二叉堆优化后,时间复杂度能从 O (n²) 大幅降至 O (m log n)。
二叉堆的核心精髓,就是「完全二叉树的结构特性 + 堆序性质」,所有复杂操作都可以拆解为上浮和下沉两个基础动作。只要掌握了这个核心思路,很多算法面试中的高频问题,都能迎刃而解。