【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)

2024-09-07 16:46:39 浏览数 (3)

C语言实现二叉树的基本操作

导读

大家好,很高兴又和大家见面啦!!!

通过前面的介绍,我们已经认识了二叉树的逻辑结构和存储结构。在今天的内容中,我们将会开始介绍二叉树的最后一个要素——二叉树的基本操作。

基本操作作为数据结构的三要素之一,对数据结构的具体实现是必不可少的。对于任何一种数据结构而言,都需要有以下几种基本操作:

  1. 创建与销毁
  • Init/Creat(&T)——初始化会创建一个数据结构T;
  • Destroy(&T)——销毁一个数据结构T;
  1. 增加与删除
  • Insert(&T,x)——将元素x添加到数据结构T中 ;
  • Delete(&T,&x)——将元素x从数据结构T中删除;
  1. 查找与修改
  • Search(T,x)——在数据结构T中查找元素x;
  • Modify(&T,&x,y)——在数据结构T中将元素x修改为y;

这些基本操作我们可以用六个字来概括——创销增删查改。在这六种基本操作的基础上,不同的数据结构又会衍生出其独特的基本操作:

  • 动态顺序表中会有修改表长的操作——IncreaseSize(&L,len)
  • 链表中增加元素的操作根据增加的位置不同衍生出了头插和尾插的基本操作——HeadInsert(&L,len)/TailInsert(&L,len)
  • 栈中查找操作根据删除的位置限制变成的获取栈顶元素的操作——GetTop(S,&x)
  • 队列中根据增加与删除位置的限制变成了入队和出队操作——EnQueue(&Q,x)/DeQueue(&Q,&x)
  • 串中查找操作从查找某一个元素变成了串定位操作——Index(S,T)

类似于上述这些独属于某一种数据结构的基本操作还有很多这里就不再一一列举。

从今天开始,我们将会介绍一些独属于二叉树的基本操作以及该操作的C语言实现。在这之前我们先要确定一下今天的内容中我们需要选择哪一种存储结构来进行介绍。在上一篇内容中我们也介绍过二叉树的两种存储结构,对于二叉树而言,不同的存储结构,其基本操作的具体实现也是有所区别的:

  • 顺序存储结构:适用于完全二叉树和满二叉树的存储;
  • 二叉链表:适合于已知根结点需要对其左右子树进行操作的场合;
  • 三叉链表:适合于需要频繁对父结点进行操作的场合;

在今天的内容中,我们会以二叉链表为例来介绍二叉树中这些基本操作的具体实现,对应的数据类型如下所示:

代码语言:javascript复制
//二叉树的数据类型
typedef int ElemType;
typedef struct BiTreeNode {
	ElemType data;//数据域
	struct BiTreeNode* lchild, * rchild;//指针域
}BTN, * BTL;
//BTN——二叉链表结点类型
//BTL——二叉链表类型

下面我们就来开始进入今天的内容吧!!!

一、二叉树的遍历

从二叉树的定义中我们可以得知,一棵二叉树无非就两种形态——空二叉树和非空二叉树:

  • 空二叉树:二叉树中的结点数量为0;
  • 非空二叉树:二叉树中的结点数量大于0;

在非空二叉树中任意一棵子树我们都可以将其视作作为一棵由左子树、根结点和右子树三部分组成的二叉树。只不过不同的子树其左右子树会有不同:

  • 度为0的子树其左右子树均为空树;
  • 度为1的子树其左右子树有一棵为空树;
  • 度为2的子树其左右子树都为非空二叉树;

借助这种递归定义,我们在遍历一棵二叉树时,就可以看做通过遍历二叉树中的每一棵子树从而完成遍历一棵二叉树。如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_二叉树_02【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_二叉树_02

在上图展示的例子中我们可以看到,对于一棵结点数量为3的二叉树而言,我们就可以将其看做一棵分别有这三个结点为根结点的结点数量为3的二叉树所组成的一棵二叉树。

此时如果我要遍历这一棵二叉树,则相当于我要遍历这三棵子树,并且这三棵子树中都只有左孩子、根结点、右孩子3个结点。根据遍历这些子树的先后顺序不同,于是便衍生出了3种遍历方式:

  1. 先序遍历(先根遍历):PreOrder(T)——从二叉树的根结点开始,按照根结点、左子树、右子树的顺序完成遍历;
  2. 中序遍历(总根遍历):InOrder(T)——从二叉树的左子树开始,按照左子树、根结点、右子树的顺序完成遍历;
  3. 后序遍历(后根遍历):PostOrder(T)——从二叉树的左子树开始,按照左子树、右子树、根结点的顺序完成遍历;

