在前面学习了基本的线性表以后,接下来学习一下二叉树这个非线性的数据结构,它的非线性在于,在逻辑上不是线性排列的。
首先我们先来了解一下树的概念
这是我们在生活中常见的树,数据结构中的树也是模仿生活中树的样子创造出来的,它长下图中这个样子。
可以看到,这个树的根是在上面的,它的叶子在下面,就像是一根倒挂的树一样。
树的概念:
树是一种非线性的数据结构,它是由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层,以此类推 | 如上图:B所在的层数就是第二层 |
树的高度或深度 | 树中节点的最大层次 | 如上图:树的高度为4 |
堂兄弟节点 | 双亲在同一层的节点互为堂兄弟 | 如上图: H、 I互为堂兄弟节点 |
节点的祖先 | 从根到该节点所经分支上的所有节点 | 如上图:P的祖先是J、E、A |
子孙 | 以某节点为根的子树中任一节点都称为该节点的子孙 | 如上图:所有节点都是A的子孙 |
森林 | 由m(m>0)棵互不相交的树的集合称为森林 |
以上这些概念都是用来描述树的,当然经常使用到的仅有几个,比如节点的度,叶节点,父节点,子节点,节点的层次,树的高度等概念。只有知道了这些,才能更好的描述出一棵树的特点。
树的表示:
那么树转化成计算机语言是怎么描述的呢?
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。
前面我们学习了链表,顺序表也就是数组等数据结构,到底采用什么方式来描述树呢?
表示的方法有很多,比如仿造链表的来表示的方法:
struct TreeNode
{
int data;
struct TreeNode** childArr;
int childSize;
int childCapacity;
};
- 创建一个结构体
- childArr是一个指针数组,这里存放的是当前父节点的子节点的地址
- childSize存放是子节点的个数
- childCapacity存放的是字节的容量
这个方法并不是特别好,下面这个方法可以算是是一个相当牛逼的方法了,叫做左孩子右兄弟表示法。
ypedef int DataType;
struct Node
{
struct Node* firstChild1; // 第一个孩子结点
struct Node* pNextBrother; // 指向其下一个兄弟结点
DataType data; // 结点中的数据域
};
- 类似于双向链表,结构体中有俩个指针,和存放数据的变量
- frstChild1指向的是该父节点的左孩子,也就是最左边的子节点
- pNextBrother指向的是兄弟节点,也就是该父子节点左边的亲兄弟节点
逻辑示意图表示
在了解了树以后,接下看看我们的重点,二叉树。二叉树是树的一种特殊形式
二叉树的概念:
一棵二叉树是结点的一个有限集合,该集合:
- 或者为空
- 或者由一个根节点加上两棵别称为左子树和右子树的二叉树组成
注意:
二叉树不存在度大于2的结点
二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
在二叉树中也有特殊存在的二叉树:
- 满二叉树:
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 2K-1,则它就是满二叉树。
- 完全二叉树:
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
要明白的一点是满二叉树也是完全二叉树。
知道完全二叉树中的节点个数是非常必要的,因为在很多时候会用的着,接下来本喵介绍一下完全二叉树中的节点个数是怎么计算的。
使用归纳法,可以得出,满二叉树第k层有2(k-1)个节点。
那么它一共有多少个节点呢?
可以看到,从第1层往下走到第k层,每一层的节点个数是按照等比数列的规律增长的,
按照等比数列的前n项和,可以得出,k层高的满二叉树供有2k-1个节点。
使用归纳法,可以得出,满二叉树第k层有2(k-2)+n个节点。其中n是最后一层的节点个数
同样的,它一共有多少个节点呢?
- 不满的完全二叉树的前k-1层是一个满二叉树,前k-1层的节点个数是2k-1-1个
- 再加上最后一层有n个节点,所以k层不完全二叉树一供有2k-1-1+n个节点
由于完全二叉树最后一层至少会有一个节点,所以k层完全二叉树所有节点的个数是从2k-1到2k-1这个范围内的。
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。这篇文章中,本喵仅介绍顺序存储。
顺序存储:
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储二叉树顺。
我们前面见到的二叉树都是逻辑结构,比如
这种二叉树的形状是我们在脑海中想象出来的,并不是真实存在的,所以我们叫它逻辑结构。
而我们所构想出来的二叉树数据结构是要存放在内存中的,这些数据在内存中是以数组的形式顺序存放的,比如
所以说,二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
堆是将二叉树这个逻辑结构在内存中顺序存储的。
注意:
这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段,只是重名了而已。
堆的概念:
如果有一个关键码的集合K = { k0,k1 ,k2 ,…,kn-1 },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2i+1 且 Ki <= K2i+2 (Ki <= K2i+1 且 Ki <= K2i+2) i = 0, 1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
只看概念是不是仍然是一头雾水,本喵接下来通过图生动的来给大家讲解一下。
我们知道,二叉树是我们所想象出来的一个逻辑结构,而在内存中是以数组的方式顺序存储的,二叉树和数组看起来完全不一样,它们是怎么联系起来的呢?
- 在二叉树中,我们从第一层开始,将每个节点从左到右从0开始逐渐增大来标号,如图中二叉树每个节点旁边的数字那样的标号。
- 按照我们上面的标号将每个节点的数据放在内存中对应下标的数组中。
这样一来是将二叉树中的数据存放在数组中了,但是我们发现,二叉树中的父子关系在数组根本是无法得知的,
其实是有一个对应关系的
leftchild表示左边的子节点,rightchild表示右边的子节点, parent表示父节点,child表示子节点,不分左边还是右边的子节点
- 我们取节点B为父节点,它的标号是1,我们将标号带入公式中
- leftchild = 1*2 + 1 = 3,左边子节点D的标号就求出来了,是3
- rightchild = 1*2 + 2 =4,右边子节点E的标号就求出来了,是4
按照这个下标看物理结构中的数组,
- 下标为1的位置是B,也就是我们的父节点
- 下标为3的位置是D,也就是我们的左边子节点
- 下标为4的位置是E,也就是我们的右边字节点
可以看到,这时就对应起来了。
接下来看怎么用子节点求父节点的标号
- 我们取节点F和节点G分别当作C的左边子节点和右边子节点
- 利用左边子节点求父节点时
parent = (leftchild-1)/2- 利用右边子节点求父节点时
parent = (rightchild-2)/2
但是我们在数组中取到一个数据的时候,并不知道它是左边的子节点还是右边的子节点,这里我们做一个统一的处理
- 取到的子节点不区分左边子节点还是右边子节点,统一看作是子节点child
- 此时求出的父节点是
parent = (child - 1)/2- 我们可以看到,如果是左边子节点,将5带入后求出的结构是2,将6带入以后,求出的结构同样是2,因为使用的是‘/’这个运算。
- 通过左边子节点和右边子节点得到的结果都是相同的,也就是父节点的下标,所以只用这一个公式就可以。
同样按照这个下标看物理结构中的数组,
- 下标为5和6在数组中是子节点F和G
- 求出来的下标2在数组中是父节点C
小根堆:
小根堆中,所有父节点的值小于等于子节点的值
- 将10看作是父节点,那么它的俩个子节点的15和56都是比它大的
- 将15看作是父节点,那么它的俩个子节点的25和30都是比它大的
- 将56看作是父节点,那么它的子节点70是比它大的
大根堆:
小根堆中,所有父节点的值大于等于子节点的值
- 将70看作是父节点,那么它的俩个子节点的56和30都是比它小的
- 将56看作是父节点,那么它的俩个子节点的25和15都是比它小的
- 将30看作是父节点,那么它的子节点10是比它小的
堆必须是大根堆或者是小根堆,这是它的必须有的一个性质。
注意: 堆总是一棵完全二叉树。
堆在内存中是以数组的形象存储的,所以我们需要创建一个顺序表。
typedef int HPDateType;
typedef struct Heap
{
HPDateType* data;
int size;
int capacity;
}HP;
结构体中包括一个指针,用来指向内存中的数组,还有size存放堆的节点个数,还有capacity存放数组的容量。
//堆的初始化
void HeapInit(HP* php)
{
assert(php);
php->data = NULL;//数组初始化为空
php->capacity = php->size = 0;//容量和个数初始化为空
}
//堆的摧毁
void HeapDestroy(HP* php)
{
assert(php);
free(php->data);//释放掉动态开辟的数组
php->data = NULL;//让指针失忆
}
我们这里只有数组的空间是动态开辟的,所以只释放数组就行。
//堆的打印
void HeapPrint(HP* php)
{
assert(php);
int i = 0;
for (i = 0; i < php->size; i++)
{
printf("%d ", php->data[i]);
}
printf("\n");
}
堆的打印只是为了我们在调试的时候更方便。
我们知道堆是分为大根堆和小根堆的,我们这里使用的是小根堆。
如上图中的堆,原本的堆是一个小根堆,在插入数据10以后,小根堆的结构被破坏掉了,所以我们需要进行调整,让堆在插入数据以后仍然保持是小根堆。
分析:
- 小根堆的结构必须是父节点小于等于子节点
- 所以插入的数据10就需要和它的父节点28进行比较,如果子节点小于父节点,那么他俩交换位置,改变父子关系
- 交换后继续比较,依次类推,直到小根堆的结构成立。
就像上图中的这个过程一样,每插入一个数据都需要进行一次这样的调整,让堆始终保持是小根堆结构。
这样从下向上的调整方式叫做向上调整
向上调整是一个非常重要的算法,它还可以用来建堆,下面来看看它的代码实现
//向上调整
void AdjustUp(HPDateType* data, int child)
{
int parent = (child - 1) / 2;//利用公式通过子节点下标计算出父节点下标
while (child > 0)
{
//子节点小于父节点时,进行交换
if (data[child] < data[parent])
{
Swap(&data[child], &data[parent]);//交换,改变父子关系
//子节点和父节点下标进行迭代
child = parent;
parent = (child - 1) / 2;
}
else
{
break;//当子节点大于等于父节点时,符合小根堆结构,不用再交换
}
}
}
- 循环的控制条件必须使用子节点的下标来控制
用子节点控制时,当堆是空的时候,插入第一个数后是自成堆的,所以不会进入循环
如果采用父节点来控制循环,有时会造成死循环- 交换俩个数的位置,改变父子节关系时封装了一个函数,这个函数的实现很简单
- 父节点和子节点下标进行迭代的时
子节点直接赋值父节点的下标,父节点需要通过公式重新计算- 调整停止时
由于堆在插入数据之前就是一个小根堆,所以在插入数据之后进行调整的过程中,不一定要调整到堆的根部,只要停下来就说明已经成小堆了。
在实现了向上调整的算法以后,向堆中插入数据便可以实现了
//交换俩个数据
void Swap(HPDateType* p1, HPDateType* p2)
{
HPDateType temp = *p1;
*p1 = *p2;
*p2 = temp;
}
//向上调整
void AdjustUp(HPDateType* data, int child)
{
int parent = (child - 1) / 2;//利用公式通过子节点下标计算出父节点下标
while (child > 0)
{
//子节点小于父节点时,进行交换
if (data[child] < data[parent])
{
Swap(&data[child], &data[parent]);//交换,改变父子关系
//子节点和父节点下标进行迭代
child = parent;
parent = (child - 1) / 2;
}
else
{
break;//当子节点大于等于父节点时,符合小根堆结构,不用再交换
}
}
}
//向堆中插入数据
void HeapPush(HP* php, HPDateType x)
{
assert(php);
//判断是否需要扩容
if (php->size == php->capacity)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDateType* temp = (HPDateType*)realloc(php->data, sizeof(HPDateType) * newcapacity);
if (temp == NULL)
{
perror("realloc fail");
exit(-1);//开辟失败,之间退出程序,返回值是-1
}
//扩容成功
php->data = temp;
php->capacity = newcapacity;
}
//插入数据
php->data[php->size] = x;
php->size++;//数据个数加1
AdjustUp(php->data, php->size - 1);//传入数组和插入的孩子下标
}
和顺序表一样,在插入数据之前需要判断是否需要扩容。
在前面本喵提过一下,向上调整是非常重要的,因为它可以建堆,那么它是怎么建的呢?
看代码:
void HeapTest1()
{
HP hp;
HeapInit(&hp);
int a[] = { 15,18,19,25,28,34,65,49,27,37,10};
for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
{
HeapPush(&hp, a[i]);
}
HeapPrint(&hp);
}
最开始堆是空的,我们将这个数组中的每一个数据挨个插入堆中,每插入一次就向上调整一次,如此便能够建成一个小根堆。
直接将数组中的数改变位置关系以后,我们发现它此时就是一个小根堆,所有的父节点都比子节点小,此时一个小根堆也被我们成功创建。
从堆中删除数据,删除的是堆顶的数据
将堆顶的数据删除以后,我们发现,成了俩个堆了,已经完全不符合一个小根堆的结构了,该怎么办?
有人说,重新排列一下,如果只是单纯的将数组中的数整体向前移动一步的话,整个堆的父子关系都会乱套,重新建堆的话,代价又太大
这里采取一个代价相对较小的方式。
这里我们们使用的方法是向下调整
这同样是一个非常重要的算法,也是可以用来建堆的,我们来看看它的实现
//向下调整
void AdjustDown(HPDateType* data, int n, int parent)
{
int minchild = parent * 2 + 1;//假设比较小的子节点是左边的子节点
while (minchild < n)//确保子节点不能越界
{
//找到真正小的子节点
if (minchild + 1 < n && data[minchild + 1] < data[minchild])
{
minchild++;
}
//如果子节点小于父节点,则交换,改变父子关系
if (data[minchild] < data[parent])
{
Swap(&data[minchild], &data[parent]);//交换父节点和子节点
//父子节点进行迭代
parent = minchild;
minchild = parent * 2 + 1;
}
else
{
break;//已经符合小根堆特征
}
}
}
- 循环控制条件是子节点的下标不能超出数组的访问界限,也就是不能让子节点越界
- 必须找出俩个子节点中较小的那个节点和父节点进行交换,否则调整就会不到位
同样需要控制子节点没有越界访问。
minchild+1就是右边子节点的下标- 较小的子节点和父节点进行交换的函数和向上调整中用的是一个交换函数。
- 进行迭代时
父节点直接赋值小的子节点的下标
新的左边子节点通过公式计算- 只要不发生交换就说明符合小根堆的特征
向下调整实现以后我们就可以删除数据了,看代码
//从堆中删除数据
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));//保证堆不为空
Swap(&php->data[0], &php->data[php->size - 1]);//交换堆顶和最后一个元素
php->size--;//删除最后一个元素,个数减1
AdjustDown(php->data, php->size, 0);//向下调整
}
分三步,交换,删除,向下调整。
看效果
void HeapTest2()
{
HP hp;
HeapInit(&hp);
int a[] = { 10,15,18,19,25,28,34,65,49,27,37 };
for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
{
HeapPush(&hp, a[i]);
}
HeapPrint(&hp);//打印堆
HeapPop(&hp);//删除
HeapPrint(&hp);//打印
}
先看我们建好的堆
是一个小堆,删除堆顶以后再看
上面是删除堆顶以后在数组中的样子,下面是转化成二叉树的堆结构的样子,可以看到,删除以后仍然是一个小根堆。
//取堆顶数据
HPDateType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));//防止堆为空
return php->data[0];
}
直接返回堆顶数据即可。
//判断堆是否为空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
通过堆中数据的个数是否为0来判断。
//堆中数据的个数
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
直接返回个数。
二叉树顺序存储就是堆,堆是一个非常重要的数据结构,使用的频率非常的高,具体的使用本喵在后面会给大家详细介绍。希望对各位有所帮助。