(一)堆数据结构的实现
(1)堆的定义
堆是特殊的完全二叉树,分成大堆小堆。
大堆:根节点元素大小大于等于所有子节点元素的大小;
小堆:根节点元素大小小于等于所有子节点元素的大小。
二叉树的性质:
设父节点下标为parent,左孩子节点下标为childleft,右孩子节点下标为childright。
childleft = parent*2+1
childright = parent*2+2;
parent = (childleft-1)/2 or (childright-1)/2
(2)堆的实现
借助数组实现。
代码实现:
typedef int HPDatatype;
typedef struct Heap
{
HPDatatype* arr;
int size;
int capacity;
}HP;
(二)堆的相关函数的实现
(1)初始化和销毁
代码实现:
//Heap.c
//初始化函数定义
void HPInit(HP* php)
{
assert(php);
php->arr = NULL;
php->size = php->capacity = 0;
}
//销毁函数定义
void HPDestroy(HP* php)
{
assert(php);
free(php->arr);
php->arr = NULL;
php->size = php->capacity = 0;
}
(2)插入(向上调整算法)
在堆的后面插入新的节点,转化到数组插入 ,就是插入到数组下标为size的地方,可要使插入后依旧成堆,就需要进行调整。
进行调整的方法也比较简单,我们只需要让新插入的节点(孩子节点)与它的父节点进行大小的比较,一直到跟根节点比完为止。
以小堆为例,如果新插入的节点比父节点要小,那就需要交换两者的位置,交换完后,新节点来到了新位置,就会拥有新的父节点,那就继续跟父节点进行大小的比较,等到跟根节点比较完后,调整也就结束了,由于比较顺序是向上,所以我们称此为向上调整算法。