对于树形结构而言,它本身是一种递归型的数据结构,因此其基本操作的实现都可以通过递归的方式来完成,下面我们就来探讨一下这三种遍历算法以及其C语言的实现;

二、先序遍历

先序遍历又称为先根遍历,意思是优先访问根结点。在先序遍历中,算法的整个流程大致分为三步:

  1. 访问根结点
  2. 访问左子树
  3. 访问右子树

以上述例子为例,在先序遍历中,我们整个的遍历顺序如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_03【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_03

从上述的遍历过程中我们不难发现,原先我们是需要对整棵二叉树进行访问的,但是在实际的过程中,我们只访问了该二叉树的三棵子树的根结点。这一整个过程我们可以大致总结为:

从遍历整棵树到最后的访问每棵子树的根结点,这种将原先的大目标逐步拆解为一个个相同的小目标的过程实际上体现的就是递归的思想,如果将这一过程用代码表示则是:

代码语言:javascript复制
//先序遍历
void PreOrder(BTN* root) {
	if (!root)
		return;
	visit(root->data);//访问根结点
	PreOrder(root->lchild);//遍历左子树
	PreOrder(root->rchild);//遍历右子树
}

可能有朋友会对递归算法比较模糊,我们先来简单的回顾一下递归的相关知识点:

递归:通过在函数体中调用自身来重复完成同一件事。在函数递归中有两点需要注意:

  1. 递归算法中需要添加一个限制条件,防止栈溢出的问题
  2. 每一次递进后都应该越来越接近这个限制条件

在回顾完了递归的知识点后,接下来我们就来看一下在二叉树的先序遍历中是如何走完整个递归流程的:

从上图演示中我们可以看到,每一次进入函数后都会进行一次条件判断,判断传入的结点是否为空,这个就是控制递归结束的限制条件:

  • 当传入的结点为空时,开始回归;
  • 当传入的结点非空时,继续往后执行;

算法中的visit函数表示的是访问根结点,函数的具体内容可以为打印结点中存放的数据,可以读取结点中存放的数据……

在访问完根结点后,算法会通过递归开始遍历左子树。遍历左子树时会不断地重复条件判断、调用visit和递归遍历左子树的过程,直到访问到的左子树为空树才会开始回归;

在左子树完成回归后,则会进入右子树的递归遍历。遍历右子树的过程同样是不断重复条件判断、调用visit和递归遍历左子树以及左子树遍历完成后的右子树递归遍历,直到右子树为空树才会开始回归;

整个算法过程以及思路并不难理解,这里需要我们关注的是递归调用的算法思路以及整个递归调用的过程分析,建议大家可以自己手动绘制一下递归调用的流程图,这里我可以给大家一个流程图作为参考:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_递归_18【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_递归_18

这里展示的是简易的递归流程图,学会画这种递归流程图能够帮助我们更好的理解递归的算法,建议大家下去一定要尝试着动手画一下。当然如果能将这个简易的流程图改为完整的流程图,我相信当你将整个流程如给画完的同时,能够对递归的理解再上升一个台阶。

三、中序遍历

中序遍历又称中根遍历,意思是在中间访问根结点。在中序遍历中,算法的整个流程同样分为三步:

  1. 访问左子树
  2. 访问根结点
  3. 访问右子树

与先序遍历相似,只不过在中序遍历中,我们是先访问的左子树再访问的根结点,对应的代码如下所示:

代码语言:javascript复制
//中序遍历
void InOrder(BTN* root) {
	if (!root)
		return;
	InOrder(root->lchild);//遍历左子树
	visit(root);//访问根结点
	InOrder(root->rchild);//遍历右子树
}

从代码中我们可以看到,在中序遍历中,对根结点的访问是在左子树开始回归后执行的,因此中序遍历访问的第一个结点一定是二叉树中第一棵左子树为空树的子树根结点,如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_栈_19【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_栈_19

中根遍历的递归简易流程图如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_递归_20【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_递归_20

