文章目录
前言
路漫漫其修远兮,吾将上下而求索;
一、二叉树链式结构的实现
上篇文章所写的二叉树是关于完全二叉树以及堆的实现,其目的在于选数;但在实际中,数据不一定会呈现完全二叉树并且也不一定会用来选数据;此时二叉树的存储便会用到链式结构;
我们先来回顾一下二叉树的链式存储结构:
二叉树的链式存储结构指的是用链表来表示一棵树,即用 链 来指示元素的逻辑关系;
常用的方法是每个结点由三个域组成,数据域与左右指针域:
- 数据域用来存放数据
- 左右指针域分别指向该节点的左节点、右节点所在的结点的存储地址
链式结构又分为二叉链与三叉链(我们此时涉及的是二叉链,学到红黑树的时候便会用到三叉链,三叉链在二叉链的基础上多了一个指针域用来指向该节点的父节点);二叉树的链式存储结构如下图所示:
链式结构就类似于链表的结构,我们需要为其结点定义结构体类型,此结构体中存在一个数值域,两个指针域分别指向该节点的左右孩子,甚至可以再来一个指针指向该节点的父节点,其具体声明如下:
可能你会有疑问,用链式结构来实现二叉树究竟有什么用?
- 实际上用链式存储结构来存储树形结构意义不是很大;其最终目的是为了服务于搜索二叉树;
什么是搜索二叉树?
- 搜索二叉树在二叉树的基础上添加了一个条件:树中的所有子树均满足:左子树 < 根节点 < 右子树;
注:搜索二叉树中左子树中的值均要小于其根节点中的值,其右子树中的值均会大于根节点中的值;
搜索二叉树是用来搜索的,其具体工作流程如下:
上图中,假设所要查找的数为42,首先42 会与根节点进行比较,大于根节点接下来便会去其右子树中继续进行比较,小于根节点便会去其左子树中继续进行比较;同理,一直查找下去,最多会查找该树的高度次;如果当找到空树了还没有找到便说明此结构中没有此树;
注:这种查找数据的方式给人的感觉很像二分查找,但是搜索二叉树比二分查找强很多;
二分查找在实际当中非常不好用:
1、排序(二分查找的前提是数据有序,数据很多的话,消耗很大)
2、二分查找查找的数据必须利用数组存放以实现随机访问的效果;但是数组存储对于数据的管理非常不友好,当删除、插入数据的时候需要挪动数据,效率低下;
但是搜索二叉树在极端情况下也存在缺陷,该树可能会变成一个单支,那么其查找的时间复杂度便为O(N),效率也没有多高;(基于此于是会学习 平衡二叉树:AVL、红黑树以及B树系列(多叉平衡搜索树)等);
二、二叉树的遍历
二叉树的主要遍历形式有四种:
- 1、前序遍历(前根遍历): 若二叉树为空,则返回空操作; 否则访问根节点的操作发生在遍历其左右子树之前,即其的访问顺序为:根节点、左子树、右子树
- 2、中序遍历(中根遍历):若二叉树为空,则返回空操作;否则访问根节点的操作发生在遍历左右子树之中,即访问的顺序为:左子树、根节点、右子树
- 3、后序遍历(后根遍历):若二叉树为空,则返回空操作;否则访问根节点的操作发生在遍历左右子树之后,即访问的顺序为:左子树、右子树、根节点;即从左向右先叶子后节点的方式遍历访问左右子树,最后访问的是根节点;
- 4、层序遍历:若二叉树为空,则返回空操作;否则从树的第一层,也就是根节点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对节点逐个进行访问;
其中,前序、中序、后序遍历为深度优先遍历,层序遍历为广度优先遍历;
为什么要研究这么多种二叉树的遍历方式?
- 当我们用图形来表示树的结构的时候,对于我们来说是非常直观和利于理解的,但是对于计算机来说,它只有循环、判断等方式来处理,也就是说计算机只会处理线性序列,而我们上述所提到的四种遍历方法,其实就是将树中的节点变成某种意义上的线性序列,于是这就会给我们的程序带来好处;不同的遍历方式提供了对节点依次处理的不同方式,可以在遍历过程中对节点进行各种处理;不同的遍历方式可能可以帮助我们解决不同的问题;
(一)、前序遍历
若二叉树为空,则返回空操作; 否则访问根节点的操作发生在遍历其左右子树之前,即其的访问顺序为:根节点、左子树、右子树
前序遍历图解:
前序遍历访问上树的结果如下:
注:将任意的二叉树均往 根、左子树、右子树上拆;当拆到空子树的时候便不用再拆了;利用分治的思想(分而治之),将遍历一棵树分为先遍历根节点、再遍历根节点的左子树,当根节点的左子树遍历完了之后再遍历根节点的右子树;而左子树又可以分为根节点与左右子树……如此反复,将大问题分解为小问题,再将小问题分解为更小的问题……直到该问题不可分解时便结束;
前序遍历的代码如下:
//前序遍历 - 根 左子树 右子树
//遇到空子树返回,
void PrevOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");//此处增加了一个遇到空树便打印NULL的细节,利于理解遍历的全过程
return;
}
printf("%d ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
代码逻辑分析如下:
注:return 会回到调用该函数源代码下一条指令的位置上;
一个函数的结束,可以是return 也可以是该函数自然结束;
物理层面分析:
首先在调用main函数的时候,会创建属于main 函数的函数栈帧,在此函数栈帧中会存放局部变量root ,当在main 函数中又要调用PrevOrder 时,又会建立属于PrevOrder 的函数栈帧……
从上图可以明显地得知,存放数据3的这个结点的左子树与右子树建立的栈帧是同一块空间(当递归到3这个结点的时候,访问其左节点又会创建函数栈帧,由于3这个结点的左子树为NULL,于是便会直接返回即3结点左子树开辟的函数栈帧的空间会销毁;当要访问3结点的右节点的时候,又会对应创建相应的函数栈帧,其创建栈帧所使用的空间或许就是3结点左子树开辟的函数栈帧的空间,即体现了空间的重复使用);道理就是函数栈帧的创建与销毁;
也可以如此抽象理解:空间就像租房子一样,操作系统相当于房东;房东手中有大量可以租的房子(操作系统掌握着许多可以支配的空间),当你malloc 一块空间的时候就相当于是向房东租了一间房子;当你不想租这个房子的时候。那么这个房间就要归还给房东,这样当有另外的人想要租这块空间的时候,房东便可以租给他;即房间始终是存在的,但并不是所有人均可以使用,只有向房东租了房子的人才可以只用这个房子所占用的空间;同理,内存空间始终是存在的,当操作系统分配给你使用的权限的时候你才可以使用;
注:当递归深度太深或者没有限制条件的时候会导致栈溢出的问题(栈溢出:不断地向下调用,不断地创建栈帧,而栈中的空间是有限的,当栈中地空间不足时便会出现栈溢出地现象);
相同的函数在Release 版本比在Debug 版本上开地函数栈帧会更小,故而Release 版本地承栈能力比Debug 版本更好一些;(在Debug 版本下大概一万层栈帧的时候便扛不住了);
question1:为什么无论此树多么地复杂,均可利用递归将此树遍历出来呢?
- 因为普通函数调用逻辑较为简单;就像上述前序遍历地代码会将这个树 的结构均走一遍,不断地去”开枝散叶“;
question2:每棵树为什么会自动拆成根和子树?
- 所写的前序遍历的代码会被编译成机器可读的二进制指令;而递归函数中会自己调用自己;(以存放了数值1的结点的函数栈帧的创建为例子)调用此函数的前部分均为建立函数栈帧,建立第一个函数栈帧的时候,会在其栈帧中存放数据1(数据1会利用寄存器在函数栈帧中压入参数),而执行了相应的指令之后还会调用自己(再创建栈帧),函数栈帧中操作过程是相同的,不同点在于栈帧中的参数不同;当遇到空树的时候便会停止”自己调用自己“(停止创建函数栈帧),返回时便会销毁对应所创建的函数栈帧;
注:
- 1、全局变量、静态变量是存放在静态区的,静态区中的数据程序一旦运行起来便存在,当该程序结束的时候该空间中的数据才会销毁;
- 2、动态开辟的空间,malloc、calloc、realloc开辟的空间在堆区,而堆区中的空间用的时候需要申请,不用的时候需要将其释放;
- 3、局部变量存放在栈上,只有当该局部变量对应的函数栈帧创建的时候才会为其分配空间;
(二)、中序遍历
若二叉树为空,则返回空操作;否则访问根节点的操作发生在遍历左右子树之中,即访问的顺序为:左子树、根节点、右子树
注:在任何一棵子树中,左子树访问完了才能访问根,根访问了才能访问右子树;
遍历图解如下:
代码如下:
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");//此处增加了一个遇到空树便打印NULL的细节,利于理解遍历的全过程
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
注:一棵二叉树可以分为左子树、根节点、右子树;当一个根节点的左子树中的结点均遍历结束的时候,才可以遍历该根节点以及该根节点的右子树;当遇到空树直接返回即可;
(三)、后序遍历
若二叉树为空,则返回空操作;否则访问根节点的操作发生在遍历左右子树之后,即访问的顺序为:左子树、右子树、根节点;
即从左向右先叶子后节点的方式遍历访问左右子树,最后访问的是根节点;
遍历图解如下:
代码如下:
//后续遍历
void PostOrder(BTNode* root)
{
if(root==NULL)
{
printf("NULL ");//此处增加了一个遇到空树便打印NULL的细节,利于理解遍历的全过程
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
(四)、层序遍历
若二叉树为空,则返回空操作;否则从树的第一层,也就是根节点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对节点逐个进行访问;
假设二叉树的根节点所在层数为1,层序遍历就是从所在的根节点出发,首先访问第一层的根节点,然后从左往右访问第二层上的结点,接着是第三层的结点,以此类推,自上而下,从左往右逐层访问树的结点的过程就是层序遍历;
实现层序遍历会使用到一个数据结构 - 队列;
借助于队列,当我们每到达一层的时候就可以将该层的所有结点从左往右存储起来,打印了一个结点就将该结点从队列中pop,倘若该节点有子节点便push 进队列之中……由于队列的特性“先进先出”,故而就可以实现对于二叉树一层一层地打印;
代码如下:
//层序遍历
//层序遍历需要借助于一个数据结构 队列 - 先进先出
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
//先将根节点push
QueuePush(&q, root);
//当队列中没有数据循环才会结束
while (!QueueEmpty(&q))
{
//取出对头的数据,并pop
BTNode* top = QueueFront(&q);
QueuePop(&q);
printf("%d ", top->data);
//将其不为空的左右孩子入队列
if (top->left)
{
QueuePush(&q, top->left);
}
if (top->right)
{
QueuePush(&q, top->right);
}
}
//销毁队列
QueueDestroy(&q);
}
有一点值得注意:
在队列之中对二叉树结点的址类型重命名得时候要加上struct ,如果不加则会报如下得错误:
正确的写法:
此处需要将结点的地址入队列,队列中存放的数据的类型为BTNode* ,但是结构体类型BTNode 是经过typedef 过的,即BTNode 的原型为struct BTNdode ;如若直接用BTNode,在 Queue.h 之中找不到在Tree.h 里面定义的BTNode ,为什么呢?
- 实际上struct BTNode 是一个前置声明,即告诉Queue.h 在一个文件之中定义了这样的结构——结构体,其名称为BTNode 。如果在Queue.h 中写成BTNode ,那么在编译链接期间编译器就不知道BTNode 到底是什么类型,我们得告诉编译器这是一个结构体类型所以得加上struct ;
当然如果你在Queue.h 之中包含Tree.h 也可以直接使用BTNode ,但是不推荐这么做;因为头文件包含会在包含得文件中展开,而Queue.h也会被其他文件包含,如果Queue.h 之中包含Tree.h就会大大增加代码量;
三、其他接口函数
(一)、求二叉树中结点的个数
看到这个问题你可能会想能否用遍历+计数器实现;
那么该计数器是局部变量还是全局变量呢?
- 局部变量出了其作用域便会被销毁,显然局部变量是不可行的(函数栈帧销毁,局部变量所占用的空间也就销毁了);可能你还会想到静态变量(静态变量也分为局部、全局),局部静态变量也是行不通的,因为局部的静态变量只能初始化一次,无论你调用该函数多少次,该静态变量均会只被初始化一次;由于局部静态变量存储在函数栈帧中,故而将该变量置0也十分不好控制(需要在作用域进行操作,不好操作);而用全局变量或者全局的静态变量可行,但是多次调用“求二叉树中结点的函数”时会出现“累加”情况, 即调用该函数前需要将计数器置0;当然,也可以将局部变量以传址调用的形式代入计算,但是仍然存在需要将计数的变量置空的操作;
综上,使用全局(变量、静态变量)均可,但是每次使用之前均需要置空;也可以使用局部变量,但是该变量必须以传址调用参数的形式带入;
上面的方法,使用起来均不方便,并且很low ,是否还有其他解决方法?
利用分治(将大问题划分成小问题)的思想来解决:
代码如下:
//二叉树结点个数
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}
(二)、二叉树中叶节点的个数
解决此问题同样有两种方法:方法一:遍历所有节点然后专门数叶节点的个数;方法二:分治
分治图解:
遍历图解:
代码如下:
//求叶子节点的个数 - 左子树中的叶节点+ 右子树中的叶节点
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
return 0;
if (root->left == NULL && root->right == NULL)
return 1;
return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
(三)、二叉树的高度 -- 最大层次
同理,可以分情况讨论:
代码如下:
//树的高度 - 最大层次
int BinaryTreeHeight(BTNode* root)
{
//根节点+左右子树中较大值
if (root == NULL)
return 0;
int height1 = BinaryTreeHeight(root->left);
int height2 = BinaryTreeHeight(root->right);
return height1 > height2 ? height1 + 1 : height2 + 1;
}
注:在写代码的时候最好创建两个变量间来记录左右子树的高度,如果直接使用三目,错误代码如下:
//以下代码是错误的
int BinaryTreeHeight(BTNode* root)
{
if (root == NULL)
return 0;
return BinaryTreeHeight(root->left) > BinaryTreeHeight(root->right) ?
BinaryTreeHeight(root->left) + 1 : BinaryTreeHeight(root -> right) + 1;
}
由于并未保留当前左右子树的高度,直接将计算其高度的式子放入三目中,便会出现多次重复计算;
(四)、该树第K层的结点个数
思考:第几层显然就是递归了几次,即递归的深度;如果用全局变量来控制深度(即递归到一层就自减),可行,但是每次使用这个接口函数的时候均要将该全局变量置为初始值;局部变量呢?在函数内部创建的局部变量不可行,因为只有当对应的函数栈帧创建的时候该局部变量才会创建,函数栈帧销毁的时候其对应的空间便会被销毁;但如果局部变量以传参的形式进入便可行;
图解如下:
代码如下:
//第k层节点的个数
int BinaryTreeLevelKSize(BTNode* root,int k)
{
if (root == NULL)
return 0;
if (k == 1)
return 1;
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
(五)、查找值为x的结点
核心思想就是遍历查找,前序遍历,先比对根节点中给的数据,不为x 在去其左子树中去查找,在左子树中找不到便去右子树中进行查找(如果在左子树中找到了便直接返回,就无需去右子树中进行查找,况且函数只能有一个返回值,故而找到一个符合值为x 的节点便可以直接返回);
图解如下:
代码如下:
//寻找值为x 的节点
BTNode* BinaryTreeXNode(BTNode* root,BTDataType x)
{
if (root == NULL)
return NULL;
//根节点中找
if (root->data == x)
return root;
//左子树中找
BTNode* leftNode = BinaryTreeXNode(root->left, x);
if (leftNode)//找到了便返回
return leftNode;
//右子树中找
BTNode* rightNode = BinaryTreeXNode(root->right, x);
if (rightNode)//找到了便返回
return rightNode;
//没有找到
return NULL;
}
注:此处使用前序遍历的思想就足够了;没有必要使用中序、后序遍历;因为前序遍历直接便可以比较当前的根节点,而中序、后序遍历会先将左子树或者左右子树遍历完了才去比对根节点中的值,有点多此一举的意味在里面;
此处还涉及多层返回,即如果在左右子树中找到结点便可以直接返回;一是因为该函数的目的是返回找到的一个结点;二是因为函数只能有一个返回值;
(六)、销毁
同之前所写数据结构实现的销毁一样,即是释放所有动态开辟的空间;在二叉树中,其节点均为动态开辟的空间,将所有节点释放了便可;
选择一种遍历方式,如果用前序或者中序遍历的话,那么就会用变量来保存该节点的下一节点,但是当前节点会有几个孩子这是不清楚的(不一定会是完全二叉树),并且即使创建变量保存了孩子节点的地址只要出了其作用域局部变量便会被销毁;如果在堆上创建空间,那么所要创建的空间究竟要多大来保存节点的地址,这是未知的;可能还会说先遍历便将树中所有的节点均保存在数组中,如此的话就会遍历两次;
有一次遍历便可以达到销毁的效果吗?
- 后序遍历,从下到上,非常符合二叉树销毁的逻辑;
代码如下:
//二叉树节点的销毁
//销毁得先销毁最下面的,然后往上进行销毁的操作,后序遍历
//遇到NULL便返回
void BinaryTreeDestroy(BTNode** root)
{
if (*root == NULL)
return;
BinaryTreeDestroy(&(*root)->left);
BinaryTreeDestroy(&(*root)->right);
free((*root));
*root = NULL;
}
(七)、判断二叉树是否为完全二叉树
完全二叉树:最后一层(第n 层)结点从左往右依次排列但是不一定会排满;但是前 (n-1) 层的结点均是满的;
显然,我们可以利用层序遍历这个点子入手,也正是因为完全二叉树的最后一层不会是满的但是一定是从左往右按顺序排列的,其前面几层均是满的,那么如果按照层序遍历的思想,将二叉树的结点(无论是否为空结点都入)入队列,当第一次遇到NULL的时候便可以进行判断;此时队列不一定为空,继续取队列中的结点,如果遇到一个结点非空结点那么该树就不是完全二叉树;相反,如何其后面均为NULL,那么该树就为完全二叉树;
代码如下:
bool BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
//取队头,出队头
BTNode* top = QueueFront(&q);
QueuePop(&q);
//空树
if (top == NULL)
{
break;
}
//非空树
//不管有没有左右孩子均放入队列之中
QueuePush(&q, top->left);
QueuePush(&q, top->right);
}
//出了循环便说明遇到了一个空树
//队列不一定为空
//接下来继续取队列中的树结点,如果取到非空结点
//便说明该二叉树为非完全二叉树
while(!QueueEmpty(&q))
{
BTNode* top = QueueFront(&q);
QueuePop(&q);
if (top != NULL)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return false;
}
总结
- 1、前序遍历(前根遍历): 若二叉树为空,则返回空操作; 否则访问根节点的操作发生在遍历其左右子树之前,即其的访问顺序为:根节点、左子树、右子树
- 2、中序遍历(中根遍历):若二叉树为空,则返回空操作;否则访问根节点的操作发生在遍历左右子树之中,即访问的顺序为:左子树、根节点、右子树
- 3、后序遍历(后根遍历):若二叉树为空,则返回空操作;否则访问根节点的操作发生在遍历左右子树之后,即访问的顺序为:左子树、右子树、根节点;即从左向右先叶子后节点的方式遍历访问左右子树,最后访问的是根节点;
- 4、层序遍历:若二叉树为空,则返回空操作;否则从树的第一层,也就是根节点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对节点逐个进行访问;
其中,前序、中序、后序遍历为深度优先遍历,层序遍历为广度优先遍历;
5、无论是求二叉树中结点、叶节点的个数、树的高度、第K层的结点个数 均是用到的分治的思想,即将大划小,分而治之;可以敲敲代码好好体会一下;