目录
[1.1 方法一(错误示范)](#1.1 方法一(错误示范))
[1.2 方法二(地址传参)](#1.2 方法二(地址传参))
[1.3 方法三(非常巧妙)](#1.3 方法三(非常巧妙))
[5.1 方法一(不能实现)](#5.1 方法一(不能实现))
[5.2 方法二(可以实现,但效率不好)](#5.2 方法二(可以实现,但效率不好))
[5.3 方法三(最优方法)](#5.3 方法三(最优方法))
前言
在上一篇文章数据结构之二叉树-堆我们详细讲解了用顺序结构实现二叉树-堆,堆是一种特殊的完全二叉树,而这种二叉树利用数组存储是非常方便的。但是如果一个二叉树不满足完全二叉树的性质而只是一个普通的二叉树,就无法利用数组进行存储了,因为每个数据的对应关系完全是乱的,所以这些二叉树我们就需要利用本篇文章所讲解的链式结构进行存储。
一、链表实现的二叉树结构
用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常的方法是链表中每个结点由三个域组成 ,数据域 和左右指针域 ,左右指针分别用来给出该结点左子树和右子树所在的链结点的存储地址,其结构如下:
cpp
//BinaryTree.h
#include <stdio.h>
#include <assert.h>
typedef int BTDataType;
//二叉树
typedef struct BinaryTreeNode
{
BTDataType data;//当前结点存放的数据
struct BinaryTree* left; //当前结点指向的左子树地址
struct BinaryTree* right;//当前结点指向的右子树地址
}BTNode;
1、手动创建链式二叉树
由于二叉树的创建方式比较复杂,刚开始就直接创建肯定很多人难以理解,为了更好的步入到二叉树内容中,我们以下图为例先手动创建一棵简单的链式二叉树:

cpp
//BinaryTree.c
#include "BinaryTree.h"
//创建结点
BTNode* CreateNode(int x)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
assert(newnode);
newnode->data = x;
newnode->left = NULL;
newnode->right = NULL;
return newnode;
}
//Test.c
#include "BinaryTree.h"
BTNode* CreateBinaryTree()
{
//创建结点
BTNode* node1 = CreateNode(1);
BTNode* node2 = CreateNode(2);
BTNode* node3 = CreateNode(3);
BTNode* node4 = CreateNode(4);
BTNode* node5 = CreateNode(5);
BTNode* node6 = CreateNode(6);
//连接结点构成二叉树
node1->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
void Test1()
{
BTNode* root = CreateBinaryTree();
}
int main()
{
Test1();
return 0;
}
回顾二叉树的概念,二叉树分为空树和非空二叉树 ,非空二叉树由根结点 、根结点的左子树 、根结点的右子树 组成的。
根结点的左子树和右子树分别又是由子树结点 、子树结点的左子树 、子树结点的右子树 组成的。因此二叉树定义是递归式的,后序链式二叉树的操作中基本都是按照该概念实现的。
二、前中后序遍历
二叉树的操作离不开树的遍历,我们先来看看二叉树的遍历有哪些方式:

1、遍历规则
按照规则,二叉树的遍历有:前序/中序/后序 的递归结构遍历:
1)前序遍历 (PreorderTraversal亦称先序遍历):访问根结点的操作 发生在遍历其左右子树之前
访问顺序为:根结点、左子树、右子树
2)中序遍历 (InorderTraversal):访问根结点的操作 发生在遍历其左右子树之中(间)
访问顺序为:左子树、根结点、右子树
3)后序遍历 (Postorder Traversal):访问根结点的操作 发生在遍历其左右子树之后
访问顺序为:左子树、右子树、根结点
2、前序遍历的代码实现及展示
cpp
//BinaryTree.h
//前序遍历
void PreOrder(BTNode* root);
//BinaryTree.c
//前序遍历
void PreOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
printf("%d ", root->data);
PreOrder(root->left);
PreOrder(root->right);
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
PreOrder(root);
}
int main()
{
Test1();
return 0;
}

可能有些人看到这个打印结果会有些难以理解为什么是这样,我通过下面的图可以让大家更好的理解前序遍历到底是怎么执行的:

上面的数字则是前序遍历执行代码的顺序,大致逻辑就是由于前序遍历访问顺序是:根结点、左子树、右子树。 当到结点1时先打印1然后来到左子树(也就是递归一次调用函数自己),接着打印2然后来到结点2的左子树(再次递归),接着打印3然后来到结点3的左子树,由于结点3的左子树为NULL所以打印N并且返回,此时当前递归结束返回到结点3的函数,然后来到结点3的右子树。
将上述递归逻辑都按照根结点、左子树、右子树的顺序进行则就是上面这张图。

我们将打印结果进行以上修改大家应该就能更好理解前序遍历的顺序关系了。
3、中序遍历的代码实现及展示
cpp
//BinaryTree.h
//中序遍历
void InOrder(BTNode* root);
//BinaryTree.c
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
InOrder(root);
}
int main()
{
Test1();
return 0;
}

由于中序遍历的访问顺序是:左子树、根结点、右子树,所以只需要将前序代码中打印和递归左子树互换则能实现中序遍历。递归逻辑就和前序基本相同,这里就不过多赘述了。
4、后序遍历的代码实现及展示
cpp
//BinaryTree.h
//后序遍历
void PostOrder(BTNode* root);
//BinaryTree.c
//后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
PostOrder(root);
}
int main()
{
Test1();
return 0;
}

