本篇博客主要来介绍堆的基本结构和操作,其中重要的点在于堆的向上调整和向下调整的实现,这两个函数的实现是堆这个数据结构最核心的地方,也是后面我们实现堆排序和TOP-K问题的关键。
目录
一、 堆的概念与结构
如果有一个关键码的集合K={k0、k1、k2……kn-1},把它所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:ki <= k2*i+1且ki <= k2*i+2(ki <= k2*i+1且ki <= k2*i+2)i=0,1,2……,则称之为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
二、堆基本结构
我们先来看一下头文件,首先定义了堆的结构,堆本身即是一个数组,在物理结构跟我们之前学习的顺序表的栈差不多,但是逻辑结构上堆是二叉树的一种形态。然后就是一些我们要实现的堆的基本操作。
#pragma once
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#include <time.h>
#include <assert.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
//交换函数
void swap(HPDataType* a, HPDataType* b);
//堆的初始化
void HeapInit(Heap* php);
// 堆的销毁
void HeapDestory(Heap* php);
//向上调整
void AdjustUp(HPDataType* a, int child);
// 堆的插入
void HeapPush(Heap* php, HPDataType x);
//向下调整
void AdjuestDown(HPDataType* a, int size, int parent);
// 堆的删除
void HeapPop(Heap* php);
// 取堆顶的数据
HPDataType HeapTop(Heap* php);
// 堆的数据个数
int HeapSize(Heap* php);
// 堆的判空
bool HeapEmpty(Heap* php);
三、 堆功能实现
3.1 堆的初始化和销毁
在顺序表和栈中已经实现过了,所以这两个功能还是十分简单的。
//堆的初始化
void HeapInit(Heap* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
// 堆的销毁
void HeapDestory(Heap* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
3.2 堆的插入
堆的插入虽然在顺序表中实现,但是在插入时要保证堆的特性。
以小根堆为例,即:父结点小于孩子结点。
所以我们每插入一个数据的时候,要不断检查插入的数据是否小于父结点;小于,则交换,直到孩子大于父亲或是孩子调整到了堆顶;
堆的插入可以看作,就是比顺序表的插入多了一个向上调整的过程;而这个向上调整即是堆的精髓。
// 堆的插入----建堆
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
//检查并扩容
if (php->size == php->capacity)
{
int NewCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* temp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * NewCapacity);
if (!temp)
{
printf("realloc fail\n");
exit(-1);
}
//将新空间赋给a
php->a = temp;
//capacity的更新
php->capacity = NewCapacity;
}
php->a[php->size] = x;
//插入后会影响祖先 ,所以要向上调整
AdjustUp(php->a, php->size);
php->size++;
}
3.3 堆的向上调整
接下来我们来实现堆的向上调整算法。
以小跟堆举例
小根堆:父结点小于子节点,即parent <= child .
思想:
① 根据child结点算出父亲结点, 套公式:parent = ( child-1 ) / 2;
② 如果parent > child 即违反堆的规律,进行交换调整;不满足则跳出循环。
③ 然后将现在的parent结点置为新child,再算新的parent结点,以此循环。
④ 停止循环有两种情况:
1.符合堆的规律,parent <= child , 即跳出循环;
2. child结点一直调整,直至child与根结点交换完之后,child成为根结点,即child == 0时则交换完全停止,结束循环,即child到根结点就停止循环,所以设置循环条件为child>0。
void swap(HPDataType* a, HPDataType* b)
{
HPDataType temp = *a;
*a = *b;
*b = temp;
}
//在尾部插入后,会影响树的祖先,所以要向上调整
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;
//while (parent>=0)
// -1 / 2 = 0
// 最多调到孩子到根节点 就停止循环
while(child>0 )
{
//如果父亲 > 孩子
if (a[child] < a[parent])
{
swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
// 不满足直接跳出循环
else
{
break;
}
}
}
3.4 堆的删除
这里我们实现的是删除堆顶的元素。相当于删除顺序表中下标为0处的数据,所以这里又引出几个问题。
①顺序表删除表头的数据时,要将后面的所有数据往前挪动,效率低下。
②堆如果覆盖删除掉堆顶的元素后,很大可能会破坏堆的结构。
例如:
所以,为了解决以上两个问题,堆的删除分为以下两步:
1. 将堆顶的数据与堆最后一个结点进行交换,size--,即可以删除堆顶元素。
2. 然后创建一个向下调整算法,将堆顶的元素下向调整到合适的位置,即可符合堆的性质。
void HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
//将堆顶的数据放到堆尾
//应是size-1, 因为size表示的是数组中的元素个数,交换的是数组下标,所以要减一
swap(&(php->a[0]), &(php->a[php->size-1]));
//size--,即将数据删除
php->size--;
//传入要向下调整数组的大小,防止调整过度,再传入要调整数组的下标,因为我们使用中,
//不是每一次调整都是调整堆顶的数据
AdjuestDown(php->a, php->size, 0);
}
3.5 堆的向下调整
因为我们将堆顶的数据和堆后一个结点进行了交换,所以我们传入参数下标为0的结点,让数组首元素不断向下调整到合适的位置。向下调整不仅适用于堆顶的元素,它可以调整堆中任意结点与子结点的关系。我们还要传入堆的大小,以来控制调整的边界。
思想:
①套用计算孩子的规律 child = parent * 2 + 1计算出左孩子的下标
②向下调整要与左右孩子中小的那个进行调换,所以我们计算出左孩子下标之后要判断做左孩子和右孩子那个更小。
③左孩子存在时,右孩子不一定存在,所以我们要判断右孩子是否存在,当左孩子位于数组最后一个元素时,右孩子的下标一定是超出数组范围的,所以我们使用child+1 < size 判断右孩子是否存在。
④如果 chlid < parent 则进行交换,并将child赋值给parent,计算出新的child,继续迭代。如果不符合则要跳出调整的循环。
⑤调整结束的两种情况:
1. child >= parent,则调整到位,break跳出循环。
2. 如果父结点调整到了最后一层,无需调整时则结束循环。父亲调整到了最后一层时,child一定是超过了size的大小,所以我们可以设置 child < size 为循环的条件,不理解可以多看几遍上面的动图。
//堆的删除,使用向下调整算法
void AdjuestDown(HPDataType* a, int size, int parent)
{
int child = parent * 2 + 1;
//目的: 小堆为例,将两个孩子中小的那个与parent交换
//如果调整到叶子节点就不用再调整了,
while (child < size)
{
//先假定左孩子比右孩子小,再判断一下左孩子是否比右孩子小
//此时会出现 没有右孩子的情况,所以加上一个 child+1<size的情况
if (child + 1 < size && a[child + 1] < a[child])
{
child++;
}
if (a[child] < a[parent])
{
swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
3.6 堆的判空
这就是一些堆的附加功能了,实现起来非常简单,不多赘述。
// 堆的判空
bool HeapEmpty(Heap* php)
{
assert(php);
return php->size == 0;
}
3.7 取堆顶的元素
// 取堆顶的数据
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
3.8 堆中的数据个数
// 堆的数据个数
int HeapSize(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->size;
}
四、 功能演示
实现了堆的删除和插入,那我们来看一看实现的效果吧。
例如,我们将数组a中的内容依次插入到堆hp中,看看插入之后的效果吧。
此时堆中的数据就会呈现一个小堆(父结点比子结点小)的形式。
接下来我们再删除两个数据,再看看堆中的数据是否符合规律吧。
此时堆中的情况:
删除了两个堆顶的数据,此时依然符合小根堆的性质。
结束总结
本篇博客的重点就在于理解堆的插入和删除,其中实现的向上调整和向下调整算法是本篇博客的主要内容同样也是堆的精髓,是我们后面学习堆排序和TOP-K问题的关键,大家一定要好好掌握。
好了,本篇博客到此就结束了,接下来会更新堆排序、TOP-K的相关内容,也有可能更新二叉树的练习题或者直接进入排序。
希望大家持续关注吧,点点关注和免费的赞,你们的反馈是我更新的巨大动力。
我们下期再见~~~