前言:
堆的结构:
下图为完全二叉数的结构
下图是满二叉树的结构
满二叉树是一种特殊的完全二叉树,其中堆属于完全二叉树。
堆的性质★:
堆的底层结构是数组,对于具有 n 个结点的完全⼆叉树,如果按照从上⾄下从左⾄右的数组顺序对所有结点从0 开始编号,则对于序号为 i 的结点有
①:若 i>0 ,i 位置结点的父节点序号: (i-1) / 2 ;若 i=0 ,则 i 为根结点编号,无父结点
②:若 2i+1<n ,左子节点序号: 2i+1 ; 若 2i+1>=n 则 i 节点处 无左子节点
③:若 2i+2<n ,右子节点序号: 2i+2 ; 若 2i+1>=n 则 i 节点处 无右子节点
小堆和大堆:
堆有两种:分别是小堆和大堆
小堆:
子节点的数总是大于其父节点的数。
大堆:
子节点的数总是小于其父节点的数
综上:堆中数据一定遵循上述两种排序方式之一。
堆的实现:
头文件:
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int HDatatype;
typedef struct HeapNode //堆的底层是数组,这里通过顺序表的方式来实现堆
{ //因此,堆的初始化和销毁与顺序表的初始化和销毁完全一致
HDatatype* arr;
int size;
int capacity;
}Heap;
//堆的初始化
void HeapInit(Heap* php);
//堆的销毁
void HeapDestroy(Heap* php);
//堆的插入 尾插
void HeapPush(Heap* php,HDatatype x);
//出堆顶数据
void HeapPop(Heap* php);
//堆顶数据的读取
HDatatype HeapTop(Heap* php);
//堆中数据个数
int HeapSize(Heap* php);
//向下调整
void AdjustDown(HDatatype* arr, int parent, int n);
//交换数组中的元素顺序
void swap(HDatatype* x, HDatatype* y);
测试文件:
#define _CRT_SECURE_NO_WARNINGS
#include "heap.h"
#include <time.h>
void test()
{
Heap s; //创建一个堆
HeapInit(&s); //初始化堆
int arr[] = { 15,17,13,20,19,10 };
for (int i = 0; i < 6; i++)
{
HeapPush(&s, arr[i]);//循环往堆中插入数据
}
for (int i = 0; i < 6; i++)
{
printf("%d ", s.arr[i]); //打印插入的数据
}
printf("\n");
printf("%d\n",HeapSize(&s)); //堆中数据个数
while (s.size > 0) //此循环实现的是 通过堆来实现数组的排序,后续介绍
{
printf("%d ", HeapTop(&s)); //取堆顶元素
HeapPop(&s); //删堆顶元素
}
HeapDestroy(&s);
}
int main()
{
test();
return 0;
}
函数实现:
1、初始化和销毁
指针置为空,数据置为零。
void HeapInit(Heap* php)
{
assert(php);
php->arr = NULL;
php->size = php->capacity = 0;
}
void HeapDestroy(Heap* php)
{
assert(php);
if (php->arr)
{
free(php->arr);
}
php->arr = NULL;
php->capacity = php->size = 0;
}
2、数据插入
思路:堆数据插入与顺序表数据插入大致类似,可以看到如下代码的前部分与顺序表一致,但是对于堆而言,堆中的数据要遵循小堆结构,或大堆结构,因此单纯的插入是不满足条件的。
void HeapPush(Heap* php, HDatatype x)
{
assert(php);
if (php->capacity == php->size)
{
int newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
HDatatype* tmp = (HDatatype*)realloc(php->arr, newcapacity * sizeof(HDatatype));
assert(tmp);
php->arr = tmp;
php->capacity = newcapacity;
}
php->arr[php->size] = x;
AdjustUp(php->arr, php->size);
php->size++;
}
堆的正确处理方法是:在每一次插入数据后,要对每个数据进行调整,因此相较于顺序表尾插而言,堆插入多了一个向上调整函数。代码如下
void AdjustUp(HDatatype* arr, int n)
{
int child = n;
int parent = (n - 1) / 2;
while (child > 0)
{
小堆
//if (arr[child] < arr[parent])
//{
// swap(&arr[child], &arr[parent]);
// child = parent;
// parent = (child - 1) / 2;
//}
//大堆
if (arr[child] > arr[parent])
{
swap(&arr[child], &arr[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
画图分析上述函数:
现将该数组 arr[] = { 15,17,13,20,19,10 }; 以大堆方式插入进堆中。
初始时,数组中无元素,因此 15 率先插入,堆长这样,注:圆旁边为下标元素
此时堆中的元素个数为 1 ,但为了方便向上调整,(数组下标为:元素个数 - 1) 先将 size 作为实参传递给形参,再后置++。
在形参中定义两个参数,child 、 parent,其中child作为形参接收 size ,而parent的初始大小对应堆的性质中的性质①(已知子节点可以求得父节点的位置);显然开始时,child为0,不进入循环,直接跳过循环,函数返回到堆插入函数后,size++,再返回到主函数,开始插入数组中第二个数据。
注:为什么要传递size呢?因为向上调整函数,是将尾部数据向上调整
主函数第二次循环将数据 17 插入到堆的尾部,此时 size 为 1,进入向上调整函数,此时 child 为 1 ,parent 为 0,结构图如下,正好对应数组下标。
此时不满足大堆排序,因此需要将两个数据进行对调,对调后child变化parent,parent变为新的子节点的父节点。调整后的堆如图:
当数据个数比较少时,child会直接变为 0,从而跳出循环。
而当数据个数表较多时,可以想象,该函数会对队尾的数据循环向上调整,直到不满足条件:
如下图,假设我们现在要在下标为 6 的位置插入 100,
则有 child = 6 ,根据性质①,parent = 2 ,100 > 13 ,下标2的数据与下标6的数据交换,交换后
child = parent = 2,根据性质①,parent = 0 ,又重新得到了新的父子关系,再通过比较发现 100>20,再次交换,最终新插入的数据到了根节点,同理小堆排序,只不过比较条件发生了变化。
通过上述代码,我们就实现了堆(大堆)的数据插入,其结果图如下:
3、出堆顶数据
思路:将堆顶数据与堆尾数据交换,新的堆顶数据不一定满足大堆或者小堆排序,因此需要用到向下调整函数,出堆顶数据函数代码如下:
void HeapPop(Heap* php)
{
assert(php);
assert(php->size);
swap(&php->arr[0], &php->arr[php->size - 1]);
php->size--;
AdjustDown(php->arr, 0, php->size);
}
向下调整函数如下:
void AdjustDown(HDatatype* arr, int parent, int n)
{
int child = parent * 2 + 1; //已知父节点 可知左子节点
while (child < n)
{
//-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
//小堆
/*if (child + 1 < n && arr[child] > arr[child + 1]) //小堆排列,找子节点中较小的
{ //数与父节点交换
child++;
}
if (arr[parent] > arr[child]) //如果子节点小于父节点,则交换父子节点
{
swap(&arr[parent], &arr[child]);
parent = child; //能够让该顶部数据持续向下调整的方式
child = 2 * parent + 1; //该节点对应的子节点
}*/
//-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
// 大堆
if (child + 1 < n && arr[child] < arr[child + 1])//与小堆排序类似
{ //需要补充的时,child + 1 不能越界
child++;
}
if (arr[parent] < arr[child])
{
swap(&arr[parent], &arr[child]);
parent = child;
child = 2 * parent + 1;
}
//-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
else
{
break;
}
}
}
思考一个问题:为什么我传这几个参数?
传数组名:数组名即为地址,我们要对数组元素进行修改,因此一定是传址。
传0:交换后,顶部数据的下标为0,我们要把顶部数据向下调整。
传数组内数据的个数:我们要对剩下的数重新进行大堆或者小堆的排列,而这个排列是不断向下进行的,因此在排列的过程中,参数child会不断变大,我们需要对child进行一定的限制,那么如何限制呢?即 child 不能 ≥ size,超了,数组就越界访问了。
分析过程:
假设有以下大堆,我们要出顶部数据:
首先 0 5 数据交换,size-- ,显然不满足大堆排序,如图所示
进入向下调整函数,此时 size 为 5,初始child = 1 , parent = 0,进入循环。
因为下标为 child 的数据 大于 下标为 child+1的数据,因此child 不变,child中数据又满足 child > parent 因此 child 和parent 中的数据交换,交换后,此时 parent = child,而child根据性质②,找到其对应的左子节点,以此循环往复,直至 child 不满足 循环条件( child < n)。
4、读取堆顶数据
读取堆顶数据时很简单的,代码如下
HDatatype HeapTop(Heap* php)
{
assert(php);
return php->arr[0];
}
而取堆顶数据+出堆顶数据,能够实现堆的排序
代码如下:
while (s.size > 0)
{
printf("%d ", HeapTop(&s));
HeapPop(&s);
}
结果如图所示:
用堆来实现排序(不通过结构体地址):
该代码是通过两次向下调整函数来实现的。
代码如下:
void Heap_paixu_test()
{
int arr[] = { 15,17,13,20,19,10 };
int n = 6;
int i = (n - 2) / 2;
for ( i; i >= 0; i--) //让数组中的元素为大堆或者小堆排列
{
AdjustDown(arr, i, n);
}
for ( i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
while (n--)//堆中元素为0时,循环结束
{
swap(&arr[0], &arr[n]);//
AdjustDown(arr, 0, n);
}
for (i = 0; i < 6; i++)
{
printf("%d ", arr[i]);
}
}
重点分析一下以下这段代码:
int arr[] = { 15,17,13,20,19,10 };
int n = 6;//数组元素个数
int i = (n - 2) / 2; 最后一个父节点
for ( i; i >= 0; i--)
{
AdjustDown(arr, i, n);
}
将数组内的元素排列成小堆或者大堆有两种方式,一种是向上,一种是向下,至于为什么选用向下排列,是因为通过数学分析后,发现向下调整函数的时间复杂度为O(N),而向上调整函数的时间复杂度为O(N*log(以2为底的N)),因此选用向下调整函数来建堆。
先前使用向下调整函数时,我们传递的参数分别为:
arr(数组名即地址),0(堆顶),n(堆中元素个数)
此函数中:
arr(数组名即地址),i(最后一个父节点),n(堆中元素个数)
因此我们可以得知,通过向下调整函数的方式来建堆,需要从倒数第一个父节点开始向下调整,每调整完一次,父节点--,移至倒数第二个父节点,以此循环,直至根节点。
其中,倒数第二层的父节点只会向下调整一次,还记得先前我们说的,每次父与子节点的数据交换后,下标会重新赋值吗?对于这一层而言,parent = child;child = parent * 2 +1 后会直接超过 n(堆中元素个数) ,当我们把这一层的父节点全部调整后,来到倒数第三层父节点时,重新开始调整。假设此时有一个节点满足判定条件,父节点跟子节点交换后, parent = child;child = parent * 2 +1 ,child 不一定会超过 n ,因此循环继续,此时 parent 下标对应原父节点数据,而 child 对应该节点处左子节点的位置,如满足判定条件则继续交换,这样就会把原先父节点的数据一直交换到底层。接下来就是如此循环往复,最终实现建堆。
分析下段代码:
while (n--)//堆中元素为0时,循环结束
{
swap(&arr[0], &arr[n]);//
AdjustDown(arr, 0, n);
}
为什么对大堆进行向下调整能够排升序,而对小堆进行向下调整能够排降序?
答:以大堆为例,当堆顶元素和堆尾元素交换时,此时堆尾元素一定是最大值,而堆顶不满足大堆排列,因此需要向下调整,直到满足大堆排列,此时新的堆顶又是最大值,同时n要--,确保是堆顶和新的堆尾进行交换,原先的最大值能够保留,如此循环下去,就能够得到升序排序。如图所示
TopK的实现:
从众多数据(几百万甚至几亿)中找到最大的或者最小的几个数,这类问题被称之为TopK问题。
此类问题的核心思想是:建堆,再用后续数据与堆顶比较,满足条件入堆,不满足则跳过该数据。
思路:
假设从20个数据中找到6个最大的数,我们先取这20个数据中的前6个数据进行建小堆
此时堆顶的数据最小,我们再从第7个数据与堆顶数据进行比较,若该数据大于堆顶数据,则交换,此时堆顶元素已被舍弃,将新的堆顶元素再进行小堆排列,得到新的小堆,此时堆顶又是最小值,而较大的值排排列在堆尾左右的位置,以此循环遍历,经过20次后,堆中一定是这20个数据中最大的五个。
找最小的6个与此类似,就不再过多分析。
通过上述思想,我们就能够解决TopK的问题。
代码如下:
//通过文本的形式来创建大量数据
void createtext()
{
const char* file = "data.txt";
srand(time(0) != EOF);
FILE* p = fopen(file, "w");
if (p == NULL)
{
perror("fopen error");
exit(1);
}
int n = 10000;
for (int i = 0; i < n; i++)
{
int x = (rand() + i) % 100000;
fprintf(p, "%d\n", x);
}
fclose(p);
p = NULL;
}
//TopK的实现
void TopK_test()
{
const char* file = "data.txt";
FILE* p = fopen(file, "r");
int k = 0;
scanf("%d", &k);
int* maxk = (int*)malloc(sizeof(int) * k);
for (int i = 0; i < k; i++)
{
fscanf(p, "%d", &maxk[i]);
}
for (int i = (k-2)/2; i >= 0; i--)
{
AdjustDown(maxk, i, k);
}
printf("建堆后的数据\n");
for (int i = 0; i < k; i++)
{
printf("%d ", maxk[i]);
}
printf("\n");
int x = 0;
while (fscanf(p, "%d", &x) != EOF)
{
//最大
/* if (x > maxk[0])
{
swap(&x, &maxk[0]);
AdjustDown(maxk, 0, k);
}*/
//最小
if (x < maxk[0])
{
swap(&x, &maxk[0]);
AdjustDown(maxk, 0, k);
}
}
printf("最小的几个数\n");
for (int i = 0; i < k; i++)
{
printf("%d ", maxk[i]);
}
}
int main()
{
createtext();
TopK_test();
return 0;
}
结果如下:
源文件中的部分数据:
注:1 2 3 4 5 9 为修改后的数据
最终结果: