当前位置: 首页 > news >正文

C语言数据结构之堆

目录

一、堆的基础:你必须知道的概念与性质

1. 堆的两种核心类型

2. 堆的关键性质(必背!)

二、堆的核心操作:从构建到增删

1. 核心算法:向下调整(AdjustDown)

作用

前提

步骤(以小根堆为例)

代码实现(C 语言)

2. 核心算法:向上调整(AdjustUp)

作用

前提

步骤(以小根堆为例)

代码实现(C 语言)

3. 堆的完整实现(结构体 + API)

1. 堆的结构体定义(Heap.h)

2. 堆的核心 API 实现(Heap.c)

(1)初始化堆(HPInit)

(2)插入元素(HPPush)

(3)删除堆顶元素(HPPop)

(4)其他常用 API

4. 建堆:将普通数组转为堆

为什么从最后一个非叶子节点开始?

步骤(以小根堆为例)

代码实现

建堆的时间复杂度

三、堆的经典应用:堆排序与 Top-K 问题

1. 堆排序:时间复杂度 O (nlogn)

排序思路(升序排列)

代码实现(升序排序)

堆排序的优缺点

2. Top-K 问题:从海量数据中找前 K 个最大 / 最小元素

解决思路(找前 K 个最大元素)

为什么用小根堆,不用大根堆?

实战代码(从文件中读取海量数据,找前 K 个最大元素)

四、常见问题与注意事项

五、总结


在数据结构的世界里,堆是一种看似简单却极具实用性的结构。它不仅是堆排序的核心,还能高效解决 Top-K 等经典问题。今天,我们就从堆的基本概念出发,一步步剖析它的性质、实现方式,再通过实战代码掌握其应用,让你彻底搞懂堆的来龙去脉。

一、堆的基础:你必须知道的概念与性质

在学习堆之前,我们得先明确:堆是一种特殊的完全二叉树,它在逻辑上是二叉树结构,物理上却用数组存储(因为完全二叉树不会造成数组空间的浪费)。根据父节点与子节点的大小关系,堆又分为两种核心类型:

1. 堆的两种核心类型

  • 小根堆(小顶堆):任意父节点的值 ≤ 其左右子节点的值,堆顶(根节点)是整个堆中最小的元素。
  • 大根堆(大顶堆):任意父节点的值 ≥ 其左右子节点的值,堆顶是整个堆中最大的元素。

举个直观的例子:小根堆的逻辑结构(左)与数组存储(右):逻辑结构:

    10/  \20  30/ \  /
40 50 60

数组存储:[10, 20, 30, 40, 50, 60]可以看到,数组的索引与完全二叉树的节点位置严格对应,这是堆能用数组存储的关键。

2. 堆的关键性质(必背!)

堆的性质是后续实现和应用的基础,尤其是完全二叉树的节点索引关系,一定要记牢:

  1. 节点索引关系(假设数组从 0 开始存储,i 为当前节点索引):

    • 父节点索引:(i - 1) / 2(整数除法,自动向下取整)
    • 左子节点索引:2 * i + 1
    • 右子节点索引:2 * i + 2比如数组中索引为 2 的节点(值 30),父节点是(2-1)/2=0(值 10),左子节点是2*2+1=5(值 60)。
  2. 堆的结构性质:堆一定是完全二叉树,即除了最后一层,其他层的节点数都满,且最后一层的节点从左到右依次排列(没有空缺)。

  3. 堆的有序性:仅保证父节点与子节点的大小关系,不保证整个堆是有序数组(比如小根堆的数组不是升序,堆排序的核心就是利用这一点逐步提取有序元素)。

二、堆的核心操作:从构建到增删

堆的操作围绕 “维持堆的性质” 展开,核心算法是向下调整向上调整。所有操作(建堆、插入、删除)都是这两个算法的组合应用。

1. 核心算法:向下调整(AdjustDown)

作用

当某个节点的左右子树已满足堆的性质,但该节点不满足时(比如父节点值大于子节点值,破坏小根堆),通过 “向下调整” 将该节点下沉到正确位置,恢复堆的性质。

