目录
一、前言
1.1 堆的概念及结构
如果有一个关键码的集合K = { ,
,
,…,
},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:
<=
且
<=
(
>=
且
>=
)( i = 0,1, 2…),则称为小堆(大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
注:
① 关键码,在数据结构中,指的是数据元素中能起标识作用的数据项,例如,书目信息中的登陆号和书名等。
②如果你还不清楚什么是完全二叉树,可以看我的这篇博客: 【数据结构】详解二叉树
堆的性质:
① 堆中某个节点的值总是不大于或不小于其父节点的值;
② 堆总是一棵完全二叉树。
二、堆的实现(以小堆为例)
堆的本质是一棵完全二叉树(逻辑结构),但它使用数组来存贮数据(物理结构),所以我们选择和链表一样,用动态数组来存贮堆的数据。
//堆的结构
typedef int HeapDataType;
typedef struct Heap
{
HeapDataType* data;
int size;
int capacity;
}Heap;
2.1 堆的重要接口实现
堆的定义要求堆要时刻保证堆的特殊结构,即堆每时每刻都要保持是大堆或者小堆,那么如何将一串毫无顺序的数据转换成符合堆结构的顺序呢?这就需要向下调整和向上调整算法了。
2.1.1 向下调整
向下调整的前提:
根节点的左右子树必须都为堆,并且为我们想要构建的堆的类型,否则在本就无序的情况下去调整,使用根节点的值去比对查找,是无法找到适合的元素的。
向下调整的基础知识:
因为堆的物理结构是数组,所以我们都是借助下标来对堆进行操作。
对于完全二叉树而言,已知父亲节点的下标为i,则他的左孩子下标为2*i+1,右孩子下标为2*i+2。
向下调整的思路:
找出左右孩子中小的那一个,如果较小的孩子比父亲小,就与父亲节点交换,然后将较小的孩子当作父亲,再次找出左右孩子中小的那一个,不断比较并交换,直到最后的下标超出了数组的范围(堆的大小)。
void HeapDataSwap(HeapDataType* px1, HeapDataType* px2) { assert(px1 && px2); HeapDataType tmp = *px1; *px1 = *px2; *px2 = tmp; }
因为向下调整与向上调整都会运用到交换,故在此封装一个函数便于使用。
//前提:左右子树已经是我们所需要的堆类型了 void AdjustDown(HeapDataType* data, int size, int parent) { //parent不一定是整棵树根节点,有可能为子树的根节点 assert(data); //假设左孩子为最小 int MinChild = 2 * parent + 1; //由于堆是完全二叉树,所以堆的最后一个节点的父亲节点,要不只有左孩子,要不两个孩子都有 //不可能出现只有右孩子,没有左孩子的情况,故把左孩子没有越界当作循环条件 while (MinChild < size) { //对左右孩子进行比较,判断谁才是真正较小的哪一个 //但要保证右孩子存在,如果不存在,那么左孩子就是较小的那一个 if (MinChild + 1 < size && data[MinChild + 1] < data[MinChild]) { //右孩子在数组中的下标比左孩子大1,所以让左孩子的下标加1就是右孩子了 ++MinChild; } //如果较小的孩子小于父亲就交换 if (data[MinChild] < data[parent]) { HeapDataSwap(&data[MinChild], &data[parent]); parent = MinChild; MinChild = 2 * parent + 1; } //如果不进行交换,就说明这个子树已经是我们的目标堆类型了,直接退出即可 else { break; } } }
我演示的是小堆的向下调整,如果要改为大堆的向下调整,只需要将小孩子的判断更改为大孩子的判断,并且把父亲和孩子的交换条件变为比父亲大交换就行了。
向下调整的时间复杂度:我们发现,如果我们以整棵树的根开始,且每层都进行一次交换的话。这棵树有2层,就交换1次;有3层就交换了2次......,所以在最坏的情况下,其实就交换了高度减1次(到
次,n为完全二叉树的节点个数),所以向下调整的时间复杂度为
。
2.1.2 向上调整
向上调整一般不用于建堆,而是用于在堆中插入数据,所以我们就插入数据的情况来说明向上调整。
向上调整的前提与向下调整一样,都是除了被调整的节点外,这棵树已经处于大堆或小堆了,实现思路也是相似的,不过是把从父亲找孩子变为了从孩子找父亲。
那么接下来,我们就要解决一下如何从孩子找到父亲。
因为左孩子 = 父亲 * 2 + 1,右孩子 = 父亲 * 2 + 2。
假设我们知道左孩子和右孩子下标
父亲 = (左孩子 - 1) / 2;
父亲 = (右孩子-2)/2。
其中,左孩子一定是奇数,右孩子一定为偶数,而在C语言中,一个奇数减1后除以2和与这个奇数后面相邻的一个偶数减1后除以2是相同的,因为下标为整数,偶数减1后除以2(整数的除法)所得必须为整数(向0取整),除不尽的部分直接舍弃,最后得出的结果是相同的。因此,我们不需要知道该孩子节点是左孩子还是右孩子。
知道孩子节点下标,求父亲节点下标公式如下:
父亲 = (孩子 - 1) / 2。
void AdjustUp(HeapDataType* data, int child) { assert(data); //不能把循环条件设为parent >= 0,因为parent不可能会小于0 //当child = 0时,parent = (0 - 1)/ 2 == 0,这样会死循环 while (child > 0) { int parent = (child - 1) / 2; if (data[child] < data[parent]) { HeapDataSwap(&data[child], &data[parent]); child = parent; } else { break; } } }
这里绝大部分都与向下调整相同,唯一需要特别注意的就是循环条件千万不要写成parent >= 0,原因就是因为整数的除法,我们假设child = 0,那么parent = (0 - 1)/ 2 == 0
,所以这样子会陷入死循环。
向上调整的时间复杂度为。
2.1.3 堆的创建
2.1.3.1 向下调整建堆
我们知道,向下调整是可以用来创建堆的,但是使用它的前提又是它的左右子树都为堆,这不是自相矛盾了吗?
想想看,当二叉树只有一个节点时,它是不是完全二叉树?那肯定是。那既然是完全二叉树,那它能不能是堆?是的话,是大堆还是小堆呢?答案是,当二叉树只有一个节点时,它既可以是大堆也可以是小堆。
那现在对于一棵任意的完全二叉树,把这棵二叉树的全部的叶子节点都当作子树的根,那么是不是每个叶子节点做根构成的二叉树就是堆了呢?所以说,每一个叶子节点都是一个堆,它既可以做大堆,也可以做小堆。
所以建堆的整体思路就是从完全二叉树的末尾开始,到完全二叉树的根结束,期间遍历的每个节点都执行一次向下调整,这样的话,一个堆就建好了。
//data为要建堆的数组 void CreatHeap(HeapDataType* data, int size) { int i = 0; //循环的初始值为倒数第一个非叶子节点的下标 //size - 1为最后一个节点的下标 for (i = (size - 1 - 1) / 2; i >= 0; --i) { AdjustDown(data, size, i);//向下调整 } }
因为叶子节点本身已经是堆了,所以就已经没有建堆的必要了,我们就从第一个非叶子节点开始建堆,也就是最后一个节点的父亲,故从i = (size - 1 - 1) / 2开始循环。
向下调整建堆的时间复杂度:
满二叉树是完全二叉树的一种,所以我们就满二叉树来求向下调整建堆的时间复杂度。
综上:向下建堆的时间复杂度为。
2.1.3.2 向上调整建堆
向上调整建堆与向下调整建堆相反,需要从二叉树根节点开始,到二叉树的最后一个节点结束。
因为向上调整也需要除了它本身外的所有节点已经为堆,当我们从整棵二叉树的根开始遍历,也就是将向上调整开始的节点看作一棵子二叉树的最后一个节点,这样的话,当每次执行向上调整时,这棵子二叉树除了被调整节点外的部分已经是一个堆了。
void CreatHeap(HeapDataType* data, int size)
{
int i = 0;
for (i = 0; i < size; ++i)
{
AdjustUp(data, i);
}
}
向上调整建堆的时间复杂度:
这里我们依旧用满二叉树来计算。
综上:向上建堆的时间复杂度为。
所以向下调整建堆比向上调整建堆更加优秀。
2.1.4 堆的初始化
由于堆常常被用来排序,所以可能会在队中放入一大串数据,所以堆的初始化与顺序表相比有些不同(当然你也可以使用与顺序表相同的初始化方式,然后将数据一个一个放入堆中)。
//php为堆的指针 data为存放数据的数组的首元素的指针 size为数组存放数据的多少
//如果刚开始没有数据 data可以传入NULL size可以传入0
void HeapInit(Heap* php, HeapDataType* data, int size)
{
assert(php);
assert(size >= 0);
if (size > 0)
{
assert(data);
//创建空间并拷贝数据
HeapDataType* tmp = (HeapDataType*)malloc(sizeof(HeapDataType) * size);
if (tmp == NULL)
{
printf("malloc err\n");
exit(-1);
}
php->data = tmp;
//将数组中的内容全部拷贝到堆中
memcpy(php->data, data, sizeof(HeapDataType) * size);
tmp = NULL;
php->size = php->capacity = size;
//向下调整建堆 时间复杂度为O(N)
int i = 0;
for (i = (php->size - 2) / 2; i >= 0; --i)
{
AdjustDown(php->data, php->size, i);
}
//向上调整建堆 时间复杂度为O(N*log2 N)
//int i = 0;
//for (i = 1; i < php->size; ++i)
//{
// AdjustUp(php->data, i);
//}
}
else
{
php->data = NULL;
php->size = php->capacity = 0;
}
}
2.1.5 在堆中插入数据
由于堆是在数组中存贮数据,所以最适合堆的插入方式为尾插。但是插入数据并不是仅仅把数据放在堆尾,我们还要保证尾插入数据后堆的性质没改变,即堆插入数据后还是一个堆。这就需要再次构建一次堆。
//检查堆的容量 static void HeapCapacityCheck(Heap* php) { assert(php); if (php->capacity == php->size) { int newcapacity = php->capacity == 0 ? 5 : php->capacity * 2; HeapDataType* tmp = (HeapDataType*)realloc(php->data, sizeof(HeapDataType) * newcapacity); if (tmp == NULL) { printf("realloc err\n"); exit(-1); } php->data = tmp; tmp = NULL; php->capacity = newcapacity; } }
//插入数据 void HeapPush(Heap* php, const HeapDataType value) { assert(php); HeapCapacityCheck(php); php->data[php->size] = value; ++php->size; //将新添的数据变成堆的一部分 让堆符合它的结构特性 AdjustUp(php->data, php->size - 1); }
这里我推荐通过向上调整来完成再次建堆,因为在插入数据前,堆已经完成了初始化,它已经符合了堆的结构,使用向上调整只需要对新插入的数据进行处理就行了;但如果采用向下调整,就需要再次从第一非叶子节点开始调整,到根节点结束,就没有利用在插入数据前,堆已经是堆的这个条件了,相当于要从头开始再建一遍堆。
2.1.6 删除堆顶的数据
我们想想,数组首的数据好删吗?那肯定是不好删的,因为如果直接删除数组首端的数据就需要我们将其他数据都要向数组首端移动,就像顺序表一样,这样的消耗太大了。但是数组的尾删是非常好删的,只需要让下标减小,就能达到删除数据的效果。那我们能不能让头删变成尾删呢?交换一下首尾的数据不久好了吗。但这样堆的结构不久改变了吗?那我们再建一次堆不久行了吗。而且我们要用向下调整,因为我们是对根进行调整,且根的左右子树还是堆啊。这样只需要调整一次就行了,与在堆中插入数据的思路恰恰相反。
void HeapPop(Heap* php)
{
assert(php);
//没有数据就不需要删了
assert(php->size > 0);
//交换首位数据
HeapDataSwap(&php->data[0], &php->data[php->size - 1]);
--php->size;
//对根进行向下调整
AdjustDown(php->data, php->size, 0);
}
所以我们在堆中插入数据时用向上调整,在堆中删除数据时用向下调整。
2.2 堆一些次要接口的实现
堆的这些次要接口实现相对简单,就不一一讲解了。
2.2.1 打印堆内数据
void HeapPrint(const Heap* php)
{
assert(php);
int i = 0;
for (i = 0; i < php->size; ++i)
{
printf("%d ", php->data[i]);
}
printf("\n");
}
2.2.2 堆的销毁
void HeapDestroy(Heap* php)
{
assert(php);
free(php->data);
php->data = NULL;
php->size = php->capacity = 0;
}
2.2.3 获取堆顶数据
HeapDataType HeapTop(const Heap* php)
{
assert(php);
assert(php->size > 0);
return php->data[0];
}
2.2.4 查询堆中数据的多少
int HeapSize(const Heap* php)
{
assert(php);
return php->size;
}
2.2.5 判断堆是否为空
bool HeapEmpty(const Heap* php)
{
assert(php);
return php->size == 0;
}
三、堆的运用
3.1 堆排序
如果让你来用堆的特性排序你会怎么做?
这还不简单?建堆呗,排升序就建小堆,排降序就建大堆,然后再依此建堆,先以第一个数据为根建堆,再以第二个数为根建堆,最后不就排好了吗?
这样想确实可行,但仔细想一想,如果用较快的向下调整建堆,时间复杂度为,堆中有N个数据就要建N次堆,这么一搞,时间复杂度不就是
了吗?那为什么我不去用时间复杂度和它一样,但是实现更简单的冒泡排序呢?
所以这样是不行的,我们要另辟蹊径,排升序就建大堆,排降序就建小堆。
我们以排升序为例,按照这么说我们就要建大堆,大堆是最大的数在最前面,可我们的需求是最小的在最前面啊,这不是南辕北辙了?此话差矣,升序最小的数是在最前面,但它最大的数在也最后面啊。我们得到的最大的数把它放到堆尾去不就行了,然后把这个数排除在外,对根进行向下调整,得到第二大的数再次放到堆尾去,这么循环下去,升序不就出来了吗。因为在交换首尾数据并把最后一个数排除在向下调整之外后,根的左右子树依旧是堆,所以直接对根向下调整就能达到再次建堆的目的,这样效率就高了。
void Heapsort(Heap* php)
{
assert(php);
int end = php->size - 1;
while (end > 0)
{
HeapDataSwap(&php->data[0], &php->data[end]);
AdjustDown(php->data, end, 0);
--end;
}
}
堆排序的时间复杂度为 。
3.2 TopK问题
TopK问题也可以说是一个排序问题,但它没有必要将数据全部排出顺序,因为它只需要排在前K个的数据。
TopK问题大致有三个思路:
我们假设要在N个数中取最大的前K个数。
① 堆排序。虽然只要前K个,但我就全部排出来,取前K个。
② 取最大我就建大堆,然后在取出堆顶的数后将这个数删除掉,再取堆顶的数,这样取出前K个数。
第一个显然是不太好的,因为它做了很多没有用的数据处理,把不需要排序的数也排序了。第二个是可行的,但是不太完美,因为第二个的基础是要将所有的数据都存进内存中,当N较小时是能成功的,但如果N为100 0000呢?N为 1 0000 0000呢?那这显然你的计算机就存不下了。所以我们就要采取第三个方法。
③ 创建一个大小为K的小堆,先把前K个数存进堆中,然后将剩下的N-K个数据依此与堆顶比较,如果数据比堆顶大就交换,然后再次向下排序建堆,这么依此比较N-K次,N个数中最大的前K个数就排出来了。这里依旧用了排升序用大堆、排降序用小堆的思想。
//创建一个有N个随机数的文本文件
static void FileCreat(char* filename, int N)
{
assert(filename);
FILE* pfin = fopen(filename, "w");
if (pfin == NULL)
{
perror("fopen");
exit(-1);
}
srand((unsigned int)time(NULL));
while (N--)
{
fprintf(pfin, "%d ", rand() % 10000);
}
fclose(pfin);
pfin = NULL;
}
void HeapToPKPrint(const char* filename, const int k)
{
assert(filename);
FILE* pfout = fopen(filename, "r");
if (pfout == NULL)
{
perror("fopen");
exit(-1);
}
//创建一个大小为K的堆
HeapDataType* FindMaxHeap = (HeapDataType*)malloc(sizeof(HeapDataType) * k);
if (FindMaxHeap == NULL)
{
perror("malloc");
exit(-1);
}
//读取文件中前K个数 并把它们存进内存(堆)中
int i = 0;
for (i = 0; i < k; ++i)
{
fscanf(pfout, "%d", &FindMaxHeap[i]);
}
//建小堆
for (i = (k - 2) / 2; i >= 0; --i)
{
AdjustDown(FindMaxHeap, k, i);
}
//比较
//读取文件中剩下的数
HeapDataType value = 0;
while (fscanf(pfout, "%d", &value) != EOF)
{
if (value > FindMaxHeap[0])
{
//找到了就直接覆盖堆顶的数据
//并对堆顶向下调整再次建堆 把堆中最小的数放到堆顶
FindMaxHeap[0] = value;
AdjustDown(FindMaxHeap, k, 0);
}
}
//打印堆中的数 即TopK
for (i = 0; i < k; ++i)
{
printf("%d ", FindMaxHeap[i]);
}
free(FindMaxHeap);
FindMaxHeap = NULL;
fclose(pfout);
pfout = NULL;
}
这里是把数据存到文件中以应对数据量大的情况,如果数据量并不是很大,可以将数据都存入一个数组中,然后把对文件的操作改为对该数组操作即可。
四、小结
堆排序和TopK都是在生活和实际生产中比较重要的运用,而堆作为其的理论基础,需要我们用心学习。
以上为个人对堆的学习和思考,如有错误还请指正!