1. 堆的概念及结构
概念
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:且(或且) i = 0,1, 2…,则称为小堆(或大堆)。
将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。
其中且的含义是:双亲结点的值都小于等于其两个孩子;
且的含义是:双亲结点的值都大于等于其两个孩子。
结构
值得注意的是,同一个双亲结点下的两个兄弟没有大小关系。
二叉树的一个重要性质
下标为n的结点的左孩子的下标为,右孩子的下标为;
下标为m的结点的双亲结点的下标为。
正是这一条性质,使得二叉树的顺序式存储成为可能,也仅有通过这一条性质,我们才能确定结点之间的链接关系。
证明
假设某结点A的下标为n,位于第k+1层,因为满二叉树的前k层有个结点,所以n与k满足关系式。
在第k+1层,A前面有个结点,这些结点的孩子共有个。
第k+2层(A孩子所在层)的第一个结点下标为,则
A左孩子的下标为,A右孩子的下标为
2. 堆的类型定义与接口
typedef int HPDataType;
//堆
typedef struct Heap
{
HPDataType* data;
int size;
int capacity;
}Heap;
//初始化
void HPInit(Heap* php);
//销毁
void HPDestroy(Heap* php);
//插入
void HPPush(Heap* php, HPDataType x);
//删除
void HPPop(Heap* php);
//返回堆顶元素
HPDataType HPTop(Heap* php);
//判空
bool HPEmpty(Heap* php);
//向上调整
void AdjustUp(HPDataType* a, int child);
//向下调整
void AdjustDown(HPDataType* a, int n, int parent);
插入
其中,插入会默认将新元素插入在数组的末尾,如果新元素与其双亲结点的大小关系与当前堆的类型不匹配,则交换二者并依次向上调整(该功能由AdjustUp函数实现)。
删除
删除指的是删除堆顶元素,但此处的删除有别于顺序表的头删,在这里,我们不会直接将所有元素前移并覆盖第一个元素(堆顶元素)。
因为这样做会导致后面元素的关系全部乱套,例如:
某结点的下标为n(n>0),其左孩子的下标为2*n+1,其右孩子的下标为2*n+2;
删除堆顶元素并将剩余元素全部前移之后
该结点的下标变为n-1,其原本左孩子的下标变为2*n,原本右孩子的下标变为2*n+1;
很明显,它们之间不再满足亲子之间的下标关系。
我们采取的做法是,用最后一个元素覆盖堆顶元素(或与堆顶元素进行交换位置),并对其进行向下调整(该功能由AdjustDown实现,原理与AdjustUp类似,但是要选取合适的孩子来进行比较交换。该函数的使用需要满足:当前结点的左右子树都为堆)。
这样就在保证其他元素亲子关系不变的情况下删除了堆顶元素。
3. 堆的代码实现
堆的类型完全由AdjustUp和AdjustDown函数决定,为了方便记忆,以下代码在实现时,做了特别调整,以出现“大于大堆,小于小堆”的规律。
即,亲子间比较用的是“>”则为大堆,亲子间比较用的是“<”则为小堆。
在改变堆的类型时,也只需要修改这两个函数中,比较亲子的符号即可。
#include "Heap.h"
void swap(HPDataType* e1, HPDataType* e2)
{
HPDataType temp = *e1;
*e1 = *e2;
*e2 = temp;
}
//小于小堆,大于大堆
void AdjustUp(HPDataType* a, int child)
{
assert(a);
int parent = (child - 1) / 2;
while (child > 0 && a[child] > a[parent])
{
swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
}
void AdjustDown(HPDataType* a, int n, int parent)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[child] > a[parent])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//初始化
void HPInit(Heap* php)
{
assert(php);
php->data = NULL;
php->size = php->capacity = 0;
}
//销毁
void HPDestroy(Heap* php)
{
assert(php);
free(php->data);
php->capacity = php->size = 0;
}
//插入
void HPPush(Heap* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* temp = (HPDataType*)realloc(php->data, sizeof(HPDataType) * newcapacity);
if (temp == NULL)
{
perror("realloc fail");
exit(-1);
}
php->data = temp;
php->capacity = newcapacity;
}
php->data[php->size] = x;
php->size++;
AdjustUp(php->data, php->size - 1);
}
//删除
void HPPop(Heap* php)
{
assert(php);
assert(php->data);
swap(&php->data[0], &php->data[php->size - 1]);
php->size--;
AdjustDown(php->data, php->size, 0);
}
//返回堆顶元素
HPDataType HPTop(Heap* php)
{
assert(php);
assert(php->data);
assert(php->size);
return php->data[0];
}
//判空
bool HPEmpty(Heap* php)
{
assert(php);
return !php->size;
}
4. 堆的应用
4.1 TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
根据上面堆的定义与实现我们会发现,大堆(小堆)堆顶的元素一定是堆中最大(最小)的元素。
那么,我们只要不断从堆中取出堆顶元素并删除,重复k次,得到的就是最大(最小)的前k个元素。
并且依据出堆的顺序,这前k个元素是按照降序(升序)排列的。
要得到前k个最大的元素--->建大堆;
要得到前k个最小的元素--->建小堆;
但是,如果数据量非常大,排序就不太可取了(可能 数据都不能一下子全部加载到内存中)。
最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或 者最大的元素。
//topk问题
int* Top_k(const char* s, int k)
{
FILE* pf = fopen(s, "r");
if (pf == NULL)
{
perror("fopen");
exit(-1);
}
int* arr = (int*)malloc(sizeof(int) * k);
for (int i = 0; i < k; i++)
{
fscanf(pf, "%d", &arr[i]);
}
//建堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, k, i);
}
int temp = 0;
while (fscanf(pf, "%d", &temp) != EOF)
{
if (temp > arr[0])
{
arr[0] = temp;
AdjustDown(arr, k, 0);
}
}
fclose(pf);
return arr;
}
但是,要进行这些操作,所有的元素首先应该要在堆中。
如何将这些元素放到堆中呢?这便是建堆问题(上述代码已有实现,接下来具体讲解)。
4.2 建堆问题
一种显而易见的办法就是创建一个堆类型的数据,然后依次将这些元素入堆。
但是,这种办法免不了需要开辟大量的空间。
我们知道,堆的元素在物理结构上是存储在数组中的,如果这些元素原本就是放在一个数组中,我们能否在原数组的基础上进行调整,使其满足堆的要求呢?
这里就不得不提到AdjustUp和AdjustDown这两个函数了,这两个函数就是用于堆中元素不满足堆所要求的关系时,对堆进行调整的。
由于二者都可以完成对元素间关系的调整,所以在解决建堆问题时,我们就有两种思路:
4.2.1 自上而下的向上调整
使用AdjustUp函数的前提是,除了要调整的这个元素以外,其他元素都满足堆所要求的关系。
但是,原本数组中的元素很明显是没有任何规律的,那么我们要如何来进行调整呢?
这里,我们可以利用递归的思想来解决这个问题——将大问题拆解为小问题。
注意到,堆顶元素这单一的一个元素可以看作是堆,于是我们就可以依次对第二层的元素进行向上调整。
这时,第一行和第二行成为了合格的堆,于是我们又可以依次对第三行元素进行向上调整……
当然,也可以将该过程理解为模拟插入的过程的过程:将该数组当成没有元素的堆,每访问一个数据就相当于在堆中插入了一个元素,如果有必要的话就进行向上调整(这里为了方便,对每个元素都调用一次AdjustUp函数)。
//从上到下建堆O(nlogn)
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
时间复杂度
按最坏的情况考虑,第二层的元素需要调整1次,第三层的元素需要调整2次,……,第k行的元素需要调整k-1次。
假设总共有n个元素,那么堆总共有层(以满二叉树做近似计算)。
第i层的元素个数为个,则第i层的元素总共需要调整次。
总次数
经计算得带入k近似可得
所以算法的时间复杂度为O(nlogn)。
4.2.2 自下而上的向下调整
同样的道理,AdjustDown函数要求被调整元素的左子树与右子树都是堆。
那么叶子结点这单一的一个元素可以看作堆,于是我们从数组末尾的第一个非叶子结点(最后一个元素的双亲结点)开始,依次对前面的元素进行向下调整。
//从下到上建堆O(n)
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
时间复杂度
直觉上我们会认为这个算法的时间复杂度与上面的如出一辙,但事实并不如此。
在刚才的算法中,层次越深,结点需要向上调整的次数越多。
而在现在这个算法中,层次越深,结点所需要向下调整的次数越少。
很明显,层次越深,每层的叶子结点越多,所以现在这个算法的时间复杂度很明显是要更底的。
经过类似的计算,结点调整的次数为,由于n远远大于,
算法的时间复杂度可认为是O(n)。
4.3 堆排序
在解决完上面的问题之后,不得不提到的就是堆排序了。
既然我们能让一个数组成为堆,那么我们也就可以顺理成章地解决TOP-K问题了,即利用与出堆相同原理的算法的,一步步得到最大(最小)的前k个数。
考虑当k=n时,我们就得到了原数组元素的一个有序(降序/升序)排列。
那么,我们能不能在这基础之上,实现一种排序算法呢?
正如这节的标题,这样的算法当然是可以实现的,并且是众多排序算法中最快的之一,也就是堆排序。
假定我们已经将数组建成了堆,那么接下来我们就需要模拟出堆(交换堆顶元素与最后一个元素的方式,因为排序是在原数组的基础上进行调整)。
每次出堆(大堆/小堆),当前堆顶的元素(最大/最小)就被交换到数组末尾,且不再算作堆中元素,所以:
进行升序排序需要建大堆,进行降序排序需要建小堆。
//堆排序
void HeapSort(int* a, int n)
{
//升序,建大堆
//降序,建小堆
//从上到下建堆O(nlogn)
//for (int i = 1; i < n; i++)
//{
// AdjustUp(a, i);
//}
//从下到上建堆O(n)
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
for (int end = n - 1; end > 0; end--)
{
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
}
}
时间复杂度
出堆的过程和与从上到下建堆的过程十分相似,即第k层的元素需要被调整k-1次。
所以,出堆的时间复杂度为O(nlogn)。
综合两个部分,堆排序的时间复杂度算作O(nlogn)。