个人主页:C++忠实粉丝
欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 C++忠实粉丝 原创数据结构之二叉树的超详细讲解(2)--(堆的概念和结构的实现,堆排序和堆排序的应用)
收录于专栏【数据结构初阶】
本专栏旨在分享学习数据结构学习的一点学习笔记,欢迎大家在评论区交流讨论💌
之前发布过数据结构之二叉树的超详细讲解(1)--(树和二叉树的概念和结构),今天重点讲解堆的概念和结构的实现,堆排序和堆排序的应用,感兴趣的宝子们赶紧点赞收藏起来吧!💓💓💓
目录
[1.2 堆的概念及结构](#1.2 堆的概念及结构)
[2.5堆排序的应用-- TOP-K问题](#2.5堆排序的应用-- TOP-K问题)
1.二叉树的顺序结构及实现
1.1二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统 虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
1.2 堆的概念及结构
如果有一个关键码的集合K = { k0,k1,k2,,,,,,,,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: ki<=k2*i 且 ki <= k2*i+2 (k >= k2*i+1 且ki >= k2*i+2) i = 0,1, 2...,则称为小堆(或大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。
这里运用了二叉树性质五:
堆的性质:
堆中某个结点的值总是不大于或不小于其父结点的值;
堆总是一棵完全二叉树。
如下图所示:
1.3堆算法的实现
1.3.1堆结构的实现
之前说到过,堆就是完全二叉树, 把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,所以我们这里使用动态数组的方式建堆,类似于使用动态数组构建顺序表
cpp
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
a就是我们的一个动态数组
size是我们堆中的元素个数
capacity是我们堆的容量,方便后面动态开辟空间
HPDataType方便后面我们进行类型的修改,比如我们不需要整形了,而是char类型,直接修改HPDataType就可以了
1.3.2堆操作函数
cpp
//堆顶和堆尾元素的交换
void Swap(HPDataType* p1, HPDataType* p2);
//向上调正算法
void AdjustUp(HPDataType* a, int child);
//向下调整算法
void AdjustDown(HPDataType* a, int n, int parent);
//堆的初始化
void HPInit(HP* php);
//堆的销毁
void HPDestroy(HP* php);
//数据近堆
void HPPush(HP* php, HPDataType x);
//堆顶元素出堆
void HPPop(HP* php);
//输出队顶元素
HPDataType HPTop(HP* php);
//堆的判空
bool HPEmpty(HP* php);
1.3.2.1堆的初始化
cpp
void HPInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
1.3.2.2堆的销毁
cpp
void HPDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
注意:
free释放掉a之后,还需要将a置为NULL
1.3.2.3堆顶和堆尾元素的交换
cpp
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
为后面的调整算法算法埋下铺垫
1.3.2.4堆的调整算法
1.3.2.4.1向上调整建堆(以小根堆为例)
如下图所示(以小根堆为例):
我们将新插入的数据不断的与它的parent节点进行比较
代码展示:
cpp
void AdjustUp(HPDataType* a, int child)
{
// 初始条件
// 中间过程
// 结束条件
int parent = (child - 1) / 2;
//while (parent >= 0)
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
注意:
根据我们之前学过的二叉树的性质,左child可以通过(child-1)/2和右孩子(child-2)/2来找到它的母亲节点,因为编译器的向下取整,所以我们直接使用int parent = (child - 1) / 2;来解决,不需要判断左右孩子
如下图 :
这里我们的循环停止条件有两种:
第一种:while (parent >= 0)
第一种需要注意:当我们的parent = 0时,假设当时child还是比parent小,child = parent,parent = (child - 1) / 2;还是会进入下一次循环,此时child = parent = 0,由于存在if的判断循环会结束,不会造成死循环现象,属于歪打正着
第二种就严谨很多:
第二种采用child作为循环条件的判断,当我们的child大于0,就会一直进行交换直到child等于0时,即为堆顶元素时,循环停止
1.3.2.4.2向下调整建堆(以小根堆为例)
注意:
向下调整建堆需要以27为根的左右子树都满足小堆的性质,只有根节点不满足.因此只需要将根节点往下调,往下调时需要与最小的那个值进行比较
代码展示:
cpp
void AdjustDown(HPDataType* a, int n, int parent)
{
// 先假设左孩子小
int child = parent * 2 + 1;
while (child < n) // child >= n说明孩子不存在,调整到叶子了
{
// 找出小的那个孩子
if (child + 1 < n && a[child + 1] < a[child])
{
++child;
}
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
这里我们使用假设法,先假设左孩子小,然后进入循环,进入循环后进行判断,因为我们需要将较小的孩子与我们的parent进行交换,如果它本来就小,那就直接跳过,然后交换parent和child,如图所示:
1.3.2.5数据进堆
这里需要注意,数据进堆时,我们需要对进堆的数据进行调整,这样我们进堆结束后,即建堆完毕
代码展示:
cpp
void HPPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
1.3.2.6堆顶元素出堆
cpp
void HPPop(HP* php)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调整算法。这样我们向下调整时两边都是小根堆,满足向下调整的条件
1.3.2.7输出堆顶元素
cpp
HPDataType HPTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
1.3.2.8堆的判空
cpp
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
练习:
1.下列关键字序列为堆的是:()
A 100, 60, 70, 50, 32, 65
B 60, 70, 65, 50, 32, 100
C 65, 100, 70, 32, 50, 60
D 70, 65, 100, 32, 50, 60
E 32, 50, 100, 70, 65, 60
F 50, 100, 70, 65, 60, 32
2.已知小根堆为8, 15, 10, 21, 34, 16, 12,删除关键字 8 之后需重建堆,在此过程中,关键字之间的比较次
数是()。
A 1
B 2
C 3
D 4
3.一组记录排序码为(5 11 7 2 3 17), 则利用堆排序方法建立的初始堆为
A(11 5 7 2 3 17)
B(11 5 7 2 17 3)
C(17 11 7 2 3 5)
D(17 11 7 5 3 2)
E(17 7 11 3 5 2)
F(17 7 11 3 2 5)
4.最小堆[0, 3, 2, 5, 7, 4, 6, 8], 在删除堆顶元素0之后,其结果是()
A[3,2,5,7,4,6,8]
B[2,3,5,7,4,6,8]
C[2,3,4,5,7,8,6]
D[2,3,4,5,6,7,8]
解析:第一题:我们上面说到过,堆其实就是完全二叉树,所以我们直接构建完全二叉树:
只有A选项满足大堆的需求
第二题:
第三题:
只有c选项满足大顶堆的要求
第四题:
删除后,堆的调整如下:
2.堆排序
2.1向上调整建堆
2.1.1小根堆
cpp
for (size_t i = 0; i < sizeof(a) / sizeof(int); i++)
{
HPPush(&hp, a[i]);
}
对每一次插入堆中的数据进行调整建堆
测试数据:
int a[] = { 4,2,8,1,5,6,9,7,3,2,23,55,232,66,222,33,7,1,66,3333,999 };
cpp
while (!HPEmpty(&hp))
{
printf("%d ", HPTop(&hp));
//a[i++] = HPTop(&hp);
HPPop(&hp);
}
printf("\n");
根据小根堆的性质:它的堆顶元素一定是数组中最小的数据,我们将堆顶数据输出,在重新建堆,直到堆中没有数据,这样就可以实现堆排序
输出结果:
2.1.2大顶堆
建立大顶堆只需要改变我们之前写的向上向下调整代码:
向上调整代码:
cpp
void AdjustUp(HPDataType* a, int child)
{
// 初始条件
// 中间过程
// 结束条件
int parent = (child - 1) / 2;
//while (parent >= 0)
while (child > 0)
{
//如果孩子比parent大
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
再向上调整代码中,我们只需要将if的判断改为>就可以了
向下调整代码:
cpp
void AdjustDown(HPDataType* a, int n, int parent)
{
// 先假设左孩子小
int child = parent * 2 + 1;
while (child < n) // child >= n说明孩子不存在,调整到叶子了
{
// 找出小的那个孩子
//找出大的那个孩子
if (child + 1 < n && a[child + 1] > a[child])
{
++child;
}
//如果孩子比母亲大
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
再向下调整代码中,我们需要调整两处,一个是我们需要找大的那个孩子,将<改为>,还有我们交换的条件需要孩子比母亲大,也是将<改为>
测试数据和代码不变:
所以你只需要会小根堆,大顶堆只需要改变三个地方
2.2向下调整建堆
再实际应用中,我们建堆并不会使用向上调整建堆,因为时间复杂度不够低,但向上调整建堆更容易理解,大家可以根据自己的情况进行选择,这里我还是推荐向下调整建堆
cpp
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
我们这里第一个非叶子节点即最后一个节点的母亲节点:
也就是(n - 1 - 1) / 2,如图所示:
2.2.1小根堆
测试数据 int a[] = { 4,2,8,1,5,6,9,7,2,7,9 };
2.2.2大根堆
数据和小根堆一样:
2.3堆排序的完整代码参考:
cpp
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
printf("%d ", a[0]);
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
int main()
{
int a[] = { 4,2,8,1,5,6,9,7,2,7,9 };
HeapSort(a, sizeof(a) / sizeof(int));
return 0;
}
2.4建堆的时间复杂度
我们上面说到过,向下建堆的时间复杂度是优于向上建堆的,这里我们具体分析一下:
2.4.1向下调整建堆的时间复杂度:
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个结点不影响最终结果):
这里我们是向下调整建堆,是从第一个非叶子节点开始一直到第一个节点的向下调整,如下图所示:
根据上面的图,我们可以很简单的得到下面的等式:
再根据我们高中学过的错位相减法,进一步化简:
因此:向下建堆的时间复杂度为O(N)。
2.4.1向上调整建堆的时间复杂度:
因此向上调整建堆的时间复杂度为N*logN
总结:向下调整:节点数量少的层*调整次数多的层
向上调整:节点数量多的层*调整次数多的层
向下建堆的时间复杂度为O(N)。
上调整建堆的时间复杂度为N*logN
2.5堆排序的应用-- TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
再比如:大家都玩过王者荣耀吧,金标或者国标都需要取前100名玩家,再几亿玩家中找出战力最高的100名玩家,如果你用快排的话,电脑估计要转冒烟了,但TOP-K可以很好的解决这个问题
方法一:
建立一个N个数的大堆,TOP K次
这个方法是可行的,不过不够完美,因为再TOP K问题中,N往往是很大的,这样你建堆的一个内存就很大,算法的空间损耗会很大
方法二:
用前K个数建一个小堆
建堆的时间复杂度为O(K),然后还进行了(N-K)比较,所以总的时间复杂度为O(K + (N-K)*log(K)),假设是最坏的情况,每一次比较都需要向下调整,因为K是远小于N的,所以K,logK可以忽略不记,总时间复杂度为O(N),也就是上亿的数据,也能再秒之内完成
测试方法二:
创建数据:
cpp
void CreateNDate()
{
// 造数据
int n = 100000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; ++i)
{
int x = (rand() + i) % 10000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
输出结果:
创造一百完个随机数据
TOP-K求解:
cpp
void TestHeap3()
{
int k;
printf("请输入k>:");
scanf("%d", &k);
int* kminheap = (int*)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc fail");
return;
}
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 读取文件中前k个数
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
// 建K个数的小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kminheap, k, i);
}
// 读取剩下的N-K个数
int x = 0;
while (fscanf(fout, "%d", &x) > 0)
{
if (x > kminheap[0])
{
kminheap[0] = x;
AdjustDown(kminheap, k, 0);
}
}
printf("最大前%d个数:", k);
for (int i = 0; i < k; i++)
{
printf("%d ", kminheap[i]);
}
printf("\n");
}
手动更改十个数据大于10000000的数,查看结果:
输出结果:
参考代码:
Heap.h:
cpp
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
//堆顶和堆尾元素的交换
void Swap(HPDataType* p1, HPDataType* p2);
//向上调正算法
void AdjustUp(HPDataType* a, int child);
//向下调整算法
void AdjustDown(HPDataType* a, int n, int parent);
//堆的初始化
void HPInit(HP* php);
//堆的销毁
void HPDestroy(HP* php);
//数据近堆
void HPPush(HP* php, HPDataType x);
//堆顶元素出堆
void HPPop(HP* php);
//输出队顶元素
HPDataType HPTop(HP* php);
//堆的判空
bool HPEmpty(HP* php);
Heap.c:
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void HPInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
void HPDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustUp(HPDataType* a, int child)
{
// 初始条件
// 中间过程
// 结束条件
int parent = (child - 1) / 2;
//while (parent >= 0)
while (child > 0)
{
//如果孩子比parent大
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void HPPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
void AdjustDown(HPDataType* a, int n, int parent)
{
// 先假设左孩子小
int child = parent * 2 + 1;
while (child < n) // child >= n说明孩子不存在,调整到叶子了
{
// 找出小的那个孩子
//找出大的那个孩子
if (child + 1 < n && a[child + 1] < a[child])
{
++child;
}
//如果孩子比母亲大
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
// logN
void HPPop(HP* php)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
HPDataType HPTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
text.c:
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include <time.h>
#include"Heap.h"
void TestHeap1()
{
int a[] = { 4,2,8,1,5,6,9,7,3,2,23,55,232,66,222,33,7,1,66,3333,999 };
HP hp;
HPInit(&hp);
for (size_t i = 0; i < sizeof(a) / sizeof(int); i++)
{
HPPush(&hp, a[i]);
}
int i = 0;
while (!HPEmpty(&hp))
{
printf("%d ", HPTop(&hp));
//a[i++] = HPTop(&hp);
HPPop(&hp);
}
printf("\n");
// 找出最大的前k个
/*int k = 0;
scanf("%d", &k);
while (k--)
{
printf("%d ", HPTop(&hp));
HPPop(&hp);
}
printf("\n");*/
HPDestroy(&hp);
}
// 堆排序 O(N*logN)
// 冒泡排序 O(N^2)
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
printf("%d ", a[0]);
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
void CreateNDate()
{
// 造数据
int n = 100000;
srand((unsigned int)time(NULL));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; ++i)
{
int x = (rand() + i) % 10000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void TestHeap3()
{
int k;
printf("请输入k>:");
scanf("%d", &k);
int* kminheap = (int*)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc fail");
return;
}
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 读取文件中前k个数
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
// 建K个数的小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kminheap, k, i);
}
// 读取剩下的N-K个数
int x = 0;
while (fscanf(fout, "%d", &x) > 0)
{
if (x > kminheap[0])
{
kminheap[0] = x;
AdjustDown(kminheap, k, 0);
}
}
printf("最大前%d个数:", k);
for (int i = 0; i < k; i++)
{
printf("%d ", kminheap[i]);
}
printf("\n");
}
int main()
{
//CreateNDate();
TestHeap3();
return 0;
}