同理,由于后序遍历的访问顺序为:左子树、右子树、根结点,所以将打印放到最后执行,先执行递归左子树再执行递归右子树。
三、二叉树相关方法实现
1、二叉树结点个数的实现
1.1 方法一(错误示范)
cpp
//BinaryTree.h
// 二叉树结点个数
int BinaryTreeSize(BTNode* root);
//BinaryTree.c
// 二叉树结点个数
int BinaryTreeSize(BTNode* root)
{
static int size = 0;
if (root == NULL)
{
return;
}
size++;
BinaryTreeSize(root->left);
BinaryTreeSize(root->right);
return size;
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
//计算二叉树结点个数
printf("%d\n", BinaryTreeSize(root));
}
int main()
{
Test1();
return 0;
}

我们首先讲解一下方法一的代码逻辑:
因为我们计算二叉树结点个数其实也需要借助递归思想,但是我们想一下如果我们在函数内部创建一个变量size,当递归进入函数一次如果此时结点不为空 则说明size要加1计数 ,如果此时结点为空 则递归返回 。按照这个逻辑好像没有问题,但是在学习C语言时我们就知道如果在函数内部创建一个变量 ,这个变量是局部变量 ,当出函数时变量是会被操作系统销毁 的,也就是说每次递归调用函数这个变量都是从0开始计数,这样就不满足我们预期,所以我们就需要利用一个关键字就是static,这个关键字会使一个变量的生命周期变成与程序生命周期相同 ,也就是说让一个局部变量相当于变成一个全局变量,这样size就可以保留之前的数据了。
那这个代码就没有问题了吗?如果当我们执行一次 printf("%d\n", BinaryTreeSize(root)); 这个打印代码时:

我们会发现两次的结果不一样,其实问题就出自于static上,由于static让局部变量size生命周期变成与程序生命周期相同 ,只有当程序结束变量才会销毁,并且被static修饰的变量的值会被一直保留,这就会导致执行第二次打印时size是从6开始计数的。
1.2 方法二(地址传参)
如果static修饰变量不可行我们怎么让变量出了函数能够发生改变,这不就是之前我们学习链表经常用到的地址传参吗。
因为地址传参在修改形参的时候也会影响实参,本质就是两者地址相同 。如果再带上递归 的话也就是说每次调用函数自己时,当前函数的形参和调用函数的形参地址又是一样 的,一直递归这样就能保证该形参在被修改时最后返回的实参也会被影响。
cpp
//BinaryTree.h
// 二叉树结点个数
int BinaryTreeSize(BTNode* root, int* psize);
//BinaryTree.c
int BinaryTreeSize(BTNode* root, int* psize)
{
if (root == NULL)
{
return;
}
(*psize)++;
BinaryTreeSize(root->left, psize);
BinaryTreeSize(root->right, psize);
return *psize;
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
//计算二叉树结点个数
int size = 0;
printf("%d\n", BinaryTreeSize(root, &size));
size = 0;
printf("%d\n", BinaryTreeSize(root, &size));
}
int main()
{
Test1();
return 0;
}

这样我们就利用了地址传参解决了二叉树结点个数的实现代码,但有没有更加巧妙的方法解决这个问题呢?其实是有的也就是接下来的方法三,这个方法三只有一条代码就能解决问题。
1.3 方法三(非常巧妙)
cpp
//BinaryTree.c
//二叉树结点个数
int BinaryTreeSize(BTNode* root)
{
return root == NULL ? 0 :
BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
//计算二叉树结点个数
printf("%d\n", BinaryTreeSize(root));
printf("%d\n", BinaryTreeSize(root));
}
int main()
{
Test1();
return 0;
}

