数据结构——链式二叉树及相关功能函数(万字解析)

文章目录

  • ⭐链式二叉树
    • ✨链式二叉树结构和基本函数
      • 二叉树结构定义
    • ✨二叉树的遍历
      • 深度优先遍历(DFS)
        • 前序遍历(先序遍历Preorder Traversal)
        • 中序遍历(Inorder Traversal)
        • 后序遍历(Postorder Traversal)
      • 广度优先遍历(BFS)
        • 层序遍历(Level Traversal)
      • 题目练习
    • ✨二叉树基本功能函数
      • 计算二叉树结点个数
        • 全局变量法
        • 分治递归法
      • 统计二叉树叶子结点个数
      • 二叉树的深度
      • 二叉树的第K层结点数量
      • 二叉树首定值的地址查找
      • 二叉树的销毁
      • 题目练习
  • ⭐后话

⭐链式二叉树

在前个章节中,我们已经对树的基本结构和完全二叉树的堆结构有了整体的认识,本章将单独对二叉树进行更详细和深入的结构认识和练习详解。一般而言,二叉树的结构是递归式的,它与其他数据结构一样,同样可以存储数据,每个数据之间都存在相应的联系。与普通的树相比,二叉树每个结点的度最大为2,即其最多仅有左右两个子结点

前一章中我们详细对比过了二叉树使用顺序表或链表的方式进行定义,发现在物理结构上,使用顺序表浪费的空间很多,其只适用于存储连续结点结构的完全二叉树或满二叉树结构。而对于一般的没有左右子结点排布规律的普通二叉树,我们则使用链表进行结构定义。

二叉树因其特殊的逻辑结构,不像其他数据结构一样适合用于对数据的增删查改,因为其开辟的空间消耗更多,逻辑更复杂,如果使用如此复杂的结构只是为了存储数据,就没有太多的实际价值和意义。该种结构的链式二叉树最大的意义在于,它是其他更高级和更复杂树状结构的基本组成部分,比如搜索二叉树,AVL树,红黑树和B+树等。

✨链式二叉树结构和基本函数

二叉树结构定义

链式二叉树使用链表的方式进行定义,双亲结点与左右子结点通过结点结构体指针相连接,其中每个结点都能存储对应数值。
数据结构——链式二叉树及相关功能函数(万字解析)_第1张图片

链式二叉树结构

//二叉树结点存储数值类型重命名
typedef int BTEtype;
//二叉树基本结构
typedef struct BinaryTree
{
	BTEtype data;					//数值域
	struct BinaryTree* left;		//左结点指针
	struct BinaryTree* right;		//右结点指针
}BTNode;
  1. 由上图可知,标识为root数值为0的结点称为二叉树的根结点,以根结点为界,根结点向左的所有结点延伸出去链接在一起而形成的树叫作左子树,右边称为右子树。
  2. 二叉树的每个结点结构体中都包含两个结构体指针和一个数值域,两个指针分别指向以该结点为首的逻辑向下的左右两个子结点,在物理结构上,因为是链式结构,所以每个结点之间在内存上并不存在图中所示的上下或左右关系,而是随机地散布在不同的内存地址中。

二叉树结点创建

BTNode* BuyTreeNode(BTEtype x)
{
	BTNode* NewNode = (BTNode*)malloc(sizeof(BTNode));
	assert(NewNode);
	NewNode->data = x;							//数值域赋值
	NewNode->left = NewNode->right = NULL;		//左右结构体指针初始化置空
	return NewNode;
}
  1. 与用户动态显性申请开辟链表结点一样,链式二叉树的结点在内存空间上独立于其他结点,又在逻辑上与其他结点相互联系而存在,二叉树的结点开辟和初始化需要用户手动输入所需存放值并在开辟完成后建立与其他结点的链接关系。

手动建立二叉树结构

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;						//将创建好关系的二叉树根结点地址返回
}
  1. 该代码建立如上图所示二叉树逻辑结构相一致,通过用户手动申请多个树的结点空间并在生成后手动链接,则每个带值结点就这样被链接起来了。

与代码对应逻辑结构图示如下

