在我们原来的学习中有提到过堆这个概念,在我们内存布局中,会有栈区,堆区等,堆是一种数据结构,用于存储数据,在我们深入学习堆之前,我们需要先了解一下什么是树,因为堆在逻辑结构上是个树,让我们来一点一点进行学习吧
树
提到树,我们会想到什么呢,是不是就是门外那颗有年代的枣树,有根,有茎,有叶子等等,而我们在数据结构中的树,并不完全是那样的,让我们一起来了解下吧
树的概念树是一种 非线性 的数据结构,它是由 n ( n>=0 )个有限结点组成一个具有层次关系的集合。 把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的 。有一个特殊的结点,称为根结点,根节点没有前驱结点除根节点外,其余结点被分成 M(M>0) 个互不相交的集合 T1 、 T2 、 …… 、 Tm ,其中每一个集合 Ti(1<= i<= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有 0 个或多个后继因此,树是递归定义的
上面便是一颗普通的树,从根节点开始,向下延申若干个结点,最底层的结点叫叶子节点,上下直接关系的结点称为父节点与子节点
下面我们来认识下树的详细概念
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图: A 的为 6叶节点或终端节点:度为 0 的节点称为叶节点; 如上图: B 、 C 、 H 、 I... 等节点为叶节点非终端节点或分支节点:度不为 0 的节点; 如上图: D 、 E 、 F 、 G... 等节点为分支节点双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图: A 是 B 的父节点孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图: B 是 A 的孩子节点兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图: B 、 C 是兄弟节点树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为 6节点的层次:从根开始定义起,根为第 1 层,根的子节点为第 2 层,以此类推;树的高度或深度:树中节点的最大层次; 如上图:树的高度为 4堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图: H 、 I 互为兄弟节点节点的祖先:从根到该节点所经分支上的所有节点;如上图: A 是所有节点的祖先子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是 A 的子孙森林:由 m ( m>0 )棵互不相交的树的集合称为森林;
认识玩树的基础概念之后,我们再来看看树的储存结构
树的表示树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,实际中树有很多种表示方式,如:双亲表示法,孩子表示法、孩子兄弟表示法等等。我们这里就简单的了解其中最常用的 孩子兄弟表示法 。
typedef int DataType ;struct Node{struct Node * _firstChild1 ; // 第一个孩子结点struct Node * _pNextBrother ; // 指向其下一个兄弟结点DataType _data ; // 结点中的数据域};
这种方法称为孩子兄弟表示法,在一个节点中设立两个指针,一个指向自己的第一个孩子节点,一个指向自己的兄弟节点,这样就可以将树的逻辑结构表达清楚了
还有一种表示方式是孩子双亲表示法,在每个孩子节点中存储其父节点的下标,不过这种存储方式不常用,因为不好去增删数据,在此我们不做更多的解释
二叉树
事实上,在我们对树进行使用的时候,用的一般都是二叉树,二叉树这种结构才真正适合我们数据的存储
二叉树的概念一棵二叉树是结点的一个有限集合,该集合或者为空,或者是由一个根节点加上两棵别称为左子树和右子树的二叉树组成。二叉树的特点:1. 每个结点最多有两棵子树,即二叉树不存在度大于 2 的结点。2. 二叉树的子树有左右之分,其子树的次序不能颠倒。
数据结构中的二叉树
我们可以看到,二叉树,顾名思义,一个父节点最多有两个子节点,依次向下排列,这便是二叉树
特殊的二叉树:1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K ,且结点总数是 (2^k) -1 ,则它就是满二叉树。2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为 K的,有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 K 的满二叉树中编号从 1 至 n 的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
对于我们的满二叉树而言,它每一层的节点个数都是固定的,第一层一个,第二层两个,第三层四个,,等等,这时我们就可以总结出规律
满二叉树的节点个数=2^0+2^1+2^2+2^3.....+2^(k-1),很显然,这是我们的等比数列求和,最后得出的结论就是(2^k)-1
完全二叉树则是由满二叉树减去x,x为空出的节点个数得到的,需要注意的是完全二叉树的左边都是满的,右边都是空的
二叉树的性质1. 若规定根节点的层数为 1 ,则一棵非空二叉树的 第 i 层上最多有 2^(i-1) 个结点 .2. 若规定根节点的层数为 1 ,则 深度为 h 的二叉树的最大结点数是 2^h- 1 .3. 对任何一棵二叉树 , 如果度为 0 其叶结点个数为 n0, 度为 2 的分支结点个数为 n2, 则有 n0 = n2 + 14. 若规定根节点的层数为 1 ,具有 n 个结点的满二叉树的深度 , h=Log2(n+1) . ( Log2(n+1) 是 log 以 2 为底,n+1 为对数 )5. 对于具有 n 个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从 0 开始编号,则对于序号为i 的结点有:1. 若 i>0 , i 位置节点的双亲序号: (i-1)/2 ; i=0 , i 为根节点编号,无双亲节点2. 若 2i+1<n ,左孩子序号: 2i+1 , 2i+1>=n 否则无左孩子3. 若 2i+2<n ,右孩子序号: 2i+2 , 2i+2>=n 否则无右孩子
这些是二叉树的一些基本运算的规律总结,在此不在过多赘述,在使用时碰到了类似的问题可以进行查阅
二叉树的存储结构二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构
我们在了解了二叉树的基本概念了之后,就需要了解下它的存储结构,二叉树可以由数组和链表进行存储
顺序存储:顺序结构存储就是使用 数组来存储 ,一般使用数组 只适合表示完全二叉树 ,因为不是完全二叉树会有空间的 浪费。而现实中使用中只有堆才会使用数组来存储,关于堆我们后面的章节会专门讲解。二叉树顺序存储在 物理上是一个数组,在逻辑上是一颗二叉树。
这便是我们的顺序结构存储方式,而使用这种结构父亲与儿子的节点关系也很明确
假设父亲节点下标为n,那么其左儿子为2n+1,右儿子为2n+2
链式存储:二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链
链式存储,顾名思义,跟我们的链表一样,用指针将各个结点连接起来
在我们链式存储中,可以想到的是,其一定拥有三个域,左儿子域,右儿子域,数据域,这就是一般二叉链表的表示方式,而三叉链表则是多加了一个双亲域,方便寻找父节点,可以类比成双向链表
// 二叉链struct BinaryTreeNode{struct BinTreeNode * _pLeft ; // 指向当前节点左孩子struct BinTreeNode * _pRight ; // 指向当前节点右孩子BTDataType _data ; // 当前节点值域}// 三叉链struct BinaryTreeNode{struct BinTreeNode * _pParent ; // 指向当前节点的双亲struct BinTreeNode * _pLeft ; // 指向当前节点左孩子struct BinTreeNode * _pRight ; // 指向当前节点右孩子BTDataType _data ; // 当前节点值域} ;
这便是他们的储存结构
以上便是我们二叉树的主要性质,有了二叉树概念的铺垫,接下来,我们来认识下我们的堆
堆
首先我们来认识一下堆,对本质上其实是个完全二叉树,并且是使用数组来构建的完全二叉树,在堆中数值是需要满足一定关系的
而我们的堆也分为两种,大根堆与小根堆
顾名思义,大根堆就是根比子节点数值要大的堆,小根堆就是根节点比子节点数值要小的堆,但是同层兄弟之间并无大小关系
在这里我们的数组关系也很明确
假设父亲节点下标为n,那么其左儿子为2n+1,右儿子为2n+2
那么当我们拿到一组数据时,该如何将其调整为堆呢
int a [] = { 27 , 15 , 19 , 18 , 28 , 34 , 65 , 49 , 25 , 37 };
我们需要将其调整为堆,我们先画出他们的树形结构
有了他们的树形结构我们只需要对其数值进行调整即可构建堆,因为这颗树更偏向于小顶堆,那么我们就来对其进行小顶堆的调整
我们对这个树进行观察会发现,其左子树与右子树都是一个小堆,那么当两边都是小堆如何把整个堆调成小堆呢?
我们可以利用向下调整算法
总结下来就是将小的孩子与父节点比较,若小孩子小,交换,并且重复这个动作,直到调整到叶子节点或者比父节点大
那么我们怎么去实现这个算法呢,我们知道,这只是我们的逻辑结构,并不是真正的物理结构,物理结构是数组种进行操作的,那么我们就可以利用孩子与父亲的关系n,2n+1与2n+2来进行操作,下面是代码演示
void AdjustDown(int *a, int n, int parent)//数组指针,数组元素个数,父节点变量
{
int child = parent * 2 + 1;//定义左孩子与父亲的关系(右孩子为child+1)
while (child < n){//当孩子结点在数组内时(当孩子不存在时父亲就到了叶子节点)
if (child + 1 < n&&a[child + 1] < a[child]){//右孩子在存在且右孩子较小(原先默认左孩子小)
++child;//那么让小的孩子变为右孩子
}
if (a[child] < a[parent]){//当孩子小于父亲(注意这里并不区分左右孩子,因为最后在计算中相除左右孩子自动向下取整得到的结果是一样的)
Swap(&a[parent],&a[child]);//交换孩子与父亲
parent = child;//孩子成为新的父亲
child = parent * 2 + 1;//恢复孩子与父亲关系,
}
else{
break;
}
}
}
我们来对向下调整算法来进行时间复杂度的分析:其满足(2^h)-1-X=N,h=logN,所以其时间复杂度为O(logN)
我们解决了两边都是小顶堆如何整个调整为小顶堆,那么我们想,当两边不是小堆呢?
当我们的树是这个样子时便不再满足两边都是小堆了我们对于这种情况,可以利用之前的向下调整算法,自己去从下向上调整
这便是我们的解决方法,从最后一个非叶子节点开始,依次向前调整,先将1用向下调整调为小堆,再将2调为小堆,此时4也就满足了向下调整算法,以此类推,这里的n-1是来寻找最后一个有叶子节点的,因为没有右叶子节点,所以我们再-1去寻找左叶子节点,又因为最后/2运算找父节点时会向下取整,所以不影响寻找父节点,然后再依次对n--去寻找下一组
我们来思考一下建堆的时间复杂度
我们可以想象的到,第一层的一个节点最多需要调整到叶子节点层去,所以得到的最多调整次数为h-1,第二层两个结点分别最多需要调整到叶子节点,所以他们调整次数为2*(h-2),以此类推,最后利用错位相减法,得到时间复杂度为O(N)
那么我们要是想将整个堆调整为大堆呢?
其实只需要将大的换上去就好了,去改变判定条件就可以
调好的小堆与大堆
堆排序
在我们对堆以及如何建堆有了了解之后,下面进入我们的下一个堆的应用,堆排序,我们了解了建堆以及堆的结构,会发现这个结构本来就有一种数据大小的排序性,那么我们如何来实现堆排序呢?
首先我们思考一个问题,想要对一堆数据进行升序排序,应该建大堆还是小堆呢?
很多人会想,在小堆中最小的在最上面,应该排小堆,依次去取出顶上的元素不就好了吗?这种思想其实是不对的,因为当我们拿走最小的之后,剩下的需要重新建堆,而建堆所需要的时间复杂度为O(N),再加上依次排序,又需要乘N,最后排序是在平方阶上,消耗很大,还不如其他我们学过的排序,所以建小堆并不适合
当小堆不合适,我们可以试着建立大堆,我们可以想到,当有一个大堆时,最大的数就一定在顶端,而我们再将其与最后一位交换,再拿走这个最大的数据时,大堆的大结构并不会被破坏,可以继续操作其他数据,直接省去了重新建堆的过程
那么我们思考一下应该如何操作
因为大堆的性质,所以我们可以直接找到最大的那一个,下一步我们将最大的与最后一个元素交换,放到了它最后排好序该在的位置,然后我们重新看堆,将最大的数不看做堆中元素,其他部分视为堆,重复上述过程直到所有的元素全部拿出,下面是图示
这便是堆排序的思想,下面我们对其进行代码实现
void Swap(int *a, int *b)//交换函数
{
int temp = *a;
*a = *b;
*b = temp;
}
void AdjustDown(int *a, int n, int parent)//数组指针,数组元素个数,父节点变量
{
int child = parent * 2 + 1;//定义左孩子与父亲的关系(右孩子为child+1)
while (child < n){//当孩子结点在数组内时(当孩子不存在时父亲就到了叶子节点)
if (child + 1 < n&&a[child + 1] < a[child]){//右孩子在存在且右孩子较小(原先默认左孩子小)
++child;//那么让小的孩子变为右孩子
}
if (a[child] < a[parent]){//当孩子小于父亲(注意这里并不区分左右孩子,因为最后在计算中相除左右孩子自动向下取整得到的结果是一样的)
Swap(&a[parent],&a[child]);//交换孩子与父亲
parent = child;//孩子成为新的父亲
child = parent * 2 + 1;//恢复孩子与父亲关系,
}
else{
break;
}
}
}
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)//不断从后往前将数放入该放的位置
{
Swap(&a[0], &a[end]);
//选出次大的
AdjustDown(a, end, 0);//从end向下调整,忽略排好的数,其他数重新调整
end--;
}
}
数据结构:堆
我们来分析一下堆排序的时间复杂度,因为我们建堆需要的时间复杂度为O(N),从后往前遍历需要N,中间调整算法需要LogN,所以最后计算执行次数为(N*LogN)+N,最后时间复杂度为O(N*LogN)
那么回到最初,我们的堆是一种数据结构,所以我们可以对其封装,然后进行各种操作,现在我们就来实现一下
#pragma once
#include<stdio.h>
#include<stdbool.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
typedef int HPDataType;
struct Heap
{
HPDataType* a;//数组指针
int size;//元素个数
int capacity;//数组容量
};
typedef struct Heap HP;
void Swap(int *a, int *b);//交换
void AdjustDown(int *a, int n, int parent);//向下调整算法
void AdjustUp(int *a, int child);//向上调整算法
void HeapInit(HP* php, HPDataType* a, int n);//初始化
void HeadDestroy(HP* php);//销毁
void HeapPush(HP*php, HPDataType x);//插入元素
void HeapPop(HP*php);//删除元素
HPDataType HeapTop(HP*php);//显示堆顶元素
int HeapSize(HP*php);//显示堆中元素个数
bool HeapEmpty(HP*php);//判空
void HeapPrint(HP*php);//打印
这是堆的存储结构以及常用的接口,下面我们对其接口进行实现
1.向上调整算法
void AdjustUp(int *a,int child)//向上调整算法
{
int parent = (child - 1) / 2;//父子关系
while (child > 0)//当子节点在数组中时
{
if (a[child] > a[parent])//比较
{
Swap(&a[child],&a[parent]);
child = parent;//上移
parent = (child - 1) / 2;//重新寻找父节点
}
else{//若较小,则退出
break;
}
}
}
因为我们之前已经对向下调整算法进行过分析,现在我们分析向上调整算法,这个算法是应用在插入接口中的,当我们对一个堆进行插入元素时,我们并不能判断这个数字与其父节点的大小关系,当我们构建一个大堆时,我们的父节点都需要比子节点大,此时我们在最后一个位置插入元素,若比父节点小,则无需交换,若比父节点大,则与父节点交换,并再次向上寻找父节点比较,直到比较到比父节点小的为止,这就是我们的向上调整算法
2.初始化
void HeapInit(HP* php, HPDataType* a, int n)//初始化
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType)*n);//当数组空间不足时扩容
if (php->a == NULL)
{
printf("malloc fail\n");
exit(-1);
}
memcpy(php->a, a, sizeof(HPDataType)*n);
php->size = n;//元素个数设为n
php->capacity = n;//容量设为n
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//从最后一个非叶子节点开始
{
AdjustDwon(php->a, php->size, i);//不断向下调整
}
}
我们初始化的时候首先考虑扩容问题,当扩容问题解决之后再对元素个数以及容量初始化,然后向下调整,调成堆,即可完成初始化
3.销毁
void HeadDestroy(HP* php)//销毁
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
销毁函数比较常规,将指针置空后,大小容量置0
4.插入
void HeapPush(HP*php, HPDataType x)//插入元素
{
if (php->size == php->capacity)
{
HPDataType*tmp = (HPDataType*)realloc(php->a, php->capacity * 2 *sizeof(HPDataType));//扩容
if (tmp = NULL)
{
printf("realloc fail\n");
exit(-1);
}
php->a = tmp;
php->capacity *= 2;
}
php->a[php->size] = x;//在最后一个位置插入
php->size++;//容量加1
AdjustUp(php->a, x);//调整为堆
}
}
这里的插入就是先在最后一个位置插入元素,而后进行向上调整为堆
5.删除
void HeapPop(HP*php)//删除元素
{
assert(php);
assert(php->size > 0);
Swap(php->a[php->size - 1], php->a[0]);//最后一个元素与第一个元素交换
php->size--;//删除最后一个元素
AdjustDown(php->a, php->size, 0);//重新调整为堆
}
我们这里的删除元素,一般都是指删除根节点的元素,我们先将根节点元素与最后一位交换,再进行删除,这样堆的大结构没有变,在进行向下调整算法来调整,就好了
6.显示堆顶元素,计数,判空,打印
HPDataType HeapTop(HP*php)//显示堆顶元素
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
int HeapSize(HP*php)//显示堆中元素个数
{
assert(php);
return php->size;
}
bool HeapEmpty(HP*php)//判空
{
assert(php);
return php->size == 0;
}
void HeapPrint(HP*php)
{
for (int i = 0; i < php->size; i++){
printf("%d ", php->a[i]);
}
printf("\n");
int num = 0;
int levelSize = 1;
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
++num;
if (num == levelSize)
{
printf("\n");
levelSize *= 2;
num = 0;
}
}
printf("\n");
}
这些基本的函数接口难度不大,值得注意的是在打印中,我们对其进行了适当的调整,为了最后打印出来的数字看起来呈二叉树的模样,第一层打印一个,第二层两个,第三层四个等等
这便是我们堆这个重要的数据结构的基本操作