数据结构--堆的深度解析

news/2024/10/8 13:06:54/

目录

引言

一、基本概念

1.1堆的概念

1.2堆的存储结构

1.3堆的特点

二、 堆的基本操作

2.1初始化

2.2创建堆

2.3插入元素 

2.4删除元素 

2.5堆化操作

2.6堆的判空

2.7获取堆顶元素

三、堆的常见应用

1. 优先队列

2. 堆排序

3. Top-k 问题

4. 图论中的应用

四、Top-k问题精讲 

方法一:遍历选择

方法二:排序

 方法三:最小堆法

总结


引言

堆(Heap)是一种非常重要的非线性数据结构,广泛应用于各种算法和问题解决中,如优先队列、堆排序、Top-k 问题等。本文将深入解析堆的定义、类型、操作、应用及其优缺点。

上篇博客我们简单介绍了堆,并用堆实现了二叉树的顺序存储,这里我们趁热打铁深入解析堆这种结构。

一、基本概念

1.1堆的概念

堆是一种特殊的完全二叉树,具有以下性质:

  • 完全二叉树:堆是一个完全二叉树,意味着树的每一层都被完全填满,除了最后一层可能没有填满,但所有节点都集中在左侧。
  • 堆性质:堆有两种类型:
    ‧ 小顶堆(min heap):任意节点的值 ≤ 其子节点的值。
    ‧ 大顶堆(max heap):任意节点的值 ≥ 其子节点的值。

1.2堆的存储结构

堆通常使用数组进行存储,这种方式的优点是可以直接通过数组下标计算父节点和子节点的位置:

数组索引映射:

对于节点 i:

父节点:parent(i) = (i - 1) / 2(使用整数除法)
左子节点:left(i) = 2 * i + 1
右子节点:right(i) = 2 * i + 2

//定义堆的结构--数组
typedef int DataType;
typedef struct Heap//小堆为例
{DataType *arr;int size;int capacity;
}HP;

存储示例
假设堆的数组表示为 {9,8,6,6,7,5,2,1,4,3,6,2}:
7的索引是4,其父节点为8(索引1),左子节点为3(索引9),右子节点6(索引10)。

这种方式有效利用了内存,避免了指针的开销。

1.3堆的特点

堆作为完全二叉树的一个特例,具有以下特性。
‧ 最底层节点靠左填充,其他层的节点都被填满。
‧ 我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
‧ 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。

二、 堆的基本操作

2.1初始化

//初始化
void HPInit(HP* p)
{assert(p);p->arr = NULL;p->size = p->capacity = 0;
}

2.2创建堆

创建堆通常有两种方式:

1.逐步插入:
从一个空堆开始,逐个插入元素。每插入一个新元素后,使用“上浮”操作来维持堆的性质。

for (int i = 0; i < n; i++)
{AdjustUp(arr, i);//向上调整算法,2.5中右有详细说明
}

2.堆化(Heapify):
给定一个无序数组,可以通过一次性构建堆来提高效率。这通常采用自底向上的方法,(先假定这组数据是一个“堆结构”)从最后一个非叶节点开始,逐个进行“下沉”操作,使之调整为堆(真正具有堆的特性)。

堆化算法步骤:
对于索引 i 从 (n-1-1)/2 到 0 进行下沉操作,n 是数组的长度。

for (int i =(n-1-1)/2 ; i >= 0; i--)//n-1为数组最后一个数据的下标,(n-1-1)/2为其父节点
{AdjustDown(arr, i, n);//向下调整算法,2.5中右有详细说明
}

2.3插入元素 

在堆中插入元素的步骤如下:

1.添加元素:
将新元素添加到堆的末尾(数组的最后一个位置)。

2.上浮操作:
比较新元素与其父节点的值,如果新元素大于父节点(在最大堆中)或小于父节点(在最小堆中),则交换两者,并继续上浮直到堆性质得以恢复。

时间复杂度:O(log n)

 

代码实现:

