文章目录
- 前言
- 1.堆的相关概念
- 1.1堆的概念
- 1.2堆的分类
- 1.2.1小根堆
- 1.2.2大根堆
- 1.3堆的特点
- 堆的实用场景
- 2.堆的实现
- 2.1初始化
- 2.2插入
- 2.3堆的向上调整
- 2.4删除
- 2.5堆的向下调整
- 2.6判空
- 2.7获取堆顶元素
- 2.8销毁
- 3.堆排序
- 3.1实现
- 3.2堆排序的时间复杂度问题
前言
在上一篇文章中,我们已经了解了树和二叉树的概念,而下面我们要学习的堆,在二叉树中非常重要;
如果的二叉树还不太了解的,大家可以参考作者的上一篇文章
详解二叉树
1.堆的相关概念
1.1堆的概念
现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,
一个是数据结构,一个是操作系统中管理内存的一块区域分段
1.2堆的分类
对于堆我们可以分成两种:
大堆和小堆;
1.2.1小根堆
1.小堆
在小堆中,堆中的某个节点的值总是不小于其父节点的值,换句话说就是父节点的值永远不大于其子节点,而如果有两个子节点时,子节点之间不存在大小限制关系;即指在逻辑上的二叉树结构中,根结点<=子结点,总是最大的,并且在堆的每一个局部都是如此。例如{1,2,3}可以看作为小根堆,而{1,3,2}亦可以看作为小根堆。小根堆的根结点在整个堆中是最小的元素。
1.2.2大根堆
2.大堆
在大堆中,堆中的某个节点的值总是不大于其父节点的值,换句话说就是父节点的值永远不小于其子节点,而如果有两个子节点时,子节点之间不存在大小限制关系;即指在逻辑上的二叉树结构中,根结点>=子结点,总是最大的,并且在堆的每一个局部都是如此。例如{3,1,2}可以看作为大根堆,而{3,2,1}亦可以看作为大根堆。大根堆的根结点在整个堆中是最大的元素。
详情请看下图:
1.3堆的特点
对于二叉树来说,我们用堆来实现,那为什么不用数组来实现呢?
因为对于普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树(也就是堆)更适合使用顺序结构存储。
并且堆还具有以下的特点,让它更加高效和适用:
1.维护有序性:
最大堆:每个节点的值都大于或等于其子节点的值,堆顶元素始终是最大值。
最小堆:每个节点的值都小于或等于其子节点的值,堆顶元素始终是最小值。
这种特性使得堆在需要频繁查找最大或最小元素的场景(如优先队列)中极为高效,无需遍历整个数组即可快速获得。
2.动态调整:
堆支持插入和删除元素的同时能够高效地(通常是O(log n)时间复杂度)重新调整结构以维持其特性,这一点在使用数组时难以直接高效实现。
3.内存利用率:
实际实现时,堆可以采用数组来存储,虽然逻辑上是树状结构,但实际上占用的是连续内存空间,因此内存使用相对高效。
4.排序应用:
堆可以作为实现堆排序的基础,这是一种不稳定的排序算法,其优势在于能够提供较好的最坏情况和平均时间复杂度(O(n log n)),并且不需要像快速排序那样依赖于数据的初始分布。
堆的实用场景
1、我们可以利用堆的性质来找出一个序列中最大/小的元素,尽管通过遍历来解决这一问题可能更好。
2、堆排序,堆排序即利用堆的思想来进行排序,总共分为两个步骤:1.建堆
升序:建大堆
降序:建小堆
2.利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。3、建立优先级队列,根据上述的小结可知,若利用堆来建立优先级队列,可以快速的获取到队列中优先级最高/低的任务。
4、n个元素中排列出前k大的元素问题,对于此类问题,可以建立一个小根堆,依次读入n个元素并调整,并在堆的规模达到k+1时,剔除掉第1个元素,剩下k个较大元素,保持堆的规模不超过k,一直循环即可得到最终的结果。
2.堆的实现
实现堆,首先要知道堆在结构体中的结构是怎样的;
//堆的结构
typedef int HeapTypeData;
typedef struct heap
{
HeapTypeData* a;
int size;
int capacity;
}HP;
还有我们要实现的一些接口:
//初始化
void HPInit(HP* php);
//插入
void HPPush(HP* php, HeapTypeData x);
//删除
void HPPop(HP* php);
//判空
bool HPEmpty(HP* php);
//获取堆顶
HeapTypeData HPTop(HP* php);
//销毁
void HPDestory(HP* php);
//向上调整
void AdjustUp(HeapTypeData* a, int n, int parent);
//向下调整
void AdjustDown(HeapTypeData* a, int child);
//交换
void Swap(HeapTypeData* p1, HeapTypeData* p2);
下面我们就来一一实现;
2.1初始化
初始化的过程和顺序表的相似
void HPInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
2.2插入
1.,由于我们是用堆实现的二叉树的顺序结构,在存储结构上还是数组,所以当我们插入时要进行扩容;
2.当我们在尾端插入一个元素时,堆所满足的大根堆或小根堆的条件可能会被违背,所以我们还要再创建一个调整函数AdjustUp将数据进行调整,那么既然要调整,就还需要一个
交换函数swap将数据进行交换
void HPPush(HP* php, HPDataType x)
{
assert(php);
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 fail");
return;
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
//交换
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
2.3堆的向上调整
1.计算父节点位置:首先创建孩子节点下标的索引child(将最后一个叶子节点数据也就是我们插入的数据的下标)计算出其父节点的索引parent,公式为(child - 1) / 2;
2.循环比较并交换:进入一个循环,在循环中不断比较当前孩子节点和其父节点的值。如果孩子节点的值小于父节点的值(对于x小根堆而言),则交换这两个节点的值。这是因为小根堆要求父节点的值不大于子节点的值;
3.更新索引并继续:在交换之后,原来的子节点变成了新的父节点,因此需要更新child为parent,同时基于新的child计算新的parent,继续进行比较和可能的交换,直到孩子节点不再小于其父节点或者到达了堆的根部(即child <= 0时);
4.退出循环:一旦发现孩子节点不小于其父节点,或者已经没有父节点可比较(即到达了树的根),循环结束,此时堆的性质已经得到恢复;
注意:1.这里我们创建的child,parent,所指向的都是节点的下标
2.这里我们使用小根堆来进行示范,当调整大根堆时只需要将循环中if语句的判断符号改为大于号就可以了;
void AdjustUp(HPDataType* a, int child)
{
// 初始条件
// 中间过程
// 结束条件
int parent = (child - 1) / 2;
//while (parent >= 0)错误
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
2.4删除
删除我们也要考虑堆的性质问题——是否成立
所以这里也需要判断,并进行调整,而删除时由于删除的时堆顶的数据,所以我们要有一个向下调整的函数来调整整个堆;那么我们删除的逻辑就是:
1.将堆顶的元素和堆尾的元素进行交换,这样删除时更加简单,只需要将size–就可以了;
2.我们再把交换到堆顶的元素,进行调整,使之满足堆的成立条件;这里我们还是用小根堆进行示范;
void HPPop(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);
}
2.5堆的向下调整
1.初始化子节点位置:首先创建出计算出当前父节点的左孩子的索引child,公式为parent * 2 + 1。
2.循环比较并交换:进入循环,只要child的值小于n,表示还有子节点可以比较。… 1.选择较小的子节点:如果右孩子存在(即child + 1 < n)且右孩子的值小于左孩子的值,则将child更新为右孩子的索引,因为我们要找到两个子节点中更小的那个。
… 2.比较并交换:如果找到的子节点child的值小于其父节点parent的值,说明违反了最小堆的性质,这时需要交换它们的值,并将当前的child位置作为新的父节点位置继续向下比较。
… 3.更新位置:交换后,原child位置已成为新的父节点位置,因此更新parent = child,并基于新的父节点重新计算其左孩子的索引child = parent * 2 + 1,继续循环。
… 4.退出条件:如果子节点不小于父节点,或者已经没有更多的子节点可比较(即child >= n),则跳出循环。3.结束:循环结束后,堆的性质得到了恢复,即以parent为根的子树满足最小堆的定义。
void AdjustDown(HPDataType* a, int n, int parent)
{
// 先假设左孩子小
int child = parent * 2 + 1;
while (child < n) // 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;
}
}
}
2.6判空
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
2.7获取堆顶元素
HPDataType HPTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
2.8销毁
销毁时要注意一一销毁,并且把变量置为零;
void HPDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
3.堆排序
3.1实现
堆排序首先要建堆,建堆时:
1.升序,建大堆;
2.降序,建小堆;
而对于这两种建堆方式,我们选择用第二种,因为我们从时间复杂度的角度去对比时会发现:
1.建大堆的时间复杂度为O(N*logN);
2.建小堆的则为O(N);
所以我们选择第二种;
1.建堆:
我们从最后一个非叶节点开始逆序遍历至根节点的方式来构建最小堆。这样做的目的是直接通过向上调整函数递归调整每个子树为最小堆,最终整个数组构成一个最小堆。计算最后一个非叶节点的索引公式为(sz-1-1)/2,然后从这个索引开始,逐步向前执行向上调整操作。
(sz-1-1)/2:sz-1->最后一个元素,(sz-1-1)/2找到父节点
2.排序:
首先,将堆顶元素(数组中的最小值)与数组末尾元素交换,确保当前最小值位于正确的位置(即数组末尾)。
然后,因为堆顶元素(现在是数组的末尾元素)已经正确排序,所以缩小堆的有效大小(end -= 1),并在剩下的元素中再次调用向上调整函数调整剩余元素为最小堆。这个过程重复,直到整个数组都被正确排序。
void HeapSort(int* a, int n)
{
// 降序,建小堆
// 升序,建大堆
// 向上调整建堆 O(N*logN)
/*for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}*/
// 向下调整建堆 O(N)
for (int i = (n-1-1)/2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
// O(N*logN)
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
3.2堆排序的时间复杂度问题
我们通过两张图来理解:
最后得出结果为O(N*logN);