堆的概念
- 堆是一种特殊的完全二叉树,堆不仅满足完全二叉树的结构要求,还额外满足堆序性质。因此堆一定是完全二叉树,但完全二叉树不一定是堆
- 堆序性质:大根堆:父节点的值大于等于其子节点的值。
小根堆:父节点的值小于等于其子节点的值.
堆的结构
堆通常用数组来表示,父节点索引为i,左子节点索引为2i+1,右子节点索引为2i+2.
一.堆的实现
堆的初始化HeapInit
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
void HeapInit(HP* php)
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (php->a == NULL)
{
perror("malloc fail");
return;
}
php->size = 0;
php->capacity = 4;
}
结构体类型HP中指针a用于动态分配内存来存储堆中数据,再用malloc动态分配一块能存4个HPDataType类型数据的内存空间并将其地址赋给a。
交换功能单独封装
void swap(HPDataType* p1, HPDataType* p2)
{
HPDataType x = *p1;
*p1 = *p2;
*p2 = x;
}
堆的判空HeapEmpty
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
向上调整AdjustUp
void AdjustUp(HPDataType* a, int child)
{
//计算的下标位置
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[parent] < a[child])//这里是建大堆的条件,小堆反过来即可
{
swap(&a[parent], &a[child]);
//递归进行下去
child = parent;
parent= (child - 1) / 2;
}
else
{
break;
}
}
}
条件:除child这个位置,前面数据构成堆。接受两个参数,第一个参数a是一个指向堆数据类型的指针,第二个参数child是子节点下标(索引)。
1.循环条件child>0,因为下标为0的结点是根结点,没有父节点。
2.if条件判断中为维持堆序性质(大根堆或小根堆),不满足调用swap交换
3.将child更新为父节点,并重新计算父节点下标,递归下去
4.若已满足堆序性质,退出循环,时间复杂度为O(logN)
ps:常用于插入数据后的调整来满足堆序
堆的插入HeapPush
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
HPDataType* tmp = (HPDataType*)realloc(php->a,sizeof(HPDataType) * php->capacity * 2);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity *= 2;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);//size-1为有效下标
}
1.首先判断size和capacity关系是否需要realloc扩容
2.size代表新数组元素的下标,插入后更新size大小
3.向上调整使满足堆序
向下调整AdjustDown
void AdjustDown(HPDataType* a,int n, int parent)
{
//n为数组有效元素个数,只要左孩子下标在该范围内循环成立
int child = parent * 2 + 1;
while (child < n)
{
//选出左右孩子中大的那一个
if (child + 1 < n && a[child + 1] > a[child])
{
//条件判断child+1确保下标有效性
child++;
}
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
//更新递归下去
parent = child;
child= parent * 2 + 1;
}
else
{
break;
}
}
}
1.通过父节点计算左孩子下标,只要左孩子下标在数组有效范围内就继续循环
2.检查右孩子下标是否有效且是否大于左孩子,若大于则将child更新为右孩子下标,child指向的是左右子结点中较大的那一个
3.比较child和parent大小,通过交换来满足堆序性质,更新child和parent递归下去
4.while循环是影响时间复杂度的主要因素,循环执行次数与堆的高度成正比,堆的高度为log(N+1),时间复杂度为O(logN)
ps:常用于堆的删除来满足堆序
堆的删除HeapPop
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
默认情况下删除操作通常是针对堆顶元素进行的,因为堆的性质决定堆顶元素始终具有最高优先级并且可维护堆序性质,删除堆顶后仅需将最后一个叶结点提到堆顶再通过向下调整即可维护堆序,比起任意位置删除重新调整整个堆的结构更加高效。
1.首先断言指针有效性并检查堆不为空
2.交换堆顶元素和最后一个元素,即数组第一个和最后一个,size–减少堆的大小,相当于删除了堆的最后一个元素(原堆顶元素)
3.从堆顶向下调整来满足堆序
取堆顶数据HeapTop
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
堆的数据个数HeapSize
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
堆的销毁HeapDestroy
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity =php->size= 0;
}
整体代码
- Heap.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
void HeapInit(HP* php);
void HeapDestroy(HP* php);
void HeapPush(HP* php,HPDataType x);
void HeapPop(HP* php);
HPDataType HeapTop(HP* php);
bool HeapEmpty(HP* php);
int HeapSize(HP* php);
void AdjustDown(HPDataType* a, int n, int parent);
void AdjustUp(HPDataType* a, int child);
void swap(HPDataType* p1, HPDataType* p2);
- Heap.c
#include "Heap.h"
void HeapInit(HP* php)
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (php->a == NULL)
{
perror("malloc fail");
return;
}
php->size = 0;
php->capacity = 4;
}
void swap(HPDataType* p1, HPDataType* p2)
{
HPDataType x = *p1;
*p1 = *p2;
*p2 = x;
}
//向上调整,条件:除child这个位置,前面数据构成堆
void AdjustUp(HPDataType* a, int child)
{
//计算的下标位置
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
//递归进行下去
child = parent;
parent= (child - 1) / 2;
}
else
{
break;
}
}
}
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
HPDataType* tmp = (HPDataType*)realloc(php->a,sizeof(HPDataType) * php->capacity * 2);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity *= 2;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);//size-1为有效下标
}
//向下调整,条件:左右子树都是大堆/小堆
void AdjustDown(HPDataType* a,int n, int parent)
{
//n为数组有效元素个数,只要左孩子下标在该范围内循环成立
int child = parent * 2 + 1;
while (child < n)
{
//选出左右孩子中大的那一个
if (child + 1 < n && a[child + 1] > a[child])
{
//条件判断child+1确保下标有效性
child++;
}
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
//更新递归下去
parent = child;
child= parent * 2 + 1;
}
else
{
break;
}
}
}
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity =php->size= 0;
}
- test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
int main()
{
HP hp;
HeapInit(&hp);
HeapPush(&hp, 1);
HeapPush(&hp, 15);
HeapPush(&hp, 66);
HeapPush(&hp, 4);
HeapPush(&hp, 9);
HeapPush(&hp, 55);
HeapPush(&hp, 11);
//打印
while (!HeapEmpty(&hp))
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
printf("\n");
return 0;
}
二.堆排序
建堆
升序 建大堆:根节点是数组中最大值,将其与最后一个叶结点交换位置,删除堆中最后一个结点(原根结点),再对剩余元素向下调整重构最大堆,此时根结点变成第二大值,通过不断将堆顶元素移到数组末尾,可以逐步让数组变成升序。
为什么升序不适合建小堆?
因为删除堆顶元素后,数组中元素整体向前挪动一位,它们之间关系全部乱了,得重新建堆,效率低下。
降序 建小堆:和上述思路同理,只是每次循环提取最小值放数组末尾而已。
向下调整建堆
通过从最后一个非叶结点开始逐层向上调整,确保满足堆序性质
n代表数组元素个数,n-1代表数组下标个数,最后一个非叶结点由(n-1-1)/2来计算,结果取整。该方法时间复杂度为O(N),证明如图
向上调整建堆
通过从叶子结点开始逐步向上比较和交换,确保每个父结点都满足堆序
该方法时间复杂度为O(N*logN),证明如图
该方法不如向下调整原因在于向下法中结点数越多的层要移动的次数越少,而该方法结点数越多的层需要移动的次数越多。
- 排升序代码
// 排升序 -- 建大堆
void HeapSort(int* a, int n)
{
建堆 -- 向上调整建堆,时间复杂度为O(N*logN)
//for (int i = 1; i < n; ++i)
//{
// AdjustUp(a, i);
//}
//向下调整建堆,推荐,O(logN)
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = n - 1;
//O(N*logN)
while (end > 0)
{
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
排降序同理。
时间复杂度分析
排序阶段时间复杂度:将堆顶元素依次取出,并将最后一个元素放到堆顶,重新调整堆,该过程需重复n次。每次取出堆顶元素后调整堆的时间复杂度为O(logN),所以总时间复杂度为O(N*logN)
建堆时间复杂度:建堆过程,从最后一个非叶子结点开始,逐层向上进行调整,每个单结点的调整时间为O(logN),因为每次调整有可能需要从当前结点移动到根结点,但并非所有结点都需要进行完整的logN次调解,实际上叶子结点不需要调整,而非叶子结点的调整次数随层数减少而减少。因此整体构建堆的时间复杂度为O(N)
三.TopK问题
即求数据结合中前k个最大元素或最小元素。一般想到用排序来解决该问题,但若数据量非常大,排序就不太好(数据可能不能一下全部加载到内存中),最好用堆来解决。
思路:用数据中前k个元素来建堆,剩余的N-K个元素依次与堆顶元素比较,不满足则替换堆顶元素,比较完后堆中剩余的K个元素就是所求的前K个最小或最大的元素
1.求前k个最大元素建小堆2.求前k个最小元素建大堆
代码实现
void CreateNData()
{
//造数据
int n = 10000;
srand(time(NULL));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (size_t i = 0; i < n; i++)
{
//取模操作为确保随机数都在有效范围内
int x = rand() % 10000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void PrintTopK(const char* file, int k)
{
//用a中前k个元素建小堆
int* TopK = (int*)malloc(sizeof(int) * k);
assert(TopK);
FILE* fout = fopen(file, "r");
//assert(fout);
if (fout == NULL)
{
perror("fopen error");
return;
}
//读出前k个数据建小堆
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &TopK[i]);
}
for (int i = (k - 2) / 2; i>=0; i--)
{
AdjustDown(TopK, k, i);
}
//将剩余n-k个元素依次与堆顶元素比较交换
int val = 0;
int ret = fscanf(fout, "%d", &val);
while (ret!= EOF)
{
if (val > TopK[0])
{
TopK[0] = val;
AdjustDown(TopK, k, 0);
}
ret = fscanf(fout, "%d", &val);
}
//打印堆中元素,即为所求topk元素
for (int i = 0; i < k; i++)
{
printf("%d ", TopK[i]);
}
printf("\n");
free(TopK);
fclose(fout);
}
这是一个求前k个最大元素的例子,需要熟练掌握文件操作方面的内容。