数据结构-二叉树(五):查找、销毁与前序序列建树

写在前面

前面的二叉树学习中,我们已经完成了链式二叉树的基本结构,并实现了前序、中序、后序、层序遍历,以及结点总数、叶子结点数、树高度、第 K 层结点数等常见接口。

这些接口解决的主要是两个问题:

  • 如何遍历一棵已经存在的二叉树
  • 如何通过递归统计二叉树中的信息

但一套相对完整的二叉树基础接口,还需要解决另外几个很实际的问题:

  1. 给定一个值,如何在二叉树中查找对应结点?
  2. 一棵动态申请出来的二叉树,用完以后应该如何正确销毁?
  3. 如果给定一组带空结点标记的前序遍历序列,如何重新构建出原来的二叉树?

因此,本篇继续完善本地二叉树代码,新增三个接口:

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 的结点。

与二叉搜索树不同,普通二叉树的结点并没有满足「左小右大」之类的顺序关系,所以我们不能根据数值大小直接决定向左还是向右。

因此,只能逐个搜索:

  1. 先判断当前结点;
  2. 当前结点不是目标值,就去左子树找;
  3. 左子树没找到,再去右子树找。

递归函数的定义可以理解为: 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->leftroot->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->leftret->rightret->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 二叉树遍历

继续训练递归返回值、左右子树结果组合、双树递归比较以及前序序列建树等问题。

相关推荐
Nontee8 小时前
MySQL 插入冲突了怎么办?两种处理方式入门笔记
数据库·笔记·mysql
Navigator_Z8 小时前
LeetCode //C - 1208. Get Equal Substrings Within Budget
c语言·算法·leetcode
浔溺8 小时前
al+大数据每日学习笔记26
笔记·学习
xx~t9 小时前
嵌入式——进程与线程1
linux·c语言·学习·嵌入式·进程与线程
老当益壮梁奶奶9 小时前
Linux软件编程学习笔记(七):线程分离与线程间通信详解
linux·c语言·c++·笔记·学习
sel_99 小时前
【多轮对话论文导读(三)】多轮对话与Agent论文阅读笔记:用户模拟、轨迹生成与长期记忆
论文阅读·人工智能·笔记·深度学习·算法·机器学习
zzm62810 小时前
《SIGIR 2018论文精读|深度学习点击序列模型:对搜索页面点击行为进行序列建模》
笔记·深度学习·推荐系统·论文精读
常驻客栈11 小时前
B-深入理解计算机系统第3版之第8章:异常控制流
笔记·学习·常驻客栈·深入理解计算机系统·学习笔记‘’
Seraphina3611 小时前
记一次实验:利用路径分隔符进行网页缓存欺骗
经验分享·笔记·安全·网络安全·缓存