🌈一、堆的基本概念
1.堆:非线性结构,是完全二叉树
2.堆分为大堆和小堆。
大堆:树中任意一个父亲都大于等于孩子,根节点值大于等于其所有子孙节点的值。
小堆:树中任意一个父亲都小于等于孩子,根节点值小于等于其所有子孙节点的值。
3.“同辈”即同一层数据间的大小顺序不做要求。
4.堆的物理结构即实际内存中存储的结构是顺序表,但逻辑结构是树。
5.堆特性:堆中最有用的数据是堆的根节点。小堆的根是整棵树的最小值,大堆的根是整棵树的最大值.
6.堆的用途:①topk问题 ②堆排序
🌈二、实现一个小堆(大堆同理)
☀️(1)头文件test.h:
#define _CRT_SECURE_NO_WARNINGS
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int HpDataType;
typedef struct {HpDataType* a;int size;int capacity;
}HP;
void Swap(HpDataType* p1, HpDataType* p2);
void AdjustUp(HpDataType* php, int x);
void HeapPrint(HP* php);
void HeapInit(HP* php);
void HeapDestroy(HP* php);
void HeapPush(HP* php, HpDataType x);
void AdjustDown(HpDataType* a, int n, int parent);
void HeapPop(HP* php);
HpDataType HeapTop(HP* php);
bool HeapEmpty(HP* php);
☀️(2)函数实现heap.c:
🎈1.初始化、销毁函数:
#define _CRT_SECURE_NO_WARNINGS
#include"test.h"
void HeapInit(HP* php) {assert(php);php->a = NULL;php->size = 0;php->capacity = 0;
}
void HeapDestroy(HP* php) {assert(php);free(php->a);php->a = NULL;php->size = php->capacity = 0;
}
🎈2.打印、交换数值函数:
void Swap(HpDataType* p1, HpDataType* p2) {HpDataType tmp = *p1;*p1 = *p2;*p2 = tmp;
}
void HeapPrint(HP* php) {assert(php);for (int i = 0;i < php->size;i++) {printf("%d ", php->a[i]);}printf("\n");
}
Swap函数细节分析:
Swap函数:用来交换父亲节点和孩子节点的值:
🎈3.插入、向上调整函数:
现在有一个堆,对其插入一个值:
插入位置:插入到根节点会破坏堆的结构;插入到树的中间部分的话我怎么知道哪里是中间部分,难不成还要先算一下树的大小?只能插入到最高层的末尾位置,即数组的末尾处,然后一层一层向上调整成一个正确的堆。
步骤一:首先需要判断该堆的容量是否足够插入一个节点,容量不够就扩容。
步骤二:根据插入的值判断是否需要对树进行调整。
eg:该情况下,若插入90,不需要对树进行调整:
若插入50,需要对树进行调整:
调整部位:右子树即50的“祖先”,不调整左子树15及其“子代”。
如何调整:一层一层地和“父亲”换位置,直到符合大小排序。
调整完的样子:
代码部分:
void AdjustUp(HpDataType* a, int child) {int parent = (child - 1) / 2;//为什么要用循环?//因为不止调整一次,有可能调整好多次直到child走到了根节点while (child > 0) {if (a[child] < a[parent]) {swap(&a[child], &a[parent]);//为什么还要加取地址符号&?//因为要对该空间的值进行更改,传值调用不会改变空间上的值,只有传地址才行child = parent;parent = (child - 1) / 2;}elsebreak;}
}
void HeapPush(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, sizeof(HpDataType) * newCapacity);if (tmp == NULL) {perror("realloc fail");exit(-1);}php->a = tmp;php->capacity = newCapacity;}php->a[php->size] = x;php->size++;//步骤二:调整//命名含义:每次插入的位置都是最后一层最右边的节点,//调整过程是一层一层向上进行的,因此叫adjust upAdjustUp(php->a, php->size - 1);//函数参数://参数一:需要知道在那棵树中调整=>数组指针即a;//参数二:需要知道调整哪个节点=>下标即size-1;
}
细节分析:
1.关于AdjustUp函数名和参数:
2.关于AdjustUp函数内部:
🎈4.删除、向下调整函数:
删除位置:基于堆的特性,堆中最有用的数据是根上的数据,因此删除也是删除根的数据。
如何删除:
👻错误方法:直接挪动后面的数据覆盖,这样会破坏堆结构,得到的不一定是堆,如图:
挪动覆盖根,关系全乱了,原先的兄弟关系被迫变成了父子或长辈晚辈关系,不一定稳定(不一定符合大小顺序),就不一定是堆了。
👻正确方法:最下面一层的最后一个数8和根位置的数据2交换,然后删除掉最后一层最后一个数据(–size就删除了),然后从根节点向下调整。下一层的最小的数据和根数据交换,直到走到最下面一层就调整成了正确的堆结构。每删除(pop)一次就能得到堆中最小的数。
👻背后原理:不破坏树原本的结构,左右子树依旧是小堆。
注:
向下调整前提:左右子树是堆;
向上调整前提:前面的数据是堆。
代码实现:
void AdjustDown(HpDataType* a, int n, int parent) {int child = parent * 2 + 1;//while循环作用:1.实现从上到下一层一层调整 //2.child>n时说明已经调整到最后一层,停止循环while (child < n) {//当有两个孩子时,需要选出较小的孩子与父节点比较//如果只有一个孩子,那child下标就是parent*2+1if (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;}//只要某一层的孩子节点值大于父节点值,就停止循环,//这是基于除了根节点其他地方都符合堆结构的前提elsebreak;}
}
void HeapPop(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);//参数分析://参数一:结构体中的数组指针,指向堆//参数二:堆的大小,计算出的孩子序号数与堆大小进行比较就可以知道是否有该孩子//参数三:父节点下标,从该位置开始向下调整
}
细节分析(关于AdjustDown函数):
①参数分析:
②while循环作用:
③孩子节点可能有一个或两个,如何选出值较小的那个?
当没有“child+1<n”条件时,如果父节点只有一个左孩子,那么a[child+1]就会造成越界访问。
④向上调整或向下调整的前提是除了被调整节点,其他部位都符合堆,即左右子树是堆:
🎈5.根节点函数、判空函数:
HpDataType HeapTop(HP* php) {assert(php);assert(php->size > 0);return php->a[0];
}
bool HeapEmpty(HP* php) {assert(php);return php->size == 0;
}
☀️(3)测试test.c:
🎈1.测试插入:
测试思路:一个数组本身不是堆,但当我将每一个元素都push进去后,它就变成了堆。通过打印看是否建堆成功。
#define _CRT_SECURE_NO_WARNINGS
#include"test.h"
int main() {int a[] = { 65,100,70,32,50,60 };HP hp;HeapInit(&hp);for (int i = 0;i < sizeof(a) / sizeof(a[0]);i++) {HeapPush(&hp, a[i]);}HeapPrint(&hp);HeapDestroy(&hp);return 0;
}
打印结果:
从物理结构转变为逻辑结构:建堆成功。
🎈2.测试从小到大对堆中的值排序:
测试思路:先通过push函数将数组建成堆结构,然后通过top函数得到根节点值(最小值),最后pop掉根节点,将剩下的数调整成堆结构,继续得到根节点值(次小值)。
#define _CRT_SECURE_NO_WARNINGS
#include"test.h"
int main() {int a[] = { 65,100,70,32,50,60 };HP hp;HeapInit(&hp);for (int i = 0;i < sizeof(a) / sizeof(a[0]);i++) {HeapPush(&hp, a[i]);}HeapPrint(&hp);while (!HeapEmpty(&hp)) {printf("%d ", HeapTop(&hp));HeapPop(&hp);}HeapDestroy(&hp);return 0;
}
打印结果:
由此可以看出,将一组数据建堆是对改组数据进行粗略的排序;将堆的根一个一个取出才能得到准确的大小关系。
思考:如何实现大堆?
AdjustUp函数和AdjustDown函数中,把if中的大于小于号换一下即可。
🌈三、堆结构基础上实现堆排序
给出一个数组,将数组中的数据一个一个放进搭建好的小堆结构中,再将每次pop出来的最小值(根节点数据)从左到右放进原数组,从而排成升序。
☀️HeapSort函数:
void HeapSort(int* a, int n) {HP hp;HeapInit(&hp);for (int i = 0;i < n;i++) {HeapPush(&hp, a[i]);}int i = 0;while (!HeapEmpty(&hp)) {a[i++] = HeapTop(&hp);HeapPop(&hp);}HeapDestroy(&hp);
}
测试将一组数排成升序:
int main() {int a[] = { 2,3,5,7,4,6,8,65,100,70,32,50,60 };HeapSort(a, sizeof(a) / sizeof(a[0]));return 0;
}
结果(通过监视看内存):
思考:如果要将改组数据排成降序呢?
法一:得到pop出来的最小值(根节点)后,从原数组最后面往前放置。
法二:建大堆,将每次pop出的最大值(根节点)从前往后放进原数组。
👻注:这种写法的缺陷:
1.需要先搭建一个完整的堆结构:先初始化,再有插入向上调整,删除向下调整等等;
2.空间复杂度的消耗:被排序的原数组占据了一部分空间,将原数组的值插入进堆中又占用了相同大小的空间,造成空间浪费。
🌈四、无堆结构,在数组上原地建堆
☀️法一:向上调整建堆
1.思路:从前往后依次将数组的值插入进堆,并向上调整。不需要初始化与销毁,只需要AdjustUp函数。
2.代码:
heap.c函数文件:
void AdjustUp(int* a, int child) {int parent = (child - 1) / 2;while (child > 0) {if (a[parent] > a[child]) {Swap(&a[child], &a[parent]);child = parent;parent = (child - 1) / 2;}elsebreak;}
}
void Swap(int* a, int* b) {int tmp = *a;*a = *b;*b = tmp;
}
void HeapSort(int* a, int n) {for (int i = 0;i < n;i++) {AdjustUp(a, i);}
}
test.c测试文件:
测试将一组数组中的无序的数排成小堆
#define _CRT_SECURE_NO_WARNINGS
#include"test.h"
int main() {int a[] = { 70,65,100,32,50,60 };HeapSort(a, sizeof(a) / sizeof(int));for (int i = 0;i < sizeof(a) / sizeof(int);i++) {printf("%d ", a[i]);}
}
测试结果:
原地建堆成功。
☀️法二:向下调整建堆
1.思路:从最后一个节点的父节点(倒数第一个父节点)开始,向下调整至正确大小顺序;然后指针向前移动,指向倒数第二个父节点,向下调整;直至从根节点向下调整。
2.代码:
heap.c函数文件:
void AdjustDown(int* a, int size,int i) {int parent = i;int child = parent * 2 + 1;while (child < size) {if (child + 1 < size && a[child] > a[child + 1]) {child++;}if (a[parent] > a[child]) {Swap(&a[parent], &a[child]);parent = child;child = parent * 2 + 1;}elsebreak;}
}
void Swap(int* a, int* b) {int tmp = *a;*a = *b;*b = tmp;
}
void HeapSort(int* a, int n) {for (int i = (n - 1 - 1) / 2;i >= 0;i--) {AdjustDown(a, n, i);}
}
细节:
为何有child+1<size,没有的话会怎样?
堆的节点不一定都有两个孩子,如果倒数第一个父节点只有一个左孩子,则child+1就越界了。因此要确保有两个孩子的情况下才能比较两个孩子谁大谁小,只有一个孩子时,child就储存这一个孩子节点的下标。
test.c测试文件:
测试将一组数组中的无序的数排成小堆
#define _CRT_SECURE_NO_WARNINGS
#include"test.h"
int main() {int a[] = { 70,65,100,32,50,60 };HeapSort(a, sizeof(a) / sizeof(int));for (int i = 0;i < sizeof(a) / sizeof(int);i++) {printf("%d ", a[i]);}
}
测试结果:
☀️比较向上和向下调整建堆
1.计算向下调整建堆时间复杂度:O(N)=N-logN
2.向上调整建堆时间复杂度:N*logN
3.比较结果:向下调整时间复杂度更低,效率更高。
更直观的理解方法:对于向上调整而言,节点最多的那一层(最高层)需要向上调整的次数最多(有h层就调整h-1次),即大数乘大数;对于向下调整而言,节点最多的那一层(最高层)需要向下调整的次数最少(最高层调整0次),即大数乘小数。因此相比较而言,向下调整建堆时间复杂度更低。
🌈五、无堆结构,对数组进行堆排序
思路:先通过向上或向下调整建堆,在没有堆结构的基础上让数组先成为堆,再通过pop的思想每次得到根节点,即为当前的最大值(最小值)。步骤为:建堆->pop。
☀️升序降序分别建什么堆?
假设现在要将一组数排成升序:
👻如果在已经有堆结构的基础上排升序,法一是建小堆,pop出来的数是从小到大的,从左到右排列在新开辟的数组空间中,从而将数组排成升序。法二是建大堆,pop出来的数是从大到小的,只需要从空间的右边往左边排,即可排成降序。
👻现在没有堆结构,并且直接在原数组上建堆,意味着不可以新开辟空间,此时需要仔细斟酌排序对应建什么堆:
🎈1.错误方法:排升序建小堆:
第一步:建成小堆后,得到物理结构与逻辑结构,如下:
第二步:pop出根节点,得到最小值32,并且刚好在数组最左边。
第三步:用剩下的数据得到次小值。
问题来了:如何排除掉第一个数据?
如果直接移动指针,将下标为1的节点作为根节点,则有破坏堆结构的风险,如图:
如果将剩下的数重新建堆,则代价太大。
因此排升序不可以建小堆。
🎈2.正确方法:建大堆,每次pop出的数和数组最右边一个数交换,再向下调整从而得出次大的数,从右往左放置从大到小的数,从而排成升序。
具体过程如下:
🎈3.结论:排升序,建大堆;排降序,建小堆。
🎈4.为何有堆结构时可以随意建大小堆,无堆结构时有限制?
本质上是因为:有堆结构时,可以将数据从原先的混乱数组中转移到一片新的空间,只需要将大小堆和放置先后顺序匹配就可排成任意顺序(排升序:如果建大堆,从右往左放,如果建小堆,从左往右放;排降序:如果建大堆,从左往右放,如果建小堆,从右往左放)。无堆结构时,从混乱的一组数据到堆结构再到有序结构,始终是在同一块空间上进行操作的,为了不破坏堆结构,只能用pop和向下调整的方式,因此对什么时候建什么堆有限制(升序建大堆,降序建小堆)。
☀️代码(将数组排成降序)
方法:让数组中的数据一个一个插入到堆的最后面,再向上调整,从而建堆。
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
void Swap(int* a, int* b) {int tmp = *a;*a = *b;*b = tmp;
}
void AdjustDown(int* a, int size, int i) {int parent = i;int child = parent * 2 + 1;while (child < size) {if (child + 1 < size && a[child] > a[child + 1]) {child++;}if (a[parent] > a[child]) {Swap(&a[parent], &a[child]);parent = child;child = parent * 2 + 1;}elsebreak;}
}void HeapSort(int* a, int n) {for (int i = (n - 1 - 1) / 2;i >= 0;i--) {AdjustDown(a, n, i);}int end = n - 1;while (end >= 0) {Swap(&a[0], &a[end]);AdjustDown(a, end, 0);end--;}
}
int main() {int a[] = { 70,65,100,32,50,60 };HeapSort(a, sizeof(a) / sizeof(int));for (int i = 0;i < sizeof(a) / sizeof(int);i++) {printf("%d ", a[i]);}
}
测试结果:
☀️应用:TopK问题
👻问题背景:一共10000个数,要找出最大的前10个。
👻程序设计:
1.随机生成10000个值小于10000的数
2.随机10挑个数改成大于10000的值(为了方便检查测试结果,如果最终得到的10个数就是这些大于10000的值的话,则说明筛选成功)
👻解决方案:
先对这10000个数的前10个数建小堆,然后后面的9990个数依次和堆顶数据比较,大于堆顶数据的话则顶替这个堆顶数据进堆。
问:为何要对前10个数据建小堆,建大堆会怎样?
答:假设这10000个数中最大的数恰好在前10个中,则建了大堆后,根节点一开始的值就是最大值,后面的9990个数就无法进堆,最终只有根节点的最大值是准确的,剩下的9个值找不到。而建小堆的话,最大的前10个数来了都可以畅通无阻的进堆,然后再向下调整至正确位置,最终堆中剩下的数就是最大的前10个。
代码:
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include<time.h>
#include<stdlib.h>
void Swap(int* a, int* b) {int tmp = *a;*a = *b;*b = tmp;
}
void AdjustDown(int* a, int k, int parent) {int child = parent * 2 + 1;while (child<k) {if (child + 1 < k && a[child + 1] < a[child]) {child++;}if (a[parent] > a[child]) {Swap(&a[parent], &a[child]);parent = child;child = parent * 2 + 1;}else {break;}}
}
void PrintTopK(int* a, int n, int k) {//对前10个数据建小堆for (int i = (k-2)/2;i >=0 ;i--) {AdjustDown(a, k, i);}//将剩下9990个数据依次与堆顶元素比较//若大于堆顶,则替换堆顶进堆,并向下调整for (int i = 0;i < n - k;i++) {if (a[k + i] > a[0]) {Swap(&a[k + i], &a[0]);AdjustDown(a, k, 0);}}//将前k个数据打印出来for (int i = 0;i < k;i++) {printf("%d ", a[i]);}
}
void TeatTopK() {int n = 10000;int k = 10;srand(time(0));int* a = (int*)malloc(sizeof(int) * n);for (size_t i = 0;i < n;i++) {a[i] = rand() % 10000;}a[10] = 10001;a[29] = 10002;a[300] = 10003;a[790] = 10004;a[1440] = 10005;a[4990] = 10006;a[6900] = 10007;a[6930] = 10008;a[8999] = 10009;a[9999] = 10010;//先展示一下最初始的前10个数for (int i = 0;i < k;i++) {printf("%d ", a[i]);}printf("\n");//再展示筛选出来的前10个数PrintTopK(a, n, k);
}
int main() {TeatTopK();
}
测试结果:
由于不对前10个最大的数有顺序要求,因此每次运行完程序得到的前10个数的顺序不一样。