认识堆
堆的两个特点:
①是完全二叉树
②所有的父亲小于等于孩子(小堆 或 小根堆) 或 所有的父亲大于等于孩子(大堆 或 大根堆)
注意:
1.堆只规定了父亲和孩子的大小关系,兄弟之间的关系并没有规定~,所以堆并不代表有序!!!
以下都是堆
2.任何一个数组都可以看成完全二叉树,但不一定是堆,还是得看父子大小关系~
3.数组是数据存储的物理结构,而完全二叉树是逻辑结构,两个可以相互转化~
典例:
只需要把数组转化成完全二叉树,然后判断是否符合堆对父子大小关系的要求即可~
堆的功能实现
由于堆在物理结构上本质是一个数组,因此很多部分就是之前博客讲解的顺序表的相关功能的实现,还有个别功能的实现较之前需要有所变化,比如说插入元素,由于实现的是堆,那么插入之后肯定得保证还是堆;再比如说删除元素,删完之后得保证还是堆等等
Heap.h
#include<stdio.h>
#include<stdbool.h>
#include<stdlib.h>
#include<assert.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
//初始化
void HeapInit(HP* php);
//销毁
void HeapDestroy(HP* php);
//插入元素
void HeapPush(HP* php);
//删除元素
void HeapPop(HP* php);
//取堆顶元素
HPDataType HeapTop(HP* php);
//判空
bool HeapEmpty(HP* php);
//求堆中数据个数
int HeapSize(HP* php);
Heap.c
①初始化
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
②销毁
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity = php->size = 0;
}
③插入元素
堆插入元素只需要分两步:
1.在现有数据后面插入元素到数组中
2.进行向上调整
下面举例来说明一下这两步:(以小堆为例)
总结一下:
1.插入元素到数组尾部之后若仍然是堆,就不再进行任何操作;
2.若插入之后不是堆,由于其他(堂/亲)兄弟和父子祖宗的关系都保持不变,因此只需要调整插入元素到整个树的根节点这条路径上数据的大小关系,而调整的办法就是两个元素交换;
3.可能只调整一次,也可能调整多次,结束标志有两个,一是满足了父亲小于或等于孩子,二是插入元素已经调整到了根节点,没有父亲节点了,就调整结束了~
代码实现(以小堆为例)
1.顺序表插入元素的常规操作~
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\n");
return;
}
php->a = tmp;
php->capacity = NewCapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
2.向上调整算法的实现~
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp;
tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;
while (child >= 0) //该处若用while(parent>=0)结果没有问题,但是逻辑是有问题的~
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
①函数有两个参数:数组首元素地址和插入的孩子下标
②向上调整算法是一个循环迭代的过程,进入函数后先通过孩子下标计算出父亲下标,然后进入循环,比较父亲和孩子大小关系,需要调整就交换,然后迭代(把父亲下标给孩子,然后再通过孩子下标计算父亲下标实现迭代);若不需要交换,直接break即可~
③注意循环结束的条件是child<0就结束,而不是parent<0结束(结果没问题,但是逻辑有问题,因为当孩子下标是0的时候已经没有父亲了,但是计算出父亲下标为(0-1)/2 = 0 ,循环还会进去,然后break),这是不符合逻辑的~
ps:如果要实现大堆插入数据,只需要改变child和parent比较大小关系的符号~
④删除元素
之前我们讲解栈,队列的删除元素功能时都是规定了在哪一端删除数据~,栈是在栈顶pop数据,队列是在队头pop数据,而堆规定了堆是在堆顶pop数据的(pop堆最后一个数据没有意义)
具体如何实现呢?
一开始的想法是 堆不就是个数组嘛,那直接挪动覆盖数据就把第一个元素删除了,但是问题来了,这是堆呀,挪动完数据之后父子关系就全乱了,可能就不是堆了,就有可能得重新建堆了~
而重新建堆是一件比较麻烦的事情,消耗也比较大,因此我们另辟蹊径~
删除堆顶元素最佳做法
为了尽可能的保证现有堆的父子关系不变,我们选择交换堆顶数据和堆中最后一个数据,然后size--,就相当与于删除了目标元素,此时剩下的可能已经不是堆了,但父子关系基本没变,这时再将堆顶数据向下调整把堆顶数据调整到合适的位置,使得其重新成为堆;
下面举例说明
删除元素代码实现(以小堆为例)
1.交换数据,size--
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
2.向下调整
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child] > a[child + 1])
{
child += 1;
}
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
①函数参数:数组首元素地址,数据个数(size--之后的),要调整的数据下标(父亲下标)
②由于要判断左右孩子大小,然后选小的孩子和父亲交换,所以本来应该用if else去判断,但是这样比较麻烦,我们假设左孩子比右孩子小,直接算左孩子,然后在循环内部只需要一个if判断左孩子是否比右孩子小,若比右孩子小,则什么都不做,若不是,则child++,此时child就是右孩子下标了(完全二叉树从左到右是连续的);由于叶子节点可能只有左孩子而没有右孩子,因此child++后可能会越界,因此我们在if条件中添加了child+1<n的条件;
③判断小的孩子和父亲的大小关系,如父亲大于孩子,就交换,然后把孩子下标赋值给父亲,再通过新的父亲下标计算新的孩子下标,就实现了迭代往后走
④循环结束条件就是child>=n,因为当堆顶数据调整到了叶子节点的时候,叶子节点是没有孩子的,此时算出的孩子一定超出了数组下标范围~
ps:如果要实现大堆删除数据,只需要改变a[child]和a[parent]比较大小关系的符号以及a[child]和a[child+1]的大小比较关系~
⑤获取堆顶数据
HPDataType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
⑥判断堆是否为空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
⑦求堆数据个数
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
Heap.c全套代码
#include"Heap.h"
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity = php->size = 0;
}
//向上调整算法的前提是 已经是堆
//建小堆的向上调整算法~
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp;
tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;
while (child >= 0) //该处若用while(parent>=0)结果没有问题,但是逻辑是有问题的~
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//堆中插入元素~
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\n");
return;
}
php->a = tmp;
php->capacity = NewCapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
//建小堆的向下调整算法~
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child] > a[child + 1])
{
child += 1;
}
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆的删除操作
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
//获取堆顶数据
HPDataType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
//判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
//求堆数据个数
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
test.c
下面简单测试一下堆插入数据的逻辑~
#define _CRT_SECURE_NO_WARNINGS
#include"Heap.h"
//功能测试
int main()
{
HP hp;
HeapInit(&hp);
int a[] = { 65,100,70,32,50,60 };
for (int i = 0;i < sizeof(a) / sizeof(int);i++)
{
HeapPush(&hp, a[i]);
}
//只是实现了有序打印,并没有实现真正的堆排序~
while (!HeapEmpty(&hp))
{
int top = HeapTop(&hp);//小堆获取的堆顶元素永远是最小的
printf("%d ", top);
HeapPop(&hp);
}
HeapDestroy(&hp);
return 0;
}
我们给了一个数组,把数组的元素依次插入堆中,使得数组元素在我们另外开辟的一块连续空间中成为堆,然后写了一个循环,每次pop堆顶数据后打印出来,由于堆顶的数据是堆中最小的(小堆),因此打印出来的数据就是升序的~
但是这样并没有对数组元素实现真正的排序,因为堆的那块空间是我们另外malloc出来的,并没有直接对数组中元素排序,那么下面要介绍的堆的其中一个重要应用就是实现堆排序~
堆的应用:
1.堆排序
堆排序也是排序算法的一种,就是利用堆这种数据结构的特点对数据进行排序~
方法一:将数组元素依次push进堆,获取堆顶元素并进行数据拷贝,而后pop堆顶元素
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))
{
int top = HeapTop(&hp);
a[i++] = top;
HeapPop(&hp);
}
HeapDestroy(&hp);
}
int main()
{
int a[] = { 7,8,3,5,1,9,5,4 };
HeapSort(a, sizeof(a) / sizeof(int));
return 0;
}
缺陷:
1.得现有一个堆(就是上面实现堆的一堆代码)
2.空间复杂度+数据拷贝
方法二:直接采用向上调整法或者向下调整法建堆,然后向下调整实现有序
法二和法一的区别是法二会直接把原数组搞成堆,法一是把数组元素插入到另外一块空间中变成堆
步骤1:建堆
灵魂拷问:升序建小堆还是建大堆?降序建小堆还是大堆?
(不合理)传统想法:升序肯定是建小堆,因为小堆的特点是父亲都小于等于孩子
传统想法的问题所在:小堆的堆顶元素确实是最小的,所以就选出了数组中最小的元素,那要排升序的话,接下来就得选次小的,次小的怎么选呢?就得把剩下的数据看成堆,但是剩下的数据很可能已经不是堆了,又得重新建堆~(这个可以类比堆的删除功能提到的开始的想法)
最佳思路:
升序应该建大堆:
建大堆的话就保证了堆的父子关系基本不变,只需要略微调整就行,大数从数组末尾依次倒着放置,就实现了升序;同理,降序应该建小堆
ps:以下示例均为升序建大堆
建堆法(1):向上调整法
向上调整法建堆的做法就是遍历数组,挨个调整
HeapSort(int* a, int n) //直接把数组搞成堆
{
//向上调整算法建堆(该算法可以建小堆,也可以建大堆, 只需要改变代码中child与parent的比大小符号)
for (int i = 1;i < n;i++)
{
AdjustUp(a, i);
}
//向下调整算法实现数据有序排列
//·····
}
建堆法(2):向下调整法
之前讲解堆pop数据提到了向下调整算法,该算法使用的前提是左右子树都是堆,因此不能从根节点开始调整,应该从叶子节点开始调整,最后一个叶子节点下标是n-1,根据完全二叉树孩子计算父亲下标公式,结果就是(n-1-1)/2
HeapSort(int* a, int n) //直接把数组搞成堆
{
//向下调整算法建堆(该算法可以建小堆,也可以建大堆, 只需要改变代码中child与
parent的比大小符号以及child和child+1的比较大小符号)
for (int i = (n - 1 - 1) / 2;i >= 0;i--)
{
AdjustDown(a, n, i);
}
//2.向下调整算法实现数据有序排列
//······
}
向下调整建堆示意图:
---------------------------------------------------------------------------------------------------------------------------------小插曲:建堆时间复杂度
向上调整法建堆时间复杂度:O(N*logN)
向下调整法建堆时间复杂度:O(N)
--------------------------------------------------------------------------------------------------------------------------------
步骤2:向下调整变有序
建大堆--->交换数据---->向下调整--->end--达到隔离最后一个数据的效果
int end = n - 1;
while (end>0)
{
Swap(&a[0], &a[end]);
//再向下调整,选出次小的数
AdjustDown(a, end, 0);
--end;
}
堆排序全套代码
HeapSort(int* a, int n) //直接把数组搞成堆
{
//1.建堆(升序建大堆,降序建小堆)
//①向上调整算法建堆
for (int i = 1;i < n;i++)
{
AdjustUp(a, i);
}
//②向下调整算法建堆
//for (int i = (n - 1 - 1) / 2;i >= 0;i--)
//{
// AdjustDown(a, n, i);
//}
//2.向下调整算法实现数据有序排列(logN)
int end = n - 1;
while (end>0)
{
Swap(&a[0], &a[end]);
//再向下调整,选出次小的数
AdjustDown(a, end, 0);
--end;
}
}
int main()
{
int a[] = { 7,8,3,5,1,9,5,4 };
HeapSort(a, sizeof(a) / sizeof(int));
return 0;
}
2.Top-K模型
即求n个数据中最大的前k个或者最小的前k个,下面以求n个数中最大的前k个为例
1.正常思路:建立n个数的大堆,然后pop堆顶数据,pop k-1 次即可(因为堆建好之后堆顶数据现成)
但是该思路是有缺陷的,那就是当数据太多的时候,即n太大的时候,假如说n=10亿,经过换算,需要4G的空间大小,那么n再大的话,数据就无法一次行加载到内存当中,这种思路就无法实现
2.最佳思路:(以求n个数中最大的前k个数为例)
①前k个数建小堆
②依次比较后n-k个数和堆顶数据的大小关系,如果比堆顶数据大,就替换堆顶数据进堆,然后向下调整
③比较完后堆中的k个数就是n个数中最大的前k个数
思路原理:前k个数建小堆,那么堆顶数据就是目前前k个数最小的;依次比较n-k个数据与堆顶数据大小,比堆顶数据大,就替换堆顶数据,然后向下调整,这时仍然保证了堆顶数据最小;因为堆顶数据始终是堆中k个数最小的,这样的话不断遍历,则最大的k个数一定都可以进堆!!!
Top-K模型全套代码
我们采用随机生成数据,然后将数据写入文件,以及从文件中读取数据,然后打印最大的前5个数据,证明我们的代码是没有问题的
#include<stdio.h>
void CreateNData()
{
//造数据
int n = 1000;
srand(time(0));
const char* file = "C:\\Users\\86155\\Desktop\\堆\\data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen fail");
return;
}
for (int i = 0; i < n;i++)
{
int x = rand() % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
fin = NULL;
}
void PrintTopK(int k)
{
//以读的方式打开文件
const char* file = "C:\\Users\\86155\\Desktop\\堆\\data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen fail");
return;
}
//为建立小堆开辟一段内存空间
int* kminheap = (int*)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc fail\n");
return;
}
//将数据从文件中加载到开辟的空间中
for (int i = 0; i < k;i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
//向下调整算法实现建立k个数的小堆
for (int i = (k - 1 - 1) / 2; i >= 0;i--)
{
AdjustDown(kminheap, k, i);
}
//依次比较n-k个数字和堆顶数据大小关系,如果比堆顶数据大,则直接覆盖,然后向下调整
int val = 0;
while (!feof(fout))
{
fscanf(fout, "%d", &val);
if (val > kminheap[0])
{
kminheap[0] = val;
AdjustDown(kminheap, k, 0);
}
}
//打印最大的k个数
for (int i = 0;i < k;i++)
{
printf("%d ", kminheap[i]);
}
printf("\n");
}
int main()
{
CreateNData();
PrintTopK(5);
}
部分原始数据截图
当然在随机生成1000个数之后,可以手动在data.txt文本文件中改变5个数的值,比如都在末尾加几位数字,然后在主函数中屏蔽掉CreateNData()函数,这样就方便确认找出来的最大的5个数
堆的相关知识就介绍到这啦,欢迎大家交流指正~