🤡博客主页:醉竺
🥰本文专栏:《数据结构与算法》
😻欢迎关注:感谢大家的点赞评论+关注,祝您学有所成!
✨✨💜💛想要学习更多数据结构与算法点击专栏链接查看💛💜✨✨
目录
导读
在本篇内容开始之前,如果你是一个新手,在阅读本篇内容之前建议先看看我专栏的上一篇文章,这篇内容详细的讲解了《树与二叉树的概念、性质及详细证明》;如果你对数据结构与算法感兴趣,无论你是为了学习备考还是为了复习竞赛,欢迎大家都可以点进入我的专栏数据结构与算法看看。我每一篇文章都有认真的讲解数据结构的基础知识和实现,以及大量精美的配图~✨✨
本篇内容将主要会讲解二叉树的顺序存储结构实现——堆,以及堆的概念、性质和应用,干货满满,准备开始吧!
一.二叉树的存储结构
二叉树的存储一般有两种方式,一种是基于数组的顺序存储方式,一种是链式存储方式。
1.1 顺序存储
顺序结构存储就是用一组地址连续的存储单元(数组),按完全二叉树的节点层次编号,依次从上到下、从左到右地存储二叉树上的节点元素。一般使用数组只适合表示完全二叉树,因为普通二叉树在顺序存储时需要补充为完全二叉树,在对应完全叉树没有孩子的位置补0,这样就会造成空间的浪费。
例如下图所示。
现实中使用中只有堆才会使用数组来存储,关于堆我们后面的章节会专门讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
注意:
- 顺序存储方式比较适合完全二叉树和满二叉树,此时结点的序号可以唯一的反映结点的逻辑关系。
- 一般的二叉树需要添加一些并不存在的空结点,让每个结点与完全二叉树上的结点相对应。
- 最坏情况下,一个高为h且只有h个结点的单支树却需要占据接近
个存储单元,空间效率较低 。
1.2 链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链表来指示元素的逻辑关系。链式存储方式要存储额外的左右子节点或者父节点的指针,而顺序存储方式是不需要指针的。所以通常来讲,链式存储方式多用于存储普通的二叉树。
链式结构又分为二叉链和三叉链,二叉链表中每个结点由三个域组成,左指针域lchild、数据域data和右指针域rchild,左右指针分别用来给出该结点左孩子和右孩子在的链结点的存储地址 。注意: 含有n个结点的二叉链表中含有n+1个空链域。
当前学习中,一般情况下二叉树采用二叉链表存储即可,但是在实际问题中,如果经常需要访问双亲节点,二叉链表存储则必须从根出发查找其双亲节点,这样做非常麻烦。为了解决这一问题,可以增加一个指向双亲节点的指针域,这样每个节点就包含3个指针域,分别指向两个孩子节点和双亲节点,还包含一个数据域,用来存储节点信息。这种存储方式称为三叉链表。后面高阶数据结构如红黑树等也会用到三叉链。
二叉链表和三叉链表的代码表示 :
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
}
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pParent; // 指向当前节点的双亲
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
二.堆:二叉树的顺序存储
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统 虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
2.1 堆的概念及结构
堆是有序的完全二叉树,这里所说的有序指的是父节点一定大于等于子节点值或者父节点一定 小于等于子节点值。 堆可以看作一棵完全二叉树的顺序存储结构。
在这样一棵完全二叉树中,如果每一个节点的值都大于等于左右孩子的值,称为最大堆(大根堆)。如果每一个节点的值都小于等于左右孩子的值,称为最小堆(小根堆)。
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
堆的相关功能实现代码框架:(相关功能下面会有讲解)
typedef int HPDataType;
// 堆的结构体
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
// 交换函数
void Swap(HPDataType* p1, HPDataType* p2);
// 堆的向上调整
void AdjustUp(HPDataType* a, int child);
// 堆的向下调整(下沉)
void AdjustDown(HPDataType* a, int n, int parent);
// 堆的初始化
void HeapInit(Heap* php);
// 堆的销毁
void HeapDestroy(Heap* php);
// 判断堆是否为空
bool HeapEmpty(Heap* php);
// 获取堆顶元素
HPDataType HeapTop(Heap* php);
// 堆的数据个数
int HeapSize(Heap* php);
// 堆的插入
void HeapPush(Heap* php, HPDataType x);
// 堆的删除
void HeapPop(Heap* php);
2.2 堆的实现
2.2.1 堆的向下调整(下沉)
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
int array[] = {27,15,19,18,28,34,65,49,25,37};
堆的向下调整代码实现:
// 交换函数
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 堆的向下调整(下沉)
void AdjustDown(HPDataType* a, int n, HPDataType parent)
{
int child = 2 * parent + 1; //先设左孩子节点较大
while (child < n) //向下调整的终止条件为:双亲节点已经下沉到叶子节点位置,其child已大于数组的size越界
{
//选出左右孩子节点中较大的
if (child + 1 < n && a[child + 1] > a[child]) //若右孩子节点大于左孩子节点
{
child++; //设右孩子节点较大
}
//此时child为左右孩子中较大的节点
if (a[child] > a[parent])
{
Swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
2.2.2 堆的创建(建堆)
下面我们给出一个无序的数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆(此例调为大根堆)。
int a[] = {1,5,3,8,7,6};
思考: 构建初始堆为什么要从最后一个分支节点(倒数的第一个非叶子节点)开始到第一个节点逆序调整堆?
因为调整堆的前提是除了堆顶之外,其他节点都满足最大堆的定义,只需要堆顶“下沉”操作即可。叶子节点没有孩子,可以认为已满足最大堆的定义,从最后一个分支节点开始调整堆,调整后该节点以下的分支已经满足最大堆的定义,其双亲节点调整时,其左右子树均已满足最大堆的定义。
建堆的时间复杂度是多少呢?
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的 就是近似值,多几个节点不影响最终结果):
因此:建堆的时间复杂度为O(N)。
2.2.3 堆的插入(向上调整)
1.堆的插入:这里演示向小根堆插入新元素。
将新元素放到堆的最后位置。 将新元素与其父节点元素做对比,若新元素值比其父节点元素值小,则将两者互换。 就这样不断将新元素向树根方向(向上)调整,一直到新元素无法向上调整为止。因为这意味着新元素值大于等于其父节点元素值了。 每次向上调整需要对比关键字一次。
例如:先插入一个10到数组的尾上,再进行向上调整算法,直到满足堆。
堆的向上调整算法代码实现:
// 堆向上调整
void AdjustUp(HPDataType* a, HPDataType child)
{
int parent = (child - 1) / 2;
while (child > 0) //向上调整终止条件:child调整到根节点结束
{
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
堆的插入代码实现:
// 堆的插入
void HeapPush(Heap* 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);
}
2.2.4 堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调整算法。
堆的删除代码实现:
// 堆顶节点的删除
void HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
三.堆的应用(代码实现)
3.1 堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1. 建堆
- 升序:建大堆
- 降序:建小堆
2. 利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
如果实现从小到大的排序算法,则使用大顶堆比较方便,实现出的算法占用的辅助空间更少。 如果实现从大到小排序,则使用小顶堆比较方便。
堆排序就是利用堆(大顶堆或者小顶堆)进行排序的方法。这里我将采用大顶堆来实现从小到大的排序。
基本思想:就是把待排序的 n 个元素的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根节点。将该节点数据删除,实际是将该节点数据与堆待排序序列末尾元素交换位置,然后 将剩余 n-1 个元素的序列重新构造成一个大顶堆,这样根节点就是 n 个元素中的次大值……如此反复,就可以得到一个排好序的序列了。
堆排序代码实现:
// 堆排序
void HeapSort(int* a, int n)
{
// 升序 -- 建大堆
// 降序 -- 建小堆
//1.建大根堆
//方法a:从第1个节点(下标为1)开始往后进行向上调整建堆
/*
for (int i = 1; i < n; i++) //时间复杂度:O(n*logn)
{
AdjustUp(a, i); // 向上调整
}
*/
//方法b:从第一个非叶子节点(分支节点)开始,往前进行向下调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--) //时间复杂度:O(n),推荐
{
AdjustDown(a, n, i);
}
//2.对堆节点进行调整排序
//方法:第0个节点与最后一个节点交换
//再从第0个节点开始往后进行向下调整
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0); // 向下调整
end--;
}
}
堆排序还可以用另外一种方法实现,不过不推荐此方法,但是可以看看下面这段程序的实现:
// 堆顶节点的删除
void HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
// 堆的应用:堆排序
void HeapSort(int* a, int n)
{
Heap hp;
HeapInit(&hp);
// N*logN
for (int i = 0; i < n; ++i)
{
HeapPush(&hp, a[i]);
}
// N*logN
int i = 0;
while (!HeapEmpty(&hp))
{
int top = HeapTop(&hp);
a[i++] = top;
HeapPop(&hp);
}
HeapDestroy(&hp);
}
3.2 TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
- 前k个最大的元素,则建小堆
- 前k个最小的元素,则建大堆
2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
- 将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
// 堆的应用2:TOP-K问题
/*
N个数找最大的前K个
正常思路:把这个N建成大堆,Pop K次,即可找出最大的前K个
有些场景,上面的思路解决不了,比如N非常大假设N是10亿,K是100,建堆很费时而且耗费资源
改进的解决思路:
1.以 前K个数建小堆。
2.后面J-K个数,依次比较,如果比堆顶的数据大,就覆盖堆顶数据,向下调整。
3.最后这个小堆的值就是最大的前K个
*/
// 生成数据
void CreateNDate()
{
// 造数据
int n = 10000;
srand(time(0));
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() % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
// 打印TOP-K的数据
void PrintTopK(int k)
{
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
int* kminheap = (int*)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc error");
return;
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
// 建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kminheap, k, i);
}
int val = 0;
while (!feof(fout))
{
fscanf(fout, "%d", &val);
if (val > kminheap[0])
{
kminheap[0] = val;
AdjustDown(kminheap, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", kminheap[i]);
}
printf("\n");
}
四.总结(代码汇总)
下面提供有关堆实现堆应用的完整代码,需要的可以直接复制运行。
本篇内容,完结撒花~,
下一篇将会介绍有关二叉树链式存储以及其相关遍历操作的详解。看到这里,如果你觉得这篇文章对你有一点点帮助,希望您能点个赞或者收藏支持我一下,这对我很重要❤️✨。
Heap.h
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
// 交换函数
void Swap(HPDataType* p1, HPDataType* p2);
// 堆的向上调整
void AdjustUp(HPDataType* a, int child);
// 堆的向下调整(下沉)
void AdjustDown(HPDataType* a, int n, int parent);
// 堆的初始化
void HeapInit(Heap* php);
// 堆的销毁
void HeapDestroy(Heap* php);
// 判断堆是否为空
bool HeapEmpty(Heap* php);
// 获取堆顶元素
HPDataType HeapTop(Heap* php);
// 堆的数据个数
int HeapSize(Heap* php);
// 堆的插入
void HeapPush(Heap* php, HPDataType x);
// 堆的删除
void HeapPop(Heap* php);
Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
// 堆的初始化
void HeapInit(Heap* php)
{
assert(php);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
// 堆的销毁
void HeapDestroy(Heap* 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;
}
// 堆判断是否为空
bool HeapEmpty(Heap* php)
{
assert(php);
return php->size == 0;
}
// 获取堆顶元素
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
// 堆的数据个数
int HeapSize(Heap* php)
{
assert(php);
return php->size;
}
// 堆向上调整
void AdjustUp(HPDataType* a, HPDataType child)
{
int parent = (child - 1) / 2;
while (child > 0) //向上调整终止条件:child调整到根节点结束
{
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
// 堆的向下调整(下沉)
void AdjustDown(HPDataType* a, int n, HPDataType parent)
{
int child = 2 * parent + 1; //先设左孩子节点较大
while (child < n) //向下调整的终止条件为:双亲节点已经下沉到叶子节点位置,其child已大于数组的size越界
{
//选出左右孩子节点中较大的
if (child + 1 < n && a[child + 1] > a[child]) //若右孩子节点大于左孩子节点
{
child++; //设右孩子节点较大
}
//此时child为左右孩子中较大的节点
if (a[child] > a[parent])
{
Swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
// 堆的插入
void HeapPush(Heap* 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 HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
Test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void HeapTest()
{
Heap hp;
HeapInit(&hp);
HPDataType a[] = { 65,100,70,32,50,60 };
for (int i = 0; i < sizeof(a) / sizeof(int); ++i)
{
HeapPush(&hp, a[i]);
}
while (!HeapEmpty(&hp))
{
HPDataType top = HeapTop(&hp);
printf("%d ", top);
HeapPop(&hp);
}
}
// 堆的应用:堆排序1
void HeapSort1(int* a, int n)
{
Heap hp;
HeapInit(&hp);
// N*logN
for (int i = 0; i < n; ++i)
{
HeapPush(&hp, a[i]);
}
// N*logN
int i = 0;
while (!HeapEmpty(&hp))
{
int top = HeapTop(&hp);
a[i++] = top;
HeapPop(&hp);
}
HeapDestroy(&hp);
}
// 堆排序2
void HeapSort2(int* a, int n)
{
// 升序 -- 建大堆
// 降序 -- 建小堆
//1.建大根堆
//方法a:从第1个节点(下标为1)开始往后进行向上调整建堆
/*
for (int i = 1; i < n; i++) //时间复杂度:O(n*logn)
{
AdjustUp(a, i);
}
*/
//方法b:从第一个非叶子节点(分支节点)开始,往前进行向下调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--) //时间复杂度:O(n),推荐
{
AdjustDown(a, n, i);
}
//2.对堆节点进行调整排序
//方法:第0个节点与最后一个节点交换
//再从第0个节点开始往后进行向下调整
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
void HeapSortTest() //堆排序测试
{
int a[] = { 7,8,3,5,1,9,5,4 };
HeapSort2(a, sizeof(a) / sizeof(int));
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
printf("%d ", a[i]);
}
}
// 堆的应用2:TOP-K问题
/*
N个数找最大的前K个
正常思路:把这个N建成大堆,Pop K次,即可找出最大的前K个
有些场景,上面的思路解决不了,比如N非常大假设N是10亿,K是100,建堆很费时而且耗费资源
改进的解决思路:
1.以 前K个数建小堆。
2.后面J-K个数,依次比较,如果比堆顶的数据大,就覆盖堆顶数据,向下调整。
3.最后这个小堆的值就是最大的前K个
*/
// 生成数据
void CreateNDate()
{
// 造数据
int n = 10000;
srand(time(0));
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() % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
// 打印TOP-K的数据
void PrintTopK(int k)
{
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
int* kminheap = (int*)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc error");
return;
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
// 建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kminheap, k, i);
}
int val = 0;
while (!feof(fout))
{
fscanf(fout, "%d", &val);
if (val > kminheap[0])
{
kminheap[0] = val;
AdjustDown(kminheap, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", kminheap[i]);
}
printf("\n");
}
int main()
{
//HeapTest();
//HeapSortTest();
//CreateNDate();
//CreateNDate();
PrintTopK(5);
return 0;
}