文章目录
- 前言
- 1.二叉树的知识铺垫
- 2.二叉树的具体实现
- 1.递归实现前中后序遍历
- 2.其它相关接口的实现
- 1.求二叉树的节点个数
- 2.求叶子节点个数
- 3.二叉树查找值为x的节点
- 3.求树高度
- 4.求k层节点的个数
- 5.层序遍历
- 6.判断二叉树是否是完全二插树
- 3.总结
前言
之前用数组实现了一种特殊的完全二叉树——堆。本文将通过链式的方式实现二叉树。具体的实现方式是通过递归,在之前的文章中就提到过二叉树的结构的递归定义的。本文开始会介绍一些相关概念,之后会介绍一些二叉树的接口实现,关于二叉树的构建,浅介绍一下,以后会具体展开细讲,本文主要的目的是入门二叉树。
1.二叉树的知识铺垫
之前介绍过树的相关知识铺垫,这里针对二叉树再介绍一些相关的概念。二叉树的具体实现主要是通过递归方式,因为二叉树本身就是递归定义的,通过递归实现起来比较容易也比较容易理解。这也算是入门二叉树最好的方式了。
这里先介绍二叉树的3种遍历方式,前序遍历,中序遍历,后序遍历。
前序遍历,遍历顺序是根 左子树 右子树。先遍历二插树的根 ,再遍历左子树,再遍历右子树。当遍历到左子树的时候,再从 根 左子树 右子树的顺序遍历,一直从这种嵌套的方式往下走,直到遇见空树再往回走。然后再用同样的方式遍历右子树,
这样讲可能比较抽象,我们画图分析。
这个二叉树要先将一个完整的大树看成由多个子树构成,这些子树又可以单独看作颗颗小树。这样去看待二叉树比较容易理解。
中序遍历的顺序是 左子树 根 右子树。我们还是画图来遍历更加直观。
后序遍历顺序是左子树 右子树 根,还是画图分析。
对这个二叉树的理解最好不要从整体来看,要学会将这整个树看成一颗颗单独的小树。前序遍历因为一开始就是根所以看起来可能有点自上向下的味道,而中序和后序不是从根节点开始,可能有种从底向上的感觉。但是不管怎样,对这3种遍历方式的顺序要牢记。
1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
2.二叉树的具体实现
1.递归实现前中后序遍历
了解了二叉树的前中后序遍历后,我们就要去具体实现。之前提到了要用递归来实现二叉树的相关接口,所以前中后序遍历也是采用递归。
在实现遍历之前,我们先对二叉树节点结构定义声明
typedef char BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
接着就是遍历具体实现了,采用递归实现
前序遍历代码示例
void BinaryTreePrevOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
printf("%c ", root->data);
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
}
我们好好分析一下这个递归过程,分析好这个后,后面的递归酌情分析了
以上就先序遍历的整个递归过程,这个过程看似复杂,但是实际上理清逻辑后还是很简单的。
中序遍历代码示例
void BinaryTreeInOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreeInOrder(root->left);
printf("%c ", root->data);
BinaryTreeInOrder(root->right);
}
后序遍历代码示例
void BinaryTreePostOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreePostOrder(root->left);
BinaryTreePostOrder(root->right);
printf("%c ", root->data);
}
通过上述3种遍历方式的代码,可以发现这样一个特点,3种遍历方式的代码只是打印代码位置或者说顺序改变了,其余都是相同的。这个打印代码就可以简单的理解为遍历,先序遍历是根先遍历,所以判断根不为空后就直接打印,中序遍历是先遍历左子树,接着是根,所以打印根节点是中间,后序遍历是最后遍历根,所以根节点的打印放再后面。
到了二叉树这里,首先要理解的是递归,递归这种操作不要过多的关注递归每一步做了什么,不要过多关注内部的细节,程序反复调用自身即是递归。既然递归是一个反复调用自身的过程,这就说明它每一级的功能都是一样的,因此我们只需要关注一级递归的解决过程即可。
用递归解决问题,首先明确以下两点:1.递归中止条件,也就是递归再什么时候结束。2.确定递归的返回值,也就是递归应该干什么。在二叉树中,不要把树看作有多节点的树,应该把树看作root(根),left(左子树),right(右子树),这3部分组成,回到这个遍历,当root节点是空时,就没必要遍历打印了直接返回,这是空树。如果不等与空,那么就是先打印根节点,然后再调用这个函数去打印left左子树,接着就是打印right右子树。同理,中序遍历,是先打印左子树,那么就是直接调用这个函数传入left作为参数,然后打印根节点,再调用这个函数传入right打印右子树;后序遍历是先打印左子树,所以先调用这个函数传入左子树left,然后是打印右子树调用这个函数传入右子树right,最后是打印root根节点。只要理清这点递归就很好写了。
同时还有个蠢方法,如果遇到需要用先中后序某一种顺序解决问题的话,记住先序就是先写操作再递归,中序就是先递归再写操作再递归,后序就是先递归,最后写操作。
不过还是推荐理解递归处理的思路,理清逻辑解决问题
2.其它相关接口的实现
1.求二叉树的节点个数
递归实现求节点个数,首先明确递归结束条件,当根节点为空时,就是空树,返回0即可。然后就是递归了,这个递归还是把树看作3部分组成,根算一个节点,然后左右子树各自节点个数,最后将这3者相加起来就是所求的节点个数
代码示例
//求二叉树节点的个数
int BinaryTreeSize(BTNode* root)
{ //如果根节点为空就是空树节点为0
//如果根节点不为空就就是左子树和右子树的节点数加1,这个1代表的是根节点
if (root == NULL)
{
return 0;
}
return BinaryTreeSize(root->left)+1+BinaryTreeSize(root->right);
}
梳理清楚后这个递归就很好写了,这个函数本身的功能就是用求树的节点个数,将左右子树也单独剥离看作成一颗树,所以递归的参数的就直接是左右子树最后加上根这个节点,即为所求。
2.求叶子节点个数
求叶子节点采用递归,递归的边界条件就是考虑当只有这颗树只有根节点时出现的所有可能出现的情况,如此一来边界条件就很好写了,当根节点为空时,叶子节点肯定是0,除此之外还有一种可能根节点本身就是叶子节点,这个时候根节点的左右子树肯定都为空,叶子节点就为1。边界条件由此确定好了,接着就是递归调用了,递归求左右子树中的叶子节点,将左右子树的叶子节点求出相加即可。
代码示例
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
if (root->left==NULL && root->right==NULL)
{
return 1;
}
int n1=BinaryTreeLeafSize(root->left);
int n2=BinaryTreeLeafSize(root->right);
return n2 + n1;
}
在代码中,我用了n1和n2接收这个左右子树的叶子节点个数,不用n1和n2和接收,直接返回递归相加也是可以的。但是这样写的话肯定递归嵌套更深一点。大致分析这个递归过程就知道不用变量接收会第一次返回时不会记住这个值,会多返回一次。可以仿照上面画一画递归展开图就知道了。
3.二叉树查找值为x的节点
采用递归的方式来进行查找,递归的边界判断是如果根节点是空表示空树,直接返回空。如果根节点的data是要查找的值x,那么就返回这个节点。如果根查找完没有找到这个值,就接着查找这颗树的左右子树。对左右树进行查找这个过程就是递归,这个函数的功能就是查找,由此传入左右子树当作参数,再接收这个函数返回值进行判断,如果不是空,就返回这个接收值。最后如果这颗树没有这个值x就直接返回空
代码示例
//二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
BTNode* ret1 = BinaryTreeFind(root->left, x);
if (ret1)
{
return ret1;
}
BTNode* ret2 = BinaryTreeFind(root->right, x);
if (ret2)
{
return ret2;
}
return NULL;
}
递归解决二叉树相关问题,应该把二叉树看作根 左子树 右子树 这3部分组成。递归边界条件就是考虑到只有一个根节点所有情况的处理。其次,这个函数本身的功能要确定,也就是这个函数返回值的意义,这个返回值的意义又取决于边界条件的处理,以上述代码为例,这个函数本身就用来查找节点的data值的,如果根节点没有查找到,那么势必会对左右子树进行查找。那么就再次调用这个函数进行查找,到了这里递归就出现了。用变量来接收递归的查找的结果,如果不是空就查找到了,直接返回即可。如果整个递归结束了也没有找到,那么最后返回也返回空。代码中对左右子树的if判断取决于边界处理,边界处理的时候就可能返回两种情况,一种是找到了返回节点,另一种就是没找到返回空。所以这个if判断递归查找的情况。
3.求树高度
求树的高度采用递归,首先按照之前递归的经验,先考虑边界条件也就是只有根节点的情况,当根节点为空时,这颗树的高度就为0,这个边界条件确定了。接着就是对左右子树的处理,求左右子树的高度,选取左右子树较高的那一个值,最后加上根节点的高度,也就是加上1。这个结果就是树的高度,之所以要选取左右子树两者中较大的一个,这颗树可能不是满二叉树,左右子树的高度可能不一致。
代码示例
int BinaryTreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
int left_height = BinaryTreeHeight(root->left);
int right_height = BinaryTreeHeight(root->right);
return left_height > right_height ? left_height + 1 : right_height + 1;
}
用变量来接收递归结果的返回值,选取较大的结果加1,处理了这么多的递归,其次我们发现这个递归调用主要发生在左右子树上,这个返回值的意义又是由边界条件确定的。所以在处理递归的时候,不要老是纠结递归每一步干了什么,要明确这个函数返回值的意义。这个返回值的意义又绕回到边界处理,这一切都是相辅相成的,通过函数功能设置合理的返回值。
4.求k层节点的个数
求k层节点的个数,还是采用递归。首先处理边界条件,当根为空时表示空树节点数为0,当求第一层时,只有一个节点就是根节点。接着处理左右子树,到了这一步就开始递归了,如果整颗树是k层,那么对于左右子树来说它们就在k-1层。因为我们将这颗树看作由根 左右子树 这3部分组成,所以对于这颗树来说,左右子树它们在k-1层,它们上面只有一层(根节点)。这样的话,递归参数的就确定下来了。
//求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);
}
递归参数的左右子树比较容易确定,这个k-1可能需要思考一会,但是这些还是逃不过之前的分析,就是把树拆成3部分来看。如果根是第一层,那么左右子树就是k-1层,当然也可以像之前那样用变量来接收递归这个返回值,最后相加返回也是可以的。边界条件的处理k==1算是一个隐藏的情况,k==1对应着也是对根的处理,因为第一层就是根节点,这点需要考虑周全。
5.层序遍历
之前介绍了二叉树的3种遍历方式,前中后序遍历。这3种遍历方式处理二叉的时候比较常见,还有一种不常见的遍历,也就是层序遍历。层序遍历就是像数组那样按顺序遍历,也就是对二叉树一层一层的遍历。
我们这里的二叉树都是以链式的形式的表示的,不像堆那样采用数组存储,这种遍历方式对链式二叉树来说还是有点棘手的。由此我们借用另一种数据结构队列来辅助处理,我们将二叉树的结构一层一层的遍历。大致思路就是将二叉树的每一层的节点插入队列中,再依次一层一层的出队头的数据,每次出一个数据就将这个数据从队列中pop掉,直到将二叉树遍历完。
代码示例
//层序遍历
void BinaryTreeLevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
if (root)
{
QueuePush(&q,root);
}
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
printf("%d ", front->data);
QueuePop(&q);
if (front->left)
{
QueuePush(&q,front->left);
}
if (front->right)
{
QueuePush(&q, front->right);
}
}
printf("\n");
QueueDestroy(&q);
}
上述代码的过程如下图
最先插入根节点,然后就是遍历,遍历完出队列头节点也就是出这个根节点,再依次插入头节点front的左右子树,再出头节点,直到队列为空。这个pop作用就是原头节点遍历完后,更新新的头节点。这个插入的过程其实并不是一层层将节点插入队列中,而是按照层序的顺序一带二,一个节点带入该节点的左右子树,层序遍历就是按照排队的顺序从前往后走,队列这种数据结构刚好符合这样的特性,先进先出。所以用队列实现层序遍历是特别合适。同时代码的if判断相当于筛选出节点的空子树,哪怕不是完全二叉树也可以遍历完所有节点。
注意上述代码中队列接口函数需要自己实现,因为C语言中不提供这样的接口,关于队列的接口实现在之前的博客中也讲解介绍。
6.判断二叉树是否是完全二插树
判断二叉树是否是完全二叉树,就要用到之前介绍的层序遍历。当我们用队列出队头数据时,一旦出到了空节点,就不再往队列中插入数据,如果这时队列不为空,就继续出数据,如果一旦出了非空节点说明这颗树就不是完全二叉树。
代码示例
// 判断二叉树是否是完全二叉树
int BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
if (root)
{
QueuePush(&q, root);
}
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)
{
break;
}
else
{
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front != NULL)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
从图中的分析和代码示例中可以看到实际上空节点也是进入队列了,空节点很重要,空节点即是不再往队列中进数据的重要依据也判断是否为完全二叉树的重要凭证,这个front临时变量也很重要,每次pop掉队列头节点后,再用这个变量将头节点的左右子树插入队列中。本段代码核心就是层序遍历的方式遍历节点以及对树中空节点的处理。
同时,这段代码中的队列接口也需要自己实现
3.总结
- 1.入门二叉树常用的方式就是递归,递归不要纠结于每一层干了什么,搞清楚一层干了什么。实际上就是确定递归边界条件,同时根据函数功能确定好递归函数的返回值。
- 2.递归处理二插树时,可以把二插树看作3部分组成,根节点,根的左右子树。对根节点的处理就是递归边界条件的处理,左右子树实际上是重复这样的处理。
- 3.以上内容如有错误,欢迎指正!