前言
本篇文章跳过二叉树的基础概念,直接从二叉树内容的正题开始,即二叉树的顺序存储结构,或者说堆。
正文
二叉树存储结构
二叉树一般可以使用两种结构来存储,即顺序结构与链式结构。我们先来看顺序结构。
顺序结构
顺序结构存储其实就是用数组来存储,这种存储方式只适合用来存储完全二叉树,如果不是存储完全二叉树,则会造成空间的浪费。
完全二叉树与非完全二叉树使用顺序结构存储的对比:
我们能够看到,为了能准确表示结点的父子关系,我们不得不在存储非完全二叉树时浪费一些空间。所以完全二叉树天然更适合顺序存储,而非完全二叉树我们则可以避免使用顺序存储。
准确点说,我们是把堆(一种二叉树)使用顺序结构的数组来存储。值得注意的是,二叉树中的堆指的是一种数据结构,而我们以往说的堆指的是操作系统中管理内存的一块区域分段。
链式结构
二叉树的链式存储结构指的是用链来表示一颗二叉树,即用链来指示元素的逻辑关系。
通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。
链式结构又分为二叉链和三叉链,在难度更高的数据结构如红黑树中,会用到三叉链。
现在我们回到本文正题:
实现顺序结构二叉树
⼀般堆使用顺序结构的数组来存储数据,堆是⼀种特殊的二叉树,具有二叉树的特性的同时,还具备其他的特性。
堆的概念与结构
⼀般堆使用顺序结构的数组来存储数据,堆是⼀种特殊的二叉树,具有二叉树的特性的同时,还具备其他的特性。
将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。
堆的物理结构本质上是顺序存储的,是线性的。但在逻辑上不是线性的,是完全二叉树的这种逻辑储存结构。
堆具有以下性质
• 堆中某个结点的值总是不⼤于或不⼩于其⽗结点的值;
• 堆总是⼀棵完全⼆叉树。
所以我们可以看到,堆分为小堆和大堆。粗略地说,小堆就是根节点最小,每个结点要不小于(也就是大于等于)其父节点;大堆就是根节点最大,每个结点要不大于(也就是小于等于)其父节点。
需要注意的一点是,我们观察上面小堆和大堆存储的两个数组,都不是有序的数组,这是初学堆时我们容易犯的一个认知错误。把堆往数组里存储,我们的数组不一定是有序的。
接下来我们要了解一个非常重要的知识,也就是二叉树的性质,这在我们后面写代码过程中非常重要。
⼆叉树性质
• 对于具有 n 个结点的完全⼆叉树,如果按照从上⾄下从左⾄右的数组顺序对所有结点从0 开始编号,则对于序号为 i 的结点有:
若 i>0 , i 位置结点的双亲序号: (i-1)/2 ;
i=0 , i 为根结点编号,⽆双亲结点。
若 2 i + 1 < n 2i+1<n 2i+1<n,左孩⼦序号: 2i+1 。 2 i + 1 > = n 2i+1>=n 2i+1>=n 则⽆左孩⼦。
若 2 i + 2 < n 2i+2<n 2i+2<n,右孩⼦序号: 2i+2 。 2 i + 2 > = n 2i+2>=n 2i+2>=n 则⽆右孩⼦。
:🐙:也就是说我们现在知道了某个非根结点的序号为i(i>0),那么其父(双亲)节点的序号就为(i-1)/2,其左孩子序号为2i+1,其右孩子序号为2i+2,要注意的是如果2i+2>=n(n是总结点数量)了,说明没有右孩子,因为已经越界了(因为第一个结点的序号是从0开始的,就像数组一样);同样的,2i+1>=n说明左孩子都没有了。
那么,现在我们可以开始写代码来实现堆这样的结构了。
创建一个头文件Heap.h(各种要包含的库函数头文件、堆的结构定义、堆要使用到的各种方法的声明),两个源文件Heap.c(各种方法的具体实现)和Test.c(测试各种方法)。
定义堆的结构
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
//定义堆的结构——数组
typedef int HPDataType;
typedef struct Heap
{
HPDataType* arr;
int size;//当前有效数据个数
int capacity;//当前空间大小
}HP;
这就是我们在Heap.h中对堆结构的定义,我们包含了接下来可能会使用到的一些头文件,然后对堆存储的数据类型重命名(方便我们想要存储其他类型时的一键修改),再将堆的结构定义重命名(让我们写代码更简洁方便)。具体在结构体中我们定义了哪些东西,其实就和我们之前的顺序表一样。
堆的初始化与销毁
然后我们先来看两个方法,初始化堆和销毁堆。注意我们的形参都为指向堆(结构体变量)的指针,也就是在使用这两个方法时我们要传递我们要初始化和销毁的那个堆的地址。
#include"Heap.h"
void HPInit(HP* php)//堆的初始化
{
assert(php);//给个空地址我们还有什么好初始化的呢?
php->arr = NULL;
php->size = php->capacity = 0;
}
void HPDestroy(HP* php)//堆的销毁
{
assert(php);//给个空地址我们还有什么好销毁的呢?
if (php->arr != NULL)
{
free(php->arr);
}
php->arr = NULL;
php->capacity = php->size = 0;
}
这就是我们在Heap.c里写的初始化和销毁方法。注意不要忘了包含Heap.h头文件,否则我们在头文件里定义的堆结构是无法使用的。也不要忘了在头文件里写这两个方法的声明(才能在Test.c中包含头文件后使用对应方法)。
往堆中插入数据
:🐙:我们要清楚的一点是,虽然在顺序表和链表的数据结构中我们也少不了插入数据操作,但是对于堆这样的数据结构,由于成为一个堆需要满足孩子结点不大于或不小于其父节点这样的条件,所以我们直接插入数据可能会导致原本的堆结构被打乱成一个不再是堆的结构,所以插入数据后我们需要调整,使得这个数组再次满足堆的条件,成为堆。
void HPPush(HP* php, HPDataType x)//插入数据
{
assert(php);
//得判断数组空间是否足够
if (php->size == php->capacity)//说明空间不足,要扩容
{
//这是对数组容量为0,也就是插入第一个数据时的处理
int newCapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
HPDataType* tmp = (HPDataType*)realloc(php->arr, sizeof(HPDataType) * newCapacity);
if (tmp == NULL);
{
perror("realloc fail!");//报错
exit(1);//空间都申请失败了那就直接退出
}
php->arr = tmp;
php->capacity = newCapacity;//更新到最新的容量状态
}
//运行到这,空间就足够了,可以插入数据
php->arr[php->size] = x;
//但此时不一定是有效的堆,需要调整!
}
那么接下来就是我们重要的的调整算法:
假设现在我们要创建的是一个小堆,数组里已有一个11,我们现在插入一个10,很明显不满足是小堆,那么我们应该如何调整?
我们前面在性质中已经知道如何根据孩子结点的下标求父节点的下标,所以我们可以将孩子结点与父节点比较大小,在这个例子中父节点比孩子结点大,所以我们就交换。
这个例子中我们只有两个节点,其实堆比较大的时候,调整不会如此简单:
堆的向上调整算法
例子:
我们在一个有效的小堆中插入一个16,然后对16进行向上调整,我们由16的下标为6,(6-1)/2得到其父节点下标为2,也就是56,16比56小,所以进行交换;此时的孩子结点下标为2,再次求其父节点下标即(2-1)/2为0,也就是10,16比10大,不交换,停止循环。此时我们就成功调整了有效的小堆。
那么我们现在用代码可以实现这个向上调整方法:
(完整的插入数据代码)
void Swap(int* x, int* y)//得是传址才能改变实参
{
int tmp = *x;
*x = *y;
*y = tmp;
}
void AdjustUp(HPDataType* arr, int child)//需要的参数是调整的哪个数组,以及要调整的这个数据的下标
{
int parent = (child - 1) / 2;
while (child>0)
{
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void HPPush(HP* php, HPDataType x)//插入数据
{
assert(php);
//得判断数组空间是否足够
if (php->size == php->capacity)//说明空间不足,要扩容
{
//这是对数组容量为0,也就是插入第一个数据时的处理
int newCapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
HPDataType* tmp = (HPDataType*)realloc(php->arr, sizeof(HPDataType) * newCapacity);
if (tmp == NULL);
{
perror("realloc fail!");//报错
exit(1);//空间都申请失败了那就直接退出
}
php->arr = tmp;
php->capacity = newCapacity;//更新到最新的容量状态
}
//运行到这,空间就足够了,可以插入数据
php->arr[php->size] = x;
//但此时不一定是有效的堆,需要调整!
AdjustUp(php->arr, php->size);
++php->size;
}
那么现在当务之急是先测试一下我们写的代码有没有问题,我们来到Test.c:
#define _CRT_SECURE_NO_WARNINGS
#include"Heap.h"
void test1()
{
HP hp;//创建一个堆
HPInit(&hp);
//来一个数组存储6个数据,将这6个数据循环入堆(hp这个堆)
int arr[] = { 17,20,10,13,19,15 };
for (int i = 0; i < 6; i++)
{
HPPush(&hp, arr[i]);
}
HPDestroy(&hp);
}
int main()
{
test1();
return 0;
}
然后我们在入堆的循环之后打个断点,看一看这个入堆有没有问题。
可以看到,我们循环入堆的数据确实成为一个有效的小堆了。
本文到此结束,但是堆的内容还没有讲完,敬请期待下文。