数据结构堆的深度解析:为什么它是高效处理最值问题的利器

数据结构堆的深度解析:为什么它是高效处理最值问题的利器

 前言

在非线性数据结构的家族中,堆是兼具 “完全二叉树特性” 与 “最值优先级” 的高效工具 —— 它以数组为物理载体,却暗藏树形逻辑,能在 O (1) 时间获取最值,O (logN) 时间完成插入删除,成为解决排序、Top-K 等经典问题的 “一把好手”。

📚 初阶数据结构

【 时间复杂度+空间复杂度 】

【 顺序表 】

【 单链表 】

【 链表OJ题(上篇)】

【 链表OJ题(下篇)】

【 栈和队列 】

【 栈和队列面试题 】

【 二叉树概念解析 】


目录

一、堆的核心概念与结构特性

1. 堆的定义

2. 核心特性

3. 直观示例

二、堆的实现 

1、堆的结构

2、堆的初始化

3、堆的销毁

4、堆的插入(含向上调整算法)

5、堆的删除(含向下调整算法)

【向下/向上调整算法时间复杂度计算】

6、堆的判空、元素个数、取堆顶元素接口

三、堆的应用

1、堆的创建

① 向下调整建堆

② 向上调整建堆

【建堆时间复杂度分析】

2、堆排序

3、TOP-K问题

四、堆相关选择题


一、堆的核心概念与结构特性

堆的本质是满足特定规则的完全二叉树,同时通过数组存储以最大化空间利用率(用连续数组存储完全二叉树,无需额外存储指针(链表存树的方式会浪费指针空间),几乎没有内存浪费)。

1. 堆的定义

堆就是这么个东西:

  • 看着像一棵 “层层填满、最后一层靠左排” 的树(完全二叉树),但实际是存在连续数组里的;
  • 有个铁规矩:要么每个节点都比自己的子节点大/相等(这叫大根堆,最顶上的是最大的数),要么每个节点都比自己的子节点小/相等(这叫小根堆,最顶上的是最小的数);
  • 核心用处就是快速找最大 / 最小数,不用挨个遍历,直接拿最顶上的就行。

2. 核心特性

 逻辑结构:必为完全二叉树,不存在 “断层” 节点,所有叶子节点集中在最后两层,且最后一层节点靠左排列;

 存储结构:物理上是连续数组,通过索引映射父子关系(设节点索引为i):

父节点索引:( i - 1) / 2(向下取整);左子节点索引:2 * i + 1;右子节点索引:2 * i + 2;

 最值特性:堆顶(数组第一个元素)始终是集合中的最大值(大根堆)或最小值(小根堆)。

3. 直观示例

 小根堆:逻辑上根节点最小,数组存储为 [10, 15, 25, 56, 30, 70]

大根堆:逻辑上根节点最大,数组存储为 [70, 56, 30, 15, 25, 10]。 

二、堆的实现 

1、堆的结构

堆的底层是用数组实现的,因此它的结构和顺序表的结构基本一致,具体定义如下:

typedef int HpDataType; typedef struct Heap { HpDataType* a; int size; int capacity; }heap;

2、堆的初始化

//初始化堆 void HeapInit(heap* php) { assert(php); php->a = (HpDataType*)malloc(sizeof(HpDataType) * 4); if (php->a == NULL) { perror("malloc"); return; } php->capacity = 4; php->size = 0; } 

3、堆的销毁

//堆的销毁 void HeapDestroy(heap* php) { assert(php); free(php->a); php->a = NULL; php->capacity = php->size = 0; } 

4、堆的插入(含向上调整算法)

入堆的操作逻辑很简单:先把新数据直接插入到堆的末尾(对应数组的最后一个位置),插入后如果堆的结构不符合小根堆 / 大根堆的规则,就通过向上调整算法,把这个新元素逐步 “往上挪”,直到它处于一个能让整个堆重新满足规则的位置

 

//入堆 void HeapPush(heap* php, HpDataType x) { assert(php); //扩容 if (php->capacity == php->size) { HpDataType* tmp = (HpDataType*)realloc(php->a, sizeof(HpDataType) * php->capacity * 2); if (tmp == NULL) { perror("realloc"); return; } php->a = tmp; php->capacity = 2 * php->capacity; } php->a[php->size] = x; php->size++; //向上调整 AdjustUp(php->a, php->size - 1); } 