之所以在中序遍历中第一个访问的结点为左子树为空树的子树根结点,是因为在进行中序遍历时,算法先通过左子树递归遍历一直往下找,直到左子树为空才会开始回归,此时我们也只能得到该子树的左子树为空这个结论,并不能保证该子树的右子树也为空,如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_21【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_21

四、后序遍历

后序遍历又称为后根遍历,意思是最后访问根结点。在后续遍历中,算法的整体流程还是分为三步:

  1. 访问左子树
  2. 访问右子树
  3. 访问根结点

后序遍历与先序遍历相比,也仅仅只是将访问根结点的顺序放在了最后,代码如下所示:

代码语言:javascript复制
//后序遍历
void PostOrder(BTN* root) {
	if (!root)
		return;
	PostOrder(root->lchild);//遍历左子树
	PostOrder(root->rchild);//遍历右子树
	visit(root);//访问根结点
}

在二叉树的后序遍历中,由于根结点的访问是在右子树开始回归后执行,因此二叉树访问的一定是左子树中的第一个叶结点,如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_25【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_25

后序遍历的递归简易流程图如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_C语言_26【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_C语言_26

之所以是左子树中的第一个叶结点,当开始进行左子树遍历回归时,说明该结点的左子树一定为空树,当进行右子树遍历回归时,说明该结点的右子树一定为空树,一个结点的左右子树都为空树那就说明该结点为叶结点,如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_27【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_数据结构_27

当开始遍历该子树时,还是先从该子树的左子树开始遍历,由于这棵子树的左子树为空,因此函数进行了左子树的回归;之后就是遍历该子树的右子树,由于这棵子树的右子树也为空,因此函数进行了右子树回归;最后才会执行访问根结点的操作。

五、结点序列

在这三种遍历算法中,如果我们去掉访问根结点的操作的话,会是怎样的结果呢?如下所示:

代码语言:javascript复制
//先序遍历
void PreOrder(BTN* root) {
	if (!root)
		return;
	PreOrder(root->lchild);//遍历左子树
	PreOrder(root->rchild);//遍历右子树
}
//中序遍历
void InOrder(BTN* root) {
	if (!root)
		return;
	InOrder(root->lchild);//遍历左子树
	InOrder(root->rchild);//遍历右子树
}
//后序遍历
void PostOrder(BTN* root) {
	if (!root)
		return;
	PostOrder(root->lchild);//遍历左子树
	PostOrder(root->rchild);//遍历右子树
}

可以看到,此时这三种算法是一样的,因此我们可以得到结论,二叉树的三种遍历算法的区别在于对根结点的访问的时机不同:

遍历算法

遍历顺序

第一个访问结点

先序遍历

根结点—>左子树—>右子树

二叉树的根结点

中序遍历

左子树—>根结点—>右子树

左子树中第一个左子树为空树的结点

先序遍历

左子树—>右子树—>根结点

左子树中的第一个叶结点

算法的这种区别会导致一个结果——通过算法得到的结点序列会有不同。

那什么是结点序列呢?

简单的理解就是由结点数量为n的二叉树的结点中存储的数据所组成的长度为n的序列。而这个序列是由结点访问的先后顺序决定的。如下所示:

【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_C语言_32【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_C语言_32

有朋友可能会好奇,这些序列是如何得到的呢?下面我们就以中根遍历为例来介绍一下手算获取结点序列的过程:

中根遍历序列的手算步骤

这整个手算的过程可以总结为以下几步:

  1. 先找到根结点对应的子树的序列
  2. 通过遍历算法找到其左右子树对应的序列
  3. 合并子树序列

大家可以按照我这个步骤来自己手算一下先序遍历和后序遍历的结点序列。

六、递归算法与非递归算法的转化

序列问题大家有没有一种熟悉的感觉呢?我们好像有在哪里遇到过一样,是在哪里呢?

没错在第三章——栈、队列与数组这个章节我们有提到过序列的问题:

  • 在栈中,根据入栈和出栈的顺序不同,我们能够得到不同的出栈序列;
  • 在队列中,根据入队和出队的顺序不同,我们能够得到不同的出队序列;

可以看到,不管是在栈中还是在队列中,其获得的序列都是与入和出的顺序相挂钩的,那如果我们把二叉树的递进看做入,访问根结点看做出,那是不是代表着我们能够通过栈或者队列来实现获取二叉树的遍历序列呢?如下所示:

