本篇内容涉及到二叉树的概念及性质,可参考文章 树和二叉树的概念及性质
文章目录
- 一、堆的概念
- 二、堆的存储结构
- 三、堆的函数接口
- 1. 初始化及销毁
- 2. 打印函数
- 3. 堆的插入
- 4. 堆的删除
- 5. 取堆顶、判空、数据个数
- 四、建堆算法和时间复杂度
- 1. 向上调整建堆
- 2. 向下调整建堆
- 五、堆排序和 TopK 问题
一、堆的概念
堆是一颗 完全二叉树,并且数据满足如下性质
- 如果树中 所有父节点 的值都 大于等于 子节点的值,称作 大堆(最大堆、大根堆)
- 如果树中 所有父节点 的值都 小于等于 子节点的值,称作 小堆(最小堆、小根堆)
二、堆的存储结构
在上篇 树和二叉树的概念及性质 的最后,介绍了 完全二叉树的编号,以及 通过某个节点的编号可以轻松的找到该节点的父节点和孩子节点,因此可以 根据编号作为下标 用 数组来存储堆
//堆的数据类型
typedef int HeapDataType;
//堆的结构
typedef struct Heap
{
HeapDataType* data;
int size; //存储的数据个数
int capacity; //当前的容量
}Heap;
三、堆的函数接口
1. 初始化及销毁
创建一个堆之后,堆结构中的成员变量存储的都是一些随机值,所以需要对其进行初始化,这里采用 初始化时不分配空间 的方式,也可以在初始化时就为其分配一些空间
初始化函数如下:
void HeapInit(Heap* pHp)
{
//pHp 不能为空指针
assert(pHp);
//初始化
pHp->data = NULL;
pHp->size = pHp->capacity = 0;
}
在堆中:存储数据的空间是动态开辟的,不使用时应手动释放
销毁函数如下:
void HeapDestroy(Heap* pHp)
{
//pHp 不能为空指针
assert(pHp);
free(pHp->data);
pHp->data = NULL;
pHp->size = pHp->capacity = 0;
}
2. 打印函数
为了验证堆的插入、删除等得到的结果是否正确,提供打印堆的函数,这里数据类型以 int 为例,当读者采用的类型不同时,自行更改该函数即可
打印函数如下:
void HeapPrint(Heap* pHp)
{
assert(pHp);
for(int i = 0; i < pHp->size; ++i)
{
printf("%d ", pHp->data[i]);
}
printf("\n");
}
3. 堆的插入
由于 堆是一颗完全二叉树,因此只能在 最后一个编号之后插入数据,以大堆为例
当 插入的值 小于 父节点的值 时,插入之后的完全二叉树 还是大堆
当 插入的值 大于 父节点的值 时,插入之后的完全二叉树就 不是大堆 了,此时便 需要将结构调整为大堆
调整方法:将插入的结点值和父节点值交换,交换之后,如果该值还大于父节点的值,则 继续和父节点交换,直到 交换后的结点值小于等于父节点值 或 该节点已经是根节点
调整过程中,所需要 判断的所有节点都是插入节点的祖先,因此 称作向上调整
插入函数如下:
//交换值
void Swap(HeapDataType* p1, HeapDataType* p2)
{
HeapDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整,调整时需要存储堆的数组、调整对象编号
void AdjustUp(HeapDataType* array, int child)
{
assert(array);
//计算孩子的父节点
int parent = (child - 1) / 2;
//交换到根节点后,便停止交换
while(child > 0)
{
//如果孩子节点值大于父节点值,则需要交换父子节点的值,否则调整完成
//将这里的 大于 改成 小于,就是小堆的的向上调整
if(array[child] > array[parent])
{
Swap(&array[child], &array[parent]);
child = parent; //父子节点的值已经交换,需要更新孩子的指向
parent = (child - 1) / 2; //计算孩子的父节点
}
else
{
break;
}
}
}
//插入
void HeapPush(Heap* pHp, HeapDataType x)
{
assert(pHp);
//扩容
if(pHp->size == pHp->capacity)
{
int newCapacity = pHp->capacity == 0 ? 4 : 2 * pHp->capacity;
HeapDataType* tmp = (HeapDataType*)realloc(pHp->data, sizeof(HeapDataType) * newCapacity);
if(tmp == NULL)
{
perror("realloc");
exit(-1);
}
pHp->data = tmp;
pHp->capacity = newCapacity;
}
//插入数据
pHp->data[pHp->size] = x;
pHp->size++;
//向上调整
AdjustUp(pHp->data, pHp->size - 1);
}
4. 堆的删除
堆只会 删除堆顶的数据,删除其他位置的数据意义不大,以大堆为例
由于 数组尾删的效率很高,因此 为了较易删除堆顶的数据,分三步进行
第一步:将 堆顶的数据 和 最后一个数据 交换
第二步:删除最后一个数据
第三步:将删除后的完全二叉树 调整为大堆
调整方法:将较大的孩子结点的值和堆顶节点值交换,交换之后,如果左右孩子中的较大值还大于该值,则 继续将较大的孩子结点的值和该节点值交换,直到 左右孩子的值小于等于交换后的结点值 或 该节点已经是叶节点
调整过程中,所需要 判断的所有节点都是堆顶节点的子孙,因此 称作向下调整
删除函数如下:
//向下调整,调整时需要存储堆的数组、调整对象编号、堆的数据个数
void AdjustDown(HeapDataType* array, int parent, int n)
{
assert(array);
//假设左孩子为需要交换的孩子
int child = parent * 2 + 1;
//交换到叶节点,便停止交换
//完全二叉树中,左孩子不存在,右孩子也就不存在了
while(child < n)
{
//如果假设错误,则需要更新 child 为右孩子
//需要注意:右孩子可能不存在
//将这里和下面 if 语句的 大于 改成 小于,就是小堆的向下调整
if(child + 1 < n && array[child + 1] > array[child])
{
++child;
}
//如果较大的子节点的值大于父节点的值,则需要交换,否则调整完成
//将这里和上面 if 语句中的 大于 改成 小于,就是小堆的向下调整
if(array[child] > array[parent])
{
Swap(&array[child], &array[parent]);
parent = child; //父子节点的值已经交换,需要更新双亲的指向
child = parent * 2 + 1; //假设左孩子为需要交换的孩子
}
else
{
break;
}
}
}
void HeapPop(Heap* pHp)
{
assert(pHp);
assert(!HeapEmpty(pHp));
//第一步:交换堆顶和最后一个数据
Swap(&pHp->data[0], &pHp->data[pHp->size - 1]);
//第二步删除最后一个数据
pHp->size--;
//第三步:向下调整
AdjustDown(pHp->data, 0, pHp->size);
}
5. 取堆顶、判空、数据个数
这些函数较为简单,就不做分析了
函数如下:
//取堆顶
HeapDataType HeapTop(Heap* pHp)
{
assert(pHp);
assert(!HeapEmpty(pHp));
return pHp->data[0];
}
//判空
bool HeapEmpty(Heap* pHp)
{
assert(pHp);
return pHp->size == 0;
}
//数据个数
size_t HeapSize(Heap* pHp)
{
assert(pHp);
return pHp->size;
}
四、建堆算法和时间复杂度
数组 array { 25, 15, 51, 30, 20, 19 },交换数组元素使之变为堆,要求空间复杂度为 O(1)
1. 向上调整建堆
将数组的元素看做一棵完全二叉树
在堆的插入中,插入数据之前,数组本身是堆,当插入的数据大于父节点时,通过向上调整,便可以将数组调整为堆
为了可以使用向上调整算法,需要满足调整之前数组本来就是堆
当数组只有一个元素时,可以将其看做一个堆,于是便可以不断的对新数据进行向上调整,最终就可以将整个数组调整为堆
调整结果:
向上调整函数 已经在 堆的插入给出,建堆循环 如下:
//数组和数组大小
int array[] = { 25, 15, 51, 30, 20, 19 };
int len = sizeof(array)/sizeof(array[0]);
//向上调整建堆
//时间复杂度:O(N * logN)
for(int i = 1; i < len; ++i)
{
AdjustUp(array, i); //对新数据进行向上调整
}
向上调整建堆的时间复杂度:
对于高度为 h 的堆,总节点数 N = 2h - 1,总调整次数:
F(h) = 21 * 1 + 22 * 2 + … + 2h - 1 * (h - 1)
小于 2 * 2h - 1 * (h - 1) = 2h * (h - 1) = (N + 1) * (log2(N + 1) - 1)
因此向上调整建堆的时间复杂度为 O(N * log2N)
2. 向下调整建堆
将数组的数据看做一棵完全二叉树:
在堆的删除中:先将堆顶和最后一个数据交换,然后删除最后一个数据,此时堆顶的左子树和右子树均是堆,通过向下调整,便可以将数组调整为堆
为了可以使用向下调整算法,需要满足调整的节点的左子树和右子树均是堆
显然数组的第一个数据的左子树和右子树不是堆,此时并不能从第一个开始向下调整,而是需要从最后一个节点开始,从后往前对每一个节点向下调整
由于数组只有一个元素时,可以将其看做一个堆,因此可以 从最后一个分支节点开始,在完全二叉树中,最后一个分支节点就是最后一个节点的父节点,最后便可以将数组调整为堆
调整结果:
向下调整函数 已经在 堆的删除给出 ,建堆循环 如下:
//数组和数组大小
int array[] = { 25, 15, 51, 30, 20, 19 };
int len = sizeof(array)/sizeof(array[0]);
//向下调整建堆
//时间复杂度O(N)
//从最后一个分支节点开始,从后往前
for(int i = (len - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(array, i, len); //向下调整为堆,为后续向下调整做准备
}
时间复杂度
对于高度为 h 的堆,总节点数 N = 2h - 1,总调整次数:
F(h) = 20 * (h - 1) + 21 * (h - 2) + … + 2h - 3 * 2 + 2h - 2 * 1
2 * F(h) = 21 * (h - 1) + 22 * (h - 2) + … + 2h - 2 * 2 + 2h - 1 * 1
2 * F(h) - F(h) 错位相减得:
F(h) = - 20 * (h - 1) + 21 * 1 + … + 2h - 3 * 1 + 2h - 2 * 1 + 2h - 1 * 1
F(h) = 2h - 2 - (h - 1) = 2h - 1 - h = N - log2N
因此向下调整建堆的时间复杂度为 O(N)
五、堆排序和 TopK 问题
对数组 array { 25, 15, 51, 30, 20, 19 } 进行原地排序,升序建大堆,降序建小堆
堆排序时间复杂度:O(N * log2N),计算方法和向上调整建堆相似
堆排序函数如下:
void HeapSort(int* array, int arrayLen)
{
//升序建大堆
for(int i = (arrayLen - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(array, i, arrayLen);
}
//每一次将堆顶和最后一个数据交换,并且不将最后一个数据看做堆的数据,进行向下调整为堆
//如果升序建小堆,出的数据没有存放的地方
int end = arrayLen - 1;
while(end > 0)
{
Swap(&array[0], &array[end]);
AdjustDown(array, 0, end);
--end;
}
}
排序过程:
选取数据中前 K 个最大数据或最小数据,一般数据量都很大,无法存储在内存中
选取前 K 个最大数据,建 K 个数据的小堆,选取前 K 个最小数据,建 K 个数据的大堆
时间复杂度:K + (N - K) * log2K -> O(N * log2K)
空间复杂度:O(K)
//用于测试
void arrayPrint(int* array, int arrayLen)
{
for(int i = 0; i < arrayLen; ++i)
{
printf("%d ", array[i]);
}
printf("\n");
}
void HeapTest5()
{
int n = 10000; //数据个数
int k = 5; //选取的 K 个数
//设置随机数种子
srand((unsigned)time(NULL));
FILE* fin = fopen("data.txt", "w");
if(fin == NULL)
{
perror("fopen");
exit(-1);
}
//制造数据
for(int i = 1; i <= n; ++i)
{
int val = rand() % 100;
fprintf(fin, "%d ", val);
//制造 2k 个较大的数
if(i % (n / k / 2) == 0)
{
fprintf(fin, "%d ", i * 100 + val);
}
}
fclose(fin);
//创建 k 个空间,用来存储堆
int* array = (int*)malloc(sizeof(int) * k);
if(array == NULL)
{
perror("malloc");
exit(-1);
}
FILE* fout = fopen("data.txt", "r");
if(fout == NULL)
{
perror("fopen");
exit(-1);
}
//读取前 k 个数据
for(int i = 0; i < k; ++i)
{
fscanf(fout, "%d", &array[i]);
}
//arrayPrint(array, k);
//建小堆
for(int i = (k - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(array, i, k);
}
//arrayPrint(array, k);
//遍历数据
int val = 0;
while(fscanf(fout, "%d", &val) != EOF)
{
//比堆顶大就替换堆顶,然后调整为小堆
if(array[0] < val)
{
array[0] = val;
AdjustDown(array, 0, k);
}
}
arrayPrint(array, k);
fclose(fout);
}
int main()
{
HeapTest5();
return 0;
}