新数据插入堆尾的操作,本质上就是顺序表的尾插(这部分内容我们在之前的文章里已经讲过啦),所以这一部分我重点讲向上调整算法的思路

【向上调整算法】:

 

//交换算法 void swap(HpDataType* p1, HpDataType* p2) { HpDataType x = *p1; *p1 = *p2; *p2 = x; } //向上调整 void AdjustUp(HpDataType* a, int child) { //计算父节点的索引 int parent = (child - 1) / 2; // 循环条件:child > 0 表示当前节点还没到堆顶(堆顶节点的child为0,无父节点) // 只要没到堆顶,就持续检查当前节点与父节点是否符合堆的规则 while (child > 0) { //孩子小于父亲就交换值 if (a[child] < a[parent]) { swap(&a[child], &a[parent]); child = parent; parent = (child - 1) / 2; } //符合堆的规则就跳出循环 else { break; } } }

这段向上调整算法是针对小根堆设计的;如果要适配大根堆,只需要把判断条件改成 “子节点值大于父节点” 即可。

5、堆的删除(含向下调整算法)

堆的删除操作是针对堆顶元素的,具体逻辑是:先把堆顶元素和堆的最后一个元素交换位置,接着删除数组末尾(原堆顶元素),最后对新的堆顶元素执行向下调整算法,让堆重新满足规则。

之所以不直接删除堆顶元素,是因为堆顶是堆的核心节点,直接删除会彻底打乱整个堆的父子节点逻辑,后续几乎无法恢复成合法的堆结构。

 

 

//出堆 void HeapPop(heap* php) { assert(php); assert(!HeapEmpty(php)); //删除数据 swap(&php->a[0], &php->a[php->size - 1]); php->size--; //向下调整 AdjustDown(php->a, php->size, 0); } 

【向下调整算法】:

 

//交换算法 void swap(HpDataType* p1, HpDataType* p2) { HpDataType x = *p1; *p1 = *p2; *p2 = x; } //向下调整 void AdjustDown(HpDataType* a, int n, int parent) { int child = 2 * parent + 1;//假设左孩子小 while (child < n) { //选出孩子中小的那一个 if (child + 1 < n && a[child + 1] < a[child])//避免越界访问 { ++child;//假如右孩子小,child++ } if (a[child] < a[parent]) { swap(&a[child], &a[parent]); parent = child; child = 2 * parent + 1; } else { break; } } } 

这段代码实现的是小根堆的向下调整逻辑。如果要适配大根堆的出堆操作,只需要把两处判断条件的符号修改为 “大于” 即可:

子节点选择部分:将 a[child + 1] < a[child] 改为 a[child + 1] > a[child](选出子节点中更大的那个);父子节点交换部分:将 a[child] < a[parent] 改为 a[child] > a[parent](若子节点值大于父节点,执行交换)。

【向下/向上调整算法时间复杂度计算】

 1、向上调整的过程,是从堆的某个叶子节点(新插入节点)向堆顶移动,调整的次数由堆的高度决定。

2、由于堆是完全二叉树结构,若堆的元素个数为 n,则完全二叉树的高度为 log2(N + 1)

3、因此,向上调整算法的时间复杂度为 O(logN)(在数据结构中log以2为底N的对数可以写作O(logN))

