数据结构‑二叉树(二):二叉堆从零实现|Heap结构设计 + 建堆优化 + 堆排序深度解析

写在前面

上一篇 数据结构-二叉树(一):树的基础概念与二叉树核心结构-CSDN博客 ,我们学习了树、二叉树以及完全二叉树,并得到数组存储下父子下标公式:

左孩子:left = parent * 2 + 1

右孩子:right = parent * 2 + 2

父节点:parent = (child - 1) / 2

堆就是建立在完全二叉树之上的数据结构,它把树形逻辑映射到连续数组,高效维护集合的最大值/最小值。堆不只是课堂考点,工程中大量使用:堆排序、Top‑K问题、优先级队列、操作系统任务调度、Dijkstra最短路径算法都离不开它。

本文基于C语言完整实现0下标二叉堆,讲解:

  1. Heap结构体设计,为什么传结构体指针;
  2. 向上调整、向下调整核心逻辑与易错坑;
  3. 向上建堆 vs 向下建堆,时间复杂度对比;
  4. 大小根堆切换方法;
  5. 堆排序原理,搞懂「降序建小堆,升序建大堆」;
  6. 排序算法横向对比;
  7. 多组测试用例解读与常见踩坑。

 代码仓库

数据结构/Heap · Luminous/Code_2026 - 码云 - 开源中国


一、什么是堆(Heap)

堆是特殊的完全二叉树,只约束父子节点的大小关系,整棵树并不全局有序,这点是绝大多数初学者的误区。

小根堆

任意父节点的值 ≤ 子节点的值;堆顶(根)一定是整个集合最小值。

        7
      /   \
    12     45
   /  \
 89   23

底层数组:{7,12,45,89,23}

大根堆

任意父节点的值 ≥ 子节点的值;堆顶(根)一定是整个集合最大值。

        89
      /    \
    45      56
   /
 23

特别注意:堆不是有序数组

示例:

        1
      /   \
     5     3
    /
   8

满足小根堆性质,但数组 {1,5,3,8} 并不是升序。堆只保证父子关系,不保证左右子树、兄弟节点之间有序。堆的优势:(O(1))拿到极值;想要整体有序,需要堆排序。


二、堆的存储与结构体设计

逻辑上是完全二叉树,但不使用链式TreeNode节点

完全二叉树几乎没有空间浪费,直接用一段连续动态数组存储,效率更高。

typedef int HPDataType;

typedef struct Heap
{
    HPDataType* a;    // 动态数组,存放堆元素
    int size;         // 当前有效元素个数
    int capacity;     // 数组总容量
}Heap;
  • a:动态开辟的数组,也可以直接挂载外部已有数组,实现原地建堆;
  • size:堆的有效元素,[0,size‑1]为有效区间;
  • capacity:数组总容量,用于判断是否需要realloc扩容。

内存示意:

capacity=8,size=5

[7][12][45][89][23][ ][ ][ ]


三、接口为什么传Heap*指针,不传Heap值拷贝

函数原型对比:

// 传值,操作副本,外面堆不会变化
void HeapPush(Heap hp, int x);

// 传指针,操作原堆对象
void HeapPush(Heap* hp, int x);

三点设计原因:

  1. 修改原堆对象 插入、删除会修改sizea指针。传值会生成结构体副本,函数内部修改全部作用在副本上,外部堆完全不受影响,逻辑失效。
  2. 减少拷贝开销 虽然结构体本身不大,但传值会完整复制结构体;传指针只传递地址,开销恒定。
  3. 核心:支持外部数组复用,原地建堆 这是HeapCreate接口设计的关键。可以直接把外部普通数组挂载到hp->a不需要malloc新空间、不需要拷贝数据
int arr[] = {12,45,7,89,23,56};
HeapCreate(&hp, arr, 6); // hp->a直接指向arr,原地建堆

省去内存分配与拷贝,也是堆排序可以做到(O(1))额外空间的底层思想。


四、堆调整核心思想:为什么需要向上和向下调整

堆的本质要求:

任意节点都必须满足父节点和孩子节点之间的大小关系。

以小根堆为例:

        父节点
          ↓
       父 ≤ 子

          7
        /   \
      12     45

但是在实际操作过程中:

  • 插入元素只能破坏从新增节点到根节点这一条路径
  • 删除堆顶只能破坏从根节点到叶子节点这一条路径

因此没有必要重新遍历整棵树,只需要沿着一条路径调整即可。

这就是堆高效的原因:

利用完全二叉树高度为 log N 的特点,只需要调整一条路径。

4.1 向上调整:新元素插入后的维护

使用场景:HeapPush 插入元素

例如当前小根堆:

          10
        /    \
       20      30
      /
     40

数组:[10,20,30,40]

现在插入新元素 5。 按照完全二叉树规则,新元素只能接在末尾:

          10
        /    \
       20      30
      / \
     40  5

对应数组:[10,20,30,40,5]

此时发现 5 < 20,破坏小根堆性质:

        10
      /    \
    20      30
   /
 40
 /
5

所以需要让新元素不断向上移动。

向上调整过程

第一次交换:

          10
        /    \
       5      30
      /
     40
    /
   20

继续比较,5 < 10,继续交换:

          5
        /   \
      10     30
     /
    40
   /
 20

最终恢复小根堆:

        5
      /   \
    10     30
   /
 40
 /
20
向上调整代码对应逻辑
while(child > 0)
{
    parent=(child-1)/2;

    if(a[child] < a[parent])
    {
        Swap(&a[child],&a[parent]);
        child=parent;
    }
    else
    {
        break;
    }
}

核心逻辑:

孩子违反规则
      ↓
和父节点交换
      ↓
继续向上检查

插入只能影响新增节点到根节点路径,因此使用向上调整。

时间复杂度: 树高度 h= log N,最坏一路交换到根,单次调整 O(log N)。

4.2 向下调整:删除堆顶后的维护

使用场景:HeapPop 删除堆顶

例如小根堆:

          5
        /   \
      10     20
     /  \
   30   40

数组:[5,10,20,30,40]

如果直接删除堆顶元素5,树形结构直接残缺:

          ?
        /   \
      10     20
     /
   30

完全二叉树结构被破坏,不能直接删除根。

正确操作分为两步:

第一步:交换堆顶和最后元素

交换:5 ↔ 40

          40
        /    \
      10      20
     /
   30

数组变为:[40,10,20,30]

此时树形结构完整,但堆性质被破坏。

第二步:让40向下移动

左右孩子比较,10 < 20,选择更小的孩子10。 交换父与子:

          10
        /    \
      40      20
     /
   30

继续比较,40 > 30,再次交换:

          10
        /    \
      30      20
     /
   40

堆性质恢复。

向下调整代码思想
child = parent*2+1;

while(child<size)
{
    //找到更优孩子

    //父子比较

    //交换

    parent=child;
}

核心逻辑:

  父节点违反规则
        ↓
选择更小/更大的孩子
        ↓
       交换
        ↓
     继续向下

删除堆顶只影响根节点到叶子的路径,所以使用向下调整。

时间复杂度:O(log N)


五、堆基础接口实现

初始化、销毁

void HeapInit(Heap* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->size = 0;
	hp->capacity = 0;
}

void HeapDestory(Heap* hp)
{
	assert(hp);
	free(hp->a);
	hp->a = NULL;
	hp->capacity = hp->size = 0;
}

特别注意:HeapCreate挂载栈数组时,栈内存不能free,销毁前务必手动置空hp->a = NULL,否则程序崩溃。

堆插入 HeapPush

尾插元素,容量不足则二倍realloc扩容,新元素做向上调整。单次插入(O(\log N))。

void HeapPush(Heap* hp, HPDataType x)
{
	if (hp->capacity == hp->size)
	{
		int num = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * num);
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		hp->a = tmp;
		hp->capacity = num;
	}
	hp->a[hp->size] = x;
	hp->size++;
	HeapAdjustUp(hp->a, hp->size-1);
}

删除堆顶 HeapPop

不能直接删除数组下标0,会破坏完全二叉树结构。

正确流程:堆顶与末尾元素交换 → size‑1逻辑删除末尾 → 对根做向下调整。单次删除(O(log N))。

void HeapPop(Heap* hp)
{
	assert(hp);
	assert(hp->size > 0);
	Swap(&hp->a[0], &hp->a[hp->size-1]);
	hp->size--;
	HeapAdjustDown(hp->a, hp->size, 0);
}

取堆顶、判空

HPDataType HeapTop(Heap* hp)
{
	assert(hp);
	assert(hp->size > 0);
	return hp->a[0];
}

bool HeapEmpty(Heap* hp)
{
	assert(hp);
	return hp->size == 0;
}

六、两种建堆方式详细对比

如果现在给你一个无序数组:

int a[]={12,45,7,89,23,56};

如何把它构建成堆?存在两种实现思路。

6.1 方法一:向上调整建堆

思想:假设前面的元素已经是堆,不断插入新元素。

推演过程:

初始:

12

加入45:

12
 \
 45

满足堆性质。

加入7,数组[12,45,7]

       12
      /  \
    45    7

7 < 12,违反小根堆,进行交换:

       7
      / \
    45 12

继续加入89:

       7
      / \
    45 12
   /
 89

继续加入23:

       7
      / \
    23 12
   / \
89 45

最终形成小根堆:

        7
      /   \
    23     12
   / \
89   45

对应代码:

for(int i=1;i<n;i++)
{
    HeapAdjustUp(a,i);
}