演示中我们以中序遍历为例进行了递归算法和栈的执行过程,从演示的结果来看我们通过栈来实现中序遍历序列的获取是没有任何问题的,下面我们就可以尝试着通过栈来实现一下二叉树的中序遍历:

代码语言:javascript复制
//中序遍历——栈
void InOrder2(BTN* root) {
	assert(root);
	LinkStack S;//创建链栈
	InitStack(&S);//初始化栈
	BTN* p = root;//遍历二叉树的指针
	while (p || !isEmpty(S)) {
		//当二叉树不为空树或者栈不为空栈时进入循环
		if (p != NULL) {
			Push(&S, p);//当树为非空树时,将根结点入栈
			p = p->lchild;//继续遍历左子树
		}
		else {
			Pop(&S, &p);//将栈顶元素出栈
			visit(p);//访问根结点
			p = p->rchild;//继续遍历右子树
		}
	}
}

该算法的逻辑并不复杂,下面我就来给大家介绍一下算法的具体思想:

  1. 在算法中通过可以存放二叉树的结点的栈来实现二叉树的中序遍历,栈对应的数据类型如下:
代码语言:javascript复制
typedef struct LinkNode {
	BTN data;//数据域存放二叉树结点
	struct LinkNode* top;
}LinkStack;
  1. 算法中通过指向二叉树各个结点的临时指针p来完成二叉树的遍历;
  2. 递归的过程则非递归的方式来实现:
  • 当指针p为空指针且栈S为空栈时表示二叉树的所有结点都已遍历完,此时就可以跳出循环了;
  • 当指针p或者栈S有一方为非空的状态,则代表二叉树中还有结点没有遍历完,需要继续进行遍历;
  1. 在循环体内通过指针p的值来控制具体的操作内容:
  • 当指针p为非空指针时,需要先将指针p指向的结点进行入栈操作,随后开始继续遍历该结点的左子树,这一过程替代的是递归的向左子树递进的过程;
  • 当指针p为空指针时,此时栈S的栈顶元素为指针p此时指向的子树的根结点,所以需要将栈顶元素进行出栈,并让指针p指向该结点,这已过程替代的是左子树回归的过程;之后在访问p指向的结点中的元素;最后开始继续遍历该结点的右子树,这一过程替代的是递归的向右子树递进的过程;

相信大家现在应该理解了如何将中序遍历的递归算法转换为非递归算法了。下面我们就来分析一下为什么可以通过栈来实现递归算法与非递归算法之间的转换:

  • 首先我们需要理清递归与非递归转换的难点在哪?
  • 二叉树是一种递归型的树形结构,当我们通过二叉链表实现时,从上往下进行递进是没有问题的,这就好比单链表的遍历,我们只需要通过移动指向结点的指针即可完成;
  • 对于递归而言,因为我们有附加限制条件,当算法向下递进到满足限制条件时就会自动进行回归,而非递归算法无法做到自动回归,因为如果需要回归,那我们就是从下往上找父结点,所以如果要实现非递归的方式进行遍历那我们需要解决的第一件事就是如何找到每个结点的父结点
  • 其次我们需要理解为什么采用栈?
  • 最后我们需要明白栈的作用
  • 栈在整个算法中主要起到记录根结点的作用,当我们从上往下遍历时,入栈的元素都是对应子树的根结点,而当我们从下往上找时找的则是只遍历了左子树的子树对应的根结点,如下所示:
【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_递归_57【数据结构】C语言实现二叉树的基本操作——二叉树的遍历(先序遍历、中序遍历、后序遍历)_递归_57

可以看到,当我们要通过栈实现后序遍历时,同一个根结点我们是需要使用两次:

  • 当左子树完成遍历后进行回归时,需要通过栈顶元素完成右子树的遍历
  • 当右子树完成遍历后进行回归时,需要将栈顶元素出栈并完成访问

因此在后序遍历中,我们需要对右子树是否完成遍历作为栈顶元素出栈的依据。那如何来判断右子树是否完成访问则是我们在后续遍历的非递归实现中需要解决的一个问题。

当我们要通过非递归的方式实现后序遍历时,我们不妨在二叉树的结点中加入一个右子树遍历标志:

  • 标志的初始值为0,当未进行右子树的遍历时,标志为0;
  • 当开始进行右子树的遍历时,标志为1;