前提

当前节点的左右子树必须已经是堆(这是向下调整能生效的关键)。

步骤(以小根堆为例)
  1. 计算当前节点(父节点)的左子节点索引child = 2 * parent + 1
  2. 比较左右子节点:如果右子节点存在(child + 1 < 堆的大小)且右子节点值更小,更新child为右子节点索引(确保child指向更小的子节点)。
  3. 比较父节点与child节点:
    • 若父节点值 ≤ child节点值:堆已满足,调整结束。
    • 若父节点值 > child节点值:交换两者,将父节点下沉到child位置;更新parent = child,重新计算新的child,重复步骤 2-3,直到child超出堆的大小。
代码实现(C 语言)
// 交换两个元素
void Swap(HPDataType* p1, HPDataType* p2) {HPDataType tmp = *p1;*p1 = *p2;*p2 = tmp;
}// 向下调整(小根堆):a是堆数组,n是堆大小,parent是起始父节点索引
void AdjustDown(HPDataType* a, int n, int parent) {int child = parent * 2 + 1; // 先默认左子节点是更小的那个while (child < n) { // 子节点存在才需要调整// 1. 找到左右子节点中更小的那个if (child + 1 < n && a[child + 1] < a[child]) {child++;}// 2. 比较父节点与更小的子节点,不满足则交换if (a[child] < a[parent]) {Swap(&a[child], &a[parent]);parent = child; // 父节点下沉child = parent * 2 + 1; // 重新计算子节点} else {break; // 满足堆性质,退出}}
}

2. 核心算法:向上调整(AdjustUp)

作用

当堆的末尾插入一个新节点后,该节点可能比父节点小(破坏小根堆),通过 “向上调整” 将新节点上浮到正确位置,恢复堆的性质。

前提

插入前的堆已满足性质,仅新节点可能破坏堆结构。

步骤(以小根堆为例)
  1. 新节点作为当前child(初始为堆的最后一个节点索引:size - 1)。
  2. 计算父节点索引parent = (child - 1) / 2
  3. 比较childparent节点:
    • child节点值 ≥ parent节点值:堆已满足,调整结束。
    • child节点值 < parent节点值:交换两者,将child上浮到parent位置;更新child = parent,重新计算新的parent,重复步骤 2-3,直到child为堆顶(child = 0)。
代码实现(C 语言)

// 向上调整(小根堆):a是堆数组,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; // 子节点上浮parent = (child - 1) / 2; // 重新计算父节点} else {break;}}
}

3. 堆的完整实现(结构体 + API)

堆的底层是动态数组(支持扩容),我们用结构体封装堆的数组、大小和容量,再实现初始化、插入、删除、销毁等 API。

1. 堆的结构体定义(Heap.h)

#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <stdbool.h>// 堆中元素的类型(可根据需求修改)
typedef int HPDataType;// 堆的结构体
typedef struct Heap {HPDataType* a;      // 存储堆元素的动态数组int size;           // 当前堆中元素的个数int capacity;       // 堆的最大容量(数组大小)
} HP;
2. 堆的核心 API 实现(Heap.c)
(1)初始化堆(HPInit)

将堆的数组置空,大小和容量初始化为 0:

void HPInit(HP* php) {assert(php); // 确保传入的堆指针有效php->a = NULL;php->size = 0;php->capacity = 0;
}
(2)插入元素(HPPush)
  • 步骤:先检查容量(不足则扩容)→ 将新元素插入到堆的末尾(size位置)→ 对新元素进行向上调整。

void HPPush(HP* php, HPDataType x) {assert(php);// 1. 扩容:容量为0时初始化为4,否则翻倍if (php->size == php->capacity) {int newCapacity = (php->capacity == 0) ? 4 : php->capacity * 2;HPDataType* tmp = (HPDataType*)realloc(php->a, newCapacity * sizeof(HPDataType));if (tmp == NULL) {perror("realloc failed"); // 打印扩容失败原因return;}php->a = tmp;php->capacity = newCapacity;}// 2. 插入新元素到末尾php->a[php->size] = x;php->size++;// 3. 向上调整,恢复堆性质AdjustUp(php->a, php->size - 1);
}
(3)删除堆顶元素(HPPop)