代码实现:
//Heap.c
//交换函数
void Swap(HPDatatype* x,HPDatatype* y)
{
HPDatatype tmp = *x;
*x = *y;
*y = tmp;
}
//向上调整算法
//以小堆为例
void AdjustUp(int* arr,int child)
{
int parent = (child-1)/2;
while(child > 0)
//直到跟根节点比过才能结束
{
if(arr[parent] > arr[child])
{
//需要交换
Swap(&arr[parent],&arr[child]);
child = parent;
parent = (child-1)/2;
}
else
{
//在向上调整的过程中,如果在跟根节点比较之前就出现不用比较的情况了
//那就说明,已经调整成功了,调整成功就跳出循环
break;
}
}
//堆的插入
void HPPush(HP* php,HPDatatype x)
{
assert(php);
//插入之前先要进行空间检查和内存申请
//由于堆不分头插尾插任意插,所以不必再单独包装一个空间检查函数
if(php->size == php->capacity)
{
int newcapacity = php->capacity==0?4:2*php->capacity;
HPDatatype* tmp = (HPDatatype*)realloc(php->arr,2*sizeof(HPDatatype)*newcapacity);
if(tmp == NULL)
{
perror("falied realloc");
return;
}
php->arr = tmp;
php->capacity = newcapacity;
}
//插入 数组插入
php->arr[php->size] = x;
php->size++;
//向上调整
AdjustUp(php->arr,php->size);
}
细节理解:
① while(child > 0)可以换成while(parent>=0)吗?
不行。

如图,如果让parent>=0作为循环条件,那么在最后一次比较完后,parent=(0-1)/2=0,还会进入循环进行比较,此时就越界访问了,所以while中应该写child>0(当child=0时,循环就结束了)。
②为什么AdjustUp函数的第一个参数是数组首元素指针,而不是堆的指针呢?
因为我们可以脱离构建堆的结构体,直接对数组进行建堆,所以写数组首元素指针是更好的。
后文也讲了如何直接对数组进行建堆。
(3)删除(向下调整算法)
堆的删除默认为删除堆顶元素。
我们知道数组最后一个元素的删除操作很简单,只需要size--,所以要是堆顶元素的删除能转换成最后一个元素的删除,操作起来就简单多了。
实际上方法就是这样的,将堆顶元素和最后一个元素进行交换,此时再删除最后一个元素就是删除了原先的堆顶元素,然后再进行调整,也就是调整交换后的堆顶元素的位置。
以小堆为例,将堆顶元素(父节点)与两个孩子中更大的子节点比较大小,若是堆顶元素(父节点)比更小的孩子大,那就要进行交换,直到跟叶子节点比完为止,但如果在途中出现了不需要调整位置的情况,那就说明已经调整好了,无需再继续向下了。
由于比较顺序是向下,所以我们称此为向下调整算法。
代码实现:
//Heap.c
//向下调整算法
//以小堆为例
void AdjustDown(int* arr,int size,int parent)
{
//左孩子
int child = parent*2+1;
while(child>size)
//不能写while(child+1>size)
//当只有一个左节点时也是需要比较的,同时也可以防止越界访问
{
//假设左孩子是两个孩子节点中更小的那一个
int childless = child;
if(child+1<size && arr[child]>arr[child+1])
childless = child+1;
//如果右孩子更小,那就将右孩子的下标赋给childless
if(arr[parent]>arr[childless]
{
//需要进行交换
Swap(&arr[parent],&arr[childless]);
parent = childless;
child = parent*2+1;
}
else
{
break;
}
}
}
//删除函数
void HPPop(HP* php)
{
assert(php);
//删除操作必须确保堆不为空
assert(php->size>0);
//删除
//先将堆顶和最后一个元素进行调换
Swap(&php->arr[0],&php->arr[php->size-1]);
//数组删除是收束边界
php->size--;
//调整
AdjustDown(php->arr,php->size,0);
}
细节理解:
① 为什么不能写while(child+1>size)?
在定义的时候,child是左孩子的下标,那么child+1就是右孩子的下标,之所以以左孩子的下标作为循环条件,是因为调整的时候可能只有左孩子没有右孩子,这时同样需要调整。
如果以右孩子的下标作为循环条件,那么在只有左孩子没有右孩子时,左孩子在数组的最后一个位置,右孩子已经超出了访问边界,按照当下循环的条件判断,是会跳出循环的,可实际上这种情况也需要进行调整。
所以我们以左孩子的下标作为循环条件。
②为什么if(child+1<size && arr[child]>arr[child+1])中要额外写child+1<size?
额外写child+1<size是为了防止越界访问,左孩子在数组的最后一个位置,右孩子已经超出了访问边界,如果我们还去比较左右孩子的大小,那就会存在越界访问,所以提前对右孩子的下标进行判断。
(4)取堆顶元素
//Heap.c
//取堆顶元素
int HPTop(HP* php)
{
assert(php);
return php->arr[0];
//取的就是数组下标为0的元素
}
(5)判空
//Heap.c
//判空
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0 ;
}
(三)脱离堆数据结构,直接对数组建堆
(1)向上调整建堆
在前面,我们是在已经成堆的基础上进行数据的插入,使用向上调整算法,我们就能重新建堆,那我们想再次使用向上调整建堆算法,那就要保证插入数据时,现有数据已经成堆了。
只要我们从前往后调整数组元素之间的位置,就能保证再插入数据时,前面的数据已经成堆了。
//直接对数组建堆
//Test.c
//以建小堆为例
//向上调整算法
void AdjustUp(int* arr,int child)
{
int parent = (child - 1) / 2;
while(child>0)
{
if (arr[parent] > arr[child])
{
//需要交换
Swap(&arr[parent], &arr[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void Test0()
{
int arr[10] = {15,13,14,9,10,11,12,1,2,3,4,5,6,7,8};
//数组由前往后都需要看是否需要调整
//根节点不需要调整
for(int i = 1 ;i<sizeof(arr)/sizeof(int);i++)
{
AdjustUp(arr,i);
}
//打印
for(int i = 0;i<sizeof(arr)/sizeof(int);i++)
printf("%d",arr[i]);
printf("\n");
}
int main()
{
Test01();
return 0;
}

(2)向下调整建堆
在上面讲删除堆顶元素后使用向下调整的前提是跟的子树已经成堆了,那想对数组实现向下调整建堆,就需要满足每次调整根节点元素时,它的子树已经成堆了。
那就说明想调整根节点,就要先调整它的子树,那就从后往前依次使用向下调整算法。
//Heap.c
//向下调整建堆
//以建小堆为例
//向下调整
void AdjustDown(HPDatatype* arr,int parent,int size)
{
//size参数是数组的元素个数
int childleft = parent * 2 + 1;
while (childleft < size)
{
//假设法
int small = childleft;
if (childleft + 1 < size && arr[childleft] > arr[childleft + 1])
{
small = childleft + 1;
}
if (arr[parent] > arr[small])
{
Swap(&arr[parent], &arr[small]);
parent = small;
childleft = parent * 2 + 1;
}
else
{
break;
}
}
}
void Test02()
{
int arr[] = { 15,13,14,9,10,11,12,1,2,3,4,5,6,7,8 };
int size = sizeof(int)/sizeof(int);
//第一个要调整的节点是从后向前的第一个有子树的节点
for(int i = (size-1-1)/2;i>=0;i--)
{
AdjustDown(arr,i,size);
}
for (int i = 0;i < size;i++)
printf("%d ", arr[i]);
printf("\n");
}
int main()
{
Test02();
return 0;
}

细节理解:
for(int i = (size-1-1)/2;i>=0;i--)可以写成i>0吗?
i作为待调整的父节点的下标传给AdjustDown函数,如果漏掉0,那就会导致根节点元素的位置出错,所以必须写成i>=0。
(四)排序
(1)向上调整建堆实现升序
建大堆可以找到最大值,建小堆可以找到最小值,除此之外,别的值的顺序是不能确定的,所以有一个很直白的思路,以实现升序为例,我们可以建大堆,那么第一个元素就是数组N个数据里最小的,然后再对除下标为0的数组N-1个元素再建小堆,我们就能找到次最小的数据元素,按照这个思路,遍历整个数组,就能实现升序。
思路很顺,但建这么多堆挺麻烦的。
所以我们一般都按第二种思路来:升序建大堆,第一次建完大堆后,将堆顶元素和最后一个元素的位置进行调换,这样最大的数据就来到了数组的最后一个位置,再收束边界,然后对前(N-1)个数据建大堆(就是要排好堆顶元素,写法和删除操作的交换后调整一模一样),再将新的堆顶元素和最后一个元素的位置进行调换,这样次最大的数据就来到了数组的倒数第二个位置,再收束边界,循环反复,等到数组的size=1时,就代表实现升序了。
//Test.c
//实现升序
void Test03()
{
int arr[] = { 15,13,14,9,10,11,12,1,2,3,4,5,6,7,8 };
int size = sizeof(arr)/sizeof(int);
int end = size-1;
//数组最后一个元素的下标
//第一次排序是大调整,每个元素都要看是否需要调整
for(int i = (end-1)/2;i>=0;i--)
{
AdjustDown(arr,i,size);
}
//升序的核心步骤:交换首尾+收束边界+向下调整堆顶元素
while(end)
{
Swap(&arr[0],&arr[end]);
//向下调整堆顶元素
AdjustDown(arr,0,end);
end--;
}
//打印
for (int i = 0;i < size;i++)
printf("%d ", arr[i]);
printf("\n");
}
int main()
{
Test03();
return 0;
}

细节理解:

这里为啥直接传了end给了AdjustDown?为什么先调整再收束边界?
对数组建完堆并交换完首尾元素后,就不需要再管数组末尾的元素了,所以size其实已经自减了,而end的值刚好也为end,所以传给AdjustDown是正好。
至于为什么可以先调整再收束边界,其实这个顺序无所谓,反正调整的也是前N-1个元素,而且不用改变size能直接传end,何乐而不为呢?
(2)向下调整建堆实现降序
建小堆后,堆顶的元素是最小的,和最后一个位置的元素换位置后,最小的数据就到了最后的位置,继续调整建堆再调换位置,那么次小、次次小的数据会依次往后走,等到排根节点时,就实现了降序,和建大堆实现升序的逻辑是一样的。
//Test.c
//建小堆实现降序
void Test04()
{
int arr[] = { 15,13,14,9,10,11,12,1,2,3,4,5,6,7,8 };
int size = sizeof(arr)/sizeof(int);
int end = size-1;
//数组最后一个元素的下标
//先对数组建小堆
for(int i = (end-1)/2;i>=0;i--)
{
AdjustDown(arr,i,size);
}
//建小堆实现降序的核心思路:交换首尾+收束边界+堆顶元素向下调整
while(end)
{
Swap(&arr[0],&arr[end]);
AdjustDown(arr,0,end);
end--;
}
for (int i = 0;i < sizeof(arr)/sizeof(int);i++)
printf("%d ", arr[i]);
printf("\n");
}
int main()
{
Test04();
return 0;
}

(3)topk问题
问题:找出十万个人最富有的十个人(建大堆)
思路一
可以创建一个数组,将十万个人的收入都放到数组里,再建大堆取堆顶的人,反复pop十次,就能得到最富有的十个人。
弊端很明显,创建一个能够容纳十万人都收入的数组,对空间的需求量太高了。
思路二
我们可以只创建十个元素大小的数组,建小堆,再将剩下的所有人与小堆的堆顶元素进行大小的比较,如果比堆顶元素大,就替代堆顶元素,成为新的堆顶元素,当剩下的所有人都比较完了,十个最大的数据也找出来了。
在实现topk问题前,我们先要有排序的数据,可以借用猜数字游戏中随机数的思路。
#include<time.h>
//造数据
void CreateData()
{
srand((unsigned int)time(NULL));
int n = 100000;
const char* file = "test.txt";
//打开文件
FILE* fin = fopen(file, "w");
//写内容
if (fin == NULL)
{
perror("fopen failed");
return;
}
for (int i = 0;i < n;i++)
{
//生成随机数
int x = (rand() + i) % 100000;
//本来写int x = rand()就行了
//但由于rand只能生成3万个左右的随机数,如果指定生成十万个就会有重复,所以我们加上i
//%10000,能保证随机数都在0-100000内,这样便于检查
//输出到文件里
fprintf(fin, "%d\n", x);
}
//关闭文件
fclose(fin);
}
//Topk问题
//Test.c
void Test05()
{
//核心思路:创建k个元素大小的数组,建k个元素的小堆,取随机数文件中N-k个元素与堆顶元素进行比较,大于堆顶元素就替换,小于堆顶元素就继续取元素进行比较,等N-k个元素都比较完了,那最大的k个数据就找到了
//确定k的大小
int k = 0;
printf("输入k:\n");
scanf("%d",&k);
//创建k个元素的数组
//由于k的大小是从键盘上获取的,所以得动态申请
int* kheap = (int*)malloc(sizeof(int)*k);
if(kheap == NULL)
{
perror("malloc falied");
return 0;
}
//从文件里读取k个元素放进kheap数组里
const char* file = "test.txt";
//打开文件
FILE* fout = fopen(file, "r");
//读
for (i = 0;i < k;i++)
{
fscanf(fout, "%d", &kheap[i]);
//将文件里的数据读出来放进kheap数组里
}
//建小堆
for(int i = (k-1-1)/2;i>=0;i--)
{
AdjustDown(kheap,i,k);
}
//取文件里的剩余元素依次与小堆堆顶元素进行大小的比较,如果大就覆盖堆顶的值
int x = 0;
while (fscanf(fout, "%d", &x) > 0 )
{
if (x > kheap[0])
{
kheap[0] = x;
//覆盖就行,不用交换Swap
//交换后就要重新调整堆,跟pop的操作一样
AdjustDown(kheap, 0, k);
}
}
//找到了topk个数据
//打印
for (int i = 0;i < k;i++)
printf("%d ", kheap[i]);
printf("\n");
}
int main()
{
CreateData();
Test05();
return 0;
}
测试:
我们是通过调试来测试的。



人为改动三个数,一定要记得手动保存txt文件。

找最大的三个数,打印在屏幕上的数据确实就是我们改动的那三个,说明程序可以实现topk问题。
------end------