数据结构——链式二叉树及相关功能函数(万字解析)_第2张图片

  1. 值得说明的是,二叉树的真正创建方式并不是以该种暴力生成和链接的方式手动诞生的,而是以递归方式,真正的方法将在后续章节中进行详细说明。

测试用例

BTNode* root = BinaryTreeCreate();

调试观察结果

数据结构——链式二叉树及相关功能函数(万字解析)_第3张图片

可以看到,创建好的二叉树每个结点的物理地址都是随机的,而在逻辑上又严格遵循用户规定的左右子结点链接关系。

✨二叉树的遍历

相比于顺序表和链表而言,数值的遍历就是从头到尾将下标或指针移动访问一遍取出数据即可,从栈开始到队列,对于该两种数据结构的遍历就不单是数据的访问了,而是通过取顶再弹栈或出队的方法将数据清空后才能完成遍历。而从堆开始,堆的遍历自堆顶取出再输出甚至具有自动排升降序的功能,二叉树整体结构与堆类似,但不局限于完全二叉或满二叉结构。因为是链式结构,数据在内存中不是连续的物理存放,所以对于二叉树的遍历方式很多,一般可以分为前序,中序,后序,层序遍历

深度优先遍历(DFS)

所谓深度优先遍历(Depth-First-Search),对于一颗二叉树而言,就是先对其以根结点为起点的左右子树向下进行不断深入探寻最下层结点,找到后再遍历相邻的其他深层结点的方式,最后层层递归回到上层结点处。换句话说,深度优先遍历就是从一棵树的最左边开始,一条路径一条路径地向下找最深处的结点,继而再遍历其他处于深处的结点。

深度优先遍历按照对子树和子结点的遍历规则不同,可以细分为前序遍历,中序遍历和后序遍历。

前序遍历(先序遍历Preorder Traversal)

void PreOrder(BTNode* root)
{
	if (root == NULL)				//如果遇到结点地址为空,打印NULL,表示没有子结点
	{
		printf("NULL ");
		return;
	}
	printf("%d ", root->data);		//直接打印每个首次遇到的结点值
	PreOrder(root->left);			//递归进入左子结点
	PreOrder(root->right);			//左子结点递归完毕后,递归进入右子结点
}
  1. 前序遍历的规则是,访问根结点的操作发生在遍历其左右子树之前。换句话说,前序遍历就是先遍历每个首次遇到的结点,打印该值,再依次对该结点的左右子结点进行顺序访问和打印
  2. 因为二叉树并不一定是像堆那样的完全二叉树或满二叉树结构,即树的前depth - 1层的每层结点都是满的,所以总会遇到某个结点的度为0或1的情况,即不是每个结点都一定有其左子结点或右子结点的情况,当遍历到这些结点的子树时,因为没有子结点,所以遍历时直接打印空值NULL即可。
  3. 从一个结点进入其左子树或右子树的方式为递归,即重复调用这个遍历打印函数但每次传参的值相对为上一个结点的左子结点地址或右子结点地址,一个一个向下传参并打印,待到遍历为空结点地址时才返回,此时又一层一层向上返回结束前面结点的递归过程,并且严格遵循先遍历根结点,再遍历左子树结点,最后到右子树结点的顺序,即为前序遍历二叉树结点的基本模式。
  4. 以下测试用例均是基于上例手动创建的二叉树为对象来实验和观察的。

测试用例

//创建一棵上例所示的二叉树
BTNode* root = BinaryTreeCreate();
//将二叉树进行前序遍历并打印
PreOrder(root);

观察结果

0 1 3 NULL NULL NULL 2 4 NULL NULL 5 NULL NULL

递归原理图

数据结构——链式二叉树及相关功能函数(万字解析)_第4张图片

前序遍历的递归过程展开如上图所示,程序将首先遍历到的每个结点值先打印,有数值就打印数值,没有数值则直接进入if判断打印NULL来代表此处没有结点,所以需要注意到即使一个结点的左右子树为空,但程序仍然会对其进行深入并遍历,而不是非常智能地遇到空值就避开。

中序遍历(Inorder Traversal)

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

递归原理图

数据结构——链式二叉树及相关功能函数(万字解析)_第5张图片

访问根结点的操作发生在遍历其左右子树之间。