因此对应的数据类型应该改为:

代码语言:javascript复制
//二叉树的数据类型
typedef int ElemType;
typedef struct BiTreeNode {
	ElemType data;//数据域
	struct BiTreeNode* lchild, * rchild;//指针域
	int right;//右子树遍历标志
}BTN, * BTL;
//BTN——二叉链表结点类型
//BTL——二叉链表类型

对应的代码实现如下所示:

代码语言:javascript复制
//后序遍历——栈
void InOrder2(BTN* root) {
	assert(root);
	LinkStack S;//创建链栈
	InitStack(&S);//初始化栈
	BTN* p = root;//遍历二叉树的指针
	while (p || !isEmpty(S)) {
		//当二叉树不为空树或者栈不为空栈时进入循环
		if (p != NULL) {
			Push(&S, p);//当树为非空树时,将根结点入栈
			p = p->lchild;//继续遍历左子树
		}
		else {
			GetTop(S, &p);//当树为空树时,找到根结点
			//当结点的右子树标志为1时
			if (p->right) {
				Pop(&S, &p);//将栈顶元素出栈
				visit(p);//访问根结点
				GetTop(S, &p);//找到下一棵子树的根结点
				p->right = 1;//将右子树标志设为1
			}
			//结点的右子树标志为0时
			else {
				p->right = 1;//将右子树标志设为1
			}
			p = p->rchild;//继续遍历右子树
		}
	}
}

在增加了这个右子树标志之后,当遍历的结点为空时,我们就可以根据先找到该结点的父结点,再通过父结点的右子树标志进行下一步操作:

  • 当标志为1时,表示该结点的右子树完成了遍历,则对栈顶元素进行出栈,并访问该结点,之后还需要再一次访问此时的栈顶元素来找到下一棵遍历的子树的根结点并将其右子树标志设为1;
  • 当标志为0时,说明该结点目前是进行的左子树回归,并未进行右子树的遍历,因此只需要将根结点的右子树标志设为1;

这时可能有朋友会好奇,为什么当我们对栈顶元素进行出栈后再一次获取的栈顶元素我们都需要访问其右子树呢?

理解这个问题的关键就在于我们在对该结点入栈后所进行的操作内容是什么?有朋友很快就反应过来了,入栈后我们继续做的是访问该结点的左子树,因此当遇到结点为空时,表示已入栈的元素的左子树以完成了遍历,只剩右子树还未进行遍历。所以只要是获取了新的栈顶元素,那我们接下来肯定是要对其右子树进行遍历。

现在二叉树的递归算法和非递归算法的转换我们就介绍完了,这里我们是以结点序列为例来进行介绍的两种算法的转换,实际上从代码中我们也可以看出,我们在访问根结点时使用visit函数代替的,因此该转换的思路不一定是只用于结点序列的问题上,我们还可以拓展到其他问题上,这里我就不展开叙述了,如果后续有遇到相关的题目,我会再和大家进一步分享。

结语

在今天的内容中,我们详细介绍了二叉树的三种遍历方式以及C语言的递归实现:

  • 先序遍历(先根遍历):根结点—>左子树—>右子树
  • 中序遍历(中根遍历):左子树—>根结点—>右子树
  • 后序遍历(后根遍历):左子树—>右子树—>根结点

之后我们为了区分这三种遍历方式又介绍了对应的结点序列以及手动计算二叉树的结点序列的方式:

  1. 先找到根结点对应的子树的序列
  2. 通过遍历算法找到其左右子树对应的序列
  3. 合并子树序列

最后我们又探讨了一下在获取结点序列时这三种遍历方式的非递归实现。在递归算法转换到非递归算法时,我们需要解决的一个问题就是如何访问结点的父结点,当然解决的方式有很多,在今天的内容中我们主要是以栈来进行说明,朋友们你们自己也可以尝试通过队列、顺序表、链表等方式来实现。

今天的内容到这里就全部结束了,如果大家喜欢博主的内容,可以点赞、收藏加评论三连支持一下博主,当然也可以转发给身边需要的朋友。在下一篇内容中,咱们将会继续介绍二叉树的一些基本操作以及C语言实现,大家记得关注哦!最后感谢各位朋友的支持,咱们下一篇再见!!!

0 人点赞