目录
3.2 Topk问题的三种解决方案(以筛选最大的前k个值为例)
一. 堆的概念及结构
1.1 堆的概念
- 如果有一个关键码的集合
,把这个集合中的所有元素都按照完全二叉树的存储规则存储在一个数组中,若满足:
且
(或
且
),则把这样的二叉树称为小堆(或大堆)。
- 大堆:树及任何一个子树中,任何一个父亲节点的值都大于或等于子节点的值。
- 小堆:树及任何一个子树中,任何一个父亲节点的值都小于或等于子节点的值。
堆结构的代码定义方式与顺序表相同,只不过堆结构要求数据按照一定的规则(所有父亲节点都大于等于子节点或都小于等于子节点)进行存储。
typedef int HPDataType; //堆中存储数据的类型
typedef struct Heap
{
HPDataType* a; //指向存储堆中数据的内存空间
int size; //堆中已有数据个数
int capacity; //堆的容量
}HP;
1.2 堆的结构及在内存中的存储
- 假设父亲节点在数组中的下标为parent,则:左孩子节点下标 leftchild = 2 * parent + 1,右孩子节点下标 right = 2 * parent + 2。
- 对于任意一个有父亲节点的节点,设其在数组中的下标为child,无论这个节点是左孩子节点还是右孩子节点,其父亲节点的下标为:parent = (child - 1) / 2 。
- 数组中的数据的父子节点值的关系要满足大堆或小堆的要求。
![](https://img-blog.csdnimg.cn/09983172e4db496a9ccd3723cdc6a98b.png)
二. 堆的主要接口函数
2.1 堆初始化函数HeapInit
- 堆的初始化与顺序表的初始化完全相同,断言确保传入的参数不为空指针后,堆中战术不存储数据,将a置为NULL,将capacity和size置为0即可。
HeapInit函数代码:
void HeapInit(HP* hp)
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
2.2 堆销毁函数HeapDestroy
- 堆的销毁也与顺序表的销毁完全相同,释放存储堆数据的内存空间,然后将size和capacity均置0即可。
HeapDestroy函数代码:
void HeapDestroy(HP* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL; //释放存储堆数据的内存空间
hp->size = hp->capacity = 0;
}
2.3 向堆中插入数据函数HeapPush(以小堆为例)
- 检验堆中是否还存在剩余空间,如果堆已满,就进行扩容操作。
- 在存储堆数据的数组最后添加数据,为了使数组中的数据满足小堆的结构要求,要对新插入的数据的位置进行向上调整。调整方法为(见图2.1):
- 将新插入的数据的大小与其父亲节点的数据进行比较,若该数据小于其父亲节点数据,则交换该节点与父亲节点的数据。
- 更新父亲节点和孩子节点,孩子节点变为原来的父亲节点,父亲节点变为新的孩子节点的父亲节点,比较更新后的父亲节点与孩子节点的数据,如须要交换就交换父亲节点和孩子节点的值。
- 当遇到父亲节点小于等于孩子节点,或将孩子节点调整到了根节点的位置,则终止调整,此时数组中的数据已经满足小堆结构要求,向堆中插入数据操作结束。
![](https://img-blog.csdnimg.cn/6739c3dbd4334d37b3e7f74aa12dbc21.png)
HeapPush函数代码:
//数据交换函数
void swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = *a;
*a = *b;
*b = tmp;
}
//向上调整函数
//参数a为指向数组首元素的指针,child为插入新数据的下标
void Adjustup(HPDataType* a, int child)
{
assert(a);
int parent = (child - 1) / 2; //父亲节点下标
while (child) //如果子节点为根节点,就停止调整
{
if (a[parent] > a[child])
{
//如果父亲节点值大于孩子节点值,调用函数交换数据
swap(&a[parent], &a[child]);
//更新父子节点下标
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//向堆中插入数据x
void HeapPush(HP* hp, HPDataType x)
{
assert(hp);
//检查是否需要扩容
if (hp->capacity == hp->size)
{
int newcapacity = (hp->capacity == 0) ? 4 : 2 * hp->capacity; //新空间大小
HPDataType* tmp = (HPDataType*)realloc(hp->a, newcapacity * sizeof(HPDataType));
if (NULL == tmp)
{
printf("realloc fail\n");
exit(-1);
}
//扩容,更新容量
hp->a = tmp;
hp->capacity = newcapacity;
}
hp->a[hp->size] = x;
++hp->size;
Adjustup(hp->a, hp->size - 1); //调用函数向上调整数据
}
2.4 删除堆根节点数据函数HeapPop(小堆为例)
- 如果直接删除根节点数据而不进行其他任何操作,根节点变为第二个数据,那么数组中剩余的数据大概率会不符合小堆的结构要求(父亲节点小于等于孩子节点)。
- 删除根节点数据,首先应当交换数组首元素(根节点元素)和末位元素的值。然后,将当前的末位元素排除出当前堆,从当前堆的根节点开始向下调整数据,使数组中的数据满足小堆的结构要求,向下调整数据的流程为:
- 选取根节点为父亲节点,根节点的子节点为孩子节点。
- 找出父亲节点的两个孩子节点中较小的那个,如父亲节点大于较小的孩子节点,就交换父亲节点的值和孩子节点的值。
- 更新父亲节点和孩子节点,父亲节点变为原来的孩子节点,孩子节点变为更新后的父亲节点的孩子节点,重复执行步骤2。
- 当孩子节点的下标超出数组的范围,或者父亲节点小于等于较小的孩子节点,则终止调整,此时数组中的数据减少了1个,且数据满足小堆的结构要求。
删除小堆根节点数据的具体操作见图2.2。
![](https://img-blog.csdnimg.cn/c523dca766da490aa2ab97b8958c99f4.png)
HeapPop函数代码:
//数据交换函数
void swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = *a;
*a = *b;
*b = tmp;
}
//向下调整函数
//a为存储堆数据的数组,n为当前堆中数据个数,parent为开始向下调整的节点
void Adjustdown(HPDataType* a, int n, int parent)
{
int child = 2 * parent + 1;
//向下调整数据
while (child < n) //孩子节点下标不超出数组范围
{
if (child + 1 < n && a[child] > a[child + 1]) //选出较小的子节点
++child;
//如果父亲节点大于较小的子节点,交换,否则终止调整
if (a[parent] > a[child])
{
swap(&a[parent], &a[child]);
//更新父子节点下标
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
void HeapPop(HP* hp)
{
assert(hp);
assert(hp->size); //断言堆中有数据
swap(&hp->a[0], &hp->a[hp->size - 1]); //交换首尾节点数据
--hp->size; //变更堆中数据个数
Adjustdown(hp->a, hp->size, 0); //向下调整数据函数
}
2.5 获取堆中数据个数函数HeapSize
- 确保传入函数的参数不为空指针,返回hp->size即可。
HeapSize函数代码:
int HeapSize(HP* hp)
{
assert(hp);
return hp->size;
}
2.6 获取堆的根节点数据函数HeapTop
- 确保传入函数的参数不是空指针并且堆中有数据,然后返回hp->a[0]即为根节点数据。
HeapTop函数代码:
HPDataType HeapTop(HP* hp)
{
assert(hp);
assert(hp->size); //确保堆中有数据
return hp->a[0]; //返回根节点数据
}
2.7 判断堆中是否有数据函数HeapEmpty
- 若hp->size == 0成立,则堆中无数据,否则堆中有数据。
HeapEmpty函数代码:
bool HeapEmpty(HP* hp)
{
assert(hp);
return hp->size == 0;
}
三. 经典Topk问题
3.1 问题描述
经典Topk问题,就是在一组数据中,筛选出最大(或最小)的前k个值。生活中常见的外卖商家热榜排行就是经典Topk问题的典型实际应用。
3.2 Topk问题的三种解决方案(以筛选最大的前k个值为例)
方法一:先调用快排函数排降序,前k个值就是最大的k个。
方法一演示代码:
int cmp(const void* e1, const void* e2)
{
return *(int*)e2 - *(int*)e1;
}
int main()
{
int arr[] = { 1,4,2,45,23,56,23,45,12,45,78,14 }; //待筛选的数组
int sz = sizeof(arr) / sizeof(arr[0]); //数组中数据个数
//调用快排函数对数组进行排序
qsort(arr, sz, sizeof(arr[0]), cmp);
//选出最大的前5个值打印
for (int i = 0; i < 5; i++)
{
printf("%d ", arr[i]); //78 56 45 45 45
}
return 0;
}
分析:
- 排降序的时间复杂度为
- Topk问题只要求筛选出前k个最大或最小值,将所有数据进行排序,做了大量无用功,效率低下。
方法二:把N个数据依次插入大堆(大堆根节点数据一定大于或等于其余所有节点数据),然后执行k次获取根节点数据操作和删除堆顶数据操作,每次获取根节点数据都取得了当前堆中的最大值。
方法二演示代码:
//打印最大的前k个数函数
void PrintTopk(HPDataType* a, int n, int k)
{
HP hp;
HeapInit(&hp); //建大堆并初始化
//将n个数据插入到大堆中
for (int i = 0; i < n; ++i)
{
HeapPush(&hp, a[i]);
}
//执行k次获取根节点数据然后删除根节点操作
while (k--)
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
}
int main()
{
int n = 10;
int* arr = (int*)malloc(n * sizeof(int));
if (NULL == arr)
{
printf("malloc fail\n");
exit(-1);
}
//随机生成10个0-100的值存入arr数组
for (int i = 0; i < n; ++i)
{
int num = rand() % 100;
arr[i] = num;
}
arr[1] = 100 + 1;
arr[3] = 100 + 2;
arr[4] = 100 + 3;
arr[7] = 100 + 4;
arr[9] = 100 + 5;
//获取arr中最大的5个值
PrintTopk(arr, n, 5); //打印101 103 105 104 102
free(arr);
arr = NULL;
return 0;
}
分析:
- 建堆操作的时间复杂度为O(N),向上调整Adjustdown和向下调整Adjustup的时间复杂度为
,用方法二实现TopK要先建堆,并且执行Pop操作时要执行向下调整操作Adjustdown,总共执行k次Pop操作。因此,使用方法二解决TopK问题的时间复杂度为:
。
- 要开辟N个节点空间来建堆,空间复杂度为
。对于数据量十分巨大,内存无法容纳全部数据的情况不适用。
方法三:假设N非常大,内存中存不下这些数据,数据存放在了文件中。
依次进行如下操作:
- 用前k个数建立一个存储k个数据的小堆(排升序建小堆,排降序建大堆)。
- 用剩下的N-k的数据,依次与堆顶数据进行比较,如果比堆顶数据大,就替换堆顶数据,再向下调整。
- 最后堆里面剩下的k个数据就是最大的k个数据。
![](https://img-blog.csdnimg.cn/7b8d526e6302447e87173678a4d78330.png)
方法三演示代码:
void HeapPrint(HP* hp)
{
assert(hp);
for (int i = 0; i < hp->size; ++i)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
//筛选出最大的k个数函数并打印函数
//a为存储待排序数据的数组,n为总数据个数
void TopkPrint(HPDataType* a, int n, int k)
{
assert(a);
HP hp;
HeapInit(&hp); //初始化堆
//将前k个数插入一个小堆
int i = 0;
for (i = 0; i < k; ++i)
{
HeapPush(&hp, a[i]);
}
//将后面n-k个数与根节点数据进行比较,如果大于根节点数据,则进行替换,然后向下调整数据
for (i = k; i < n; ++i)
{
if (a[i] > hp.a[0])
{
hp.a[0] = a[i]; //替换数据
Adjustdown(hp.a, k, 0);
}
}
HeapPrint(&hp); //调用堆数据打印函数打印最大的前k个数据
HeapDestroy(&hp); //销毁堆
}
int main()
{
//选出10000个数中最大的5个数
int* arr = (int*)malloc(10000 * sizeof(int)); //开辟存储数据的内存空间
if (NULL == arr) //检验内存开辟是否成功
exit(-1);
int i = 0;
for (i = 0; i < 10000; ++i)
{
//生成10000个0-9999的数存入数组中
arr[i] = rand() % 10000;
}
//将arr数组中随机5个数改到10000以上
//筛选出最大的5个数就应该是被修改的这5个数
arr[467] = 10000 + 1;
arr[2345] = 10000 + 2;
arr[3567] = 10000 + 3;
arr[8934] = 10000 + 4;
arr[9678] = 10000 + 5;
//选出最大的5个数并打印
TopkPrint(arr, 10000, 5); //10001 10002 10003 10004 10005
free(arr);
arr = NULL;
return 0;
}
分析:
- 建堆操作的时间复杂度为
,向下调整操作的时间复杂度为
。
- 该方法要建立一个函数k个数据的小堆,最多进行N-K次向下调整操作,因此,整体的时间复杂度为
,渐进表示为
。
四. 堆排序问题
4.1 问题描述
给定一组数据,要求利用建堆和堆操作的思想,将这一组数据按升序或降序排列。
4.2 解决方法(排升序为例)
首先,对于应该建大堆还是建小堆的问题,给出下面的结论:
- 排升序,建大堆
- 排降序,建小堆
堆排序的操作流程如下(排升序):
- 建大堆,这里不再新开辟空间来建堆,而是将给定数据的数据顺序调整为满足大堆结构的排列。采用向下调整的方法来进行数据调整,从最后一个非叶子节点(度不为0的节点)开始向下调整,调整到根节点结束,此时数据的排列顺序已满足大堆的结构要求。
- 交换首尾节点的数据值,此时末尾节点为堆中的最大数据。
- 将末尾的节点排除出堆,从根节点开始,对堆进行向下调整操作。
- 重复步骤2和步骤3,直到堆中仅剩一个数据为止。
![](https://img-blog.csdnimg.cn/e7f4e60625884a6fa852dcca82cecf25.png)
堆排序函数HeapSort代码:
//数据交换函数
void swap(DataType* px, DataType* py)
{
int tmp = *px;
*px = *py;
*py = tmp;
}
//向下调整函数
//a为存储待排序数据的数组,n为待排序数据的个数
void Adjustdown(DataType* a, int n, int parent)
{
assert(a);
int child = 2 * parent + 1;
while (child < n)
{
if (child + 1 < n && a[child] < a[child + 1])
++child;
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
//堆排序函数(升序)
void HeapSort(DataType* a, int n)
{
assert(a);
//先采用向下调整的方式将a中的数据建立为大堆
//从后往前调整,叶子节点不用单独调整
//因此,从第一个度不为0的节点开始往前调整到第一个节点即可
int end = (n - 1 - 1) / 2;
while(end >= 0)
{
Adjustdown(a, n, end);
--end;
}
//将a排为大堆后
//将a的数据首尾交换,排除最后一个节点,将堆进行向下调整
//重复进行上述操作n-1次,堆(数组)中的数据变为升序
end = n - 1;
while (end)
{
swap(&a[0], &a[end]);
Adjustdown(a, end, 0);
--end;
}
}
五. 建堆操作和向上向下调整操作的时间复杂度证明
5.1 建堆操作的时间复杂度证明
5.1.1 通过向下调整法建堆的时间复杂度证明
向下调整法建堆的时间复杂度为O(N)。
满二叉树是一种特殊的完全二叉树,这里使用满二叉树来证明建堆操作的时间复杂度。由于时间复杂度本身就是渐进表示,因此相差几个节点,并不会改变时间复杂度。
观察上图,为了让二叉树中的数据满足堆结构的要求,第一层的到第h层的节点分别至多向下移动h-1、h-2、h-3、...、2、1、0次,从第一层到第n-1层每层的节点数分别为、
、
、...、
、
、
个,因此,总共要移动的次数
为:
(1)
(2)
错位相减,由(2)-(1)得:
根据满二叉树的性质,节点数(N)和满二叉树深度的关系为:,
则有:,用大O渐进法表示时间复杂度为:O(N)。
综上,证得向下调整法建堆的时间复杂度为O(N)。
5.1.2 通过向上调整法建堆的时间复杂度证明
向上调整法建堆的时间复杂度为O(NlogN)
由上图得,对于高度为h的满二叉树构成的堆,最多进行向上调整的次数设为,有:
(1)
(2)
错位相减,由(2)-(1)得:
由:,
,用大O渐进法表示时间复杂度为
综上,证得向上调整法建堆操作的时间复杂度为O(NlogN)。
5.2 向上(向下)调整操作的时间复杂度证明
向上(向下)调整操作的时间复杂度为O(logN)。
对于节点个数为N、层数为h的满二叉树,向上(向下)调整操作最多进行h-1次数据交换,同时,,则调整次数为:
,用大O渐进法表示为
。综上,证得向上(向下)调整操作的时间复杂度为