通过打印结果我们会发现方法三也能解决问题,但为什么这一条代码就能实现呢?我用图为大家进行解释:

等号左边的三个数对应的就是BinaryTreeSize(root->left)、BinaryTreeSize(root->right)和1了,右边就是对应结点函数的返回值,这样应该就能理解这个在三目操作符中结合递归的代码逻辑了。
2、二叉树叶子结点个数的实现
cpp
//BinaryTree.h
//二叉树叶子结点个数
int BinaryTreeLeafSize(BTNode* root);
//BinaryTree.c
//二叉树叶子结点个数
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
if (root->left == NULL && root->right == NULL)
{
return 1; //为叶子结点
}
else
{
return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
}

其实看到这个代码我们就会发现递归就是将一个大问题逐步分解成若干个小问题进行解决,就如求叶子结点个数为例,我们想一下对于根结点来说二叉树叶子结点总个数 是不是可以分解成:左子树的叶子节点个数 + 右子树的叶子节点个数 ,然后左子树根节点的叶子节点个数 是不是又可以分解成:左子树根节点的左子树叶子节点个数 + 左子树根节点的右子树叶子节点个数 ,同理将每个问题都进行分解,一直将问题分解到不可再分解时递归就结束了;然后再将每个小问题的解返回代入带前面一个问题中将问题的解得出,然后再返回。这样我们也就通过解决若干个小问题来解决最开始的问题了。
3、二叉树高度/深度的实现

我们先思考一下怎么求二叉树的高度,以上面的图为例:想一下我们应该还是会借助递归的思想,那也就是我们需要把问题进行分解,那怎么分解呢?
首先我们要求出二叉树的高度 ,我们就先分别把根结点左右子树的高度求出来 ,然后取两者较大值 ,再加1也就是根结点本身高度 ,是不是就是二叉树的高度了。
好按照我们这个逻辑是不是就可以按照前面方法一样继续将问题进行分解,直到遇到空结点也就是问题不能再分解了递归也就结束了。

上面的图就是大致递归的逻辑了,具体代码如下:
cpp
//BinaryTree.h
//二叉树的深度/高度
int BinaryTreeHeight(BTNode* root);
//BinaryTree.c
//二叉树的深度/高度
int BinaryTreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return BinaryTreeHeight(root->left) > BinaryTreeHeight(root->right)
? BinaryTreeHeight(root->left) + 1 : BinaryTreeHeight(root->right) + 1;
}
//Test.c
BTNode* CreateBinaryTree()
{
//创建结点
BTNode* node1 = CreateNode(1);
BTNode* node2 = CreateNode(2);
BTNode* node3 = CreateNode(3);
BTNode* node4 = CreateNode(4);
BTNode* node5 = CreateNode(5);
BTNode* node6 = CreateNode(6);
BTNode* node7 = CreateNode(7);
//连接结点构成二叉树
node1->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
node5->right = node7;
return node1;
}
void Test1()
{
BTNode* root = CreateBinaryTree();
//二叉树的深度/高度
printf("BinaryTreeHeight:%d\n", BinaryTreeHeight(root));
}
int main()
{
Test1();
return 0;
}
利用一个三目操作符来判断较大者就能解决这个问题了,但是这个代码我们会发现不管是判断两者谁大还是判断完后返回哪个都是用的函数调用,这就会发生下面一个问题,问题来自于一个OJ题:计算二叉树的深度


我们会发现所写代码的确在逻辑上是没有任何问题的,之所以这个题目过不了就是因为超出了时间限制,也就是说我们代码是在效率上出现了问题。
那为什么会这样呢?这就要回到刚刚我们提到的:不管是判断两者谁大还是判断完后返回哪个都是用的函数调用 。这样就会导致我们好不容易递归完返回值后判断左子树高度和右子树高度谁大时,结果得到的还是函数调用,也就是说我们需要将上述的递归重新执行一遍 。并且每次到一个根结点都需要这样进行判断,可想而知计算量有多大,效率有多低。
但出现这个问题的本质原因就在于我们没有对每次的左子树和右子树高度分别用两个变量进行记录,从而导致这些重复调用函数,我们对代码进行如下修改即可实现:
cpp
//BinaryTree.c
//二叉树的深度/高度
int BinaryTreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
int leftheight = BinaryTreeHeight(root->left); //将每次递归的高度记录下来
int rightheight = BinaryTreeHeight(root->right);
return leftheight > rightheight ? leftheight + 1 : rightheight + 1;
}
用两个变量分别记录左右子树高度后,再用三目操作符返回值时就不需要在进行函数调用了。
4、二叉树第k层结点个数的实现
首先求二叉树第k层结点个数其实和前面的求二叉树叶子结点个数有点类似,在求叶子结点个数中我们对如果是叶子结点的情况就返回1,所以在这里我们也用同样的方法:将满足在第k层的结点的情况返回1 。
那就有一个问题了:怎么找到第k层呢?

