写在前面
前面的二叉树学习中,我们已经完成了链式二叉树的基本结构,并实现了前序、中序、后序、层序遍历,以及结点总数、叶子结点数、树高度、第 K 层结点数等常见接口。
这些接口解决的主要是两个问题:
- 如何遍历一棵已经存在的二叉树
- 如何通过递归统计二叉树中的信息
但一套相对完整的二叉树基础接口,还需要解决另外几个很实际的问题:
- 给定一个值,如何在二叉树中查找对应结点?
- 一棵动态申请出来的二叉树,用完以后应该如何正确销毁?
- 如果给定一组带空结点标记的前序遍历序列,如何重新构建出原来的二叉树?
因此,本篇继续完善本地二叉树代码,新增三个接口:
cpp
// 二叉树查找值为 x 的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x);
// 二叉树销毁
void BinaryTreeDestory(BTNode** root);
// 通过前序遍历的数组构建二叉树
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi);
这三个接口虽然功能不同,但本质上仍然离不开我们前面一直在使用的核心思想:
把一棵树的问题拆成「当前结点的问题 + 左子树的问题 + 右子树的问题」。
本文全部代码已托管至 Gitee,代码采用头文件与实现文件分离的模块化写法,可直接拉取本地编译调试。
Gitee 仓库地址:
数据结构/BinaryTree · Luminous/Code_2026 - 码云 - 开源中国
一、二叉树查找:递归结果不能丢
1.1 问题分析
现在有一棵普通二叉树,希望查找值为 x 的结点。
与二叉搜索树不同,普通二叉树的结点并没有满足「左小右大」之类的顺序关系,所以我们不能根据数值大小直接决定向左还是向右。
因此,只能逐个搜索:
- 先判断当前结点;
- 当前结点不是目标值,就去左子树找;
- 左子树没找到,再去右子树找。
递归函数的定义可以理解为: BinaryTreeFind(root, x):在以 root 为根的二叉树中寻找值为 x 的结点,找到就返回该结点地址,找不到返回 NULL。
1.2 递归终止条件
如果当前子树已经为空:
cpp
if (root == NULL)
return NULL;
说明这一条路径走到底了,并没有找到目标结点。
如果当前根结点就是目标值:
cpp
if (root->data == x)
{
return root;
}
那么直接返回当前结点即可。
1.3 为什么左子树的返回值一定要保存
接下来搜索左子树:
cpp
BTNode* leftRet = BinaryTreeFind(root->left, x);
这里不能只写:
cpp
BinaryTreeFind(root->left, x);
因为递归函数真正有价值的内容不仅仅是「调用了一次」,而是它返回的查找结果。 例如左子树深处真的找到了结点,那么递归会一路把这个结点的地址向上传递。
所以必须保存返回值:
cpp
BTNode* leftRet = BinaryTreeFind(root->left, x);
如果找到:
cpp
if (leftRet != NULL)
{
return leftRet;
}
就不需要继续搜索右子树了。
只有左子树没找到,才搜索右子树:
cpp
BTNode* rightRet = BinaryTreeFind(root->right, x);
return rightRet;
1.4 完整代码
cpp
// 二叉树查找值为x的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
{
return root;
}
// 先去左子树查找
BTNode* leftRet = BinaryTreeFind(root->left, x);
if (leftRet != NULL)
{
return leftRet;
}
// 左子树没有找到,再去右子树查找
BTNode* rightRet = BinaryTreeFind(root->right, x);
return rightRet;
}
实际上最后两行也可以直接写成:
cpp
return BinaryTreeFind(root->right, x);
不过学习阶段保留 rightRet,能更直观地看出返回值是如何向上传递的。
二、二叉树销毁:为什么必须先销毁孩子
前面的二叉树结点都是通过 malloc 动态申请出来的。 既然申请了堆空间,在二叉树使用结束以后,就需要主动释放,否则会造成内存泄漏。
那么问题来了:应该按照什么顺序销毁二叉树?
2.1 不能先释放父结点
假设直接这样写:
cpp
free(root);
BinaryTreeDestory(&root->left);
BinaryTreeDestory(&root->right);
这是错误的。
因为:free(root); 执行完成以后,root 指向的结点空间已经释放。 这时候继续访问 root->left、root->right,就是访问已经失效的内存。
因此,父结点必须最后释放。 顺序应该是:
销毁左子树
↓
销毁右子树
↓
释放当前根结点
这实际上就是后序遍历思想:左 -> 右 -> 根。
2.2 为什么销毁函数使用二级指针
我们最终不仅希望释放结点,还希望让外部保存的根指针变成 NULL。
假如函数写成:
cpp
void BinaryTreeDestory(BTNode* root)
即使在函数内部写 root = NULL;,修改的也只是形参 root 自己。 外部真正保存树根地址的指针并不会发生改变。
这和我们之前学习顺序表、链表时遇到的指针传参问题是相同的。
如果希望修改外部的 BTNode* root;,就需要把它的地址传进去:
cpp
BinaryTreeDestory(&root);
所以函数参数应该是 BTNode** root。
2.3 完整代码
cpp
// 二叉树销毁
void BinaryTreeDestory(BTNode** root)
{
if (*root == NULL)
{
return;
}
// 递归销毁左子树
BinaryTreeDestory(&(*root)->left);
// 递归销毁右子树
BinaryTreeDestory(&(*root)->right);
// 释放当前结点
free(*root);
// 外部根指针置空
*root = NULL;
}
这里 &(*root)->left 表示把当前结点左孩子指针本身的地址传进去。 这样递归销毁左子树以后,(*root)->left 也会被自动置成 NULL,右子树同理。
最后再执行:
cpp
free(*root);
*root = NULL;
最终整棵树销毁完成以后,外部 root == NULL。
三、为什么二叉树只靠普通前序遍历无法唯一构建
接下来继续解决一个很重要的问题:
已知一棵树的前序遍历结果,能不能把原来的树重新建出来?
例如:ABC 只知道序列 A B C 是不够的。 它可能是:
A
/
B
/
C
也可能是:
A
\
B
\
C
甚至还可能存在其他结构。
问题就在于:普通前序遍历只保存了非空结点,没有记录空位置。
因此,为了能够还原树结构,需要把空结点也记录下来。 例如规定:# 表示空树。
假设一棵树为:
A
/ \
B C
带空结点的前序序列就是:AB##C##
展开来看:
A
B
#
#
C
#
#
这样每一个空位置都被保留下来了,二叉树结构就可以唯一确定。
四、根据前序序列递归建树
我们希望实现:
cpp
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi);
其中:
a:保存前序遍历序列;n:数组长度;pi:当前读取位置的下标;#:表示空结点。
4.1 为什么 pi 不能直接传 int
假设写:
cpp
BTNode* BinaryTreeCreate(BTDataType* a, int n, int pi);
那么每一次递归调用拿到的都是自己的局部副本。 左子树处理完以后,右子树无法知道前面已经读取到什么位置。
因此,我们实际上希望:整个递归过程中所有函数共享同一个遍历下标。
所以需要传 int* pi,每处理一个字符执行 (*pi)++;,所有递归层看到的下标都会一起向后移动。
4.2 建树递归过程
首先判断是否越界:
cpp
if (*pi >= n)
{
return NULL;
}
如果当前位置是 #,表示这里应该是一棵空树:
cpp
if (a[*pi] == '#')
{
(*pi)++;
return NULL;
}
注意:即使遇到 #,也必须让下标向后走一位。否则下一次递归仍然会读到同一个 #。
4.3 创建当前根结点
如果当前字符不是 #:
cpp
BTNode* newNode = BuyBTNode(a[*pi]);
(*pi)++;
创建当前结点以后,根据前序遍历「根 -> 左 -> 右」的顺序: 先构建左子树:
cpp
newNode->left = BinaryTreeCreate(a, n, pi);
再构建右子树:
cpp
newNode->right = BinaryTreeCreate(a, n, pi);
最后把当前已经构建好的子树根结点向上返回:
cpp
return newNode;
4.4 完整建树代码
cpp
// 通过前序遍历的数组构建二叉树
// #代表空结点,n为数组长度,pi为下标指针
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi)
{
// 下标越界
if (*pi >= n)
{
return NULL;
}
// # 表示空结点
if (a[*pi] == '#')
{
(*pi)++;
return NULL;
}
// 创建当前根结点
BTNode* newNode = BuyBTNode(a[*pi]);
(*pi)++;
// 前序序列:根 -> 左 -> 右
newNode->left = BinaryTreeCreate(a, n, pi);
newNode->right = BinaryTreeCreate(a, n, pi);
return newNode;
}
这里我把合并判断拆成了两个判断。 原因是:*pi >= n 表示已经没有字符可以读取,这种情况下其实没有必要继续 (*pi)++;,分别处理会更加严谨。
五、头文件新增接口
在 BinaryTree.h 中加入:
cpp
// 二叉树查找值为x的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x);
// 二叉树销毁
void BinaryTreeDestory(BTNode** root);
// 通过前序遍历的数组构建二叉树
// #代表空结点,n数组长度,pi下标指针
BTNode* BinaryTreeCreate(BTDataType* a, int n, int* pi);
至此,本地二叉树代码又补充了三个比较重要的基础功能。
六、接口测试
6.1 测试 BinaryTreeFind
cpp
BTNode* ret = BinaryTreeFind(root, 'E');
if (ret != NULL)
{
printf("找到了:%c\n", ret->data);
}
else
{
printf("没有找到\n");
}
如果树中存在 E,返回的不是一个简单的真假值,而是这个结点本身的地址 。 因此后续还可以继续访问 ret->left、ret->right、ret->data。
6.2 测试 BinaryTreeCreate
cpp
int main()
{
BTDataType a[] = "ABD##E##CF##G##";
int i = 0;
BTNode* root = BinaryTreeCreate(
a,
sizeof(a) / sizeof(a[0]) - 1,
&i
);
BinaryTreePrevOrder(root);
printf("\n");
BinaryTreeInOrder(root);
printf("\n");
BinaryTreePostOrder(root);
printf("\n");
BinaryTreeDestory(&root);
return 0;
}
这样就可以完成完整流程:
字符序列
↓
递归构建二叉树
↓
遍历验证结构
↓
销毁整棵树
七、三个接口背后的递归思想
这一篇新增了三个接口,看起来做的是三件完全不同的事情。 实际上,把它们放在一起看会发现非常有意思。
BinaryTreeFind 函数负责:在当前子树中找到目标结点,并把结果返回给上一层。 核心是:
cpp
当前结点
↓
左子树查找
↓
右子树查找
BinaryTreeDestory 函数负责:先处理完左右子树,再释放自己。 核心是:
cpp
销毁左子树
↓
销毁右子树
↓
释放当前根
BinaryTreeCreate 函数负责:根据当前位置创建当前结点,再递归构建左右子树。 核心是:
cpp
创建当前根
↓
构建左子树
↓
构建右子树
其实它们分别对应了非常典型的递归处理模式:
- 查找:获得子问题结果
- 销毁:先解决子问题,再解决当前问题
- 建树:先解决当前问题,再创建子问题
这也是学习二叉树以后,递归思维开始真正变得清晰的地方。
八、一个值得特别记住的递归细节
这次实现 BinaryTreeFind 时,有一个细节非常重要:
cpp
BTNode* leftRet = BinaryTreeFind(root->left, x);
为什么不能只写:
cpp
BinaryTreeFind(root->left, x);
因为递归函数不仅仅是在「继续执行」,还在向上返回答案。 如果返回值没有保存或者继续 return,那么下面所有递归得到的答案都会被丢掉。
这个问题在后面做二叉树题时会再次出现,而且非常典型。 下一篇的 LeetCode 572. 另一棵树的子树,我就因为没有正确处理递归返回值,以及错误改变了 subRoot,出现了错误。
这也是为什么数据结构基础接口完成以后,还需要专门通过题目继续训练递归。
写在最后
到这里,我们继续完善了二叉树的三个基础接口:
BinaryTreeFind:在普通二叉树中递归寻找目标结点BinaryTreeDestory:使用后序思想释放所有动态结点BinaryTreeCreate:根据带 # 的前序序列递归还原二叉树
相比单纯记住代码,更重要的是理解:
二叉树递归函数到底负责解决什么问题,又应该把什么结果交给上一层。
前面的遍历、结点统计、树高等问题,让我们建立了最基础的递归框架;而查找、销毁、建树则进一步说明,递归既可以用来「读取树」,也可以用来「修改树、创建树和释放树」。
下一篇开始进入新的二叉树专题练习。 将通过:
- LeetCode 965. 单值二叉树
- LeetCode 100. 相同的树
- LeetCode 572. 另一棵树的子树
- LeetCode 144. 二叉树的前序遍历
- TSINGK110 二叉树遍历
继续训练递归返回值、左右子树结果组合、双树递归比较以及前序序列建树等问题。