//插入
void HPPush(HP* p, DataType x)
{assert(p);if (p->size == p->capacity)//判断空间是否足够{//扩容int NewCap = p->capacity == 0 ? 4 : 2 * p->capacity;DataType* tmp = (DataType*)realloc(p->arr, NewCap * sizeof(DataType));if (tmp == NULL) {perror("realloc fail!");exit(1);}p->arr = tmp;p->capacity = NewCap;} p->arr[p->size] = x;AdjustUp(p->arr, p->size);++p->size;
}

2.4删除元素 

到通常,删除操作是从堆中移除根节点(最大或最小值),其步骤如下:
1.替换根节点:
将根节点(堆顶)替换为最后一个元素,然后从数组中删除该元素。
2.下沉操作:
从根节点开始,依次将该节点与其子节点进行比较,将其值与较大的子节点(在最大堆中)或较小的子节点(在最小堆中)进行交换,直堆的性质恢复。

时间复杂度:O(log n)

 代码实现:

//删除,出堆顶数据
void HPPop(HP* p)
{assert(p && p->size);Swap(&p->arr[0], &p->arr[p->size - 1]);--p->size;AdjustDown(p->arr, 0, p->size);
}

2.5堆化操作

堆化操作是保持堆性质的关键,主要包括:
1.向上调整(AdjustUp):
用于插入操作。当新插入的元素大于(或小于)其父节点时,通过交换将其上移,直至堆性质满足。

//堆的向上调整
void AdjustUp(DataType* arr, int child)
{int parent = (child - 1) / 2;//父节点while(child>0)//注意child可能会被调整到根节点,这时候就不能再调整了{//建小堆  <  //建大堆  >if (arr[child] < arr[parent])//如果条件语句不成立,就说明堆已经成型{Swap(&arr[child], &arr[parent]);child = parent;parent = (child - 1) / 2;//循环以上步骤}else{break;}}
}
计算向上调整算法建堆时间复杂度
因为堆是完全⼆叉树,⽽满⼆叉树也是完全⼆叉树,此处为了简化使⽤满⼆叉树来证明(时间复杂度本来看的就是近似值,多⼏个结点不影响最终结果)

分析:
第1层, 2^0 个结点,需要向上移动0层
第2层, 2^1 个结点,需要向上移动1层
第3层, 2^2 个结点,需要向上移动2层
第4层, 2^3 个结点,需要向上移动3层
......
第h层, 2^(h-1) 个结点,需要向上移动h-1层
则需要移动结点总的移动步数为:每层结点个数 * 向上调整次数(第⼀层调整次数为0)

由此可知,

向上调整算法建堆时间复杂度为:O(n ∗ log2 n)

2.向下调整(AdjustDown):
用于删除操作和堆化。根节点(或其他节点)与其子节点进行比较,将其值与较大的子节点(或较小的子节点)进行交换,直至堆性质满足。