堆的删除只能删堆顶(删其他位置无意义,会破坏堆结构):

  • 步骤:交换堆顶(索引 0)和堆尾(索引size-1)元素→ 缩小堆的大小(size--,相当于删除原堆顶)→ 对新堆顶进行向下调整。

void HPPop(HP* php) {assert(php);assert(php->size > 0); // 堆为空时不能删除// 1. 交换堆顶和堆尾元素Swap(&php->a[0], &php->a[php->size - 1]);// 2. 删除堆尾(原堆顶)php->size--;// 3. 向下调整新堆顶,恢复堆性质AdjustDown(php->a, php->size, 0);
}
(4)其他常用 API
  • 获取堆顶元素(HPTop):直接返回数组索引 0 的元素(需确保堆非空)。
  • 判断堆是否为空(HPEmpty):返回size == 0的结果。
  • 销毁堆(HPDestroy):释放动态数组,重置大小和容量。

// 获取堆顶元素
HPDataType HPTop(HP* php) {assert(php);assert(php->size > 0);return php->a[0];
}// 判断堆是否为空
bool HPEmpty(HP* php) {assert(php);return php->size == 0;
}// 销毁堆
void HPDestroy(HP* php) {assert(php);free(php->a); // 释放动态数组php->a = NULL;php->size = 0;php->capacity = 0;
}

4. 建堆:将普通数组转为堆

如果我们有一个普通数组(如[4,2,8,1,5,6]),如何将它构建成一个堆?核心思路:从最后一个非叶子节点开始,依次向前对每个节点执行向下调整

为什么从最后一个非叶子节点开始?
  • 叶子节点没有子节点,本身就是 “单个节点的堆”,无需调整。
  • 最后一个非叶子节点的索引:(size - 2) / 2(因为最后一个节点的索引是size-1,其父节点就是最后一个非叶子节点)。
步骤(以小根堆为例)
  1. 计算堆的大小size = 数组长度
  2. parent = (size - 2) / 2开始,依次parent--,对每个parent执行AdjustDown
代码实现

// 建堆(小根堆):将普通数组a转为堆,n是数组长度
void HeapCreate(HP* php, HPDataType* a, int n) {assert(php);// 1. 初始化堆并拷贝数组HPInit(php);HPDataType* tmp = (HPDataType*)malloc(n * sizeof(HPDataType));if (tmp == NULL) {perror("malloc failed");return;}memcpy(php->a, a, n * sizeof(HPDataType)); // 拷贝数组元素php->size = n;php->capacity = n;// 2. 从最后一个非叶子节点开始向下调整for (int parent = (n - 2) / 2; parent >= 0; parent--) {AdjustDown(php->a, n, parent);}
}
建堆的时间复杂度

很多人以为建堆是 O (nlogn),但实际上是O(n)!推导思路:完全二叉树的节点集中在底层,上层节点的调整次数少。通过数学推导(错位相减),最终建堆的总操作次数约为 n,时间复杂度为 O (n)(具体推导见文末附录)。

三、堆的经典应用:堆排序与 Top-K 问题

堆的应用非常广泛,最核心的两个场景是堆排序Top-K 问题,我们结合实战代码来讲解。

1. 堆排序:时间复杂度 O (nlogn)

堆排序的核心是 “利用堆的性质逐步提取有序元素”,分为两步:建堆排序

排序思路(升序排列)
  • 步骤 1:将待排序数组建为大根堆(为什么是大根堆?因为大根堆的堆顶是最大值,提取堆顶后,剩余元素再建堆,可依次得到第二大、第三大... 元素)。
  • 步骤 2:排序阶段:
    1. 交换堆顶(最大值)和堆尾元素→ 最大值被放到数组末尾(有序区)。
    2. 缩小堆的范围(end--,排除已排序的堆尾)→ 对新堆顶执行向下调整,恢复大根堆性质。
    3. 重复步骤 1-2,直到堆的范围为 0,数组完全有序。