后序遍历(Postorder Traversal)

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

递归原理图
数据结构——链式二叉树及相关功能函数(万字解析)_第6张图片

访问根结点的操作发生在遍历其左右子树之后。


广度优先遍历(BFS)

与深度优先遍历的一开始就寻找最深处的左子树结点不同,二叉树的广度优先遍历(Breath-First-Search)则是根据每层的最大结点个数横向遍历,从而达到一层一层向下依次遍历的效果,而层序遍历就是二叉树遍历中最常见的遍历算法。

数据结构——链式二叉树及相关功能函数(万字解析)_第7张图片

层序遍历(Level Traversal)

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);					//层序遍历结束后,将队列销毁防止内存泄漏
}
  1. 层序遍历顾名思义,就是按照树的层层往下遍历模式,先对每层的结点从左向右依次遍历,全部遍历结束后再向下一层执行相同遍历的模式。因为层序遍历的特殊性,所以需要使用到队列的数据结构

  2. 使用队列的原因是,将每层的结点从左往右依次入队,并在队头每出一个父结点,就在该层的所有结点队列后入队下一层的左右子结点,从而达到所有结点在队列中保持相对顺序而不会被打乱。

    原理图如下:
    数据结构——链式二叉树及相关功能函数(万字解析)_第8张图片

  3. 值得注意的是,上述图使用数据的方式代表结点的入队,而实际上将数据入队的是树结点的地址,即队列的操作元素QEType为结构体指针,而不能直接将树每个结点中数值域的数据入队,这是因为如果以数值作为入队的依据,将会无法区分空结点和树结点值为0的情况,且因为叶子结点的存在,会造成入队死循环。

typedef BTNode* QEType;

仍以上图为例,调用层序遍历函数,观察结果

0 1 2 3 4 5

注:关于队列的定义及函数接口请参考前序章节中数据结构——栈和队列_VelvetShiki_Not_VS,其中有对于队列入队,出队和取队头,队列销毁等函数的详细说明。

题目练习

  1. 某完全二叉树按层次输出(同一层从左到右)的序列为 ABCDEFGH ,该完全二叉树的前序序列为?
A B D H E C F G

⭕解释:
数据结构——链式二叉树及相关功能函数(万字解析)_第9张图片

  1. 二叉树的先序遍历和中序遍历有:先序遍历:EFHIGJK;中序遍历:HFIEJKG,则二叉树根结点为?
E

⭕解释:先序遍历从根结点开始,则E为root根结点。

  1. 设一课二叉树的中序遍历序列:badce,后序遍历序列:bdeca,则二叉树前序遍历序列为?
a b c d e

⭕解释:

先观察后续遍历的末字符,该值一定为根结点字符,则c为根a的右子结点;再观察中序遍历的与a相邻的字符,则b一定是根a的左子结点,将这几个字符确定好相对位置后其他位置就很容易推出了:

数据结构——链式二叉树及相关功能函数(万字解析)_第10张图片

  1. 某二叉树的后序遍历序列与中序遍历序列相同,均为 ABCDEF ,则按层次输出(同一层从左到右)的序列?
FEDCBA

⭕解释:

如果后续遍历结果与中序遍历相同,因为两者都是从左子树开始的深度优先遍历,所以从左侧最深处开始层层向上递归而回,每递归一次带回一个字符,而左->根->右的中序遍历与左->右->根的后序遍历在单子树的情况下结果是相同的,如下所示:

数据结构——链式二叉树及相关功能函数(万字解析)_第11张图片

  1. 已知某二叉树的前序遍历序列为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,并在中序遍历中找到根的位置,然后确定根左右子树的区间,即根的左侧为左子树中所有节点,根的右侧为右子树中所有节点。

  1. 根左侧有4,7两个结点,根右侧有6,9,1,2四个结点。

  2. 根结点5的左子树的子根结点为7,右子根结点为9,以此类推,得到如下图:
    数据结构——链式二叉树及相关功能函数(万字解析)_第12张图片

  3. 已知某二叉树的中序遍历序列为JGDHKBAELIMCF,后序遍历序列为JGKHDBLMIEFCA,则其前序遍历序列为( )

    A ABDGHJKCEFILM

    B ABDGJHKCEILMF

    C ABDHKGJCEILMF

    D ABDGJHKCEIMLF

