目录
摘要:
本博客从0实现堆,从堆的概念到实现的难点,再到每个函数内部实现的解析,然后进行代码测试,最后总结求大堆和求小堆,在向上/向下调整函数中的区别
一:堆的概念
堆是一种特殊的完全二叉树结构,其中每个节点的值都必须满足特定的堆属性:对于大堆,父节点的值大于或等于其子节点;对于小堆,父节点的值小于或等于其子节点。这意味着大堆的根节点是最大值,小堆的根节点是最小值。本文将以小堆为例进行详细讲解。
注:大堆也叫作大根堆,小堆也叫作小根堆,这样叫侧面说明了堆顶是最值的特性
二:堆实现的难点理解
1:数组 OR 链式结构?
二叉树可以用链式结构实现,也可以用数组实现,对于普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用数组实现,现实中我们通常把堆(完全二叉树)使用顺序结构的数组来实现
对于二叉树来说最重要的就是能够找到自己的左右孩子,所以如果采取链式结构实现,则单个节点内部具有两个指针即可找到两个孩子,如果采取数组实现,则利用下标的特性来存储二叉树,也可以找到对应的左右孩子,甚至还可以通过孩子的下标找到父的下标,而数组对于普通二叉树会浪费一些空间,这是唯一的缺点,但对于完全二叉树,则只剩优点了
注:有时我也会叫做插入的值为节点,只是为了方便讲述和理解,本质实现结构还是数组....
如图:

2:用数组来逐层存储堆,可以得到三个公式
leftchild = parent *2 +1(左孩子的下标等于父亲的下标*2+1)
例:D的下标 = B的下标1 去*2 + 1 = 3
rightchild = parent *2 +2(右孩子的下标等于父亲的下标*2+2)
例:E的下标 = B的下标1 去*2 + 2 = 4
parent = (child-1)/2*(父亲的下标等于孩子的下标-1再/2)
例:B的下标 = D的下标(3-1)/2 = 1 或 E的下标(4-1)/2 =1(int的运算法则,1.5也是1)
注:这就是前文所说的用数组存储二叉树可以找到孩子的原因,下标之间具有规律
3:堆的插入
堆的实现难点之一,就是堆的插入,如果是普通的二叉树,插入也就是直接根据下标放入值,但是堆则不同,堆具有特性,在插入之后,必须保证堆仍为堆,所以我们在插入之后,需要用到向上调整函数(AdjustUp)
注:为什么叫向上调整,因为堆插入值,就是在最后一个元素的后面进行插入,此时按照二叉树的结构来看,该插入值是位于最底层的,而要通过AdjustUp函数使得插入后形成新的堆,则需要将插入的值往上进行调整,停留在正确的位置...
画图解释,向上调整函数的内部逻辑,都以小堆为例.....
插入的几种情况:
第一种:插入的孩子值直接大于父亲(90>56)

解释:插入进行仍为小堆,所以不作任何处理
第二种:插入的孩子值小于父亲,但未小于根节点(50<56 但 50>10)

解释:插入的值小于父亲,则直接交换,再进行下一轮比较,发现不小于下一个父亲,则停留
第三种:插入的孩子节点直接小于了根节点(5<10)

解释:一直比较父亲,最后发现比堆顶还小,则交换后,停留
总结:向上调整函数(AdjustUp)的本质:当孩子小于父亲,即交换,以此类推。时间复杂度为logn,极其高效...
4:堆的删除
堆的难点之二就是堆的删除,对于堆而言,若删除最后一个叶子节点,则毫无意义,直接根据下标找到值删除即可,所以对于堆而言,堆的删除指的是删除堆顶!所以我们要确保每次删除后,他都还是一个堆!
那我们删除堆顶之后,剩余的数组整体向左移动之后,再进行恢复成堆吗?这种想法必错!
①:时间复杂度太高,为O(n)
②:整体向左移动,原有的下标的父子映射关系全部失效
③:最重要的一点是,整体左移之后,我们无法保证新的完全二叉树仍为堆!
正确做法:既然删除堆顶,则我们将堆顶和最后一个元素交换,然后元素个数--,达到删除的效果,此时剩余的完全二叉树,除开堆顶元素,其余的左子树和右子树仍为堆结构,此时我们对堆顶元素进行向下调整,让堆顶元素位于正确的位置,则达到恢复成堆的效果
重点:
向上调整(Sift-Up)和向下调整(Sift-Down)算法本身都是局部修复算法。它们的设计目标不是从零开始构建一个完整的堆,而是基于已有部分堆结构的假设,去修复单个节点的位置,使其最终融入整个堆。两个函数操作之所以快,是因为它们利用了堆的性质(即只需要与父节点或子节点比较和交换,沿着树的高度移动),能够以O(log n) 的时间完成插入和删除
比如向上调整函数,除了插入的值,其余的值形成的结构仍为堆;
比如向下调整函数,在交换首尾删除尾之后,除了堆顶,其余的值形成的左子树和右子树仍为堆;
Q:那我非要整体左移得到新的完全二叉树,然后再恢复成堆可以吗?
A:正确做法的向下调整和堆的插入的向上调整的时间复杂度都为logn,是非常优秀的,而如果堆的删除,你选择体左移得到新的完全二叉树,然后再恢复成堆的思路的话,那么相当于需要从0开始重新建堆了,你得对剩余的数组的元素从头到尾进行向上调整函数建堆了,时间复杂度肯定是远远高于正确做法
画图解释,向下调整函数的内部逻辑,都以小堆为例.....

