[数据结构]堆
1.堆的定义
堆是一种特殊的完全二叉树,它具有以下关键特性:
堆序性:堆中每个节点的值都满足特定的顺序关系(堆一定具有堆序性,否则就是一个普通的完全二叉树)
在大根堆中:每个节点的值都大于或等于其子节点的值
在小根堆中:每个节点的值都小于或等于其子节点的值
完全二叉树:堆总是一棵完全二叉树,这意味着除了最后一层,其他层都是满的,且最后一层的节点都靠左排列(如果你不知道什么是完全二叉树,请翻阅至本文疑难解答处)。
2.堆的存储结构
堆通常使用数组来实现顺序存储,这种实现方式既节省空间又高效:
typedef int HPDataType;
typedef struct Heap {HPDataType* a; // 存储堆元素的数组int size; // 当前堆中元素个数int capacity; // 堆的容量
} HP;
对于数组中位置为 i
的节点:
父节点位置:
(i-1)/2
左孩子位置:
2*i+1
右孩子位置:
2*i+2
给大家配个图便于理解,Arr是我们存放堆的数组:
大家好好看一下,应该不难理解。
3.堆的建成
3.1堆的插入
因为堆序性的存在,所以我们在数组末尾插入了新的数据后,必须对数据进行调整,使堆具有堆序性。我们先看调整的算法:
3.1.1向上调整算法
将新数据插入到数组的尾上,再进行向上调整算法,直到满足堆。
void AdjustUp(HPDataType* a, int child)
{根据子节点位置,找到父节点的位置int parent = (child - 1) / 2;//child等于0时,新加入节点已经是根节点,无需再调整,结束循环while(child > 0){//此时为小根堆,大根堆将小于号换成大于号即可if (a[child] < a[parent]){//swap函数用于交换两个参数的值Swap(&a[child], &a[parent]);child = parent;parent = (parent - 1) / 2;} //如果父节点比子节点小,则符合堆排序,无需再进行调整,结束循环else{break;}}
}
学会了向上调整算法,我们插入堆的代码就正式登场啦:
void HPPush(HP* php, HPDataType x)
{// 检查容量并可能扩容if (php->size == php->capacity) {size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newCapacity);//如果没有成功开辟空间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);
}
3.2堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后⼀个数据交换位置,然后删除数组最后⼀个数据,再进行向下调整算法。
3.2.1向下调整算法
向下调整算法有⼀个前提:左右子树必须是⼀个堆,才能调整。
过程如下图:
代码如下:
// 小根堆的向下调整算法
void AdjustDown(HPDataType* 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; // 已经满足小堆性质,调整结束}}
}
OK,学会了向下调整算法,我们一起看一下删除操作的代码吧:
void HPPop(HP* php)
{//assert是断言,保证该堆不为空assert(php);assert(php->size > 0);//先将首节点和尾节点交换位置Swap(&php->a[0], &php->a[php->size - 1]);php->size--;AdjustDown(php->a, php->size, 0);
}
现在恭喜你,学会了最难的部分,下面我们将其他堆常用的功能也加入代码,大家理解起来应该不会有太大困难:
4.完整代码
该代码完整了堆的功能。已给大家详细的注释了(该例子中为大根堆):
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>typedef int HPDataType;// 堆结构体定义
typedef struct Heap
{HPDataType* a; // 指向存储堆元素的数组int size; // 当前堆中元素个数int capacity; // 堆的容量
} HP;// 交换函数 - 交换两个元素的值
void Swap(HPDataType* a, HPDataType* b)
{HPDataType temp = *a;*a = *b;*b = temp;
}// 向上调整算法(大堆) - 从child位置开始向上调整
void AdjustUp(HPDataType* a, int child)
{int parent = (child - 1) / 2; // 计算父节点位置while (child > 0) // 当child不是根节点时{if (a[child] > a[parent]) // 如果孩子比父节点大(大堆性质){Swap(&a[child], &a[parent]); // 交换父子节点child = parent; // 继续向上调整parent = (parent - 1) / 2;}else{break; // 已经满足堆性质,调整结束}}
}// 向下调整算法(大堆) - 从parent位置开始向下调整
void AdjustDown(HPDataType* 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; // 已经满足堆性质,调整结束}}
}// 默认初始化堆 - 创建一个空的堆
void HPInit(HP* php)
{assert(php); // 确保指针有效php->a = NULL; // 初始时数组为空php->size = 0; // 初始时元素个数为0php->capacity = 0; // 初始时容量为0
}// 利用给定数组初始化堆 - 使用已有数组构建堆
void HPInitArray(HP* php, HPDataType* a, int n)
{assert(php && a); // 确保指针和数组有效// 分配内存存储数组元素php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);if (php->a == NULL){perror("malloc fail");return;}// 复制数组元素for (int i = 0; i < n; i++){php->a[i] = a[i];}php->size = n; // 设置元素个数php->capacity = n; // 设置容量// 从最后一个非叶子节点开始,向下调整构建堆// 最后一个非叶子节点的索引 = (n-1-1)/2 = (n-2)/2for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(php->a, n, i);}
}// 堆的销毁 - 释放堆占用的内存
void HPDestroy(HP* php)
{assert(php); // 确保指针有效if (php->a){free(php->a); // 释放数组内存php->a = NULL; // 指针置空,防止野指针}php->size = 0; // 元素个数归零php->capacity = 0; // 容量归零
}// 堆的插入 - 向堆中插入新元素
void HPPush(HP* php, HPDataType x)
{assert(php); // 确保指针有效// 检查容量,如果不够则扩容if (php->size == php->capacity){// 如果当前容量为0,则初始化为4,否则翻倍size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;// 重新分配内存HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);if (tmp == NULL){perror("realloc fail");return;}php->a = tmp; // 更新数组指针php->capacity = newCapacity; // 更新容量}// 插入新元素到数组末尾php->a[php->size] = x;php->size++; // 元素个数加1// 向上调整,恢复堆性质AdjustUp(php->a, php->size - 1);
}// 获取堆顶元素 - 返回堆顶的元素值
HPDataType HPTop(HP* php)
{assert(php); // 确保指针有效assert(php->size > 0); // 确保堆不为空return php->a[0]; // 返回堆顶元素
}// 删除堆顶的数据 - 移除堆顶元素
void HPPop(HP* php)
{assert(php); // 确保指针有效assert(php->size > 0); // 确保堆不为空// 将堆顶元素与最后一个元素交换Swap(&php->a[0], &php->a[php->size - 1]);php->size--; // 元素个数减1(相当于删除原堆顶)// 从根节点开始向下调整,恢复堆性质AdjustDown(php->a, php->size, 0);
}// 判空 - 检查堆是否为空
bool HPEmpty(HP* php)
{assert(php); // 确保指针有效return php->size == 0; // 如果大小为0则为空
}// 求size - 返回堆中元素个数
int HPSize(HP* php)
{assert(php); // 确保指针有效return php->size; // 返回元素个数
}
5.堆的应用
5.1堆排序
// 1、需要堆的数据结构
// 2、空间复杂度 O(N)
void HeapSort(int* a, int n)
{HP hp;//将数组a中的元素循环入堆hp中for(int i = 0; i < n; i++){HPPush(&hp,a[i]);}int i = 0;//循环出堆顶元素知道堆为空while (!HPEmpty(&hp)){a[i++] = HPTop(&hp);HPPop(&hp);} //销毁堆HPDestroy(&hp);
}
版本二:大多数时候,我们的堆都是由数组存储的。这个版本其实是借用的堆的思想进行排序
// 升序,建⼤堆
// 降序,建⼩堆
// O(N*logN)
void HeapSort(int* a, int n)
{// a数组直接建堆 O(N)//i的初值为最后一个节点的父节点位置//i<0则终止循环,此时已经完成排序,再循环会越界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可以理解为记录需要排序的数组//数组末尾是最大值,已完成排序,end--,下次即可不用再排//这里前后倒置了一下,所以大堆升序,小堆降序--end;}
}
堆的时间复杂度为O(n log n)。
想了解堆排序的时间复杂度如何计算的同学可以去看后文的疑难解答。
5.2TOPK问题
顾名思义,就是在一堆数据当中,找到前k个最大(最小/自定义关系)的值。
void CreateNDate()
{// 造数据int n = 100000;//利用时间戳造随机数srand(time(0));const char* file = "data.txt";//写入文件FILE* fin = fopen(file, "w");//如果无法成功打开并写入if (fin == NULL){//报错返回perror("fopen error");return;}//将随机数控制在0-999999内,并打印在文件中for (int i = 0; i < n; ++i){//可以不加i,加i只是为了增强随机性int x = (rand()+i) % 1000000;fprintf(fin, "%d\n", x);}fclose(fin);
}
void topk()
{printf("请输⼊k:>");int k = 0;scanf("%d", &k);const char* file = "data.txt";FILE* fout = fopen(file, "r");if (fout == NULL){perror("fopen error");return;}int val = 0;//建小堆,小堆为降序//创建k个空间,这里是整型空间int* minheap = (int*)malloc(sizeof(int) * k);if (minheap == NULL){perror("malloc error");return;}for (int i = 0; i < k; i++){//从文件读取数据到minheapfscanf(fout, "%d", &minheap[i]);}// 建k个数据的⼩堆并堆排序for (int i = (k - 1 - 1) / 2; i >= 0; i--){AdjustDown(minheap, k, i);}int x = 0;while (fscanf(fout, "%d", &x) != EOF){// 读取剩余数据,⽐堆顶的值⼤,就替换他进堆if (x > minheap[0]){minheap[0] = x;//重新对堆进行排序AdjustDown(minheap, k, 0);}}for (int i = 0; i < k; i++){printf("%d ", minheap[i]);}fclose(fout);
}
这里需要提一下,最后输出的代码是按堆中的顺序进行输出的,如果大家需要按需输出,可以自行对minheap数组进行排序。
6.疑难解答
6.1什么是完全二叉树?
完全二叉树是一种特殊的二叉树,它满足以下条件:
除了最后一层外,所有层的节点数都达到最大值(即第 k 层有 2^(k-1) 个节点)
最后一层的所有节点都连续集中在该层的最左边
是不是有点抽象?我们给张图理解一下:
讲人话就是:完全二叉树是从上到下、从左到右依次填充节点的二叉树。
6.2什么是堆序性?
堆序性要结合二叉树理解,找了个例子给大家看一下:
上图是一个小根堆,其子节点全部大于或等于其父节点。(至于那个节点为什么是红的?当然是我因为我懒随便找了个图)
6.3堆排序的时间复杂度计算
———(如有问题,欢迎评论区提问)———