⭕解释:

由后序遍历确定子树的根为A(后序遍历从后向前看,最后一个元素为根,和前序遍历刚好相反)。在观察中序遍历中根A的位置,可以确定:

  1. JGDHKB为左子树,ELIMCF为右子树。

  2. 再由后序遍历中左右子树出现在子树序列中最后的位置确认子根位置:A的左子树的根为B,A的右子树的根为C。

  3. 结合后序遍历找末尾子树根,与中序遍历区分左右子树的规则,以此类推:

    B的左子树为JGDHK,B的右子树为空。同理,C的左子树为ELIM,右子树为F(同时为右子根),B的左子树的根为D,C的左子树根为E。

  4. D的左子树有JG,根为G;D的右子树有HK,根为H。同理,E的左子树为空,右子树有ILM,根为I。

  5. 再看中序遍历,I的左子树为L,右子树为M,所以根也是这两个值。

  6. 根据上述结论,最终推得该二叉树逻辑结构示意图如下所示:
    数据结构——链式二叉树及相关功能函数(万字解析)_第13张图片

总结:和前序遍历刚好相反,从后向前看后序遍历,应该是根,右,左,根据中序遍历确定子树的左右区间。

  1. 设某种二叉树有如下特点:每个结点要么是叶子结点,要么有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);		//遍历右子树
}
  1. 全局变量的创建有两种方法,一种是在该函数定义的地方直接定义全局变量count,并在头文件中extern使其他文件知道在该函数文件中存在一个全局变量count,从而可以直接引用该变量,方便输出观察。也可以在头文件中直接声明全局变量count,并在该函数定义文件中extern使用,两种方式都是可取的(前者全局变量在每个文件中的地址都是同一个,而后者的声明和extern后的地址是不同的)。
  2. 因为count存储在静态区,所以通过该种前序遍历结点并使全局变量自增的方式不会让count随着函数栈帧的销毁而丢失,每进入一个结点就自增一次,最终当全部结点遍历结束后count也能得到与结点数对应的数值。
  3. 前序遍历计数count的方式只是其中一种,同样可以采用中序,后序遍历的方式来使count进行自增。

测试用例
数据结构——链式二叉树及相关功能函数(万字解析)_第14张图片

//手动建树
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;//分类统计,并在递归返回时累加
}

递归原理图

数据结构——链式二叉树及相关功能函数(万字解析)_第15张图片

图中的矩形框代表每个结点数值,方便对应二叉树的逻辑结构图,圆圈住的数字为单次递归返回的结点个数,一个父结点对应的左结点或右结点存在多少个实际存在的有效结点,则圆圈数字就为多少,这是通过递归返回的累加实现的。

测试用例同上

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结点个数为:62结点个数为:103结点个数为:0

统计二叉树叶子结点个数

首先需要明确树的叶子结点概念,一个没有左右子结点的父结点即为叶结点,树的最下层结点均为叶子结点,其他处于更高层但向下没有更小子树的结点也为叶结点。

数据结构——链式二叉树及相关功能函数(万字解析)_第16张图片

叶节点统计函数

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;		//返回更深一侧的子树深度
}

递归原理图
数据结构——链式二叉树及相关功能函数(万字解析)_第17张图片

图中矩形代表的代码框为树结点实际存储的数值,方便观察二叉树遍历递归的箭头顺序,圆圈所表示的数值为该次递归返回的深度,取大的返回到上一次递归掉调用的函数并存储起来,用作下一次的深度比较和结果返回。

测试用例

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的深度为:32的深度为:53的深度为:0

二叉树的第K层结点数量

回顾二叉树的基本性质,规定根结点的层数为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层的结点总个数

递归原理图:
数据结构——链式二叉树及相关功能函数(万字解析)_第18张图片

测试用例——仍以上图三棵树为例

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层结点个数为:22的第3层结点个数为:43的第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));

调试观察结果
数据结构——链式二叉树及相关功能函数(万字解析)_第19张图片

树3因为为空树,所以进入函数后直接返回空值,查找不到用户对应所需的值8。

二叉树的销毁