(向下调整算法的时间复杂度逻辑一致:从堆顶向叶子节点移动,最大次数也是树的高度,复杂度同样为 O(logN) 

6、堆的判空、元素个数、取堆顶元素接口

//取堆顶元素 HpDataType HeapTop(heap* php) { assert(php); return php->a[0]; } //判空 bool HeapEmpty(heap* php) { assert(php); return php->size == 0; } //堆元素个数 int HeapSize(heap* php) { assert(php); return php->size; }

三、堆的应用

1、堆的创建

下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?

① 向下调整建堆

向下调整建堆逻辑:这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆;

建大根堆
// n: 数组中元素的总个数 // 核心逻辑:从最后一个非叶子节点开始,向前逐个调整节点,使每个子树满足堆的性质 // 最后一个非叶子节点的下标计算: // 数组下标从0开始时,最后一个元素的下标是n-1,它的父节点下标为 (n-1-1)/2 for (int i = (n - 1 - 1) / 2; i >= 0; i--) { // 对下标为i的节点进行"向下调整",使以i为根的子树成为堆 AdjustDown(a, n, i); }

② 向上调整建堆

向上调整法建堆:从第 2 个元素(i=1)开始,逐个对每个元素调用 AdjustUp,让新元素向上和父节点比较、交换,直到满足堆的性质,最终把整个数组建成堆;

//建堆 -- 向上调整建堆 for (int i = 1; i < n; i++) { AdjustUp(a, i); } 

我就不画图展示啦,这个过程其实相当于模拟堆的插入操作:每往堆里加入一个新元素,就对它做一次向上调整;等所有元素都插入并调整完毕,整个堆也就构建完成了。

【建堆时间复杂度分析】

向下调整建堆:O (N)

 

 


向上调整建堆:O(N * logN)

 

核心差异:

 1、向上调整建堆:低层(数量多)+ 调整次数多(底层节点要逐层往上调,最多需 logN 次),最终总代价是 “大量节点 × 多次调整”→ O (N logN)。

 2、向下调整建堆:高层(数量少)+ 调整次数多(根节点要逐层往下调,最多需 logN 次);

低层(数量多)+ 调整次数少(底层节点基本不用调),最终总代价是 “少量节点 × 多次调整 + 大量节点 × 少量调整”→ O (N)。


2、堆排序

堆排序(升序)思想:先建大顶堆(堆顶是最大值),再反复将堆顶与堆尾交换(最大值归位),然后调整剩余元素为大顶堆,直到堆空,数组升序

//排升序 建大堆 O(N*logN) void HeapSort_Up(int *a, int n) { //建堆 -- 向下调整建堆 -- O(N) for (int i = (n - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, n, i); } int end = n - 1; //排序 -- O(N*logN) while (end > 0) { swap(&a[end], &a[0]); AdjustDown(a, end, 0); end--; } } 

拍降序的话,建小顶堆就行:堆顶始终是当前堆的最小值,把它和堆尾元素交换(最小值就放到了当前未排序区的末尾,逐步归位),再将剩下的元素重新调整为小顶堆,重复这个过程,直到堆处理完,数组就降序排好了。

//排降序 建小堆 O(N*logN) void HeapSort_Down(int* a, int n) { //建堆 -- 向上调整建堆 -- O(N*logN) for (int i = 1; i < n; i++) { AdjustUp(a, i); } int end = n - 1; //排序 -- O(N*logN) while (end > 0) { swap(&a[end], &a[0]); AdjustDown(a, end, 0); end--; } } 

3、TOP-K问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

1、用数据集合中前K个元素来建堆前k个最大的元素,则建小堆前k个最小的元素,则建大堆

2、用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
//TOP-K 求前k个最大/最小的元素 //前k个最大的元素,则建小堆 //前k个最小的元素,则建大堆 //当前 n = 15; k = 10; //求前10个最大的元素: void TOP_K() { //造数据 int n = 15; int* a = (int*)malloc(sizeof(int) * n); srand((unsigned int)time(NULL)); for (size_t i = 0; i < n; ++i) { a[i] = rand() % n + 1; } int k = 10; //向下调整建堆 -- 建小堆 for (int i = (k - 1 - 1)/ 2; i >= 0; i--) { AdjustDown(a, 10 ,i); } //接下来剩下的元素和堆顶的元素进行比较 for (int i = k; i < n; i++) { if (a[0] < a[i]) { swap(&a[0], &a[i]); //向下调整 AdjustDown(a, 10, 0); } } //打印所有数据,观察是否满足 for (int i = 0; i < n; i++) { printf("%d ",a[i]); } } 

四、堆相关选择题

  

堆的判断需验证序列是否符合 “大根堆(父节点≥子节点)” 或 “小根堆(父节点≤子节点)” 的规则。以选项 A [100,60,70,50,32,65] 为例:

 根节点 100,左子节点 60、右子节点 70(100≥60 且 100≥70,符合大根堆);节点 60,左子节点 50、右子节点 32(60≥50 且 60≥32,符合);节点 70,左子节点 65(70≥65,符合);所有节点均满足大根堆规则,因此 A 是堆。 

 


 

只有向下调整建堆的结果,所以选 C


 

    

Read more

PMBus电压监测精度提升:核心要点之ADC前端电路

PMBus电压监测为何不准?揭秘ADC前端电路的设计玄机 你有没有遇到过这种情况:系统明明工作正常,PMBus上报的 READ_VOUT 却显示输出电压波动剧烈?或者在高低温环境下,电源监控数据“飘”得离谱,触发误告警?更让人头疼的是——换了个MCU、改了块PCB,同样的电源模块读数居然对不上。 问题往往不在于PMBus协议本身。 真正的“罪魁祸首”,藏在你看不见的地方: ADC前端模拟电路 。 为什么高分辨率ADC也救不了你的PMBus? 现代数字控制器普遍集成12位甚至16位ADC,理论精度看起来非常可观。但实际应用中,很多系统的有效位数(ENOB)只有8~10位,甚至更低。这意味着你花大价钱买的“精密测量”能力,被前端电路白白浪费了。 根源就在于: PMBus是数字总线,但它监控的是模拟世界 。 从真实电压到 READ_VOUT 字段之间的这段路径——也就是ADC前端电路——决定了最终数据的可信度。 举个例子:某通信设备使用12V供电,通过分压电阻接到MCU的ADC引脚。如果前端设计不当,哪怕ADC本身误差只有±1LSB,

By Ne0inhk
双剑破天门:攻防世界Web题解之独孤九剑心法(七)

双剑破天门:攻防世界Web题解之独孤九剑心法(七)

免责声明:用户因使用公众号内容而产生的任何行为和后果,由用户自行承担责任。本公众号不承担因用户误解、不当使用等导致的法律责任 **本文以攻防世界部分题为例进行演示,后续会对攻防世界大部分的web题目进行演示,如果你感兴趣请关注** 目录 一:Newscenter 二:upload1 三:Xff_referer 四:Command_execution 五:总结 1. Newscenter(SQL注入) 2. upload1(文件上传漏洞) 3. Xff_referer(HTTP头伪造) 4. Command_execution(命令注入) 一:Newscenter 打开为如下所示 经过尝试,得知在输入框中输入数字可得到不同内容 输入23就没有新闻 所以我们得知这个输入框和数据库有交互,那这题考察的可能就是SQL注入 发现将数据库中所有的内容都查询了出来,那这个题考察的就是SQL注入 字段长度为3 23' order by

By Ne0inhk
年度心得总结——前端领域

年度心得总结——前端领域

又是一年时光转,岁月如梭学习繁。 笔耕岁月求知路,心悟真谛志愈坚。 往昔耕耘结硕果,未来展望展宏愿。 共聚一堂话成就,再创辉煌谱新篇。 此刻,我暂且搁下手中的键盘,让思绪飘回那过往的日日夜夜。回望这一年的风雨兼程,心中不禁涌动着无尽的感慨。前端领域,这片充满无限可能的天地,又经历了一轮轰轰烈烈的蓬勃发展与变革。新技术如雨后春笋般涌现,旧框架在不断迭代中焕发新生,这一切都让我对这份事业充满了无尽的热爱与敬意。 同样是在这流转的一年里,我踏上了ZEEKLOG技术博主的星辰大海之旅,愿以我余温之烛,照亮同行者的征途,期盼自己能成为ZEEKLOG夜空中那颗即便只刹那闪耀,亦能点亮梦想的星辰。 文章目录 * 一、React 框架 * (一) React 优化 * (二) 开发效率提升 * (三) 服务端渲染(SSR)集成 * (四) 其他重要优化和功能支持 * 二、Vue 框架 * (一) Vue 版本与维护方面 * (二) 性能优化与增强 * 三、技术探索

By Ne0inhk
前端国际化之i18n(VUE项目)

前端国际化之i18n(VUE项目)

解释与说明         i18n,全名是internationalization,称为国际化。         我理解的就四个字:语言转换。         让以其他语言作为母语的人能看懂你的前端中的文字。         我们常用的就是中文简体(zh_CN)与英文(美国)(en_US)的转换。         当然也可以增添中文繁体(zh_TW)等等你想要的其他语言。 缩写的由来 internationalization,首字母 i 和末字母 n 之间有 18 个字母,故缩写为 i18n 。 与之对应的是L10n,本地化,Localization。         最好在项目初期就计划使用国际化,这样相对后期使用会大大减少工作量。 项目使用 安装 1,在你的软件中打开控制台         我使用的是IDEA,其实前端更推荐使用VSCode。 2,进入前端的文件夹 cd web         我的前端的文件夹名称是web,相应变换成你自己命名的前端文件夹名称。 3,使用下载安装命令 npm

By Ne0inhk