所以 向下调整函数(AdjustDown)的本质:
①:先交换根节点10和尾节点90,然后删除尾节点10(即起到删除根节点的作用)
②:然后新的根节点作为父亲(90作为父亲),与左右孩子中的最小一个比较(90与15比较),如果大于最小的孩子(90>15),即交换,以此类推.......
③:最后新的堆的元素个数会比删除前少一个,因为最后一个空间被抹去
5:向上/向下调整函数的总结
①:向上调整(Sift-Up)和向下调整(Sift-Down)算法本身都是局部修复算法。它们的设计目标不是从零开始构建一个完整的堆,而是基于已有部分堆结构的假设,去修复单个节点的位置,使其最终融入整个堆。两个函数操作之所以快,是因为它们利用了堆的性质(即只需要与父节点或子节点比较和交换,沿着树的高度移动),能够以O(log n) 的时间完成插入和删除!比如向上调整函数,除了插入的值,其余的值形成的结构仍为堆;比如向下调整函数,在交换首尾删除尾之后,除了堆顶,其余的值形成的左子树和右子树仍为堆;
②:向上调整只需与父亲进行比较,而向下调整则需要和孩子中的最值进行比较,小堆则找两个孩子中的min,大堆则找孩子中的max进行比较,而父亲或孩子的下标,则根据公式即可求得
③:向上/向下调整函数的复杂度都为logn
而下面实现的重点就是向上/向下调整函数的实现.....
三:堆的实现框架
1:文件分配
本文采用三个文件进行实现
1:Heap.h(堆有关函数的声明)
2:Heap.c(堆有关函数的实现)
3:text.c(对函数的检测)
2:assert
所有的函数都会接受到结构体指针的地址,本文统一命名为php,hp是堆heap的缩写,前面加上p前缀,所以php代表是堆的指针,所以会对php进行assert,确保指针不为空,删除函数还会去assert确保堆中存在元素能够删除
注:后续的断言就不再单独解释....
四:函数实现
前提:堆的结构体的声明

解释:
①:我们用数组来实现堆,所以要有一个 数组a
②:size代表堆中元素的个数,capacity代表堆的容量;所以size就是待插入位置的下标
③:以下的函数需要改变堆这个结构体中的时候,都需要向函数传结构体指针(php),才能真正的修改到堆
1:初始化堆

解释:初始化结构体中的各项
2:交换函数

解释:在向上调整和向下调整中都会用到此函数,用于交换父亲和孩子的值,参数为指针,进行址传递,达到真正的交换彼此
3:插入函数

解释:
①:插入先判断是否堆已经为满

②:开辟为什么用realloc
不管是哪种情况,都会去开辟空间给a,所以我们采用realloc。此处两个重点:
Q1:为什么第一次开辟不用malloc?
A1:我们后面会在a的空间基础上进行再次开辟,所以realloc才对,其次我们初始化的时候a = NULL了,在realloc函数中,接收的指针为NULL,realloc就等同于malloc了,也就相当于第一次开辟用的是malloc。
Q2:为什么不直接用a来接收开辟的空间,而是用了一个tmp中间变量,最后在赋值给a;为什么不用capacity直接接收三目操作符的返回值,而是中间变量newcaoacity?
A2:如果用a接收,如果realloc开辟失败,但是a如果之前不是空栈,那之前的栈元素也会丢失,而用一个tmp去接受,再去判断是否开辟成功会更好保护a之前的数据。capacity同理。
③:此时,确保有容量了,再进行赋值和size的++,最后堆插入的元素进行向上调整,才能成为一个堆。
4:向上调整函数