以上面的图为例,假设有个二叉树有4层,我们要找到第3层的结点。那我们要用递归的话也就还是要把问题进行分解。
当我们递归一次也就相当于进入到结点1的左子树,也就来了第2层,如果此时我们把第2层的根结点看成第1层 ,那第3层 是不是就相当于变成第2层 了;我们再递归一次就相当于进入结点2的左子树,也就来到了第3层,此时我们再把第3层根结点看成第1层,那我们的判断条件是不是就可以写出来了:当k为1的时候则得到第3层结点返回1 。也就是说我们每次递归就让层数k减1,具体代码如下:
cpp
//BinaryTree.h
//二叉树第k层结点个数
int BinaryTreeLevelKSize(BTNode* root, int k);
//BinaryTree.c
//二叉树第k层结点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
{
return 0; //结点为空则返回0
}
if (k == 1)
{
return 1; //递归到了第k层且对应结点不为空,返回1
}
else
{
return BinaryTreeLevelKSize(root->left, k - 1) +
BinaryTreeLevelKSize(root->right, k - 1);
}
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
//二叉树第k层结点个数
printf("BinaryTreeLevelKSize:%d\n", BinaryTreeLevelKSize(root, 3));
printf("BinaryTreeLevelKSize:%d\n", BinaryTreeLevelKSize(root, 4));
}
int main()
{
Test1();
return 0;
}

5、二叉树查找值为x的结点
相较于前面几个方法的实现这个难度会更大,原因是虽然这个问题的解决思路非常简单,但很多人一开始写这个时很容易想当然而写错。接下来我会为大家讲解几个非常典型的错误案例、能够实现的方法以及最优代码。

5.1 方法一(不能实现)
cpp
//BinaryTree.c
//二叉树查找值为x的结点
//方法1
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
return BinaryTreeFind(root->left, x) == root ? root : BinaryTreeFind(root->right, x);
}
//方法2
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
if (BinaryTreeFind(root->left, x) == root)
{
return root;
}
else
{
BinaryTreeFind(root->right, x);
}
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
//二叉树查找值为x的结点
BTNode* node1 = BinaryTreeFind(root, 5);
if (node1 == NULL)
{
printf("没找到\n");
}
else if (node1->data == 5)
{
printf("找到了\n");
}
}
int main()
{
Test1();
return 0;
}

虽然是方法一,但算是一类方法,这些方法逻辑是类似的并且都不能解决这个问题,接下来我就为大家进行解释:
假设我们以上面的图为例查找x=5这个结点,虽然图上的确有这个结点 但是打印结果显示的是没有找到 ,也就是说这个方法最后返回的值是NULL 而不是我们想要结点的地址,正常来说只有当二叉树的确没有这个结点才会返回NULL,但这里明显就有问题了。
我们先看一下我们所写代码的逻辑:
首先判断root是否为空,为空则返回NULL没什么好讲的;
然后就是判断root->data是否为目标结点的x,如果是的话就说明我们找到了,则返回root;
如果该结点既不为空也不是目标节点我们就需要利用递归一直往下找了,如果左子树找完了则NULL == root为假,三目操作符执行 :右边的BinaryTreeFind(root->right, x) 也就是递归右子树查找。当某次递归我们找到了目标结点则返回root,此时root == root则三目操作符返回root。
貌似逻辑上没什么问题,但我们想想假设此时函数已经递归到了结点4再进入函数:
首先还是判断是否为空和是否为目标结点,都不是则执行 return BinaryTreeFind(root->left, x) == root ? root : BinaryTreeFind(root->right, x);这个代码,到这里就是我们问题所在了,此时我们还没有执行该代码仍然在结点4的位置 ,那此时 == 右边的root是不是结点4的地址对吧 ,好我们再递归到结点4的左子树 ;此时我们就来到结点5的位置也就是目标结点,那就满足了root->data == x返回root ,但是此时的root才是我们目标结点的地址 ,当我们会回到结点4函数中时现在大家表面上认为的root == root还为真吗?是不是就不对了,因为表面上都是root但是一个地址是目标结点地址但是另一个却是对应的根结点地址,这两者是不可能相等的。也就是说回到结点4时理论上我们应该就已经可以直接返回root了,但是由于判断为假则就会继续递归右子树,最终返回的就一定是NULL而找不到目标结点。
到此这个方法的问题所在我们就找到了,不仅是在一个方法,只要是判断BinaryTreeFind(root->left, x) == root;的相关类似方法就都是不对的,这也是刚学习这里很多人都会犯下的错误,所以需要好好进行讲解。
5.2 方法二(可以实现,但效率不好)
cpp
//BinaryTree.c
//二叉树查找值为x的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
BTNode* node1 = BinaryTreeFind(root->left, x);
BTNode* node2 = BinaryTreeFind(root->right, x);
return node1 == NULL ? node2 : node1;
}
//Test.c
void Test1()
{
BTNode* root = CreateBinaryTree();
//二叉树查找值为x的结点
BTNode* node1 = BinaryTreeFind(root, 5);
if (node1 == NULL)
{
printf("没找到\n");
}
else if (node1->data == 5)
{
printf("找到了\n");
}
}
int main()
{
Test1();
return 0;
}