代码实现(升序排序)

// 堆排序(升序):a是待排序数组,n是数组长度
void HeapSort(int* a, int n) {// 步骤1:建大根堆(修改AdjustDown为大根堆逻辑)// 大根堆的AdjustDown:比较时取更大的子节点for (int parent = (n - 2) / 2; parent >= 0; parent--) {AdjustDownBig(a, n, parent);}// 步骤2:排序int end = n - 1;while (end > 0) {Swap(&a[0], &a[end]); // 堆顶(最大值)放到末尾AdjustDownBig(a, end, 0); // 对剩余元素建大根堆end--;}
}// 大根堆的向下调整(适配堆排序)
void AdjustDownBig(int* 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;child = parent * 2 + 1;} else {break;}}
}
堆排序的优缺点
  • 优点:时间复杂度稳定 O (nlogn),空间复杂度 O (1)(原地排序)。
  • 缺点:不稳定排序(相等元素的相对位置可能变化),对小规模数据不如插入排序快。

2. Top-K 问题:从海量数据中找前 K 个最大 / 最小元素

Top-K 问题是面试高频题,比如 “从 1000 万个数中找前 100 个最大的数”。如果直接排序,时间复杂度 O (nlogn),但用堆可以优化到 O (nlogK),且无需加载所有数据到内存(适合海量数据)。

解决思路(找前 K 个最大元素)
  • 核心:用小根堆存储前 K 个元素,堆顶是这 K 个元素中最小的那个。
  • 步骤:
    1. 取数据的前 K 个元素,构建小根堆。
    2. 遍历剩余的n-K个元素:
      • 若当前元素 > 堆顶元素:替换堆顶,执行向下调整(确保堆顶仍是 K 个元素中最小的)。
      • 若当前元素 ≤ 堆顶元素:跳过(该元素不可能是前 K 大)。
    3. 遍历结束后,堆中的 K 个元素就是前 K 个最大元素。
为什么用小根堆,不用大根堆?
  • 若用大根堆存储前 K 个元素,堆顶是最大的元素,剩余元素中比堆顶小的元素无法进入堆,无法更新前 K 大的元素。
  • 小根堆的堆顶是 “当前前 K 大元素中的最小值”,只要有元素比它大,就可以替换它,保证堆中始终是当前最大的 K 个元素。
实战代码(从文件中读取海量数据,找前 K 个最大元素)

假设数据存储在data.txt中,我们先生成 10000 个随机数写入文件,再找前 K 个最大元素:

// 生成n个随机数,写入data.txt
void CreateData() {int n = 10000;srand(time(0)); // 初始化随机数种子const char* file = "data.txt";FILE* fin = fopen(file, "w");if (fin == NULL) {perror("fopen failed");return;}for (int i = 0; i < n; i++) {int x = (rand() + i) % 1000000; // 生成0~999999的随机数fprintf(fin, "%d\n", x);}fclose(fin);
}// 找data.txt中前K个最大元素
void TopK() {int k;printf("请输入要找的前K个最大元素(K>0):");scanf("%d", &k);if (k <= 0) {printf("K必须为正整数!\n");return;}// 1. 申请大小为K的数组,存储前K个元素int* kHeap = (int*)malloc(sizeof(int) * k);if (kHeap == NULL) {perror("malloc failed");return;}// 2. 打开文件,读取前K个元素const char* file = "data.txt";FILE* fout = fopen(file, "r");if (fout == NULL) {perror("fopen failed");free(kHeap); // 记得释放内存return;}for (int i = 0; i < k; i++) {if (fscanf(fout, "%d", &kHeap[i]) != 1) {printf("文件数据不足K个!\n");free(kHeap);fclose(fout);return;}}// 3. 构建小根堆(前K个元素)for (int parent = (k - 2) / 2; parent >= 0; parent--) {AdjustDown(kHeap, k, parent); // 复用之前的小根堆AdjustDown}// 4. 遍历剩余元素,更新堆int x;while (fscanf(fout, "%d", &x) == 1) {if (x > kHeap[0]) { // 当前元素比堆顶大,替换并调整kHeap[0] = x;AdjustDown(kHeap, k, 0);}}// 5. 输出结果printf("前%d个最大元素为:", k);for (int i = 0; i < k; i++) {printf("%d ", kHeap[i]);}printf("\n");// 6. 释放资源free(kHeap);fclose(fout);
}// 主函数:生成数据并执行TopK
int main() {CreateData(); // 生成随机数据到文件TopK();       // 找前K个最大元素return 0;
}

