数据结构之二叉树-堆
目录
前言
一、堆的概念
二、堆的代码结构
三、堆的相关方法实现
1、堆的初始化
2、堆的销毁
3、堆的插入数据(向上调整算法)
3.1 插入数据的代码实现
3.2 测试堆的插入数据代码
4、堆的删除数据(向下调整算法)
4.1 删除数据的代码实现
4.2 测试堆的删除数据代码
5、获取堆顶数据
6、堆判空
6.4 获取前k个最小数(例子)
四、堆的应用
1、堆排序
1.1 空间复杂度O(N) = N的代码
1.2 空间复杂度为O(N) = 1的代码
1.3 堆排序时间复杂度计算
2、TOP-K问题
结束语
前言
上一篇文章数据结构之二叉树-初见介绍我们又见到了一个新的数据结构-二叉树,并且对树以及二叉树进行了介绍,但还没有具体实现二叉树。本篇文章主要就是讲解实现顺序结构二叉树-堆。=
一般堆使用顺序结构的数组来存储数据,堆是一种特殊的二叉树,具有二叉树的特性的同时,还具备其他的特性。
一、堆的概念
如果有一个关键码的集合 K = { k0,k1,k2,...,kn-1 },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:
且
,i= 0、1、2...,则称为小堆(或大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。


通过上面的图我们可以知道,堆具有以下性质:
(1)堆中某个结点的值总是不大于或不小于其父结点的值;
(2)堆总是一棵完全二叉树。
二、堆的代码结构
//Heap.h
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>typedef int HPDataType;typedef struct Heap
{HPDataType* arr;int size;//当前堆的数据个数int capacity; //总空间大小
}Heap;
由上面的代码我们会发现堆的结构和之前顺序表的结构基本是完全一样的,原因在于顺序表是基于数组实现的,而二叉树虽然在逻辑结构上是一棵倒挂着的树,但物理结构上还是数组,也就是说堆的底层逻辑还是数组结构。
三、堆的相关方法实现
1、堆的初始化
//Heap.h
//堆的初始化
void HPInit(Heap* php);//Heap.c
//堆的初始化
void HPInit(Heap* php)
{assert(php);php->arr = NULL;php->size = 0;php->capacity = 0;
}
2、堆的销毁
//Heap.h
//堆的销毁
void HPDestroy(Heap* php);//Heap.c
//堆的销毁
void HPDestroy(Heap* php)
{assert(php);free(php->arr);php->arr = NULL;php->size = 0;php->capacity = 0;
}
堆的初始化、销毁和顺序表没什么区别,逻辑是一样的,在这里就不重复说明了,堆主要是讲解插入删除数据的方法。
3、堆的插入数据(向上调整算法)
3.1 插入数据的代码实现
首先我们要知道向上调整算法是什么一个逻辑:
(1)先将元素插入到堆的末尾,即最后一个孩子之后;
(2)插入之后如果堆的性质遭到破坏,将新插入结点顺着其父亲结点往上一直调整到合适位置即可。

就如上图例子所示,由于我们知道堆当前数据个数,所以插入的孩子结点下标知道,所以我们就可以算出这个孩子对应的父亲结点的下标,如果父亲结点数据大于孩子,则不满足小堆的性质,需要将孩子与父亲的数据进行交换,将这个过程一直重复知道父亲结点数据小于等于孩子即可。
//Heap.h
//向上调整算法
void AdjustUp(HPDataType* arr, int child);
//交换父子结点位置
void Swap(HPDataType* child, HPDataType* father);
//堆的插入数据(向上调整算法)
void HPPush(Heap* php, HPDataType x);
//判断数组否有空间或者空间够不够
void HPCheckCapacity(Heap* php);//Heap.c
//判断数组否有空间或者空间够不够
void HPCheckCapacity(Heap* php)
{if (php->size == php->capacity){int newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;HPDataType* tmp = (HPDataType*)realloc(php->arr, sizeof(HPDataType) * newcapacity);assert(tmp);php->arr = tmp;php->capacity = newcapacity;}
}//交换父子结点位置
void Swap(HPDataType* child, HPDataType* father)
{HPDataType tmp = *child;*child = *father;*father = tmp;
}//向上调整算法
void AdjustUp(HPDataType* arr, int child)
{while (child > 0){int father = (child - 1) / 2;if (arr[child] < arr[father]) //不满足堆的要求,需要交换父子结点{Swap(&arr[child], &arr[father]);//由于要改变实参数组的数据,所以要地址传参child = father;}else //某次满足堆的条件即可跳出循环{break;}}
}//堆的插入数据(向上调整算法)
void HPPush(Heap* php, HPDataType x)
{assert(php);HPCheckCapacity(php);php->arr[php->size] = x;php->size++;AdjustUp(php->arr, php->size - 1);//第二个形参传递的是数组的下标,需要注意
}
3.2 测试堆的插入数据代码
//Test.c
#include "Heap.h"void Test1()
{int arr[] = { 4, 2, 8, 1, 5, 6, 9, 7 };//创建一个数组,将数组变成小堆Heap hp; //创建堆HPInit(&hp); //初始化堆for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++){HPPush(&hp, arr[i]); //将数组的元素放入堆中}//此时数组的元素在堆里面就是以小堆的形式进行排序
}int main()
{Test1();return 0;
}

通过调试代码我们发现堆和我们创建的数组存放的数据顺序的确发生了改变,但是不是以小堆的形式存放的呢?我们可以把数组以树的形式表示出来:

我们会发现每个父亲结点的数据都小于对应孩子节点,这就符合了小堆的性质。
但是我们要知道的是:之所以while循环是从数组的第一个元素开始向上调整而不是从数组末尾往前向上调整的本质原因就是向上调整的前提必须保证该数据以上的左、右两边满足堆的性质,才能进行向上调整。
如果直接从数组末尾开始往前进行向上调整,由于该数据上面可能不满足堆的性质,就会导致最后调整完不是堆。所以我们必须从头开始向上调整,这样就能保证后续数据在进行调整时,上面的左、右两边满足堆的性质,从而实现建堆。
4、堆的删除数据(向下调整算法)
4.1 删除数据的代码实现
首先我们要知道删除堆是删除堆顶的数据,那有些人就会说删除堆顶数据这不简单,不就是数组的头删数据吗,将数组的所有数据全部往前移一位不就可以了。的确如果只是数组的话就是这样,但是堆就不同,如果按照这个方法删除头部数据可能就会导致以下结果:

由于直接头删数据让所有数据往前移一位就会导致所有数据的父子关系全部发生改变,可能就会导致父亲结点数据大于对应孩子结点的情况,导致不满足小堆性质。
所以我们考虑如下方法实现堆删除:
将堆顶的数据跟最后一个数据进行交换,然后删除数组最后一个数据,再进行向下调整算法。

这样交换的好处首先是删除堆顶数据就变成了删除尾部数据,而尾删非常简单,只需要 size-- 即可,而且删除数据后不会对其他所有数据的父子关系进行改变,也就是说除开堆顶其他数据都仍然满足小堆性质。
那也就是说我们需要对新的堆顶数据进行移位使整个数组变成小堆,这个方法就是向下调整算法。

这个方法就和上面的插入数据的向上调整算法非常类似了,只是从已知孩子结点下标计算对应父亲结点下标,变成了已知父亲结点下标计算对应左、右孩子节点下标。
不同于上面方法的是,父亲是与两个孩子中较小的孩子进行交换位置,所以还需要判断两个孩子的大小,具体的代码如下:
//Heap.h
//向下调整算法
void AdjustDown(HPDataType* arr, int n);
//堆的删除(向下调整算法)
void HPPop(Heap* php);//Heap.c
//向下调整算法
void AdjustDown(HPDataType* arr, int n)
{int father = 0;while (2 * father + 1 < n)//当一个父亲的左孩子下标超出当前堆最后一个数据下标时,//也就是说这个父亲没有孩子,也就是说已经调整完成了{int leftchild = 2 * father + 1;int rightchild = 2 * father + 2;if (arr[father] > arr[leftchild] || (arr[father] > arr[rightchild] && rightchild < n))//如果父亲大于孩子则需要调整位置,如果是右孩子则还需要判断此时父亲是否还有右孩子{if (arr[leftchild] > arr[rightchild] && rightchild < n)//当左孩子较大时,父亲与右孩子进行交换,但是如果此时父亲没有右孩子则必须与左孩子交换{Swap(&arr[father], &arr[rightchild]);father = rightchild;}else{Swap(&arr[father], &arr[leftchild]);father = leftchild;}}else//只要当父亲同时小于两个孩子则调整完成了{break;}}
}//堆的删除(向下调整算法)
void HPPop(Heap* php)
{assert(php);assert(php->size); //堆没有数据不能进行删除Swap(&(php->arr[0]), &(php->arr[php->size - 1]));php->size--; //这个代码相当于就是把数组尾部数据进行删除AdjustDown(php->arr, php->size);
}
4.2 测试堆的删除数据代码
//Test.c
void Test1()
{int arr[] = { 10, 15, 19, 25, 18, 34, 65, 49, 27, 37, 28 };//创建一个数组,将数组变成小堆Heap hp; //创建堆HPInit(&hp); //初始化堆for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++){HPPush(&hp, arr[i]); //将数组的元素放入堆中}//此时数组的元素在堆里面就是以小堆的形式进行排序HPPop(&hp); //删除堆顶数据
}int main()
{Test1();return 0;
}

这是将堆顶的数据跟最后一个数据进行交换,然后删除数组最后一个数据的调试代码,与上面第一个图的结果是一样的。

这是向下调整算法实现完后的调试代码,我们会发现原本下标为0的数据28已经调整到下标为4的位置了,和图的结果完全一致,也就是说此时数组满足了小堆性质,完成了堆删除数据代码。
而且在这里我们还会发现堆中第二小的数据15跑到了堆顶位置,这其实也解释了为什么我们堆的删除数据是删除堆顶数据而不是随意删除,原因就是随意删除数据没有任何实际意义,而删除一次堆顶数据也就是最小的数据,我们就能找到堆中第二小的数据,再删我们就能找到第三小的数据,以此类推。这样我们就可以目的性的得到有序的数据个数(比如获取堆中最小的前十个数据)
5、获取堆顶数据
//Heap.h
//获取堆顶数据
HPDataType HPTop(Heap* php);//Heap.c
//获取堆顶数据
HPDataType HPTop(Heap* php)
{assert(php);assert(php->size);return php->arr[0];
}
6、堆判空
//Heap.h
//堆判空
bool HPEmpty(Heap* php);//Heap.c
//堆判空
bool HPEmpty(Heap* php)
{assert(php);return php->size == 0;
}
6.4 获取前k个最小数(例子)
通过获取堆顶数据和堆删除的代码,我们就可以目的性的得到有序的数据个数,比如给一个任意排序的数组,让你获取前十个最小数:
//Test.c
void Test1()
{int arr[] = { 10, 15, 19, 25, 18, 34, 65, 49, 27, 37, 28 };//创建一个数组,将数组变成小堆Heap hp; //创建堆HPInit(&hp); //初始化堆for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++){HPPush(&hp, arr[i]); //将数组的元素放入堆中}//此时数组的元素在堆里面就是以小堆的形式进行排序//获取数组前k个最小数int k = 0;scanf("%d", &k);while (k--){printf("%d ", HPTop(&hp)); //打印的形式进行排序HPPop(&hp);}printf("\n");
}int main()
{Test1();return 0;
}

我们就会发现用堆来目的性的得到有序的数据个数非常的便捷,不仅如此,用堆来实现的时间复杂度也非常小,在最坏情况也就是:。
而如果要找数组中前k个最大数只需要将数组变成大堆即可,也就只需要将向上和向下调整算法里面的父子关系大小相反即可。
四、堆的应用
1、堆排序
通过上面的获取堆顶数据和堆删除的代码,我们可以目的性的得到有序的数据个数,但是这个有序的结果只是在打印的形式进行展示,并没有对原数组进行修改,所以我们需要将排好序的数据再存放到数组中。
1.1 空间复杂度O(N) = N的代码
//Test.c
void Test1()
{int arr[] = { 4, 2, 8, 1, 5, 6, 9, 7 };//创建一个数组,将数组变成小堆Heap hp; //创建堆HPInit(&hp); //初始化堆for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++){HPPush(&hp, arr[i]); //将数组的元素放入堆中}//此时数组的元素在堆里面就是以小堆的形式进行排序//堆排序for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++){arr[i] = HPTop(&hp);HPPop(&hp);}for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++){printf("%d ", arr[i]);}
}int main()
{Test1();return 0;
}

通过打印的结果我们会发现此时数组的数据就是以升序进行排列的,原因就是小堆进行获取堆顶数据和堆删除的代码得到的就是最小的数据。
但是我们会发现由于我们创建了一个堆进行存放数组数据,就会导致该代码的空间复杂度为:O(N) = N。
那我们能不能让空间复杂度为1,也就是说在不额外创建堆的同时使得数组有序存放数据,答案是可以的,也就是下面一个方法。
1.2 空间复杂度为O(N) = 1的代码
我们可以在不创建新的堆存放数组数据的情况下,直接对原数组的数据进行操作。
假设我们想要得到升序的数组,我们是要将数组变成大堆还是小堆呢?
有些人看了上面的知识就会猜想应该是变成小堆吧,因为小堆每次获取的都是堆顶数据,而小堆的堆顶都是较小数,挨个取不就得到升序数组吗。但是我们想一下这个方法的前提就是需要一个新堆来存放数组数据,每次获取到堆顶数据后放入原数组对应位置,再进行删除堆得到次小数据,这样是不会影响原数组内容的。
但是此时只有一个原数组,是没法取出数据的。所以我们想一下变成大堆能不能实现升序?我们还是以上面数组 int arr[] = { 4, 2, 8, 1, 5, 6, 9, 7 } 为例:
我们先while循环通过向上调整算法将原数组变成大堆形式;

此时变成了大堆形式后我们想一下怎么让数组变成升序形式呢?我们到现在也就学习了堆的插入(向上调整算法)和堆的删除(向下调整算法),前者已经用了,那我们尝试一下后者能不能实现升序:
我们先进行一次堆的删除代码(包括数组首尾交换、size--、向下调整算法);

在这里我再强调一句就是这个删除尾部数据并非真正意义上的删除,而且数组内的数据我们也不能删除,而只是将size--,使得我们自己访问不了"被删除"的数据。而正是这样我们得以完成升序操作,此时我们对数组再次进行堆的删除代码:

我们就会发现此时"被删除"的数据就是以升序进行存放的,所以当我们再用while循环堆的删除代码时,则我们就可以实现所有数据以升序进行存放了。具体代码如下:
//Test.c
void HeapSort(HPDataType* arr, int n)
{for (int i = 1; i < n; i++){AdjustUp(arr, i); //实现升序排序,将原数组变成大堆}int num = n; //存放n,用于后续打印数组验证堆排序效果while(n > 1) //数组只要一个数据时则不需要操作{Swap(&arr[0], &arr[n - 1]);n--;AdjustDown(arr, n);}for (int i = 0; i < num; i++){printf("%d ", arr[i]);}
}void Test2()
{int arr[] = { 4, 2, 8, 1, 5, 6, 9, 7 };HeapSort(arr, sizeof(arr) / sizeof(arr[0]));
}int main()
{//Test1();Test2();return 0;
}

要是实现数组降序排序,则需要将数组先变成小堆,再一样while循环进行上述堆的删除代码,需要注意的是要将向上调整算法和向下调整算法中的父子关系进行对调。
由于我们知道在算法中尽量要让算法复杂度小,所以我们能不创建新的堆就尽量不创建,直接对原数组进行修改即可,所以方法二需要好好消化吸收。
1.3 堆排序时间复杂度计算

分析:
第1层,个结点,交换到根结点后,需要向下移动0层
第2层,个结点,交换到根结点后,需要向下移动1层
第3层, 个结点,交换到根结点后,需要向下移动2层
第4层,个结点,交换到根结点后,需要向下移动3层
……
第h层, 个结点,交换到根结点后,需要向下移动h-1层
通过分析发现,堆排序第二个循环中的向下调整与建堆中的向上调整算法时间复杂度计算一致,此处不再赘述。
因此,堆排序的时间复杂度为:O(n 十 n * ),即 O(n *
)
而在C语言中我们学习了冒泡排序,冒泡排序的时间复杂度为:O(n^2),所以两者对比我们就知道了为什么会有堆这个东西,不是凭空就出现的,而是用堆进行排序的效率是远远高于冒泡排序的。
2、TOP-K问题
这个问题其实就是在一堆数据中获取前k个最大或者最小数,但一般情况下数据量都比较大。在上面我们已经讲解了获取前k个最小数的一个例子,逻辑就是先将创建一个空堆,将数组每个数据存放在空堆中并用向上调整算法进行建堆,然后获取堆顶数据再进行堆的删除,将这个操作while循环进行k次则可以获取到前k个最大或最小数了。
这个的确也是一个解决TOP-K问题的方法,但是会有个致命的问题:虽然数据是存放在磁盘文件中的,而磁盘文件相较于内存是非常大的,所以不需要担心数据过多的情况;但是我们知道创建的堆是存放在电脑的内存中的。假设此时有一个例子总共有十亿个 int 类型的数据从中获取前10个最大值,如果按照上面的方法我们就需要创建一个堆来存放这些十亿个数据,十亿个 int 类型数据通过换算大概是4GB的大小,这就需要在内存中开辟这么大的空间,这是非常消耗内存空间的。
所以一般如果总数据个数不大的情况直接堆排序就可以解决问题,但如果遇到数据量非常大的情况时我们就需要用其他方法来解决。
接下来我所讲解的方法非常的妙,可以通过利用极小的内存空间就能解决TOP-K问题,我们以获取前k个最大数为例:
(1)首先我们还是要先建一个堆,但是不同于上面把所有数据都存放堆中,我们只需要取前k个数据进行建堆。如果是获取前k个最大数则建小堆;如果是获取前k个最小数则建大堆。为什么要这样等会就会详细解释。
(2)再用剩余的 N-K 个元素依次与堆顶元素来比较,如果大于堆顶数据则替换堆顶元素。
(3)替换完之后再利用向下调整算法重新变成小堆,将上述操作循环执行 N-K 次。
将剩余 N-K 个元素依次与堆顶元素比完之后,堆中的K个元素就是所求的前K个最大的元素。
原理其实很简单,这其实就是利用了小堆一个性质:堆顶数据在当前堆中是最小的。也就是说我们要获取前k个最大值则建小堆,用剩余数据依次和堆顶数据比较,如果大于堆顶数据则替换堆顶元素,再用向下调整算法重新变成小堆后,堆顶数据就是第二小的,以此类推我们就可以把所有较小数进行覆盖,到最后只留下前k个最大值也就是堆中数据。但要注意的点就是此时这k个数并不一定是有序的,只是获取到了前k个最大值。具体代码如下:
//Test.c
#include <time.h>
void CreateNData()
{//通过rand获取100000个随机数并存放到文件中int n = 10000;srand((unsigned int)time(NULL));const char* file = "data.txt";FILE* fin = fopen(file, "w");if (fin == NULL)//打开失败则返回{perror("fopen error");return;}for (int i = 0; i < n; i++){int x = (rand() + i) % 100000; //使数据约束在100000以内并保证数据更加随机fprintf(fin, "%d\n", x); //换行符的作用是后续取出数据放入堆中时相当于将两个数进行分开}fclose(fin);
}//TOP-K问题
void Test3()
{int k = 0;printf("请输入k的值:");scanf("%d", &k);int* kheap = (int*)malloc(sizeof(int) * k);const char* file = "data.txt";FILE* fout = fopen(file, "r");if (fout == NULL)//打开失败则返回{perror("fopen error");return;}for (int i = 0; i < k; i++){fscanf(fout, "%d", &kheap[i]);//将前k个数据放入堆中}for (int i = 1; i < k; i++){AdjustUp(kheap, i); //建小堆}//用剩余的 N-K 个元素依次与堆顶元素来比较,如果大于堆顶数据则替换堆顶元素int x = 0;while (fscanf(fout, "%d", &x) != EOF)//fscanf当读取到文件末尾结束返回EOF(-1){if (x > kheap[0]){kheap[0] = x; //大于堆顶数据则替换堆顶元素AdjustDown(kheap, k); //替换后重新变成小堆}}printf("前%d个最大数为:\n", k);for (int i = 0; i < k; i++){printf("%d\n", kheap[i]);}
}int main()
{//TOP-K问题CreateNData();Test3();return 0;
}

看到打印结果有些人就会说你怎么证明打印的10个数就是这些数据中前10个最大值,也可能不是呢。这个解决方法其实很简单,由于我们限制了随机数的范围是小于100000,所有只需要在"data.txt"文件中修改其中10个数字使其超过100000,如果打印结果正好是这修改的10个数就说明我们代码是正确的,如果不是则说明有问题。

通过上面打印结果我们会发现的确满足我们的预期,说明代码没有问题了。
结束语
到此,二叉树中以顺序结构实现的堆我们就讲解完了,相较于前面的栈和队列,二叉树的整体难度是比较大的,不管是逻辑上还是应用方面都需要仔细思考理解。尤其是非常经典的堆排序以及TOP-K问题,不管是怎么解决问题还是在解决问题的基础上优化方法,都需要好好消化吸收。下一篇文章我将会为大家讲解以链式结构实现二叉树。希望这篇文章对大家学习二叉树能有所帮助!