虽然很多人会出现方法一的错误,但还是会有人一开始就发现这个问题,所以在最后递归时,他并不是用BinaryTreeFind(root->left, x) 与 root进行判断,而是选择与NULL进行判断,这样的好处我们应该就知道了不会出现两者表面一样但实际所指地址不同的情况。
这个方法具体逻辑就是当左子树找完没有找到目标结点则返回NULL,但左子树没有找到不代表没有 ,可能在右子树里面 ,所以此时NULL == NULL为真则执行node2递归到右子树进行查找 ;如果某一次递归找到了目标结点则返回root ,此时root == NULL为假则直接返回node1也就是目标结点的地址,这样我们也就解决了这个问题。
但为什么说这个方法效率不好呢?我们想想如果某一次在左子树就找到了目标结点,我们是不是就应该直接返回了而不用再去右子树进行查找了。
但是对应这个代码而言,如果在左子树找到了目标结点则node1就是目标结点的地址,但紧接着就会执行BTNode* node2 = BinaryTreeFind(root->right, x);递归到右子树进行查找 ,那这个查找还有意义吗?是不是就不需要查找了,这也就是方法二的一个缺陷,但是可以解决问题的。
5.3 方法三(最优方法)
cpp
//BinaryTree.c
//二叉树查找值为x的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
//方法三(最优方法)
//第一种写法(更加清晰明了,但代码量较多)
BTNode* node1 = BinaryTreeFind(root->left, x);
if (node1)
{
return node1;
}
BTNode* node2 = BinaryTreeFind(root->right, x);
if (node2)
{
return node2;
}
return NULL;
//第二种写法(更加简洁,但较难理解)
BTNode* node1 = BinaryTreeFind(root->left, x);
return node1 == NULL ? BinaryTreeFind(root->right, x) : node1;
}
方法三其实就是基于方法二解决了左子树如果找到目标结点则不需要递归右子树进行查找的缺陷,只需要在每次递归后用if判断即可,如果找到了则直接进行返回,不需要再递归右子树进行查找,只有当左子树全部找到还没有找到则返回NULL,if判断为假才会继续递归右子树进行查找。
但是方法三也有一个比较巧妙的写法就是上面第二种写法,相当于是把方法一的三目操作符和方法二的技巧结合在一起 。
就是三目操作符左边为 node1 与 NULL 的判断 ,是避免方法一的问题 ,然后如果左子树没有找到目标结点则返回NULL ,则NULL == NULL为真递归右子树进行查找 ;如果左子树某次找到了目标结点则返回node1 ,则node1 == NULL为假直接返回node1 ,就不需要再递归右子树进行查找了,并且一个三目操作符就可以完成第一种写法的所有代码。
结束语
到此数据结构中链式结构实现二叉树就先告一段落了,这篇文章主要是讲解了以链表实现二叉树结构以及相关方法的实现,最重要的就是二叉树查找值为x的结点,这个问题也是花了挺大篇幅进行了讲解。接下来我还要为大家继续讲解层序遍历,以及有关二叉树的一些非常经典的OJ题,希望这篇文章对大家学习二叉树有所帮助!