每新增一个元素就做一次向上调整,单次最多 log N。 总时间复杂度:O(N logN)。

6.2 方法二:向下调整建堆(推荐)

思想:叶子节点天然满足堆,只需要调整非叶子节点。

数组:12 45 7 89 23 56 对应完全二叉树:

          12
       /      \
     45        7
    /  \      /
  89   23   56

叶子节点:89、23、56,叶子不需要调整。

最后一个非叶子节点向前处理。 最后非叶子节点下标:(n‑2)/2,本例:(6‑2)/2 = 2

1)处理下标2节点:

        7
       /
      56

满足堆,无需交换。

2)处理下标1节点:

       45
      /  \
    89    23

孩子23更小,执行交换:

       23
      /  \
    89    45

3)处理下标0节点:

          12
       /      \
     23        7
    /  \      /
  89   45   56

孩子7最小,执行交换:

          7
       /      \
     23        12
    /  \       /
  89   45    56

小根堆建立完成。

为什么向下建堆是 (O(N))?

很多人直觉认为每个节点调整代价 log N,总复杂度就是 O(N log N),这个理解是错误的。

原因在于不同层节点数量、调整次数不一样:

  • 底层节点数量最多,但高度为0,几乎不用向下调整;
  • 越靠近上层,节点数量越少,但向下调整步数变多。

求和:

 

拿到完整数组时,优先选择向下调整建堆。

建堆方式最终总结

方式核心思想复杂度适用场景
向上建堆模拟不断Push插入O(N log N)动态逐个插入元素
向下建堆从最后非叶子节点向前调整(O(N))已有完整数组,原地建堆


七、堆排序深度解析

重点区分:建堆O(N),完整堆排序整体(O(N logN))

堆排序分为两步:

  1. 将无序数组建堆;
  2. 不断把堆顶极值交换到数组尾部,尾部区间逐步有序,前面区间继续向下调整维护堆。
void HeapSort(int* a, int n)
{
    // 降序,建小堆
    // 升序,建大堆
    //for (int i = 1; i < n; i++)//向上调整 O(N*logN)
    //{
    //    HeapAdjustUp(a, i);
    //}

    //向下调整建堆 O(N)
    for (int i = (n - 1 - 1) / 2; i >= 0; i--)
    {
        HeapAdjustDown(a, n, i);
    }

    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);
        HeapAdjustDown(a, end, 0);
        --end;
    }
}

核心口诀:降序建小堆,升序建大堆

很多同学在这里记反,推导逻辑: 堆顶永远是当前区间的极值;每次把堆顶交换到数组末尾,末尾位置就排好序,不再参与后续堆调整。

  1. 建小根堆:堆顶是最小值。最小值不断放到数组末尾,尾部依次存放最小、次小……最终整体数组降序。
  2. 建大根堆:堆顶是最大值。最大值不断放到数组末尾,尾部依次存放最大、次大……最终整体数组升序。

复杂度拆解:

  • 建堆阶段:O(N)
  • while循环执行n‑1次,每次向下调整(O(log N)):(O(N log N))

总复杂度 T=O(N)+O(N log N)=O(N log N);大O表示法保留高阶项。

  • 空间复杂度:(O(1)) 原地排序;稳定性:不稳定排序。

八、排序算法横向对比

算法平均时间最坏时间额外空间稳定性评价
冒泡排序O(N^2)O(N^2)(O(1))稳定仅教学,大数据量性能极差
堆排序O(N log N)O(N log N)(O(1))不稳定最坏情况性能稳定,内存友好,缓存局部性较差
快速排序O(N log N)O(N^2)O(log N)递归栈不稳定实际运行最快,语言内置排序核心,常数因子小

堆排序最坏时间不会退化,但实际跑的速度一般弱于快排。堆真正强项不是完整排序,而是Top‑K、优先级队列场景。

Top‑K简单理解

海量数据求前K大元素,不需要全部排序。维护大小为K的堆,复杂度O(N log K);K远小于N的时候,比全局排序O(N log N)效率高很多。


九、项目代码结构与test.c测试解读

项目三层文件拆分:

  1. Heap.h:结构体、函数声明;Swap为内部辅助函数,不对外暴露;
  2. Heap.c:全部接口实现,默认小根堆;
  3. test.c:三组测试,同一时间只启用一组main,其余注释。

测试1:裸数组原地堆排序,求前K大元素(当前启用)

直接操作普通数组,不使用Heap结构体,原地完成堆排序,排序完成数组降序,数组前k个即为前k大元素。注释保留向上调整建堆代码用于对比学习。

测试2:堆接口Push/Pop整套功能测试

完整验证初始化、插入、弹出、堆顶、判空、销毁。当前是小根堆,弹出顺序从小到大;切换大根堆后输出会改变。

测试3:拷贝堆数组,不破坏原堆求前k大

