目录
一.堆的定义
堆是以数组的方式实现,通过父结点和子结点之间的关系,来构建的一棵完全二叉树.
大堆:父结点的值都大于其子结点的值,比如说下面这棵完全二叉树
父亲(70)大于它的两个孩子(56,34),以此类推,父亲(56)大于它的两个孩子(38,27).
小堆:父结点的值都小于其子结点的值,比如说下面这棵完全二叉树
父亲(10)小于它的两个孩子(28,37),以此类推,父亲(28)小于它的两个孩子(34,78).
既然堆是一棵完全二叉树,而且以数组的形式存储,这也就意味着,它父亲和孩子之间的下标实
际上是有关联的.
我们拿上面大堆的例子进行举例
左边是我们所想的逻辑结构,而右边是在内存中实际存储的物理结构(数组).
我们可以观察到38,27(孩子)的下标,先减1,再除以2,刚好是56(父亲)的下标.
而相应的 56(父亲)的下标1,乘2加1,可以得到它左孩子(38)的下标3
乘2加2,可以得到它右孩子(27)的下标4
实际上,这个规律可以进一步推广到所有的堆
二.堆的函数介绍与实现
1.堆的结构
现在我们就可以开始从0开始构建一个堆.
首先把需要包含的头文件全部包含.
和顺序表完全相同,我们用一个结构体来表示堆,只是命名有所不同而已.
size用来记录堆中一共有多少个元素,capacity是我们开辟给数组的空间,当空间不够的时候,用
realloc重新开辟空间,这些操作都和顺序表相同,这里不多解释.
2.HeapInit(堆的初始化)
//堆的初始化
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->capacity = php->size = 0;
}
3.HeapDestroy(堆的摧毁)
//堆的摧毁
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity = php->size = 0;
}
4.HeapPush(往堆里面插入元素)
我们依旧拿前面的大堆举例子,假设现在来了一个新的数字12.
我么把它放到数组的最后面,可以发现,刚刚好,这个数组依旧可以保持一个大堆.
它的父亲(34)依旧大于左孩子(12).
但是,这并非是绝对的,假如来了一个新的数字81.
比如下图所示,则这个数组很明显就不再是一个大堆了.
所以,我们在插入一个元素的时候,除了要考虑插入问题外(是否要扩容?然后新元素直接放到最后),还需要考虑调整问题.
一个有效的方法是向上调整.(AdjustUp)
我们可以发现,即便插入一个新的元素会破坏原来的堆,但它会影响它的祖先.
比如下图的蓝色圈圈里面的数,它依旧保持一个大堆.
因此,我们只需要将新插入的数(81),沿着它祖先,逐一进行调整,直到最后形成一个大堆,便可
以停止.
总体的思路是,不断循环,通过孩子的下标,我们可以得到它父亲的下标[parent = (child - 1)/2],
将孩子的值和父亲的值进行比较.
如果孩子的值比父亲的值大,则交换两个数;否则,跳出循环.
由于交换两个数的代码后续会经常使用,所以我们也可以把它单独分装成一个函数出来.
//交换两个元素
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整
void AdjustUp(HPDataType * a, int child)
{
int parent = (child - 1) / 2;
//当孩子已经是根节点,则没必要继续调整
while (child > 0)
{
//如果孩子大于父亲
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
//调整坐标,并计算新的父亲的下标,判断是否需要进一步调整
child = parent;
parent = (child - 1) / 2;
}
else
{
//孩子小于父亲,跳出循环
break;
}
}
}
当然,这里是建大堆的向上调整代码示范,如果是建小堆,只需要将判断孩子大于父亲,改为孩子
小于父亲即可.
而且由于孩子只会和祖先进行比较,它的时间复杂度只和树高有关,即它的时间复杂度为O(logN).
实现完AdjustUp后,那往堆里面插入元素,总体的思路,我们已经理清楚.
第一步,判断数组是否需要扩容
第二步,往数组里最后一个位置插入新元素
第三步,向上调整,建成新堆.
//往堆里面插入元素
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//考虑是否需要扩容
if (php->capacity == php->size)
{
//由于初始化的时候,未指定空间大小,所以若php->caapacity == 0,则初始化开辟一个
//4个元素的空间即可.
int newcapacity = php->capacity == 0 ? 4: php->capacity *2;
int * tmp = realloc(php->a, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail.\n");
exit(-1);
}
php->a = tmp;
php->capacity = newcapacity;
}
//末尾插入元素,同时调整size
php->a[php->size] = x;
php->size++;
//向上调整,注意最后一个数的下标是size - 1
AdjustUp(php->a,php->size-1);
}
5.HeapPrint(打印堆里面的元素)
//打印堆
void HeapPrint(HP* php)
{
assert(php);
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
6.HeapPop(将堆顶元素弹出)
对于一个堆来说,堆顶元素有着重要的意义.
对于大堆来说,堆顶的元素,它一定是整个堆(数组)里面的最大值.
对于小堆来说,堆顶的元素,它一定是整个堆(数组)里面的最小值.
比如这样一个大堆,65(堆顶元素),一定是整个堆中最大的数.
利用这个性质,我们可以实现堆的应用——PopK问题.
当然在这之前,我们还需要解决HeapPop函数(将堆顶元素弹出后,仍然保证它是一个堆的难题.
依旧拿上面的大堆举例,假设我们要取出65,一个简单的想法,就是将65后面的数字全部往前移
动.
但仔细思索,我们就会发现这根本行不通.
第一,时间复杂度是O(N),假如数据非常多,时间消耗还是比较大的.
第二,移走65后,剩下的数字,根本不是一个堆.
37明显比34大,这显然不是一个大堆.
为了解决这个问题,我们必须思考出现这种现象的原因是什么.
之所以会出现上述两个问题,第一个原因是数组的头删,本身效率就不高,真正效率高的,是尾删
第二个原因是向前移动,虽然空间相对位置并没有改变,但改变了原来数组数字的相对逻辑位置.
原本49和34是一对好兄弟,结果49却背叛了39,他一直想当34的爸爸.这显然是不行的.
所以我们将堆顶元素(65),和堆最后的一个元素(18)互换.
那这时候,我们再删除65,也就非常轻松,进行的正好就是尾删的操作.
同时,也保证了原来堆中数字的相对逻辑位置不变.(左边仍然是一个堆,右边也仍旧是一个大
堆)
现在我们可以抽象出这样的一种结构,只需要把它变成堆即可.
类比AdjustUp的思路,我们可以设计出AdjustDown函数.
左堆的堆顶元素,一定是左堆中最大的数;右堆的堆顶元素,也一定是右堆最大的数.
我们一直循环,直到超出堆的长度或者不需要再调整为止(孩子小于父亲).
每次循环进行这样的操作.
第一步,找出左堆和右堆中,较大的数
第二步,和我们的数字(原来堆末尾的数字)进行交换.
49是较大的数,将18和49交换.
37是较大的数,将18和37交换.
然后我们就可以构建一个新的大堆,并且时间复杂度也是只和树的高度相关,为O(logN).
//向下调整
void AdjustDown(HPDataType * a, int size, int parent)
{
int child = (parent * 2) + 1;
//a[size]并不是堆中的元素,所以循环不需要加等于
while (child < size)
{
//先判断右孩子是否存在,若存在判断其是否大于左孩子
if (child + 1 < size && a[child] < a[child + 1])
{
//若大于左孩子,则右孩子为较大值.
child++;
}
//比较孩子
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = (parent * 2) + 1;
}
else
{
break;
}
}
}
实现完AdjustDown后,我们也基本理清HeapPop函数的实现思路.
第一步,判断数组是否是空
第二步,将堆顶元素和堆最后一个元素互换
第三步,向下调整,建成新堆.
//弹出堆顶元素
void HeapPop(HP* php)
{
assert(php);
//判断堆是否为空,无元素则可以直接报错
assert(php->size > 0);
//将堆中的第一个元素,和最后一个元素进行交换.
Swap(&php->a[0], &php->a[php->size - 1]);
//向下调整
AdjustDown(php->a, php->size, 0);
}
7.HeapTop(取堆顶元素)
HPDataType HeapTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
8.HeapSize(返回堆的大小)
//堆大小
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
9.HeapEmpty(判断堆是否为空)
//堆的判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
10.HeapCreate(创建一个堆)
PS:下面讨论的前提,都是默认会给你一堆数据,也就是一个数组,让你利用这些数据建堆.
否则,我们就需要手动自己进行录入数据.
1.向上调整建堆
我们前面其实已经介绍过HeapPush函数,通过它,我们其实就可以实现建堆的操作.
假设给了我们这样一个数组array(27,15,19,18,34,65,49,25,37)
现在我们要对其进行相应的调整,使之变成堆.
而HeapPush函数,本身的功能就是插入一个新的元素,并且把这堆数据调整成新堆.
所以,我们很容易就想到
把数组第一个元素放进堆里(一个数据也是堆),然后遍历数组,逐一HeapPush每个元素即可建堆.
具体过程,可以看下面的图进一步理解.
//创建堆(向上调整建堆)
void HeapCreate(HP* php , HPDataType* a , int size)
{
assert(php);
HeapInit(php);
for (int i = 0; i < size; ++i)
{
HeapPush(php, a[i]);
}
}
2.向下调整建堆
那可不可以,直接对这个数组进行建堆呢?
答案是可以的,利用的恰好是我们前面提到过的AdjustDown函数.
之前我们提到过这样的一种结构,我们将采取同样的思路进行建堆.
假如左边是堆,右边也是堆,那我们就可以用AdjustDown函数,将其调整为一个新堆.
那假如左边不是堆呢?
那其实就可以把它看作一个新的任务,而这个建堆任务在结构上是和之前完全相同的.
28和37不是一个大堆,那就用AdjustDown调整为一个大堆.
18,49,25不是一个大堆,那就用AdjustDown调整为一个大堆.
19,34,65不是一个大堆,同样的用 AdjustDown调整为一个大堆.
...
以此类推最后就可以将数组调整为一个堆.
有意思的是,我们并不需要从最后一个元素开始向下调整建堆.
叶子结点一定是一个堆,我们只需要从堆中最后一个结点的父亲开始调整即可.
//创建堆(向下调整建堆)
void HeapCreate(HP* php, HPDataType* a, int size)
{
assert(php);
HeapInit(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * size);
if (php->a == NULL)
{
perror("malloc fail.\n");
exit(-1);
}
//把需要建堆的数组直接拷到堆里
memcpy(php->a, a, sizeof(HPDataType)*size);
//记得及时调整堆的容量和数据个数
php->capacity = php->size = size;
//从堆中最后一个元素的父亲开始调整
for (int i = (size - 1 - 1)/2; i >= 0; --i)
{
AdjustDown(php->a, size, i);
}
}
3.两种建堆方式的时间复杂度对比
按我们的直觉理解,无论是向上还是向下调整,时间复杂度都是O(LogN)
然后有n个数,则建堆的时间复杂度都是O(NlogN).
但实际上并非如此.(下面的讨论都是用完美二叉树进行讨论,讨论最坏时间复杂度,即需要移动的
最大次数)
我们先看向下调整建堆.
向下调整建堆,是从第h-1层开始调整(堆中最后一个元素的父亲),该层有个元素,每个元素最
多需要移动1次.
第h-2层有个元素,每个元素最多需要移动2次.
...
以此类推,第2层有个元素,每个元素最多需要移动(h - 2)次.
第1层有1个元素,每个元素最多需要移动(h - 1)次.
全部加起来,即为需要移动的最多次数.
利用错位相减进行化简,再根据树高和结点个数的关系(式3)
即可得到最坏时间复杂度O(N).
接下来,我们看向上建堆.
从第2层开始向上调整,总共有个元素,每个元素最多需要移动1次
...
第h-2层有个元素,每个元素最多需要移动(h - 2)次.
第h-1层有个元素,每个元素最多需要移动(h - 1)次.
由于我们知道,最后一层所移动的总次数绝对是最多的,无论是结点个数,还是移动次数.
因此我们可以直接对最后一项估算,即可得到向上调整的时间复杂度,而不需要再用错位相减法进
行计算.
所以,向上调整建堆,时间复杂度是O(NlogN).
为什么会出现这种现象呢?
从推理的过程其实我们也可以知道原因,就是因为最后一层的结点个数最多,几乎占据一半.
而向下调整,刚好跳过了最后一层,所以移动次数就会大大减少.
4.总结
向上建堆,是从一棵小树,逐渐长成一棵大树.
向下建堆,是将几棵小树,逐渐合成一棵大树.
这个思想后续我们也会遇到.
三.堆的应用
1.堆排序(HeapSort)
堆排序,进行的首先是建堆,毕竟我们操作的对象就是一个堆.
这里我们采用向下建堆的方式(时间复杂度较小,为O(N)).
那假如我们要升序,是建大堆还是小堆呢?
假如是小堆,那堆顶元素,就是升序排列后,相应的位置.
但后面的元素,又要重新建堆,假设向下建堆,那时间复杂度也是O(N),有n个数,则时间复杂度
是O(N^2),那还不如直接用冒泡排序或者插入排序.
所以升序要用大堆
我们可以借鉴HeapPop函数的思想,将堆顶元素,放到数组的最后(也就是正确位置)
然后对堆顶元素向下调整建堆,使除了排好的元素外的元素成为一个新堆,时间复杂度为O(logN).
调整N - 1次(剩下一个元素的时候,不需要调整),时间复杂度便为O(NlogN).
总的时间复杂度为O(N + NlogN),近似可以看作O(NlogN).
//堆排序
void HeapSort(HPDataType* a, int size)
{
//向下调整建堆
for (int i = (size - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, size, i);
}
//end始终指向需要建的堆中最后一个元素
int end = size - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
同样的,如果需要降序的话,要用小堆.
2.topK问题
假设有一堆数据,总共N个,我们从中找出前K个最大的数,就被称为我们的topK问题.
这类问题在生活中其实非常常见,比如确定全世界几十亿人口中,最有钱的前十个人(乔布斯排行
榜),全省高考分数前十名(高考状元).
这个时候,采取先对所有数据排序,然后打印前k个数,显然就不现实.
而我们采取堆这种数据结构,就可以巧妙解决这个问题.
假设我们要在N个数据中(N >>>>>> K)找前K个最大的数,采取的主要思路如下:
我们先把一堆数据中的前K个数,建成小堆.
然后逐一遍历所有的数据,比堆顶数据大,就入堆,并且向下调整,成为一个新堆.
当所有数据遍历结束后,我们建的小堆里面放的数,就是前K个最大的数.
为什么这样的方式可以呢?
由于小堆堆顶的元素,必然存放整个堆中最小的数,如果不是前K个最大的数,堆顶的元素就一定会
被替掉.
它的时间复杂度是多少呢?
K个数建小堆,时间复杂度是O(K)
假设剩下的N - K个数升序排列,则每次都需要和堆顶元素交换,并向下调整.
所以总的时间复杂度为O(K + (N - K)*logK),近似可以看作O(N)
空间复杂度就是O(K).
由于N非常大,所以我们只能将其存进磁盘中,这里模拟采用的是建立相应的txt文件.
随后,第一步便是在文件中产生相应的随机数.
这里我们调用rand函数来随机产生随机数,但为了保证随机数每次产生都不一样,所以还要搭配
srand,time函数来使用.
//数据先假设为10000个
int n = 10000;
int k = 5;
srand((size_t)time(0));
//造数据
FILE* fin = fopen("Data.txt", "w");
if (fin == NULL)
{
perror("fopen fail.\n");
exit(-1);
}
for (int i = 0; i < n; ++i)
{
int val = rand();
fprintf(fin, "%d\n", val);
}
fclose(fin);
第二步,便是将前k个数,建成小堆.(采用向下调整的方式)
//将前k个数建成一个小堆
int minHeap[5];
FILE* fout = fopen("Data.txt", "r");
if (fout == NULL)
{
perror("fopen fail.\n");
exit(-1);
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minHeap[i]);
}
//向下调整建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(minHeap, k, i);
}
第三步,便是逐一遍历数据,假如比堆顶数据大,则交换堆顶元素,同时向下调整,建立新堆.
//逐一遍历数据,找出最大的数
int val = 0;
while (fscanf(fout, "%d", &val) != EOF)
{
if (val > minHeap[0])
{
minHeap[0] = val;
AdjustDown(minHeap, k, 0);
}
}
fclose(fout);
完整代码如下:
//测试N个数中找前k个数的topK问题
void TestHeap4()
{
//数据先假设为10000个
int n = 10000;
int k = 5;
srand((size_t)time(0));
//造数据
FILE* fin = fopen("Data.txt", "w");
if (fin == NULL)
{
perror("fopen fail.\n");
exit(-1);
}
for (int i = 0; i < n; ++i)
{
int val = rand();
fprintf(fin, "%d\n", val);
}
fclose(fin);
//将前k个数建成一个小堆
int minHeap[5];
FILE* fout = fopen("Data.txt", "r");
if (fout == NULL)
{
perror("fopen fail.\n");
exit(-1);
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minHeap[i]);
}
//向下调整建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(minHeap, k, i);
}
//逐一遍历数据,找出最大的数
int val = 0;
while (fscanf(fout, "%d", &val) != EOF)
{
if (val > minHeap[0])
{
minHeap[0] = val;
AdjustDown(minHeap, k, 0);
}
}
fclose(fout);
for (int i = 0; i < k; i++)
{
printf("%d ", minHeap[i]);
}
printf("\n");
}