之前的文章我们介绍了栈和队列,那个部分算是数据结构中的中场休息,比较容易,今天我们将要介绍的堆,难度会骤然上升,我们需要耐心的去慢慢理解,掌握了难的知识,我们才能变得更加强大。
本章将会讲到许多重要的知识,二叉树,堆排序,向上调整,向下调整算法,希望本篇文章能帮助到你,也希望你认真学习这些知识。
目录
树
在讲解堆之前,我们需要先基本的介绍一下树,大家可能在学习数据结构前就听过树的名字,以后我们还将要学习二叉树,红黑树等等,久仰大名了,今天我们就来初步接触一下树。
树概念及结构
1.树的概念
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
- 有一个特殊的结点,称为根结点,根结点没有前驱结点
- 除根结点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
- 因此,树是递归定义的。
以下就是树的逻辑结构图
注意:树形结构中,子树之间不能有交集,否则就不是树形结构
注意一下,不是树的情况,不要搞错了概念
2.树的相关概念
结点的度:一个结点含有的子树的个数称为该结点的度; 如上图:A的为6
叶结点或终端结点:度为0的结点称为叶结点; 如上图:B、C、H、I...等结点为叶结点
非终端结点或分支结点:度不为0的结点; 如上图:D、E、F、G...等结点为分支结点
双亲结点或父结点:若一个结点含有子结点,则这个结点称为其子结点的父结点; 如上图:A是B
的父结点
孩子结点或子结点:一个结点含有的子树的根结点称为该结点的子结点; 如上图:B是A的孩子结点
兄弟结点:具有相同父结点的结点互称为兄弟结点; 如上图:B、C是兄弟结点
树的度:一棵树中,最大的结点的度称为树的度; 如上图:树的度为6
结点的层次:从根开始定义起,根为第1层,根的子结点为第2层,以此类推;(有些书上会写根为第0层的情况,都可以,推荐第一层)
树的高度或深度:树中结点的最大层次; 如上图:树的高度为4
堂兄弟结点:双亲在同一层的结点互为堂兄弟;如上图:H、I互为兄弟结点
结点的祖先:从根到该结点所经分支上的所有结点;如上图:A是所有结点的祖先
子孙:以某结点为根的子树中任一结点都称为该结点的子孙。如上图:所有结点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林;
以上树的相关概念,除了加粗部分需要掌握,其他知道即可
3.树的表达
树的表达有很多种
1.明确知道树的度时,直接用指针数组
//明确告诉树的度为N
#define N 4
struct treeNode
{
int val;
struct treeNode* subs[N];
};
这种方法在N很大时,可能会导致极大的空间浪费,因为树最大的有N个节点,不代表每个父节点都有N个子节点,很可能会导致空间浪费。
2.利用顺序表存指针数组
struct treeNode
{
int val;
Seqlist* subs;//顺序表内部存struct treeNode*
};
这种方式区别与第一种指针数组,就在于顺序表可以动态开辟内存,能减少空间浪费
3.左孩子右兄弟表示方法(高手结构)
struct treeNode
{
int val;
struct treeNode* leftchild; // 第一个孩子结点
struct treeNode* rightbrother; // 指向其下一个兄弟结点
};
结构图如下
无论父亲节点有几个孩子节点,child指向左边的第一个孩子,我们只需要用指针就可以找到所有的节点,设计非常的巧妙
以上就是树的初步认识,下面我们来进一步学习二叉树的基本知识
二叉树概念及结构
1.概念
一棵二叉树是结点的一个有限集合,该集合:
1. 或者为空
2. 由一个根结点加上两棵别称为左子树和右子树的二叉树组成
注意:
1. 二叉树不存在度大于2的结点
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
2.特殊的二叉树
分为两种:
1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 ,则它就是满二叉树。
2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
完全二叉树即为我们之后要讲的堆。
3.二叉树的性质
1. 若规定根结点的层数为1,则一棵非空二叉树的第i层上最多有 2^(i-1)个结点.
2. 若规定根结点的层数为1,则深度为h的二叉树的最大结点数是 2^h-1.
3. 对任何一棵二叉树, 如果度为0其叶结点个数为 a, 度为2的分支结点个数为b ,则有a = b+1.
4. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有结点从0开始编号,则对于序号为i的结点有:
Ⅰ. 若i>0,i位置结点的双亲序号:(i-1)/2;i=0,i为根结点编号,无双亲结点
Ⅱ. 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子
Ⅲ. 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子
堆
想要学习堆,我们需要先从二叉树的顺序结构开始说起
1.二叉树的顺序结构
普通的二叉树并不适合用数组来存储,因为会浪费很大的空间,而完全二叉树比较适合用数组来存储,现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。我们看一下结构图,能让我们更好的理解。
从下标来看,如果父节点下标为i,那么左孩子的下标就为2*i+1,右孩子的下标就为2*i+2
而孩子节点下标为i,无论是左孩子还是右孩子,那么他们父亲的节点下标都为i/2
2.堆的概念
由上面的叙述,我们可以了解到堆是由完全二叉树所实现的,而堆分为两种,大堆和小堆,
小堆:每个根节点都小于子节点即为小堆
大堆:每个根节点都大于子节点即为大堆
看一下结构图
由上,我们可以总结出堆的性质
- 堆中某个结点的值总是不大于或不小于其父结点的值;
- 堆总是一棵完全二叉树。
3.堆的实现
这里我们要实现堆,是小堆
以下是我们要实现堆的具体函数
// 堆的构建
void HeapInit(Heap* hp);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
int HeapEmpty(Heap* hp);
//堆排序
void HeapSort(int* a, int n);
由于堆的结构实际上就是顺序表,所以有很多函数的逻辑结构与顺序表类似,而相对重要的在于插入删除 ,所以和顺序表内容类似的,我们不会过多讲解,大多是一笔带过,如果有些模糊,可以看一下顺序表的基本应用,复习一下
1.堆的结构体
由我们刚才的分析可知,完全二叉树比较适合于用顺序表来创建,而堆又是完全二叉树的一个特殊分支,那我们就用顺序表的形式来实现堆,定义指针开辟空间,再创建size和capacity变量,确保空间和数据的对应。
typedef int HPDataType;
typedef struct HeapNode
{
HPDataType* a;
int size;
int capacity;
}Heap;
2.堆的构建
与顺序表类似
代码如下:
void HeapInit(Heap* hp)
{
assert(hp);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
3.堆的销毁
与顺序表类似
代码如下
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
4.堆的插入
这个部分相对重要,希望大家认真对待
先放代码,再逐步讲解
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);
}
从代码中,我们可以看到实现堆的插入部分,和顺序表类似,先检查容量扩容的问题,这段代码除了最后一句与顺序表完全一致,那么问题就来了,最后一句是什么意思呢?AdjustUp?向上转换?
OK,我们先来分析一下,堆的插入,我们首先将数据插入到数组末尾,但是这一步可能会导致堆结构的错误,会违背大堆小堆的结构,因此,我们需要进行向上调整,下面是具体的结构图
正如结构图中所说,我们需要将新数据顺着双亲节点向上互换位置
向上调整
//向上调整
void AdjustUp(HPDataType* 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;
}
}
}
我们利用循环,如果父节点大于子节点,就用交换彼此的值即可。
这里有一个细节,不知道各位发现没有,我们传入的变量,并非结构体指针,而是普通的数组指针,这个细节大家记住,在后面我们会具体讲。
5.堆的删除
首先我要说明堆的删除,删除的是堆顶,而非堆尾,如果是堆尾的话,那就没必要再列出来单独讲了。
如果你认为,删除堆顶数据和顺序表的头删一样,只需要将数据依次向前挪动一位即可,那么就有些欠妥了,我们来分析一下这样做会发生什么。
由上面的图,我们可以发现,如果将堆顶数据按照顺序表头删的方法直接删除的话,会出现结构错乱,这样父子关系变成了兄弟关系,兄弟关系变成了父子关系,因此我们不能这样去实现堆的删除,那么我们应该如何去做呢?
这个神奇的方法叫做向下调整,即AdjustDown
看一下这个神奇的思路:
我们按照思路来书写代码
// 堆的删除
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
Swap(&(hp->a[0]), &(hp->a[hp->size - 1]));
hp->size--;
AdjustDown(hp->a, hp->size, 0);
}
可以看到,和思路一致,我们先将堆顶元素与最后一个元素进行交换,然后size--,就成功的将末尾的元素,即堆顶元素删除,那么这时候,很显然堆的结构发生了破坏,我们需要调整顺序,那么这个调整由于是从上往下调整,因此叫做向下调整
向下调整
//向下调整
void AdjustDown(HPDataType* a,int n,int parent)
{
int child = parent * 2 + 1;
while (child < n)//child >= 0说明孩子已经不存在,调整到叶子了
{
if (child+1 < n&&a[child] > a[child + 1])
{
child++;
}
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = child * 2 + 1;
}
else
{
break;
}
}
}
很显然,我们还需要用到循环,然后我们依次判断,将父节点与两个子节点中更小的进行交换,依次到叶节点部分,因此我们需要先假设左孩子节点为更小的节点,再利用判断语句来进行判断,变换,注意只有左孩子没有右孩子的情况,否则会出现越界访问的情况。
6.获取栈顶数据
// 取堆顶的数据
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
7.堆的数据个数
// 堆的数据个数
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
8.堆的判空
// 堆的判空
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
以上就是堆的基本应用,下面我们来具体讲一下堆排序,这可是个效率很高的排序方法,很重要!!!
4.堆排序
堆排序分为两个部分
1.建堆
这里希望大家思考一个问题,如果是排降序应该建大堆还是建小堆,排升序呢?
当我第一次接触这个问题的时候,我毅然决然的觉得排降序应该建大堆,这样每次不就是把堆顶的数据拿出来就行了吗,但是这恰恰就是进坑的标志。
我按这个思路画个图,就该明白了
我们可以看到,如果我们按照这个思路,虽然可以依次取出堆顶最大的数据,但是当取出堆顶数据后,堆的结构就被破坏了,如果想要继续排序,那么我们还必须重新建堆,周而复始,这样太麻烦了。
因此,我们需要用逆向思维,如果正向排序无法实现,那么堆排序就用逆向排序
这样排序即:
降序:建小堆
升序:建大堆
但是问题又来了,数组怎么建堆呢
我们有两种方法,第一种就是用向上调整算法,依次将数组内的数据插入到堆中,就能完成堆的创建
向上调整方法建堆
向上调整算法复杂度为O(n*logn)
for (int i = 1; i < length; i++)
{
AdjustUp(arr,i)
}
第二种方法就是用向下调整算法建堆
当利用向下调整的时候,我们必须保证左右子树都是堆,那么怎么保证左子树和右子树都堆呢,这时先辈们就想到了另一种方法,倒着往前调整
例如我们建大堆
我们清楚的知道,叶节点是无法向下调整的所以我们从倒数第一个父节点往前调整,依次将其与两个子节点进行比较,与更大的节点进行交换,这样我们一步步的就成功建成了大堆
思路如下:
我们需要倒着进行调整,因此需要从倒数第一个父节点开始,至根节点。
代码如下:
for (int i =(length-1-1/2); i > 0; i--)
{
AdjustDown(arr,i)
}
向下调整算法的时间复杂度为O(N)
可以发现向下调整的算法时间复杂度优于向上调整
2.利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序
void HeapSort(int* a, int n)
{
//降序建小堆
//升序建大堆
for (int i = 1; i < n; i++)
{
AdjustUp(a,i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
我们先建堆,然后利用向下调整的算法,将最大的数据放到最后,然后end--,但这不是真正的删去。然后再利用循环找第二大的数据,放到最后,再end--,从末尾排序,从大至小依次放到"最后",这样就实现了升序的排序。
思路图如下:
以上即堆排序的实现。
总结:
本章我们介绍了树的基本概念,二叉树,堆的实现,以及最重要的堆排序,向上调整算法,向下调整算法。我很清楚进入树的领域后,数据结构的难度会骤然上升,不过如果不挑战自己,又怎能进步呢,路漫漫其修远兮,吾将上下而求索,希望这篇文章能帮助到你,共同进步!