malloc复制一份堆内存,在副本上执行Pop取TopK,原始堆数据不受影响。

注意:tmpHp只是借用malloc出来的内存,不要调用HeapDestory(&tmpHp),手动free拷贝内存即可,避免二次释放。

代码仓库

已经上传本篇及以往博客代码,可以拉取代码学习使用。

数据结构/Heap · Luminous/Code_2026 - 码云 - 开源中国


十、常见坑点汇总

  1. 0下标堆父子公式不要和1下标混用,混用直接逻辑错乱;
  2. 大小根堆三处比较符号必须同步修改,否则堆性质失效;
  3. HeapCreate挂载栈数组,销毁前hp->a = NULL,禁止free栈内存;
  4. Pop不能直接删除下标0,必须交换堆顶与末尾元素再向下调整;
  5. 已有完整数组建堆,优先向下调整建堆O(N),不要向上调整;
  6. 区分概念:建堆O(N),堆排序整体(O(N logN)),不要混淆两个复杂度。

写在最后

从树形结构到数组存储,从堆调整到堆排序,二叉堆看似只是完全二叉树的一种特殊形式,但背后体现的是数据结构中非常重要的思想:利用结构特点降低操作成本

本篇重点掌握了几个核心:

  • 堆本质是完全二叉树,通过数组实现,避免了链式结构的额外空间开销;
  • 向上调整解决「插入后维护堆性质」的问题,向下调整解决「删除堆顶以及建堆」的问题;
  • 已有完整数组时,优先使用向下调整建堆,时间复杂度从 (O(N logN)) 优化到 (O(N));
  • 堆排序虽然实际效率通常不如快速排序,但它拥有稳定的最坏情况复杂度和 (O(1)) 空间优势;
  • 堆真正强大的地方并不是排序,而是在动态维护极值,例如 Top-K、优先级队列、任务调度等场景。

学习数据结构时,不应该只停留在记忆代码和复杂度,更重要的是理解每个设计背后的原因:

  • 为什么完全二叉树适合数组存储?
  • 为什么插入使用向上调整,删除使用向下调整?
  • 为什么建堆推荐从最后一个非叶子节点开始?
  • 为什么堆排序选择大根堆或小根堆会影响最终排序方向?

当这些问题真正理解后,堆就不再是一段需要背诵的代码,而会成为解决实际问题的一种工具。

下一篇将继续深入二叉树专题,学习二叉树的遍历、递归思想以及更多经典应用。从基础结构出发,逐步构建数据结构与算法能力体系。

flash builder 4.7破解方法1. 拔掉网线断掉网络(非常重要的一步),清理你的HOST文件(C:\WINDOWS\system32\drivers\etc),确保没有诸如: 127.0.0.1 lmlicenses.wip4.adobe.com 127.0.0.1 lm.licenses.adobe.com 2. 打开注册机“xf-mccs6.exe”,点击“generate” 生成序列号;3. 将生成的序列号前4位替换为:1424 1325-1627-6964-9232-5771-47551424-1627-6964-9232-5771-47554.安装flash builder 4.7软件5、解压成功后,请不要关掉注册机进行软件安装;6、当出现“请连接于是internet,然后重试”时,请点击“稍后链接”;7、然后依提示点击“安装”即可;8、软件正在安装中,请稍后;9、成功安装后,打开桌面上生成的flash builder 4.7程序;10、出现“请连接到internet以验证序列号”时,选择“连接internet时是否出现问题”;11、再选择“脱机激活”;12、选择“生成请求代码”13、复制请求代码到注册的requests框中,点击“generate”生成“activation”;14、请求代码【Doqj e2pB OC9s xjWu mU3H EQNQ 0u6x Qmdg oSwD S9vM cxc=】15、得到响应代码【sz73 DiHS XVMj zA/S pQA+ f5GD lgb6 B0Yv 0Yic gIRK 9GE=】16、成功激活后,打开软件,点击帮助菜单--flash builder产品注册,你会发现软件试用期为32767天,事实上已经将flash builder 4.7成功破解。17、为了防止日后我们在使用的时候,adobe flash builder 4.7自动联网验证,导致激活失效,还是要把下面这几行东西加到Host表中。127.0.0.1 activate.adobe.com127.0.0.1 practivate.adobe.com127.0.0.1 lmlicenses.wip4.adobe.com127.0.0.1 lm.licenses.adobe.com18、大功告成,现在软件可能无限制免费使用。软件官方下载地址(请复制到迅雷再下载)flash builder 32位:http://trials3.adobe.com/AdobeProducts/FLBR/4_7/win32/FlashBuilder_4_7_LS10.exe flash builder 64位:http://trials3.adobe.com/AdobeProducts/FLBR/4_7/win64/FlashBuilder_4_7_LS10_win64.exe
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值