解释:当孩子小于父亲,即交换,以此类推.......
①:因为插入的时候调用向上调整函数,所以参数为插入节点的下标,此节点当然就为孩子节点,所以形参命名为child,然后通过child得到其父亲parent的下标,从而进行循环的比较,比较完一次,则更新child和parent的下标即可比较
②:终止循环条件1:child<=0,确切的说是当child为0的时候,这代表插入的节点经过向上调整之后,已经来到了堆顶的位置,则此时就是正确的位置,所以终止循环while(child>0)
③:终止循环条件2:不满足堆的特性,比如我们实现的小堆,当向上调整的过程中,发现child不小于parent,则代表此时child已经来到了正确的位置,不必再继续向上调整了,所以break终止
Q1:为什么while条件不是parent>=0,对于child最后会成为堆顶的情况来说,当最后一次,child为0的时候,parent的下标不就更新为负数了吗,所以parent>=0也可以作为循环条件?
A2:把parent的下标赋给child之后,parent自己更新为 (0-1)/2,还是0,所以parent的下标最小也是0,若while条件为parent>=0,则死循环!
Q2:既然parent的下标最小恒为0,那child不也是被parent赋值的吗,child最后不也是恒为0吗,为什么while(child>0)可以?
A2:因为这是while(child>0),最后child为0,当然终止循环
5:删除函数

解释:
①:断言size是因为删除的前提是确保有元素
②: 根据前文的向下调整的思路,先交换根节点和尾节点,在抹去尾节点,然后向下调整
6:向下调整函数

解释:当父亲小于两个孩子的min值,即交换,以此类推
①:因为删除的时候调用向下调整函数,所以参数为堆顶节点的下标,此节点当然就为父亲节点,所以形参命名为parent,然后通过parent得到其左孩子child的下标,再通过child得到右孩子下标,从而得到两个孩子的min值,再进行比较,比较完一次,则更新parent和child的下标即可比较
②:终止循环条件1:child>=n,也就是child越界了。当进行到某一次向下调整时,此时parent已经停留在了正确的位置,但child依旧会通过p*2+1更新坐标,此时发现child求到的下标值越界了,系统才知道parent已经停留在正确位置了,所以终止循环,所以循环条件为while(child<n)
③:终止循环条件1:不满足堆的特性,比如我们实现的小堆,当向下调整的过程中,发现parent不小于child,则代表此时parent已经来到了正确的位置,不必再继续向下调整了,所以break终止
这②③两点和向上调整函数是类似的,但是对于向下调整函数来说,还需考虑找不到右孩子的情况
④:在确定孩子中较小值的时候,我们还需要其根据child+1得到右孩子的下标,对比二者得到min值,但如下图,是可能出现该堆结构,导致p的右孩子不存在的情况,所以为了避免child+1越界,在对比来个孩子时,应先确保child+1<n成立,才会执行&&右面的比较

❌️:不能想要综合两个条件,所以直接让while的条件直接 child<n-1,因为child的下标是可以为n-1的,此时的child就是最后一个元素下标,此时代表还能进行一次向下调整中的比较,万一parent对比这个child之后,需要交换呢?所以若条件为child<n-1,则可能无法让parent调整到正确的位置
7:取堆顶

解释:返回数组的第一个元素
8:判空

解释:size为0,即为空。
9:获取堆的元素个数

解释:size即代表堆的数据个数
10:打印

解释:循环打印每一个元素即可,%d后跟一个空格更容易观察。
11:销毁堆

解释:将结构体中的一切恢复初始即可。
12:建堆函数
上面的函数已经足够使用了,但是需要一个一个的插入进堆,我们可是再实现一个HeapInitArray建堆函数,直接把一个数组作为参数调用此建堆函数,其实本质就是把每次单个循环插入集合成了一个函数

