目录
Hello,各位未来的高级程序员们,你们好,今天我们就开始进入下一个篇章了。也就是这一个篇章,树和二叉树的精彩讲解。
一.概念:
1.一些重要概念:
(1).叶节点和终点节点:
度为0的节点称为是叶节点,也就是没有孩子节点的节点,例如说下图的E,I,J,K等节点称为叶节点。
(2).双亲节点或父节点:
若一个节点含有子节点,则这个节点称为其节点的父节点,如上图所示:A是B的父节点。
(3).孩子节点或子节点:
一个节点含有的子树的根节点称为该节点的子节点,如上图所示:B是A的子节点。
(4).树的高度和深度:
树中节点的节点的最大层次,例:上述树的高度为4。
2.构成:
任何一棵树都是由两部分组成。
树 {根 }树是由递归定义的
{N颗子树(N>=0) }
二.二叉树的概念与实现:
1.概念:
一棵二叉树是结点的一个有限集合,该集合是由一个根节点加上两棵别称为左子树和右子树的组成二叉树,或者该集合为空。
{ 二叉树不存在度大于2的节点
{ 二叉树的子树有左右之分,次序不能颠倒,因此二叉树也叫有序树
2.特殊的二叉树:
(1)满二叉树:
一个二叉树,如果每一层的结点数都达到最大值,那么这个二叉树就为满二叉树:(高度为K,节点数为2^K-1)
(2)完全二叉树:
对于深度为K的,有n个结点的二叉树,当且仅当其每一个节点都与深度为K的满二叉树中编号从1到n的节点一一对应时,称为完全二叉树。(前K-1层都是满的,只有最后一层不满,且最后一层从左到右必须是连续中间不能有空。)
三.二叉树的内存存储:
1.顺序存储(数组存储):
如下图所示:
注意:这种顺序结构存储只适用于完全二叉树,其余均不适用。
2.链式存储:
使用与非完全二叉树(解释在下一个博客,这篇博客我们先来讲一下顺序存储结构)。
四.顺序存储的相关解释与应用:
其实呢,当我们说到顺序存储的时候,就需要知道一个知识点叫做堆,这里的堆和操作系统中的堆它不是同一个东西,它是同一个名字,但却是两个不同的东西,这里的堆指的是一棵完全二叉树。由于我们接下来要讲的内容是需要用到堆这个知识点的,因此,我们在将顺序存储之前,先来了解一下堆这个知识点。
1.堆:
(1).小堆:
a.完全二叉树,b.任何一个父亲<=孩子,特点:根最小,
(2).大堆:
a.完全二叉树,b.任何一个父亲>=孩子,特点:根最大。
我们已经了解了有关堆的相关知识了,那么接下来,我们就以小堆为例来实现与二叉树有关的一些操作。
2.堆的各项操作:
(1).定义一个堆
(这里我们使用数组来作为堆的底层结构,因为我们通过上面的一些知识,我们可以知道堆在物理上是以数组的顺序存储方式存储的,而在逻辑上我们是将其想象成一棵树,因此这里我们采用数组来作为堆的底层结构来建堆):
typedef int HPDataType;//堆中的元素不一定是int类型,这步操作是将其换一个名字,方便日后好进行替换。
typedef struct Heap
{
HPDataType* _a;//这里我们没有直接定义一个静态数组,是因为在日后如果二叉树中插入的元素越来越多的话,而我们的数组是静态的,就会有溢出的风险,会造成数据丢失,因此我们在这里定义一个指针,让其指向一块动态数组空间,方便日后对其进行扩充这一步操作。
int _size;//堆中元素的有效个数。
int _capacity;//堆中的容量大小。
}Heap;
(2).堆的初始化:
void HeapInit(Heap* hp)
{
assert(hp);//首先我们得保证hp指针接收到了传过来的类型为Hp结构体的地址,否则无法通过解引用操作一一对结构体中的变量进行初始化操作。
hp->_a = NULL;//指针一般初始化为NULL。
hp->_capacity = 0;//整数类型一般初始化为0。
hp->_size = 0;
}
(3).堆的数据个数:
int HeapSize(Heap* hp)
{
assert(hp);
return hp->_size;//通过我们上面对结构体的解释,就可以知道size变量中存放的就是堆中的数据个数。
}
(4).取堆顶的数据:
这一步操作就是将数组中的第一个元素取出来。
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->_size);//要想得到堆顶元素的值,首先就得保证堆中有元素,也就是size必须大于0。
return hp->_a[0];
}
(5).堆的判空:
这一步操作就是判断堆中是否含有元素。
int HeapEmpty(Heap* hp)
{
assert(hp);
if (hp->_size)//如果size为0,则说明数组中没有元素。
{
return 0;
}
else
{
return 1;
}
}
(6).向上调整建堆
(这里我们先来说明一下为什么要写这一步操作,通过我们上面对于堆这个概念的讲解,我们知道堆中的数据它不是凌乱无章的,而是有一定的逻辑在里面的,堆中的数据必须遵守根最小的这一条件,因此,我们想往堆中插入一个元素,首先就得确定这个元素在堆中的位置,是在某一棵子树上还是在某一个根上),这个方法在插入元素的时候会使用到:
这里来给大家讲解一下向上调整建堆的具体操作方法,这里所谓的向上调整建堆就是某一个要调整的刚刚插入的新节点的位置,我们让这个节点作为孩子节点,让其往上走,就是让这个孩子节点与它的所有的祖宗节点进行一一比较操作,如果比某一个祖宗节点大,就与该祖宗节点交换,否则,直接结束(我们这里是以建小堆为例来讲解的)。
void AdjustUp(HPDataType* a, int child)
{
assert(a);//这一句代码的解释与上面的解释相同。
int parent = (child - 1)/2;//我们要让这个节点与它的祖宗节点进行比较,就必须要先找到祖宗节点(这里的祖宗节点也就是父节点)。
while (child >= 0)//我们在这里要考虑到最坏的情况,就是这个节点它可能比所有的祖宗节点都要大,因此,我们这里采用孩子节点不小于0这个条件来作为向上调整建堆这一个操作的结束条件。
{
if (a[child] < a[parent])//如果该节点比父亲节点小,则说明当前的这个堆在加入了该节点之后它就不是小堆了,就必须改变这个堆。
{
Swap(&a[child], &a[parent]);//交换该节点和它的父亲节点中元素的位置,这里只是元素进行交换,并不是节点进行交换操作。
child = parent;//交换了之后,我们就要继续往上再去判断它是否比它交换后的这个位置的父亲节点还小,因此,我们在这里让这个节点成为新的孩子节点。
parent = (child - 1) / 2;//找到它的新的父亲节点,进行新一轮的判断,比较。
}
else//该节点比父亲节点大,则说明当前的这个堆在加入了该节点之后它仍然是小堆,这样的话就不需要去改变这个堆了。
{
break;//直接跳出循环即可。
}
}
}
(7).交换元素:
void Swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = 0;
tmp = *a;
*a = *b;
*b = tmp;
}
(8).堆的插入
(当我们知道了向上调整建堆这一操作之后,我们就可以开始进行堆的插入操作了):
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);//这一句代码的解释与上面的解释相同。
if (hp->_capacity == hp->_size)//要想完成建堆操作,首先就要判断一下该堆中有没有空间,也就是判断hp->_capacity和hp->_size是否相同,若这两个变量相同,就说明该堆中没有空间了来进行插入了,就必须对原数组扩容了。
{
int newcapacity = hp->_capacity == 0 ? 4 : hp->_capacity * 2;//在进行扩容之前,先看一下原数组中的空间是不是0,若是,则我们就要开创空间,我们将开创的空间设置为4个空间,反之,就要扩容,扩容的空间是原数组空间的2倍。
Heap* tmp = (Heap*)realloc(hp->_a, newcapacity*sizeof(HPDataType));//用calloc函数进行扩容操作。
if (tmp == NULL)//判断有没有扩容成功。
{
perror("realloc fail");
return;
}
hp->_capacity = newcapacity;//若扩容成功,就要重新为结构体中的变量赋值(因为数据变了)。
hp->_a = tmp;
}
hp->_a[hp->_size] = x;//插入新节点。
hp->_size++;
AdjustUp(hp->_a, hp->_size-1);//我们插入的新节点是在数组的最后,我们要进行上调整建堆这一个操作来确定新节点在堆中的位置。
}
(9).向下调整建堆
(我们这里再来介绍一下写这一步的原因,这一步操作实际上是用来进行排序的内容的,根据堆的特点我们可以得知小堆的根是整个堆中的所有元素中最小的那个元素,我们要想对堆进行从小到大的排序的话,就可以利用堆的这个特点进行操作,当然,也可以进行插入操作和删除元素操作(这里的删除元素指的是删除堆中的第一个元素)):
接下来我们来讲解一下这个操作的具体过程,向下调整建堆其实就是要删除堆中第一个元素,我们可以将第一个元素与最后一个元素进行交换操作,然后size--,删除掉元素之后,由于我们这个堆是一个小堆,因此,我们要对其进行向下调整的操作,让其变成一个小堆,首先找到它的孩子节点,判断谁小,若孩子小,交换,否则,直接结束向下建堆操作。
void AdjustDown(HPDataType* a, int n, int parent)
{
assert(a);
int child = parent * 2 + 1;//找到它的孩子节点。
while(child < n )//这里我们要以最坏的情况来结束向下建堆操作,结束的条件是孩子节点不能越界,也就是child < n。
{
if (a[child + 1] < a[child] && child + 1 < n)//由于我们这里建的是小堆,因此,我们得保证根必须是最小的,所以我们要找到它的两个孩子节点中最小的那个孩子。
{
child++;
}
if (a[child] < a[parent])//如果该节点比它的最小的孩子小的话,就要进行交换操作,就说明在与最后一个元素进行交换操作之后,这个堆就不再是小堆了。
{
Swap(&a[child], &a[parent]);//进行交换操作。
parent = child;//交换了之后,我们就要继续往下再去判断它是否比它交换后的这个位置的孩子节点还小,因此,我们在这里让这个节点成为新的父亲节点。
child = parent * 2 + 1;//找到孩子节点。
}
else//该节点比孩子节点小,则说明当前的这个堆在删除了第一个元素之后它仍然是小堆,这样的话就不需要去改变这个堆了。
{
break;
}
}
}
(10).堆的删除
(这里我们删除的元素是堆顶元素(就是堆中第一个元素)):
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->_size);
Swap(&hp->_a[0], &hp->_a[hp->_size-1]);//根据我们上面的解释,首先让第一个元素与堆中最后一个元素交换。
hp->_size--;//size--。
AdjustDown(hp->_a, hp->_size, 0);//由于我们建立的是小堆,我们就得让进行调整操作,就是向下调整建堆操作。
}
(11).对数组进行堆排序:
这里我们还是和上面一样来为大家解释一下过程,我们大家都是学习数据结构的,相比大家肯定都或多或少的通听过堆排序吧,每错,接下来,我就为大家来介绍一下堆排序的过程,说起堆排序,想必大家的脑海中第一个印象就是建立一个第三方数组,来帮助进行排序(我们要想实现堆排序,首先就得建立一个堆),这样也是可以的,但是这样的话,会让空间复杂度上升的,我们这里有更好的方法可以去实现堆排序,就是我们直接在原数组上建堆。
我们就以上述数组来实现堆排序,由上图我们可以看到该数组不是堆,我们将其想象成一棵二叉树,首先要先建立一个堆,可以直接在原数组上建小堆,我们从倒数第一个非叶子节点开始向下调整(叶子节点不用调,我们默认它是小堆或大堆),一直往前调,具体操作如下图所示。
void HeapSort(int* a, int n)
{
assert(a);
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//这一步操作就是建堆操作,i = (n - 1 - 1) / 2这句代码是让i指向倒数第一个非叶子节点(n-1-1)/2(n指的是数组中的元素个数)。
{
AdjustDown(a, n, i);
}
//当程序进行到这里时,就说明小堆已经建立好了,那么该堆的根就是整个堆中最小的元素,我们就可以借鉴前面的删除操作,完成排序操作,我们将堆顶元素放到最后一个元素的位置上,将其打印出来,size--,将其删掉(必须确保堆是小堆)。
while (n > 0)
{
Swap(&a[0], &a[n - 1]);
AdjustDown(a, n - 1, 0);
n--;
printf("%d ", a[n]);
}
}
OK,我们关于堆和的知识和顺序存储的知识就先讲到这里了,我们下一篇博客是链式存储的内容,最后,感谢大家的观看,希望这个知识可以帮到大家。