目录
[1.1 堆的定义](#1.1 堆的定义)
[1.2 堆的存储结构](#1.2 堆的存储结构)
[1.2.1 物理存储:数组](#1.2.1 物理存储:数组)
[1.2.2. 数组下标与树节点的对应关系](#1.2.2. 数组下标与树节点的对应关系)
[2.1 定义结构体成员以及方法](#2.1 定义结构体成员以及方法)
[2.2 初始化堆](#2.2 初始化堆)
[2.3 销毁堆](#2.3 销毁堆)
[2.4 交换两个元素的值](#2.4 交换两个元素的值)
[2.5 向堆中插入元素](#2.5 向堆中插入元素)
[2.6 向上调整算法](#2.6 向上调整算法)
[2.7 删除堆顶元素](#2.7 删除堆顶元素)
[2.8 向下调整算法](#2.8 向下调整算法)
[2.9 获取堆顶元素](#2.9 获取堆顶元素)
[2.10 判断堆是否为空](#2.10 判断堆是否为空)
[2.11 获取堆中元素个数](#2.11 获取堆中元素个数)
[2.12 测试](#2.12 测试)
[3.1 堆排序的基本思想](#3.1 堆排序的基本思想)
[3.2 堆排序的步骤详解](#3.2 堆排序的步骤详解)
[3.2.1 建堆](#3.2.1 建堆)
[3.2.2 排序过程](#3.2.2 排序过程)
[3.3 堆排序的代码实现](#3.3 堆排序的代码实现)
(本文章所用语言均为 C 语言)
一、堆的概念与结构
1.1 堆的定义
堆(Heap) 是一种特殊的完全二叉树,它满足以下性质:
1. 堆是完全二叉树
-
除了最后一层,其他层都是满的
-
最后一层节点从左到右连续排列
2. 堆序性(大堆/小堆)
-
大根堆(大堆):每个节点的值 ≥ 其子节点的值
-
小根堆(小堆):每个节点的值 ≤ 其子节点的值

1.2 堆的存储结构
1.2.1 物理存储:数组
虽然堆是树形结构,但因为它是完全二叉树,可以用数组进行存储。
代码定义如下:
cpp
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a; // 指向动态数组
int size; // 堆中元素个数
int capacity; // 数组容量
}HP;
1.2.2. 数组下标与树节点的对应关系
对于数组中下标为 i 的节点:
父节点下标 = (i - 1) / 2
左孩子下标 = i * 2 + 1
右孩子下标 = i * 2 + 2
因为堆是特殊的完全二叉树,自然符合二叉树的公式定义。我们可以画图验证一下。
(这里就以求父节点为例子)

好的,在了解完这些之后,我们就可以用代码来实现堆
二、堆的代码实现
- 项目结构

2.1 定义结构体成员以及方法
代码如下:
cpp
//Heap.h
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
#include<time.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a; // 动态数组,存储堆元素
int size; // 堆中元素的个数(也相当于堆顶位置)
int capacity; // 数组的容量(当size == capacity时需要扩容)
} HP;
//初始化堆
void HeapInit(HP* php);
//销毁堆
void HeapDestroy(HP* php);
//交换两个元素的值
void Swap(HPDataType* p1, HPDataType* p2);
//向堆中插入元素(入堆)
void HeapPush(HP* php, HPDataType x);
//删除堆顶元素(出堆)
void HeapPop(HP* php);
//获取堆顶元素
HPDataType HeapTop(HP* php);
//判断堆是否为空
bool HeapIsEmpty(HP* php);
//获取堆中元素个数
int HeapSize(HP* php);
//向上调整算法(用于插入操作)
void AdjustUp(HPDataType* a, int child);
//向下调整算法(用于删除操作和建堆)
void AdjustDown(HPDataType* a, int n, int parent);
2.2 初始化堆
代码如下:
cpp
//Heap.c
void HeapInit(HP* php)
{
assert(php);
// 开辟初始容量为4的动态数组
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (tmp == NULL)
{
perror("malloc error");
return;
}
php->a = tmp;
php->size = 0;
php->capacity = 4;
}
2.3 销毁堆
代码如下:
cpp
//Heap.c
void HeapDestroy(HP* php)
{
assert(php); // 检查堆指针是否为空
free(php->a); // 释放动态数组空间
php->a = NULL //防止变为野指针
php->size = 0; // 堆元素个数置为0
php->capacity = 0; // 堆容量置为0
}
2.4 交换两个元素的值
代码如下:
cpp
//Heap.c
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
2.5 向堆中插入元素
代码如下:
cpp
//Heap.c
//这里我们以大根堆的形式来入堆
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//判断空间是否满了
if (php->size == php->capacity)
{
//满了的话,就扩容
HPDataType* tmp = (HPDataType*)realloc(php->a,sizeof(HPDataType) * php->capacity * 2);
if (tmp == NULL)
{
perror("realloc error");
return;
}
php->a = tmp;
php->capacity *= 2;
}
//然后将值放进去
php->a[php->size] = x;
//堆中元素个数 +1
php->size++;
//之后进入这个函数,让堆以大根堆的形式来排列
//为什么要传 php->size - 1 作为参数
//因为向上调整是从最后一个节点来进行调整的
//所有需要传入最后一个节点的位置,也就是 php->size - 1
AdjustUp(php->a, php->size - 1);
}
2.6 向上调整算法
我先给大家举个例子来理解向上调整是怎么运行的:
比如说我现在要插入15 10 10 10 15 / \ 插入15 / \ 向上调整 / \ 循环继续,向上调整 / \ 7 8 → 7 8 → 15 8 → 10 8 / / \ / \ / \ 5 5 15 5 7 5 7符合要求,退出循环
代码如下:
cpp
//Heap.c
//向上调整算法(用于插入操作)
void AdjustUp(HPDataType* a, int child) // 向上调整(建大根堆)
{
// 找到当前结点的父节点(前面讲的公式)
int parent = (child - 1) / 2;
// 直到调整到根节点
while (child > 0)
{
// 如果子节点大于父节点
//如果想改成小根堆的形式,只需要将
// a[parent] < a[child] 改为:
// a[parent] > a[child]
if (a[parent] < a[child])
{
// 然后交换父子节点
Swap(&a[parent], &a[child]);
// 子节点移动到父节点位置
child = parent;
// 更新父节点位置
parent = (child - 1) / 2;
}
else
{
// 满足堆结构,就结束
break;
}
}
}
2.7 删除堆顶元素
代码如下:
cpp
//Heap.c
void HeapPop(HP* php) // 删除堆顶元素
{
assert(php); // 检查堆指针是否为空
assert(!HeapIsEmpty(php)); // 判断堆是否为空
//因为堆顶元素是数组的最后一个元素
//所以我们只需要与数组第一个元素交换就可以
Swap(&php->a[0], &php->a[php->size - 1]); // 堆顶与最后一个元素交换
php->size--; // 删除最后一个元素
//为什么向下调整要传三个参数
//如果只传两个参数(如数组和父节点),就无法知道数组的边界,可能导致越界访问;
//如果只传数组和大小,则无法指定从哪个节点开始调整。
//所以要传三个参数
AdjustDown(php->a, php->size, 0); // 从根节点开始向下调整
}
2.8 向下调整算法
代码如下:
cpp
//Heap.c
//向下调整算法(用于删除操作和建堆)
void AdjustDown(HPDataType* a, int n, int parent) // 向下调整(建大根堆)
{
// 默认左孩子最大的,
int child = 2 * parent + 1;
//防止越界
while (child < n)
{
// 找左右孩子中较大的那个
//如果想改成小根堆的形式,只需要将
//a[child] < a[child + 1] 、 a[child] > a[parent]
//改为:a[child] > a[child + 1] 、 a[child] < a[parent]
if (child + 1 < n && a[child] < a[child + 1])
{
//不是左孩子大,就移到右孩子这边
++child;
}
// 如果孩子大于父节点,就进行交换
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
这里的交换思路和向上调整算法差不多,只不过 向上调整算法 是从最后一个节点开始移动的,而 向下调整算法 是从头节点开始移动的,就像图中这样
现在的栈顶元素是15 15 7 7 / \ 栈顶和栈尾交换位置 / \ 删除栈顶元素 / \ 10 8 → 10 8 → 10 8 / \ / \ / 5 7 5 15 5
然后向下调整 10 (记住是从头开始的) / \ (符合要求,退出循环) → 7 8 / 5
2.9 获取堆顶元素
代码如下:
cpp
//Heap.c
HPDataType HeapTop(HP* php)
{
assert(php);
//直接返回堆顶的第一个元素
return php->a[0];
}
2.10 判断堆是否为空
代码如下:
cpp
//Heap.c
bool HeapIsEmpty(HP* php)
{
assert(php);
//看元素个数为是否为 0,0的话就是空的
return php->size == 0;
}
2.11 获取堆中元素个数
代码如下:
cpp
//Heap.c
int HeapSize(HP* php)
{
assert(php);
//直接返回元素个数即可
return php->size;
}
2.12 测试
- 测试代码:
cpp
//test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
int main()
{
HP hp;
HeapInit(&hp);
HeapPush(&hp, 4);
HeapPush(&hp, 18);
HeapPush(&hp, 42);
HeapPush(&hp, 12);
HeapPush(&hp, 21);
HeapPush(&hp, 3);
int k = 0;
scanf("%d", &k);
//取出 K 个栈顶元素
while (!HeapIsEmpty(&hp) && k--)
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
printf("\n");
return 0;
}
- 测试结果:

我们验证一下看看对不对:

我们可以看到是符合要求的。
三、堆排序
堆排序是一种利用 堆这种数据结构所设计的选择排序算法。它的核心思想是将待排序的序列构造成一个大堆(或小堆),然后利用堆的特性(堆顶元素为最大或最小值)反复取出堆顶元素,从而实现排序。
3.1 堆排序的基本思想
堆排序分为两个主要阶段:
-
建堆:将无序的数组构建成一个堆(大堆或小堆)。
-
排序:反复将堆顶元素(最大或最小值)与堆的最后一个元素交换,然后对堆顶元素执行向下调整,将剩余的元素重新调整为堆。
堆排序的时间复杂度为 O(n log n) ,空间复杂度为 O(1)。
3.2 堆排序的步骤详解
3.2.1 建堆
建堆有两种常用方法:
-
向上调整建堆:从第二个元素开始,依次将每个元素向上调整,使其满足堆的性质。时间复杂度为 O(n log n)。
-
向下调整建堆:从最后一个非叶子节点开始,依次向下调整。时间复杂度为 O(n),效率更高。
一般情况下,向下调整建堆是更优的选择。
3.2.2 排序过程
将堆顶元素(即
a[0])与堆的最后一个元素a[end]交换。将
end--,将最后一个元素从堆中排除(此时最大或最小值已放在数组末尾)。对新的堆顶元素执行向下调整,使其重新满足堆的性质。
重复上述步骤,直到堆中只剩下一个元素。
3.3 堆排序的代码实现
以下以升序排序(建大堆)为例:
cpp
void HeapSort(int* a, int n)
{
// 建堆 -- 向上调整建堆 -- O(n log n)
for (int i = 1; i < n; ++i)
{
AdjustUp(a, i);
}
//也可以使用向下调整建堆
// 建堆 -- 向下调整建堆 -- O(N)
//为啥要 i = (n - 1 - 1) / 2?
//因为你要从父节点开始出发,而父节点下标 = (i - 1) / 2
//n 代表当前数组长度,n-1就代表数组最后一个元素的下标
//所以代入进去就是 i = (n - 1 - 1) / 2
/*for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}*/
int end = n - 1;
while (end > 0)
{
//将堆顶元素(即 a[0])与堆的最后一个元素 a[end] 交换。
Swap(&a[0], &a[end]);
//交换完之后,使用向下调整,维持大根堆
AdjustDown(a, end, 0);
//将 end--,将最后一个元素从堆中排除(此时最大或最小值已放在数组末尾)。
end--;
}
}
(这里选择用 建大堆 来 升序排序 的理由是:升序排序 需要每次将当前最大的元素放到数组末尾,而大堆的堆顶恰好是最大值 ,交换堆顶和末尾元素后,剩余部分可以快速通过向下调整恢复成新的大堆,从而高效地依次选出剩余最大值。如果建小堆,堆顶是最小值,只能依次选最小值放到数组开头,这会破坏数组后续元素的原有堆结构,导致需要额外维护或重新建堆,增加了复杂度和时间开销,所以升序排序用建大堆更直接高效。大家只需要记住:升序排序 → 建大根堆;降序排序 → 建小根堆。)
写完这个功能函数之后,我们来查看一下结果:
- 测试代码:
cpp
int main()
{
int a[10] = { 2, 1, 5, 7, 6, 8, 0, 9, 4, 3 }; // 对数组排序
HeapSort(a, 10);
for (int i = 0; i < 10; i++)
{
printf("%d ", a[i]);
}
printf("\n");
return 0;
}
- 测试结果:

完整版代码如下:
cpp
void HeapSort(int* a, int n)
{
// 建堆 -- 向上调整建堆 -- O(n log n)
for (int i = 1; i < n; ++i)
{
AdjustUp(a, i);
}
//也可以使用向下调整建堆
// 建堆 -- 向下调整建堆 -- O(N)
//为啥要 i = (n - 1 - 1) / 2?
//因为你要从父节点开始出发,而父节点下标 = (i - 1) / 2
//n 代表当前数组长度,n-1就代表数组最后一个元素的下标
//所以代入进去就是 i = (n - 1 - 1) / 2
/*for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}*/
int end = n - 1;
while (end > 0)
{
//将堆顶元素(即 a[0])与堆的最后一个元素 a[end] 交换。
Swap(&a[0], &a[end]);
//交换完之后,使用向下调整,维持大根堆
AdjustDown(a, end, 0);
//将 end--,将最后一个元素从堆中排除(此时最大或最小值已放在数组末尾)。
end--;
}
}
int main()
{
int a[10] = { 2, 1, 5, 7, 6, 8, 0, 9, 4, 3 }; // 对数组排序
HeapSort(a, 10);
for (int i = 0; i < 10; i++)
{
printf("%d ", a[i]);
}
printf("\n");
return 0;
}
四、TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
- 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
- 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
那为什么要这样做,理由如下:
- 需要前 k 个 最大 元素时,建一个大小为 k 的 小堆(堆顶是堆中最小的值)。
- 遍历数据时,若当前元素 大于 堆顶,则替换堆顶并向下调整,使堆始终保持当前已遍历数据中最大的 k 个元素。
- 最终堆中留下的就是最大的 k 个元素,堆顶是这 k 个中的最小值,但整体已保留最大的 k 个。
- 同理,需要前 k 个 最小 元素时,建 大堆 (堆顶是最大值),遍历时若当前元素 小于 堆顶则替换并调整,最终堆中留下最小的 k 个元素。
- 这种做法的核心在于: 用小堆淘汰小值来保留大值,用大堆淘汰大值来保留小值 。
如果还不理解,我再举一个通俗易懂的例子,比如说:这份数据前 k 个元素,里面已经有元素的最大值了,这份数据找不出比它大的了,那我要找到前k个最大的元素,那如果我采用 建大堆的方式来收集的话,我就要向下调整,向下调整之后了,那这个最大值就作为堆顶了,那我遍历后面的数据时,只要没有大于 最大值的,就无法进入堆里面,所以就无法找到前k个最大的元素,而用 建小堆 的方式不一样,当前k个的元素里面即使有了最大值了,那它也只能呆在堆尾,不会影响到后续的替换堆顶并向下调整操作。
代码实现(这里以前K个最大的元素为例):
cpp
void PrintTopK(const char* file, int k)
{
// 1. 建堆--用a中前k个元素建小堆
int* topk = (int*)malloc(sizeof(int) * k);
assert(topk);
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 读出前k个数据建小堆
for(int i = 0; i < k; ++i)
{
fscanf(fout, "%d", &topk[i]);
}
for (int i = (k-2)/2; i >= 0; --i)
{
//记得是小根堆,如果用我的代码的话,记得改里面的判断条件
//我这个函数里面写的是大根堆的
AdjustDown(topk, k, i);
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
int val = 0;
int ret = fscanf(fout, "%d", &val);
while (ret != EOF)
{
if (val > topk[0])
{
topk[0] = val;
//记得是小根堆,如果用我的代码的话,记得改里面的判断条件
//我这个函数里面写的是大根堆的
AdjustDown(topk, k, 0);
}
ret = fscanf(fout, "%d", &val);
}
for (int i = 0; i < k; i++)
{
printf("%d ", topk[i]);
}
printf("\n");
free(topk);
fclose(fout);
}
void CreateNDate()
{
// 造一万条数据
int n = 10000000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (size_t i = 0; i < n; ++i)
{
//让它生成的数字小于等于 10000;
int x = rand() % 10000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
int main()
{
CreateNDate();
PrintTopK("data.txt", 10);
return 0;
}
运行结果:
1.为了方便测试,我先把 **PrintTopK("data.txt", 10)这个函数先注释掉,只需要执行CreateNDate()**这个函数,让它生成一份数据,如图中所示:

2.然后将 CreateNDate()这个函数先注释掉,只需要执行PrintTopK("data.txt", 10)
这个函数,得到的结果,如图中所示:

可以看到返回了这一万份数据里面,最大的前10个数据了。
五、总结
以上便是我对堆部分的全部理解了。有啥不好的地方也欢迎大家积极指出,希望大家早日被自己喜欢的offer录取。那我们就下一个博客见。