解释:
①:参数除了php外,还要接受一个数组和数组的元素个数n,n用于malloc堆的数组的大小
②:然后将a数组的内容拷贝到堆结构体的成员变量数组中(php->a)
③:最后对php->a进行建堆,从第二个元素开始向上调整即可,此方法虽然方便理解,但是效率较低,最优方式是从最后一个非叶子节点向下调整建堆,至于为什么,在堆排序博客中有讲
五:代码
1:Heap.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
#include<string.h>
#include<time.h>
// 堆的数据类型定义
typedef int HPDataType;
// 堆结构体
typedef struct Heap
{
HPDataType* a; // 指向堆数组的指针
int size; // 堆中元素的个数
int capacity; // 堆的容量
}HP;
// 向上调整堆
void AdjustUp(HPDataType* a, int child);
// 向下调整堆
void AdjustDown(HPDataType* a, int n, int parent);
// 交换
void Swap(HPDataType* p1, HPDataType* p2);
// 打印
void HeapPrint(HP* php);
// 初始化堆
void HeapInit(HP* php);
// 根据给定数组初始化堆
void HeapInitArray(HP* php, int* a, int n);
// 销毁堆,释放内存
void HeapDestroy(HP* php);
// 堆的插入
void HeapPush(HP* php, HPDataType x);
// 堆的删除
void HeapPop(HP* php);
// 获取堆顶元素
HPDataType HeapTop(HP* php);
// 判空
bool HeapEmpty(HP* php);
2:Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
/**
* @brief 初始化堆结构
* 将堆的指针置空,大小和容量置为0
*/
void HeapInit(HP* php)
{
assert(php); // 确保传入指针非空
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
/**
* @brief 使用给定数组初始化堆
* 将输入数组拷贝到堆内部数组,然后通过逐个向上调整的方式将其构建成堆
* 时间复杂度: O(N)
*/
void HeapInitArray(HP* php, int* a, int n)
{
assert(php);
assert(a);
// 为堆分配内存空间
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL)
{
perror("malloc fail"); // 分配失败,打印错误信息
exit(-1); // 异常退出程序
}
php->size = n;
php->capacity = n;
// 拷贝数组内容
memcpy(php->a, a, sizeof(HPDataType) * n);
// 从第二个元素开始,逐个向上调整,建立堆结构
// 此方法比从最后一个非叶子节点开始向下调整更直观,但通常效率略低
for (int i = 1; i < n; i++)
{
AdjustUp(php->a, i);
}
}
/**
* @brief 销毁堆,释放其占用的内存
*/
void HeapDestroy(HP* php)
{
assert(php);
free(php->a); // 释放动态分配的数组
php->a = NULL;
php->size = php->capacity = 0; // 重置大小和容量
}
/**
* @brief 交换两个HPDataType类型的变量
*/
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
/**
* @brief 向上调整堆(小根堆)
* 当一个节点的值变小时,可能需要向上移动以维持堆的性质
* @param a 指向堆数组的指针
* @param child 待调整节点的索引
*/
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2; // 计算父节点索引
while (child > 0) // 如果当前节点不是根节点
{
// 如果子节点小于父节点,则违反了小根堆性质,需要交换
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent; // 更新child为原parent的位置
parent = (parent - 1) / 2; // 计算新的parent位置
}
else
{
// 如果已满足堆性质,调整结束
break;
}
}
}
/**
* @brief 向下调整堆(小根堆)
* 当一个节点的值变大时,可能需要向下移动以维持堆的性质
* @param a 指向堆数组的指针
* @param n 堆的有效元素个数,用于边界检查
* @param parent 待调整节点的索引
*/
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1; // 计算左孩子的索引
while (child < n) // 如果左孩子存在
{
// 在左右孩子中找出较小的那个
if (child + 1 < n && a[child + 1] < a[child]) // 检查右孩子是否存在且更小
{
++child; // 选择右孩子
}
// 如果最小的孩子比父节点还小,则违反了小根堆性质,需要交换
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
// 继续向下调整,更新parent和child为新的位置
parent = child;
child = parent * 2 + 1;
}
else
{
// 如果已满足堆性质,调整结束
break;
}
}
}
/**
* @brief 向堆中添加一个元素
* 插入到数组末尾,然后进行向上调整以维持堆性质
*/
void HeapPush(HP* php, HPDataType x)
{
assert(php);
// 检查容量,如果不足则扩容
if (php->size == php->capacity)
{
int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2; // 初始容量为4,后续翻倍
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
perror("realloc fail"); // 扩容失败,打印错误信息
exit(-1); // 异常退出程序
}
php->a = tmp;
php->capacity = newCapacity;
}
// 将新元素放入数组末尾
php->a[php->size] = x;
php->size++;
// 对新插入的元素进行向上调整
AdjustUp(php->a, php->size - 1);
}
/**
* @brief 打印堆中所有元素
*/
void HeapPrint(HP* php)
{
assert(php);
for (size_t i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]); // 依次打印元素,以空格分隔
}
printf("\n"); // 换行
}
/**
* @brief 删除堆顶元素(对于小根堆,即最小元素)
* 将堆顶与最后一个元素交换,然后对新的堆顶进行向下调整
*/
void HeapPop(HP* php)
{
assert(php);
assert(php->size > 0); // 确保堆非空
// 将堆顶元素与最后一个元素交换
Swap(&php->a[0], &php->a[php->size - 1]);
--php->size; // 减少堆的大小
// 对新的堆顶元素进行向下调整,恢复堆性质
AdjustDown(php->a, php->size, 0);
}
/**
* @brief 获取堆顶元素的值
* @return 返回堆顶元素的值(对于小根堆,即最小值)
*/
HPDataType HeapTop(HP* php)
{
assert(php);
assert(php->size > 0); // 确保堆非空
return php->a[0]; // 返回数组第一个元素
}
/**
* @brief 检查堆是否为空
* @return 如果堆为空则返回true,否则返回false
*/
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0; // 直接判断size是否为0
}
六:测试
test.c
#pragma once
#include "Heap.h"
#include <stdio.h>
#include <stdlib.h> // 需要包含 malloc 和 free
void TestBasicOperations()
{
printf("=== 测试基本操作 ===\n");
HP hp;
HeapInit(&hp);
// 测试 Push 和 Print
printf("1. 插入元素 [50, 30, 70, 20, 40, 60, 80]:\n");
int values[] = { 50, 30, 70, 20, 40, 60, 80 };
for (int i = 0; i < 7; ++i)
{
HeapPush(&hp, values[i]);
}
printf(" 堆内容 (应为小根堆): ");
HeapPrint(&hp);
// 测试 Top
printf("2. 堆顶元素 (应为最小值 20): %d\n", HeapTop(&hp));
// 测试 Pop
printf("3. 弹出堆顶元素 (20):\n");
HeapPop(&hp);
printf(" 堆内容 (应为新的小根堆): ");
HeapPrint(&hp);
printf(" 新的堆顶元素 (应为 30): %d\n", HeapTop(&hp));
// 测试 Empty
printf("4. 堆是否为空 (应为 false): %s\n", HeapEmpty(&hp) ? "true" : "false");
// 清空堆
while (!HeapEmpty(&hp))
{
printf(" 弹出: %d, ", HeapTop(&hp));
HeapPop(&hp);
printf("剩余: %s\n", HeapEmpty(&hp) ? "empty" : "not empty");
}
HeapDestroy(&hp);
printf("--- 基本操作测试完成 ---\n\n");
}
void TestInitArray()
{
printf("=== 测试 InitArray (建堆) ===\n");
HP hp;
int arr[] = { 9, 5, 2, 7, 3, 8, 1, 6, 4 };
int n = sizeof(arr) / sizeof(arr[0]);
printf("1. 原始数组: ");
for (int i = 0; i < n; ++i) printf("%d ", arr[i]); printf("\n");
HeapInitArray(&hp, arr, n);
printf("2. 用数组初始化堆后 (应为小根堆): ");
HeapPrint(&hp);
printf(" 堆顶元素 (应为最小值 1): %d\n", HeapTop(&hp));
HeapDestroy(&hp);
printf("--- InitArray 测试完成 ---\n\n");
}
void TestEdgeCases()
{
printf("=== 测试边界情况 ===\n");
HP hp;
HeapInit(&hp);
// 测试空堆
printf("1. 测试空堆:\n");
printf(" 是否为空 (应为 true): %s\n", HeapEmpty(&hp) ? "true" : "false");
// 注意:调用 HeapTop 或 HeapPop 在空堆上会触发 assert,这里不演示
// 测试单个元素
printf("2. 插入单个元素 42:\n");
HeapPush(&hp, 42);
printf(" 堆内容: ");
HeapPrint(&hp);
printf(" 堆顶 (应为 42): %d\n", HeapTop(&hp));
printf(" 是否为空 (应为 false): %s\n", HeapEmpty(&hp) ? "true" : "false");
printf(" 弹出唯一元素:\n");
HeapPop(&hp);
printf(" 是否为空 (应为 true): %s\n", HeapEmpty(&hp) ? "true" : "false");
HeapDestroy(&hp);
printf("--- 边界情况测试完成 ---\n\n");
}
int main()
{
TestBasicOperations();// 测试基本操作
TestInitArray();// 测试 InitArray (建堆)
TestEdgeCases();// 测试边界情况
printf("所有测试用例执行完毕。\n");
return 0;
}

七:求大堆
如果要实现大堆该怎么做?只需要改变几个符号即可


当然,死记硬背是没用的,重点在于理解:
向上调整函数,子大于父进行交换,代表大的值往上层走,所以目标是大堆,反之小堆;
向下调整函数,父大于子进行交换,代表大的值往下层走,所以目标是小堆,反之大堆;
📌 [ 作者 ] shylyly
📃 [ 首次发布 ] 2024.8.10
❌ [ 最新修改 ] 2026.5.30
📜 [ 声明 ] 由于笔者水平有限,文中难免有疏漏或不妥之处,还望读者不吝赐教
1万+




被折叠的 条评论
为什么被折叠?