四、常见问题与注意事项

  1. 堆与二叉搜索树(BST)的区别

    • 堆只保证父节点与子节点的大小关系,不保证中序遍历有序;BST 保证左子树所有节点 < 根节点 < 右子树所有节点,中序遍历有序。
    • 堆的插入 / 删除时间复杂度 O (logn),BST 在最坏情况下(退化为链表)是 O (n)。
  2. 小根堆与大根堆的切换

    • 只需修改AdjustDownAdjustUp中的比较条件(a[child] < a[parent]改为a[child] > a[parent])。
  3. 内存泄漏问题

    • 堆的动态数组(a)和TopK中申请的kHeap,使用后必须free,避免内存泄漏。
  4. 文件操作的异常处理

    • 打开文件后必须检查FILE*是否为NULL,读取数据时检查返回值(确保读取成功)。

五、总结

堆是一种 “用数组存储的完全二叉树”,核心是AdjustDownAdjustUp两个算法。通过这两个算法,我们可以实现堆的插入、删除、建堆,进而解决堆排序和 Top-K 等经典问题。

  • 核心知识点:堆的类型(小根堆 / 大根堆)、节点索引关系、向下 / 向上调整算法。
  • 实战重点:堆排序的 “建大根堆 + 交换调整” 逻辑、Top-K 问题的 “小根堆优化” 思路。

掌握堆的关键在于 “理解调整算法的本质”—— 无论是向下还是向上调整,都是为了 “修复被破坏的堆结构”,只要记住这一点,就能灵活应对各种堆相关的问题。

http://www.dtcms.com/a/585463.html

相关文章:

  • VIVO算法/大模型面试题及参考答案
  • 临海网站制作好了如何上线网站开发的要求
  • KingbaseES:从MySQL兼容到权限隔离与安全增强的跨越
  • 网站改版竞品分析怎么做可以先做网站再开公司吗
  • Go语言基础:语言特性、语法基础与数据类型
  • 解决 PyQt5 中 sipPyTypeDict() 弃用警告的完整指南
  • 内网门户网站建设要求西安摩高网站建设
  • github访问响应时间过长解决
  • Spring AoP的切点匹配
  • Cookie 与 Session 全解析:从属性原理到核心逻辑,吃透 Web 状态保持
  • STM32HAL库-F1内部Flash读写操作(官网驱动)
  • 辛集建设网站网络营销推广渠道
  • 外国排版网站企业名录2019企业黄页
  • 微信小程序开发实战:图片转 Base64 全解析
  • 秒杀-订单创建消费者CreateOrderConsumer
  • 单层前馈神经网络的万能逼近定理
  • C# 如何捕获键盘按钮和组合键以及KeyPress/KeyDown/KeyUp事件之间的区别
  • Windows系统不关闭防火墙,允许某个端口的访问怎么设置?
  • UniApp 多个异步开关控制教程
  • 邯郸哪家公司做企业网站比较专业中国制造网是干什么的
  • 做视频网站把视频放在哪里wordpress建站用什么意思
  • ASP.NET Core Web 应用SQLite数据连接显示(1)
  • 网易门户网站建设网站建设及发布的流程
  • 基于python的jlink单片机自动化批量烧录工具
  • 从三路快排到内省排序:探索工业级排序算法的演进
  • CPP 学习笔记 语法总结
  • Qt 跨平台 2048 游戏开发完整教程 (含源码)
  • SortScope 排序算法可视化
  • 组件库引入
  • 手写Spring第25弹:Spring JdbcTemplate深度解析:数据操作如此简单