在前个章节中,我们已经对树的基本结构和完全二叉树的堆结构有了整体的认识,本章将单独对二叉树进行更详细和深入的结构认识和练习详解。一般而言,二叉树的结构是递归式的,它与其他数据结构一样,同样可以存储数据,每个数据之间都存在相应的联系。与普通的树相比,二叉树每个结点的度最大为2,即其最多仅有左右两个子结点。
前一章中我们详细对比过了二叉树使用顺序表或链表的方式进行定义,发现在物理结构上,使用顺序表浪费的空间很多,其只适用于存储连续结点结构的完全二叉树或满二叉树结构。而对于一般的没有左右子结点排布规律的普通二叉树,我们则使用链表进行结构定义。
二叉树因其特殊的逻辑结构,不像其他数据结构一样适合用于对数据的增删查改,因为其开辟的空间消耗更多,逻辑更复杂,如果使用如此复杂的结构只是为了存储数据,就没有太多的实际价值和意义。该种结构的链式二叉树最大的意义在于,它是其他更高级和更复杂树状结构的基本组成部分,比如搜索二叉树,AVL树,红黑树和B+树等。
链式二叉树使用链表的方式进行定义,双亲结点与左右子结点通过结点结构体指针相连接,其中每个结点都能存储对应数值。
链式二叉树结构
//二叉树结点存储数值类型重命名
typedef int BTEtype;
//二叉树基本结构
typedef struct BinaryTree
{
BTEtype data; //数值域
struct BinaryTree* left; //左结点指针
struct BinaryTree* right; //右结点指针
}BTNode;
二叉树结点创建
BTNode* BuyTreeNode(BTEtype x)
{
BTNode* NewNode = (BTNode*)malloc(sizeof(BTNode));
assert(NewNode);
NewNode->data = x; //数值域赋值
NewNode->left = NewNode->right = NULL; //左右结构体指针初始化置空
return NewNode;
}
手动建立二叉树结构
BTNode* BinaryTreeCreate()
{
BTNode* root = BuyTreeNode(0); //根结点
BTNode* n1 = BuyTreeNode(1); //左子结点
BTNode* n2 = BuyTreeNode(2); //右子结点
BTNode* n3 = BuyTreeNode(3);
BTNode* n4 = BuyTreeNode(4);
BTNode* n5 = BuyTreeNode(5);
root->left = n1; //建立链接关系
root->right = n2;
n1->left = n3;
n2->left = n4;
n2->right = n5;
return root; //将创建好关系的二叉树根结点地址返回
}
与代码对应逻辑结构图示如下
测试用例
BTNode* root = BinaryTreeCreate();
调试观察结果
可以看到,创建好的二叉树每个结点的物理地址都是随机的,而在逻辑上又严格遵循用户规定的左右子结点链接关系。
相比于顺序表和链表而言,数值的遍历就是从头到尾将下标或指针移动访问一遍取出数据即可,从栈开始到队列,对于该两种数据结构的遍历就不单是数据的访问了,而是通过取顶再弹栈或出队的方法将数据清空后才能完成遍历。而从堆开始,堆的遍历自堆顶取出再输出甚至具有自动排升降序的功能,二叉树整体结构与堆类似,但不局限于完全二叉或满二叉结构。因为是链式结构,数据在内存中不是连续的物理存放,所以对于二叉树的遍历方式很多,一般可以分为前序,中序,后序,层序遍历。
所谓深度优先遍历(Depth-First-Search),对于一颗二叉树而言,就是先对其以根结点为起点的左右子树向下进行不断深入探寻最下层结点,找到后再遍历相邻的其他深层结点的方式,最后层层递归回到上层结点处。换句话说,深度优先遍历就是从一棵树的最左边开始,一条路径一条路径地向下找最深处的结点,继而再遍历其他处于深处的结点。
深度优先遍历按照对子树和子结点的遍历规则不同,可以细分为前序遍历,中序遍历和后序遍历。
void PreOrder(BTNode* root)
{
if (root == NULL) //如果遇到结点地址为空,打印NULL,表示没有子结点
{
printf("NULL ");
return;
}
printf("%d ", root->data); //直接打印每个首次遇到的结点值
PreOrder(root->left); //递归进入左子结点
PreOrder(root->right); //左子结点递归完毕后,递归进入右子结点
}
测试用例
//创建一棵上例所示的二叉树
BTNode* root = BinaryTreeCreate();
//将二叉树进行前序遍历并打印
PreOrder(root);
观察结果
0 1 3 NULL NULL NULL 2 4 NULL NULL 5 NULL NULL
递归原理图
前序遍历的递归过程展开如上图所示,程序将首先遍历到的每个结点值先打印,有数值就打印数值,没有数值则直接进入if判断打印NULL来代表此处没有结点,所以需要注意到即使一个结点的左右子树为空,但程序仍然会对其进行深入并遍历,而不是非常智能地遇到空值就避开。
void InOrder(BTNode* root)
{
if (root == NULL) //遇到空值打印NULL并直接返回
{
printf("NULL ");
return;
}
InOrder(root->left); //先遍历左子树
printf("%d ", root->data); //再打印结点值
InOrder(root->right); //最后遍历右子树
}
中序遍历大体思路与前序遍历相同,都是需要代码的递归实现,而最大的区别在于不同于前序遍历的先对遇到的每个根结点值打印再遍历左右子结点,中序遍历首先要访问和打印左子树的左子结点,将左子树值全部打印后进而才能访问父结点的值,最后才能访问和打印右子树结点的值。
上例创建的二叉树进行中序遍历,观察结果
NULL 3 NULL 1 NULL 0 NULL 4 NULL 2 NULL 5 NULL
递归原理图
访问根结点的操作发生在遍历其左右子树之间。
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
PostOrder(root->left); //先遍历左子树
PostOrder(root->right); //再遍历右子树
printf("%d ", root->data); //最后打印结点值
}
先打印最深层的左子结点值,再打印右子结点值,最后打印根结点的值是后序遍历递归的基本思路。
同样依据上述二叉树用例进行后续遍历测试,观察结果
NULL NULL 3 NULL 1 NULL NULL 4 NULL NULL 5 2 0
访问根结点的操作发生在遍历其左右子树之后。
与深度优先遍历的一开始就寻找最深处的左子树结点不同,二叉树的广度优先遍历(Breath-First-Search)则是根据每层的最大结点个数横向遍历,从而达到一层一层向下依次遍历的效果,而层序遍历就是二叉树遍历中最常见的遍历算法。
void LevelOrder(BTNode* root)
{
QNode* QueueHead = NULL; //定义一个队列
QueuePush(&QueueHead, root); //先将根结点地址入队
while (!QueueEmpty(&QueueHead)) //当队列不为空时,将后续结点根据要求入队
{
printf("%d ", QueueTop(&QueueHead)->data); //取队头结点打印数值域中的数据
if (QueueHead->data->left) //如果该结点存在左子结点,将该子结点地址入队
{
QueuePush(&QueueHead, QueueHead->data->left);
}
if (QueueHead->data->right) //右子结点同理入队,如果为空结点则不入队
{
QueuePush(&QueueHead, QueueHead->data->right);
}
QueuePop(&QueueHead); //每取完队头并入完子结点,将队头结点地址出队
}
QueueDestroy(&QueueHead); //层序遍历结束后,将队列销毁防止内存泄漏
}
层序遍历顾名思义,就是按照树的层层往下遍历模式,先对每层的结点从左向右依次遍历,全部遍历结束后再向下一层执行相同遍历的模式。因为层序遍历的特殊性,所以需要使用到队列的数据结构。
使用队列的原因是,将每层的结点从左往右依次入队,并在队头每出一个父结点,就在该层的所有结点队列后入队下一层的左右子结点,从而达到所有结点在队列中保持相对顺序而不会被打乱。
值得注意的是,上述图使用数据的方式代表结点的入队,而实际上将数据入队的是树结点的地址,即队列的操作元素QEType为结构体指针,而不能直接将树每个结点中数值域的数据入队,这是因为如果以数值作为入队的依据,将会无法区分空结点和树结点值为0的情况,且因为叶子结点的存在,会造成入队死循环。
typedef BTNode* QEType;
仍以上图为例,调用层序遍历函数,观察结果
0 1 2 3 4 5
注:关于队列的定义及函数接口请参考前序章节中数据结构——栈和队列_VelvetShiki_Not_VS,其中有对于队列入队,出队和取队头,队列销毁等函数的详细说明。
A B D H E C F G
E
⭕解释:先序遍历从根结点开始,则E为root根结点。
a b c d e
⭕解释:
先观察后续遍历的末字符,该值一定为根结点字符,则c为根a的右子结点;再观察中序遍历的与a相邻的字符,则b一定是根a的左子结点,将这几个字符确定好相对位置后其他位置就很容易推出了:
FEDCBA
⭕解释:
如果后续遍历结果与中序遍历相同,因为两者都是从左子树开始的深度优先遍历,所以从左侧最深处开始层层向上递归而回,每递归一次带回一个字符,而左->根->右的中序遍历与左->右->根的后序遍历在单子树的情况下结果是相同的,如下所示:
已知某二叉树的前序遍历序列为5 7 4 9 6 2 1,中序遍历序列为4 7 5 6 9 1 2,则其后序遍历序列为?
A 4 2 5 7 6 9 1
B 4 2 7 5 6 9 1
C 4 7 6 1 2 9 5
D 4 7 2 9 5 6 1
⭕解释:
本题可以通过前序遍历的第一个位置找到子树的根,为5,并在中序遍历中找到根的位置,然后确定根左右子树的区间,即根的左侧为左子树中所有节点,根的右侧为右子树中所有节点。
根左侧有4,7两个结点,根右侧有6,9,1,2四个结点。
已知某二叉树的中序遍历序列为JGDHKBAELIMCF,后序遍历序列为JGKHDBLMIEFCA,则其前序遍历序列为( )
A ABDGHJKCEFILM
B ABDGJHKCEILMF
C ABDHKGJCEILMF
D ABDGJHKCEIMLF
⭕解释:
由后序遍历确定子树的根为A(后序遍历从后向前看,最后一个元素为根,和前序遍历刚好相反)。在观察中序遍历中根A的位置,可以确定:
JGDHKB为左子树,ELIMCF为右子树。
再由后序遍历中左右子树出现在子树序列中最后的位置确认子根位置:A的左子树的根为B,A的右子树的根为C。
结合后序遍历找末尾子树根,与中序遍历区分左右子树的规则,以此类推:
B的左子树为JGDHK,B的右子树为空。同理,C的左子树为ELIM,右子树为F(同时为右子根),B的左子树的根为D,C的左子树根为E。
D的左子树有JG,根为G;D的右子树有HK,根为H。同理,E的左子树为空,右子树有ILM,根为I。
再看中序遍历,I的左子树为L,右子树为M,所以根也是这两个值。
总结:和前序遍历刚好相反,从后向前看后序遍历,应该是根,右,左,根据中序遍历确定子树的左右区间。
设某种二叉树有如下特点:每个结点要么是叶子结点,要么有2棵子树。假如一棵这样的二叉树中有m(m>0)个叶子结点,那么该二叉树上的结点总数为( )
A 2m+1
B 2(m-1)
C 2m-1
D 2m
⭕解释:根据题目提示,该二叉树仅有度为0或2的结点存在,即满二叉树。而对于任意二叉树,存在叶子结点数m总比度为2的数多1个的规律,所以带入公式有N = m + m - 1 = 2m - 1。
总结:
不管是深度优先遍历还是广度优先遍历,在二叉树中采用的对左右子树分门别类的左右递归方法统称为分治算法,即相同规模的子问题,可以使用递归来处理。
对二叉树结点个数的统计的整体思路可以类比为对二叉树的遍历,每遇到一个结点就记录该结点,并继续向后遍历,等遍历完成时,将统计完的结点个数返回即可。有两种思路可以解决。
定义一个全局变量count,对每个遍历到的结点进行count自增计数,即将先前的打印结点数值功能替换计数,不使用局部变量count的原因也很简单,因为递归本质上是函数栈帧的创建,当递归回去时,每个临时开辟的函数栈帧将会被销毁,从而局部变量也随之消失,而全局变量存放在静态区,不会随着函数的消失而消失。
全局变量count计数
int count; //定义全局变量count,初始化为0
void TreeSize(BTNode* root)
{
if (root == NULL) //当从根结点开始递归到空结点时,直接返回
{
return;
}
count++; //每遍历一个结点,count计数自增
TreeSize(root->left); //遍历左子树
TreeSize(root->right); //遍历右子树
}
//手动建树
BTNode* root = BinaryTreeCreate(); //该树如上图右,共6个结点
BTNode* root1 = BinaryTreeCreate2(); //该树如上图左,共10个结点
BTNode* root2 = NULL; //空树
TreeSize(root);
printf("树1结点个数为:%d\n", count);
count = 0;
TreeSize(root1);
printf("树2结点个数为:%d\n", count);
count = 0;
TreeSize(root2);
printf("树3结点个数为:%d\n", count);
观察结果
树1结点个数为:6 //图右的树结点个数
树2结点个数为:10 //图左的树结点个数
树3结点个数为:0 //空树
该方法的弊端在于如果使用的是多进程,则全局变量的值可能会受到干扰,且每次使用后全局变量的值应该置0,否则将对后续结点计算结果产生影响。
采用之前所述的二叉树分治思路,对左右子树分别进行统计,每递归回依次,默认返回值加1的方式对左子树中左右结点,右子树中的左右结点进行分类加法,最后将根结点左右子树的结点个数累加起来再加上根结点自身一个结点个数,即可得到二叉树的所有结点个数。
分治递归计数算法
int TreeSize(BTNode* root)
{
if (root == NULL) //如果遇到空结点,返回0不计入统计中
{
return 0;
}
return TreeSize2(root->left) + TreeSize2(root->right) + 1;//分类统计,并在递归返回时累加
}
递归原理图
图中的矩形框代表每个结点数值,方便对应二叉树的逻辑结构图,圆圈住的数字为单次递归返回的结点个数,一个父结点对应的左结点或右结点存在多少个实际存在的有效结点,则圆圈数字就为多少,这是通过递归返回的累加实现的。
测试用例同上
BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
printf("树1结点个数为:%d\n", TreeSize(root));
printf("树2结点个数为:%d\n", TreeSize(root1));
printf("树3结点个数为:%d\n", TreeSize(root2));
观察结果
树1结点个数为:6
树2结点个数为:10
树3结点个数为:0
首先需要明确树的叶子结点概念,一个没有左右子结点的父结点即为叶结点,树的最下层结点均为叶子结点,其他处于更高层但向下没有更小子树的结点也为叶结点。
叶节点统计函数
int LeafSize(BTNode* root)
{
if (root == NULL) //当遇到空结点,返回0,表示该结点为空
{
return 0;
}
if (root->left == NULL && root->right == NULL) //当该结点左子和右子均为空结点时,该结点为叶结点
{
return 1; //递归返回1
}
return LeafSize(root->left) + LeafSize(root->right); //将递归返回值累加,统计仅当为叶结点时返回+1
}
对叶结点的统计同样对根结点左右子树采用分治算法,与统计所有结点个数不同在于返回1和累加的规则不同,仅当一个结点左右没有子结点时才进行累加运算,并识别到叶结点后直接返回,将每个叶结点累加的1全部递归返回到根结点处全部汇总合计出整棵树的叶结点个数。
测试用例
BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
printf("树1的叶子结点个数为:%d\n", LeafSize(root));
printf("树2的叶子结点个数为:%d\n", LeafSize(root1));
printf("树3的叶子结点个数为:%d\n", LeafSize(root2));
观察结果
树1的叶子结点个数为:3 //图右的叶子结点个数
树2的叶子结点个数为:4 //图左的叶子结点个数
树3的叶子结点个数为:0 //空树叶子结点个数
树的深度即树的高度,以根结点为第一层开始,要计算树的最深处结点所在层数,则同样可以模仿树的结点遍历思路,先递归至树的最深层结点处,从最底层开始,每递归返回向上一层,累加一次,因为是二叉树,所有分左右两个子树的递归和返回,所以需要将累加的结果分别保存在左子树深度和右子树深度的临时变量中,并且比较左子树与右子树深度,让深度更深的一个变量作为返回值传回即可。
递归的深度计算
int TreeDepth(BTNode* root)
{
if (root == NULL) //遇到空结点,返回0不计入累加
{
return 0;
}
int LeftDepth = 0, RightDepth = 0; //定义左右子树深度累加变量作为深度递归累加数值存储
LeftDepth = TreeDepth(root->left) + 1; //每递归返回一次,累加依次
RightDepth = TreeDepth(root->right) + 1;
return LeftDepth > RightDepth ? LeftDepth : RightDepth; //返回更深一侧的子树深度
}
图中矩形代表的代码框为树结点实际存储的数值,方便观察二叉树遍历递归的箭头顺序,圆圈所表示的数值为该次递归返回的深度,取大的返回到上一次递归掉调用的函数并存储起来,用作下一次的深度比较和结果返回。
测试用例
BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
printf("树1的深度为:%d\n", TreeDepth(root));
printf("树2的深度为:%d\n", TreeDepth(root1));
printf("树3的深度为:%d\n", TreeDepth(root2));
观察结果
树1的深度为:3
树2的深度为:5
树3的深度为:0
回顾二叉树的基本性质,规定根结点的层数为1,则一棵非空二叉树的第k层上最多有2(k-1)个结点,若为满二叉树,则该棵树最多共有2k-1个结点。对于完全二叉树而言,深度为k的完全二叉树,这一类树中最少和最大结点取值范围:2(k-1)< k < 2k - 1,最少为最后第k层只有一个结点的情况,而最多为k层的满二叉树情况。
对于普通的二叉树,每一层的结点个数是没有公式或具体的规律可寻的,一层上可以没有结点(存在于某个子树上),也有可能满足2(k-1)个满结点的情况,此时如果想计算出普通二叉树某层上的结点个数,可以将整棵树的第K层问题转换为递归到第K层上统计结点个数的子问题,既然要递归遍历到第K层上,就需要分左右子树分别递归下去。
第K层结点数
int KLevelCount(BTNode* root, int k)
{
assert(k >= 1); //将传入所需知道的第k层数据,断言该数据不能低于根结点所在的第一层
if (root == NULL) //如果遇到空结点,则返回0表示该处没有结点
{
return 0;
}
if (k == 1) //当K-1递归到目标层,则将有结点所在之处返回1使上层的递归调用接收返回值,得知其下一层有一个结点
{
return 1;
}
return KLevelCount(root->left, k - 1) + KLevelCount(root->right, k - 1); //左右子树相加得最终K层结点数
}
函数传入一个K值,作为需要查询的第K层结点个数。函数向下递归,每向下遍历一个子结点,就将K值减1,这样当达到第K层时,此时K值为1,通过递归向下找到第K层的一个结点,直接返回1,让上一层接收1值并返回累加,每个子树沿着左右路径分别向下查找,等返回值根结点时所有路径的子树都要么遍历到第K层带着累加的1值返回,要么没有达到或达到K层为空结点,汇总后就可以得到第K层的结点总个数。
测试用例——仍以上图三棵树为例
BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
printf("树1的第%d层结点个数为:%d\n", 2, KLevelCount(root, 2));
printf("树2的第%d层结点个数为:%d\n", 3, KLevelCount(root1, 3));
printf("树3的第%d层结点个数为:%d\n", 4, KLevelCount(root2, 4));
观察结果
树1的第2层结点个数为:2
树2的第3层结点个数为:4
树3的第4层结点个数为:0
在二叉树中对数值的遍历也是有一定价值的,虽然对于数据的查找和存储而言没有像顺序表,链表那样简单和直观,但这并不代表二叉树就不能够胜任这些查找和数据筛选的工作。同前例一样,秉承着二叉树递归遍历的思路,对于二叉树中某个所需值的查找可以将该结点的地址返回以供用户知道该值首次出现在二叉树中的哪个结点上。
定值地址查找
BTNode* NodeFind(BTNode* root, BTEtype x) //传参二叉树根结点地址,和待查值
{
if (root == NULL) //遇到空结点,返回空地址
{
return NULL;
}
if (root->data == x) //首次查找到指定值,则返回该结点地址
{
return root;
}
BTNode* LeftSearch, * RightSearch; //定义两个二叉树结点指针,用于对不同的子树进行搜索
if (LeftSearch = NodeFind(root->left, x))
{ //如果递归查询到与目标值相同的结点值,则进入判断返回地址
return LeftSearch;
}
if (RightSearch = NodeFind(root->right, x))
{
return RightSearch;
}
return NULL; //如果全部遍历完都没有找到,则直接返回空,表示没有对应值结点
}
使用分治思路,对处于根结点和其他父节点的不同子树上进行分路递归查找,当在左路径查找到对应值时,递归返回使if判断条件为真,进入子语句并返回该地址,且一路将该地址送回根结点函数,并返回给主调函数,右路径查找同理。
测试用例
BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
printf("值为%d的1树结点地址为:0x%p\n", 5, NodeFind(root, 5));
printf("值为%d的2树结点地址为:0x%p\n", 3, NodeFind(root1, 3));
printf("值为%d的3树结点地址为:0x%p\n", 8, NodeFind(root2, 8));
树3因为为空树,所以进入函数后直接返回空值,查找不到用户对应所需的值8。
本章中二叉树是建立在链表的父子结点相互链接的关系之上的,如果二叉树在使用完毕之后每个在堆开辟的结点空间不及时进行空间销毁,则会同其他数据结构一样,会造成内存空间的消耗殆尽。二叉树销毁是有讲究的,如同顺序表,链表的销毁从尾部数据释放到头部并置空一样,二叉树也需要从最深处的子结点将空间释放,并递归向次深层逐层向上释放空间,不能从根结点开始向下(比如使用前序遍历)的方式,如果先释放了根结点,则会造成左右子树及结点的地址丢失,造成只能释放一个根结点的后果。
树销毁
void TreeDestroy(BTNode** root)
{
if (*root == NULL)
{
return;
} //采用后序遍历的方式
TreeDestroy(&(*root)->left); //递归遍历左子树
TreeDestroy(&(*root)->right); //再遍历右子树
free(*root); //从深层向上逐层释放并置空
*root = NULL;
}
测试用例
BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
TreeDestroy(&root);
TreeDestroy(&root1);
TreeDestroy(&root2);
可以看到,通过传入二级根结点地址指针的方式,成功使三棵树都进行了销毁,根结点后的所有结点都被成功释放并置空了。
[log(n + 1),n - 1]
⭕解释:
最大深度: 即每次只有一个节点,次数二叉树的高度为n,为最高的高度(有几个结点深度就为几,比如全部仅在左子树或全部仅在右子树的二叉树);最小深度: 完全二叉树情况,根据二叉树性质,完全二叉树的高度为 h = log(n+1)向上取整。
14种
⭕解释:
首先这棵二叉树的高度一定在3~4层之间:
三层:A(B(C,D),()), A((),B(C,D)), A(B(C,()),D), A(B((),C),D),A(B,C(D,())), A(B,C((),D))共6种。
四层:如果为四层,就是单边树,每一层只有一个节点,除了根节点,其他节点都有两种选择,在上层节点的左侧或右侧,所以2 * 2 * 2共8种,两层总计为14种。
只有一个叶子结点
比如下面这几种情况:
所以可以总结为,每个节点只有一个孩子,即只有一个叶子节点,此时前序与后续遍历正好相反。