本章中二叉树是建立在链表的父子结点相互链接的关系之上的,如果二叉树在使用完毕之后每个在堆开辟的结点空间不及时进行空间销毁,则会同其他数据结构一样,会造成内存空间的消耗殆尽。二叉树销毁是有讲究的,如同顺序表,链表的销毁从尾部数据释放到头部并置空一样,二叉树也需要从最深处的子结点将空间释放,并递归向次深层逐层向上释放空间,不能从根结点开始向下(比如使用前序遍历)的方式,如果先释放了根结点,则会造成左右子树及结点的地址丢失,造成只能释放一个根结点的后果。

树销毁

void TreeDestroy(BTNode** root)	
{
	if (*root == NULL)
	{
		return;
	}								//采用后序遍历的方式
	TreeDestroy(&(*root)->left);	//递归遍历左子树
	TreeDestroy(&(*root)->right);	//再遍历右子树
	free(*root);					//从深层向上逐层释放并置空
	*root = NULL;
}
  1. 根据所学的二叉树遍历规则,已知前序遍历和中序遍历都是需要以根结点为媒介链接左右两个子结点,换句话说,父结点的释放都是在其两个子结点完全释放之前就释放了,而这样造成的结果不言而喻,使二叉树不能完全让所有子结点的空间得到有效释放。
  2. 所以采用后序遍历的方式,只不过此处不再打印结点值,而是将打印功能替换为结点的释放和结点指针的置空。先让二叉树通过后序遍历的方式寻找到最深处的左子结点处,该结点的释放并不会影响其他结点的存在和链接关系,通过逐个后序遍历并释放后,上层的结点也能通过递归返回到父结点依次释放,最终遍历完整个左右子树后,最上层递归返回的根结点所处函数也被释放,这样整棵二叉树就被完全的空间释放和置空了。
  3. 在代码中可以注意到,函数的参数使用了二级结构体指针的方式,因为对树的销毁涉及到根结点的实参传递,如果想对实参指向的根结点地址修改,则传一级指针是不能达成效果的,因为传入的一级指针是实参地址的一份临时拷贝,如果想对指针指向的值进行修改,则需要传入指向根结点地址的二级指针,此时在函数中将根结点释放,置空的过程中使实参接收到了被释放和置空的根结点地址,才能得到空地址值NULL。

测试用例

BTNode* root = BinaryTreeCreate();
BTNode* root1 = BinaryTreeCreate2();
BTNode* root2 = NULL;
TreeDestroy(&root);
TreeDestroy(&root1);
TreeDestroy(&root2);

调试观察三棵树销毁结果
在这里插入图片描述

可以看到,通过传入二级根结点地址指针的方式,成功使三棵树都进行了销毁,根结点后的所有结点都被成功释放并置空了。


题目练习

  1. 设根结点的深度为1,则一个拥有n个结点的二叉树的深度一定在( )区间内。
[log(n + 1),n - 1]

⭕解释:

最大深度: 即每次只有一个节点,次数二叉树的高度为n,为最高的高度(有几个结点深度就为几,比如全部仅在左子树或全部仅在右子树的二叉树);最小深度: 完全二叉树情况,根据二叉树性质,完全二叉树的高度为 h = log(n+1)向上取整。

  1. 如果一颗二叉树的前序遍历的结果是ABCD,则满足条件的不同的二叉树有( )种?
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种。

  1. 一棵非空的二叉树的先序遍历序列与后序遍历序列正好相反,则该二叉树一定满足( )
只有一个叶子结点

比如下面这几种情况:
数据结构——链式二叉树及相关功能函数(万字解析)_第20张图片
所以可以总结为,每个节点只有一个孩子,即只有一个叶子节点,此时前序与后续遍历正好相反。


⭐后话

  1. 博客项目代码开源,获取地址请点击本链接:链式二叉树及相关函数接口。
  2. 若阅读中存在疑问或不同看法欢迎在博客下方或码云中留下评论。
  3. 欢迎访问我的Gitee码云,如果对您有所帮助还可以一键三连,获取更多学习资料请关注我,您的支持是我分享的动力~。

你可能感兴趣的:(数据结构,C语言,数据结构,链表,算法,c语言)