//堆的向下调整
void AdjustDown(DataType* arr, int parent, int n)
{int child = parent * 2 + 1;//左孩子while (child < n) {//小堆:寻找左右孩子最小的那个//大堆:寻找左右孩子最大的那个if (child + 1 < n && arr[child] > arr[child + 1]){child++;}//这里和向上调整就一样了,比较,交换,循环//建小堆  <   //建大堆  >if (arr[child] < arr[parent]){Swap(&arr[child], &arr[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}
计算向下调整算法建堆时间复杂度

分析:
第1层, 2^0 个结点,需要向下移动h-1层
第2层, 2^1 个结点,需要向下移动h-2层
第3层, 2^2 个结点,需要向下移动h-3层
第4层, 2^3 个结点,需要向下移动h-4层
......
第h-1层, 2^(h-2) 个结点,需要向下移动1层

由此可知,

向下调整算法建堆时间复杂度为:O(n)

2.6堆的判空

堆的大小为0,堆为空,反之则非空。

//判空
bool HPEmpty(HP* p)
{assert(p);return p->size == 0;
}

2.7获取堆顶元素

获取堆的根节点(最大或最小值)非常简单,只需返回数组的第一个元素。

//返回堆顶元素
DataType HPTop(HP* p)
{assert(p && p->size);return p->arr[0];
}

三、堆的常见应用

1. 优先队列

优先队列是一种数据结构,允许根据优先级处理元素。堆可以高效地实现优先队列,支持在 O(logn) 时间内插入和删除元素,适用于任务调度和图算法(如 Dijkstra 算法)。

2. 堆排序

堆排序是一种基于堆的排序算法,时间复杂度为 O(nlogn)。它通过构建最大堆并逐步提取最大元素来实现排序,适合需要原地排序的场景。

3. Top-k 问题

Top-k 问题涉及从数据集中找出前 k 个最大或最小的元素。使用最小堆可以在 O(nlogk) 的时间复杂度内有效解决该问题,常用于数据分析和排行榜。

4. 图论中的应用

在图算法中,堆常用于 Dijkstra 和 Prim 算法,通过优先队列高效管理节点和边,从而加速最短路径和最小生成树的计算。

下面我们来详细讲一下 Top-k问题

四、Top-k问题精讲 

方法一:遍历选择

我们可以进行 𝑘 轮遍历,分别在每轮中提取第 1 2 𝑘 大的元素,时间复杂度为 𝑂(𝑛𝑘)
此方法只适用于 𝑘 ≪ 𝑛 的情况,因为当 𝑘 𝑛 比较接近时,其时间复杂度趋向于 𝑂(𝑛^ 2 ) ,非常耗时。 (当 𝑘 = 𝑛 时,我们可以得到完整的有序序列,此时等价于“选择排序”算法。 )

方法二:排序

我们可以先对数组 nums 进行排序,再返回最右边的 𝑘 个元素,时间复杂度取决于排序所使用的算法,最快为 𝑂(𝑛 log 𝑛)
显然,该方法“超额”完成任务了,因为我们只需找出最大的 𝑘 个元素即可,而不需要排序其他元素

 

 方法三:最小堆法

1. 初始化一个小顶堆,其堆顶元素最小。
2. 先将数组的前 𝑘 个元素依次入堆。
3. 从第 𝑘 + 1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
4. 遍历完成后,堆中保存的就是最大的 𝑘 个元素。

 代码实现:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>typedef int DataType;// 堆的结构
typedef struct Heap
{DataType* arr;int size;int capacity;
} HP;// 交换两个元素
void Swap(DataType* x, DataType* y)
{DataType tmp = *x;*x = *y;*y = tmp;
}
// 向下调整堆
void AdjustDown(DataType* arr, int parent, int n)
{int child = parent * 2 + 1; // 左孩子while (child < n) {if (child + 1 < n && arr[child] > arr[child + 1]){child++;}if (arr[child] < arr[parent]) {Swap(&arr[child], &arr[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}// 建小堆
void BuildMinHeap(int* arr, int k)
{for (int i = (k-1-1) / 2; i >= 0; i--){AdjustDown(arr, i, k);}
}// 查找Top-k元素
void FindTopK(DataType* arr, int n, int k) {if (k <= 0 || k > n){printf("k的值非法\n");return;}//开辟所需小堆的内存int* minheap = (int*)malloc(sizeof(int) * k);if (minheap == NULL){perror("malloc error");return;}// 将前k个元素放入小堆for (int i = 0; i < k; i++){minheap[i] = arr[i];}BuildMinHeap(minheap, k);// 遍历剩余元素,更新小堆for (int i = k; i < n; i++){if (arr[i] > minheap[0]){minheap[0] = arr[i]; // 替换堆顶AdjustDown(minheap, 0, k); // 重新调整堆}}// 输出Top-k元素printf("最大的前%d个元素为:\n", k);for (int i = 0; i < k; i++){printf("%d ", minheap[i]);}printf("\n");// 释放内存free(minheap);
}int main() {DataType array[] = {1,3,5,7,9,2,4,6,8,10};int n = sizeof(array) / sizeof(array[0]);int k = 3; // 查找前3个最大的元素FindTopK(array, n, k);return 0;
}
总共执行了 𝑛 轮入堆和出堆,堆的最大长度为 𝑘 ,因此时间复杂度为 𝑂(𝑛 log 𝑘) 。该方法的效率很高,当 𝑘 较小时,时间复杂度趋向 𝑂(𝑛) ;当 𝑘 较大时,时间复杂度不会超过 𝑂(𝑛 log 𝑛)
另外,该方法适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现最大的 𝑘 个元素的动态更新。

总结

堆是一种强大的数据结构,使用堆排序解决 Top-k 问题是一种高效且简洁的方法。通过维护一个最小堆,我们能够在遍历数据的同时有效地找出前 k 个最大元素。这种方法在实际应用中非常有用,特别是在处理大规模数据时。如果你对堆或其他相关数据结构有进一步的兴趣,欢迎留言讨论!


http://www.ppmy.cn/news/1536135.html

相关文章

【实战教程】SpringBoot全面指南:快速上手到项目实战(SpringBoot)

文章目录 【实战教程】SpringBoot全面指南&#xff1a;快速上手到项目实战(SpringBoot)1. SpringBoot介绍1.1 SpringBoot简介1.2系统要求1.3 SpringBoot和SpringMVC区别1.4 SpringBoot和SpringCloud区别 2.快速入门3. Web开发3.1 静态资源访问3.2 渲染Web页面3.3 YML与Properti…

【包教包会】速通LLM《从头开始构建大型语言模型》免费pdf分享

介绍 在当今人工智能技术飞速发展的时代&#xff0c;大型语言模型&#xff08;LLM&#xff09;作为聊天机器人、文本生成和理解等应用的核心&#xff0c;已经成为研究和商业领域的关注焦点。尽管这些模型的应用无处不在&#xff0c;但对于大多数开发者来说&#xff0c;它们的工…

微信小程序:一个小程序跳转至另一个小程序

一、微信小程序支持一个小程序跳转至另一个小程序吗&#xff1f; 支持。 1.1、目标小程序需开放被跳转&#xff1a;目标小程序需要在其 app.json 文件中配置 navigateToMiniProgramAppIdList&#xff0c;将源小程序的 AppID 加入其中。 1.2、用户授权&#xff1a;用户需要授…

使用 Spring Boot 在电商平台中动态调整促销信息

业务背景 在电商平台上&#xff0c;促销活动是吸引用户的重要手段之一。然而&#xff0c;促销活动的状态&#xff08;如开始、结束&#xff09;可能会频繁变化&#xff0c;而这些变化需要实时反映在商品详情页上。如果每次促销状态改变都需要重新部署应用或者手动更改代码&…

20241004给荣品RD-RK3588-AHD开发板刷Rockchip原厂的Android12【HDMI0显示】

20241004给荣品RD-RK3588-AHD开发板刷Rockchip原厂的Android12【HDMI0显示】 2024/10/4 19:40 1、配置RK3588S的默认DTS为&#xff1a;rk3588s-evb4-lp4x-v10.dts D:\Android\rk3588s4_3588a12\device\rockchip\rk3588\rk3588s_s\BoardConfig.mk Z:\rk3588s4_3588a12\device\ro…

海陆钻井自动化作业机器人比例阀放大器

海陆钻井自动化作业机器人是现代海洋石油勘探与钻井领域的关键装备&#xff0c;它通过自动化和无人化技术显著提高了钻井效率和安全性。海陆钻井自动化作业机器人主要用于在海上和陆地的钻井平台上进行自动化、无人化的一体化作业。这种设备能够自动切换钻杆&#xff0c;极大地…

毕业设计项目 深度学习语义分割实现弹幕防遮(源码分享)

文章目录 0 简介1 课题背景2 技术原理和方法2.1基本原理2.2 技术选型和方法 3 实例分割4 实现效果最后 0 简介 今天学长向大家分享一个毕业设计项目 毕业设计 深度学习语义分割实现弹幕防遮(源码分享) &#x1f9ff; 项目分享:见文末! 1 课题背景 弹幕是显示在视频上的评论…

Python调试技巧:高效定位与修复问题

Python调试技巧&#xff1a;高效定位与修复问题 在Python编程过程中&#xff0c;调试是不可避免的重要环节。无论是刚接触编程的初学者还是经验丰富的开发者&#xff0c;都可能会遇到代码运行不符合预期的情况。高效的调试技巧不仅能帮助我们快速找到问题&#xff0c;还能减少…