一、什么是堆?
heap 是一个抽象的数据结构,也可以说堆是逻辑上的数据结构,是我们人为想象出来的一种数据结构,并不是一个物理上真实存在的数据结构,堆的底层其实是用数组来实现的。
heap 其实有很多种实现方式,但是面试最常考的,也是最经典的,就是 binary heap 二叉堆,也就是用一棵完全二叉树来实现的。
那这颗完全二叉树怎么实现呢?其实它并没有那么难,底层就是用一个动态开辟的数组来实现的。
那是不是所有的二叉树都可以用数组来存储呢?本质上是可以的,但是如果不是完全二叉树,那就不适合用数组来存储了,因为完全二叉树的性质是除了最后一层之外,其他层的结点都是满的,并且最后一层的结点从左到右是连续的,这就决定了这颗完全二叉树的每一个结点都是连续的,而数组刚好就是一块连续的空间,所以用数组实现完全二叉树是适合的。但是如果不是完全二叉树,那每一个地方都有可能是空结点,那就会导致浪费了大量的空间,所以非完全二叉树就不适合用数组实现。
堆的特点:
1、堆是一棵完全二叉树;
2、堆序性 (heap order): 任意节点的优先级都优于它的所有孩子。
a.如果是任意节点都大于等于它的所有孩子,这样的堆叫大堆;
b. 如果是任意节点都小于等于它的所有孩子,这样的堆叫小堆;
c.堆是用数组来实现的,那么我们可以找到每个节点和它的父母/孩子之间的关系,从而可以直接访问到它们。
堆的父节点和子节点的下标存在以下关系:
1、leftchild=parent * 2+1;
2、rightchild=parent * 2+2;
3、parent=(child-1)/2;
二、堆的实现
2.1 结构体变量的声明
typedef int HeapDataType; typedef struct Heap { //堆的结构体的内部就是一个动态开辟的数组 HeapDataType* a; //记录有效数据的个数 int sz; //记录数组的容量,方便增容 int capacity; }HP;
2.2 堆的初始化
这个地方就是顺序表的初始化,大家应该都老生熟路了。
void HeapInit(HP* php) { assert(php); php->a = (HeapDataType*)malloc(sizeof(HeapDataType) * 4); if (php->a == NULL) { perror("malloc fail"); return; } php->sz = 0; php->capacity = 4; }
2.3 堆的销毁
void HeapDestroy(HP* php) { assert(php); free(php->a); php->a = NULL; php->sz = php->capacity = 0; }
2.4 插入数据
这里的插入数据可以随机插入吗??明显是不能的,因为堆是一种具有特殊性质的数据结构,每一个数据在哪一个位置都是有要求的,如果支持任意位置插入的话,那这个堆也就乱了,所以插入数据是在堆的最后一个位置插入的,插入之后你要保证这个堆还是原来的大/小堆啊,但是新插入的这个数据不一定满足原来堆的大小关系的,所以需要我们稍加调整才能让它继续保持插入前的堆的性质。这里描述为向上调整。
这里插入了一个3,通过观察可以看到,这个3只会影响从它开始一路往根节点的这条路径上的结点,而不会影响其它结点,其他结点还是保留着本来的特性(大堆/小堆),也就是说向上调整只需要从3开始往根节点这条路径上的结点做调整,因为这里是大堆,所以大的结点应该做父节点。调整后的结果如下:
如果插入的数据是6,那么结果如下:
向上调整代码:
void swap(HeapDataType* a, HeapDataType* b) { HeapDataType tmp = *a; *a = *b; *b = tmp; } void AdjustUp(HeapDataType* arr, int child) { //根据堆的性质求父子结点之间的关系 int parent = (child - 1) / 2; while (child > 0)//当子节点的下标都为0了,那么循环就结束了 { //子节点大于父节点就交换,建大堆 if (arr[child] > arr[parent]) { swap(&arr[child], &arr[parent]); //交换后需要往上迭代,参考以上描述和图片 child = parent; parent = (child - 1) / 2; } else { //往上都符合大堆的性质,就break break; } } }
void HeapPush(HP* php, HeapDataType x) { assert(php); if (php->sz == php->capacity) { //增容 HeapDataType* tmp = (HeapDataType*)realloc(php->a, sizeof(HeapDataType) * (php->capacity * 2)); if (tmp == NULL) { perror("realloc fail"); return; } php->a = tmp; php->capacity *= 2; } php->a[php->sz] = x; php->sz++; //向上调整 AdjustUp(php->a, php->sz - 1); }
2.5 删除数据
堆的删除只能从堆顶处删除,不能删除其他位置的数据,这也是堆的性质决定的。删除数据也是不能像顺序表那样简单地直接把后面的数据往前覆盖就可以实现的,那样的话父子结点之间的下标关系也是全部都乱套了的,这里用到了一种非常牛的方法,“替换法”。
具体操作就是用第一个结点和最后一个结点交换,然后删掉最后一个结点(注意:这里的最后一个结点本质上已经变成了原来堆顶的数据了,也就是完成了对堆顶数据的删除)。这时原本的最后一个结点已然变成了堆顶结点,但是这个数据不一定就是不一定是最大的(这里是大堆),所以需要往下调整保持原来大堆的性质(所有的父节点都大于等于自己的子节点),但是父节点可能存在左右子节点,那我们应该选取大的结点进行交换,以保证交换上来的子结点比另一个子节点大。迭代往下调整,直到到达叶子节点(中间遇到不需要交换的时候就可以break)。
先用堆顶元素和最后一个元素交换:
交换后删除最后一个元素:
从根节点开始向下调整(找大的子树):
void AdjustDown(HeapDataType* arr, int parent, int sz) { int child = parent * 2 + 1; while (child < sz)//child的下标不能大于或者等于size(最后一个元素下标的下一个) { //找子树中数据大的结点(注意child+1也不能越界) if (child + 1 < sz && arr[child] < arr[child + 1])//(child+1<sz必须先判断,不然如果右子树不存在,arr[child+1]会越界) { child++; } //大堆是父节点大,所以子节点大于父节点就要交换 if (arr[child] > arr[parent]) { swap(&arr[child], &arr[parent]); parent = child; child = parent * 2 + 1; } //没交换就可以break了 else { break; } } }
void HeapPop(HP* php) { assert(php); swap(&php->a[0], &php->a[php->sz - 1]); //php->sz--就把数据删除了 php->sz--; //从根节点开始向下调整保证堆的性质 AdjustDown(php->a, 0, php->sz); }
2.6 堆内有效数据的数目
size_t HeapSize(HP* php) { assert(php); return php->sz; }
2.7 取堆顶元素
HeapDataType HeapTop(HP* php) { assert(php); return php->a[0]; }
2.8 判断堆是否为空
bool HeapEmpty(HP* php) { assert(php); return php->sz == 0; }
2.9 代码汇总
Heap.h
#pragma once //Heap.h #include <stdio.h> #include <assert.h> #include <stdlib.h> #include <stdbool.h> typedef int HeapDataType; typedef struct Heap { HeapDataType* a; int sz; int capacity; }HP; extern void HeapInit(HP* php); extern void HeapDestroy(HP* php); extern void HeapPush(HP* php, HeapDataType x); extern void HeapPop(HP* php); extern HeapDataType HeapTop(HP* php); extern size_t HeapSize(HP* php); extern bool HeapEmpty(HP* php); extern void HeapCreate(HP* php, HeapDataType* arr, int n); void AdjustDown(HeapDataType* arr, int parent, int sz); void AdjustUp(HeapDataType* arr, int child);
Heap.c
#define _CRT_SECURE_NO_WARNINGS 1 //Heap.c #include "Heap.h" void HeapInit(HP* php) { assert(php); php->a = (HeapDataType*)malloc(sizeof(HeapDataType) * 4); if (php->a == NULL) { perror("malloc fail"); return; } php->sz = 0; php->capacity = 4; } void HeapCreate(HP* php, HeapDataType* arr, int n) { assert(php); //建堆 int i = 0; for (i = 0; i < n; i++) { HeapPush(php, arr[i]); } } void HeapDestroy(HP* php) { assert(php); free(php->a); php->a = NULL; php->sz = php->capacity = 0; } void swap(HeapDataType* a, HeapDataType* b) { HeapDataType tmp = *a; *a = *b; *b = tmp; } void HeapPush(HP* php, HeapDataType x) { assert(php); if (php->sz == php->capacity) { HeapDataType* tmp = (HeapDataType*)realloc(php->a, sizeof(HeapDataType) * (php->capacity * 2)); if (tmp == NULL) { perror("realloc fail"); return; } php->a = tmp; php->capacity *= 2; } php->a[php->sz] = x; php->sz++; AdjustUp(php->a, php->sz - 1); } bool HeapEmpty(HP* php) { assert(php); return php->sz == 0; } void HeapPop(HP* php) { assert(php); swap(&php->a[0], &php->a[php->sz - 1]); php->sz--; AdjustDown(php->a, 0, php->sz); } HeapDataType HeapTop(HP* php) { assert(php); return php->a[0]; } size_t HeapSize(HP* php) { assert(php); return php->sz; } void AdjustDown(HeapDataType* arr, int parent, int sz) { int child = parent * 2 + 1; while (child < sz) { if (child + 1 < sz && arr[child] < arr[child + 1]) { child++; } if (arr[child] > arr[parent]) { swap(&arr[child], &arr[parent]); parent = child; child = parent * 2 + 1; } else { break; } } } void AdjustUp(HeapDataType* arr, int child) { int parent = (child - 1) / 2; while (child > 0) { if (arr[child] > arr[parent]) { swap(&arr[child], &arr[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } }
三、经典“TopK”问题
学到堆这个数据结构怎么可能少得了“TopK”问题呢?非程序员可能不一定听过这个名词,但是在这个世界上的万事万物都存在着“TopK”问题,“TopK”问题就是经典的选数问题,选取最大的K个数,就举一个很简单的生活中的例子:假如说你现在饿了,想要点个外卖,那你怎么点呢?打开美团或者饿了么,你肯定点好吃的店吧!!那美团上有那么多的外卖店,你怎么选到好吃的店呢?是不是可以通过好评率来选取啊,好评率前五的店肯定不错吧!那你怎么看到这几家好评率前五的店呢?这就是经典的“TopK”问题。
有没有发现我们的堆天生就是用来干这事的?大堆的堆顶不就是最大的数吗?不也就是好评率最高的店吗?得到了这个堆顶的元素后pop一下第二大的数不也就到达了堆顶的位置了吗?以此类推,前K个最大的数不就选出来了!
把一堆数插入到一个大(小)堆里确实可以做到选取前K个最大(小)的数,但是你有没有想过,如果现在有一百亿个数据,让你选取前K个最大的数呢?要建一个一百亿个数据的堆是不是要把这一百亿个数据存在内存中,显然,内存的大小是有限的,无法存储过多的数据,那这种直接建堆选数的方法就走不通了。
有人就发明了这样的一个方法:我建一个k个数的堆,然后把剩下的存在磁盘中的数依次和堆顶的元素进行比较,比它大就插入到这个堆中(因为是选最大的前K个数),最后得到的这个堆不就是最大的前K个数吗?
但是这里还有一个小细节需要注意的哦,那就是选取最大的前K个数是建一个K个数的大堆呢还是小堆呢?思考5秒钟。很多人会认为,这是什么问题啊?选最大的前K个数那肯定是建大堆啊,这还用想?那我告诉你,如果真的是这样的话,我也就不会让你思考了,事实上,这里是建小堆。为什么呢?
你试想一下,如果建大堆,这一百亿个数中第一个数就是最大的数,那它就一定在这个大堆的堆顶,那其它的数来跟它比较的时候,全都比这个堆顶的元素小,所以其它的元素就不可能进得了这个堆了,所以最后这个堆里的数也就不是最大的前K个数。
但是建小堆就不一样了,剩下的在磁盘中的数据比堆顶的元素大就进堆,但是由于这个是小堆,所以这个堆顶的大的数一定会往下沉的,即不会堵在堆顶,这个数越大就在堆的越底下,所以等所有的数都对比完了之后堆里的k个数也就是最大的前K个数了。是不是很妙啊!这就是大佬的思维!!!!
接下来的难点就是建堆了,别人随便给你一个磁盘,里面有一百亿个数据,叫你找出最大的前K个,你需要随便拿取K个数,然后建一个小堆,再把一百亿个数遍历一遍,插入到这个小堆中,得到这K个数,但是你现在没有这个堆的数据结构啊,你不可能每次选数都手撕一个堆的增删查改的数据结构吧!这样也太慢了,其实这里只是让你选取这最大的前K个数而已,并不需要堆的其它功能,所以没必要写一个堆的数据结构,并且人家还可能要求你不能另外开辟空间呢!
基于这个原因,大佬们又想了一个原地建堆的方法,不用写堆的数据结构,就通过操作这个数组,使它变成堆,然后再把其它数插进来就可以了。
分别是以下两种方法:
1、向上调整建堆:从第二个数开始,往后的每一个数都向上调整一遍,就建成了一个小堆。
见下图:
向上调整的时间复杂度为:O(N*logN) (以2为底),证明如下:
2、向下调整建堆:可不可以从堆顶的元素开始向下调整呢?答案是不可以的,因为向下调整的要求是每一颗子树都得是一个小堆,所以从堆顶的元素开始向下调整是会有问题的。
所以大佬又想出了从最后一个叶子结点的父节点开始往上迭代,每一个结点都向下调整,形成一个个的小堆,直到堆顶的元素。
最后整体就是一个小堆。(注意,这里是建一个小堆,所以向下调整应该和子节点中小的那个交换,才能保证堆的性质)。
见下图:
向下调整的时间复杂度为:O(N)(以2为底),证明如下:
所以用数组建堆的的话建议用向下调整,能够大大地提高效率,因为O(N)和O(N*logN)的差距还是挺大的。
TopK参考代码:
void test_TopK(void) { srand((unsigned int)time(NULL)); //写文件 int i = 0; FILE* fout = fopen("test_TopK.txt", "w"); if (fout == NULL) { perror("open fail"); return; } int n = 1000; for (i = 0; i < n; i++) { int ret = rand() % 1000; fprintf(fout, "%d\n", ret); } fclose(fout); fout = NULL; //读文件并且建堆 FILE* fin = fopen("test_TopK.txt", "r"); if (fin == NULL) { perror("fin fail"); return; } int val = 0; int k = 0; scanf("%d", &k); int* a = (int*)malloc(sizeof(int) * k); for (i = 0; i < k; i++) { fscanf(fin, "%d", &a[i]); } for (i = (k - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, i, k); } //选取前TopK个数 while (fscanf(fin, "%d", &val) != EOF) { if (val > a[0]) { a[0] = val; AdjustDown(a, 0, k); } } //这个数组的k个数就是最大的前K个数了 //这样就选出来了。 for (i = 0; i < k; i++) { printf("%d ", a[i]); } printf("\n"); fclose(fin); fin = NULL; free(a); a = NULL; } int main() { test_TopK(); return 0; }