二叉树实验
引言
在数据结构的学习过程中,二叉树作为一种典型的非线性结构,其构造和操作方式具有高度的层次性和结构性。而递归正是处理这种结构最自然的方式之一。关于二叉树的基本结构如下图所示:
- 递归的本质是函数调用自身的过程,这恰好与二叉树的定义相吻合——一个二叉树由根节点、左子树和右子树构成,而左右子树本身又是二叉树;
- 在构建和遍历时,递归能够很好地模拟树的访问顺序,使得代码逻辑清晰简洁;
- 此外,使用递归可以避免手动维护栈等复杂结构,降低实现难度。
因此,在本实验中,我们采用递归方法来完成二叉树的构建以及中序、前序、后序三种深度优先遍历的实现。整个过程不仅帮助我们理解递归与树结构之间的内在联系,也为后续更复杂的树操作打下坚实基础。
一、实验需求分析
1.1 功能目标
- 接收用户输入的一个字符串,该字符串表示一棵二叉树的先序遍历结果;
- 利用递归方法将该字符串还原为一棵完整的二叉树;
- 对这棵二叉树执行中序、前序、后序三种遍历;
- 输出每种遍历的结果;
- 最后释放整棵树所占用的内存。
1.2 输入格式
- 使用字符
'#'
表示空节点; - 示例输入:
ABD##E##CF###
- 该字符串表示如下二叉树结构:
A/ \B C/ \ \
D E F
1.3 技术目标
- 理解递归如何构建二叉树;
- 掌握三种基本的深度优先遍历方式;
- 学会设计递归函数;
- 掌握动态内存分配与释放技巧,防止内存泄漏。
二、实验思路构建
为了实现上述功能,我们需要围绕以下关键步骤展开思考:
- 接受用户输入并进行合法性检测;
- 利用递归构建二叉树;
- 分别实现三种遍历的函数;
- 统一输出三种遍历的结果;
- 释放整棵树的内存。
2.1 接收用户输入并进行有效性检查
首先,我们需要让用户输入一个字符串,这个字符串是根据先序遍历生成的,并且其中用 #
表示空节点。例如,ABD##E##CF###
就是一个合法输入。
为什么需要先序输入?
因为先序遍历的顺序是“根 -> 左 -> 右”,这正好与递归构建树的过程一致。我们可以依次读取字符,遇到字母就创建节点,遇到
#
就返回 NULL,从而构建出整棵树。
为什么要对输入进行合法性检查?
防止程序因为非法字符或空输入崩溃,提升用户体验和程序鲁棒性。
2.2 递归构建二叉树
接下来,我们需要根据输入字符串递归地构建二叉树。这是整个程序的核心部分。
为什么使用递归?
因为二叉树的结构本身就是递归定义的。构建时,每个节点都可能有自己的左右子树,因此非常适合用递归的方法去处理。
如何设计递归函数?
函数每次取出一个字符:
- 如果是
#
,则返回 NULL;- 否则新建一个节点,然后递归构建它的左右子树。
2.3 实现三种遍历函数
构建完树之后,我们需要对它进行遍历。常见的三种遍历方式分别是:
- 前序遍历(根 -> 左 -> 右)
- 中序遍历(左 -> 根 -> 右)
- 后序遍历(左 -> 右 -> 根)
为什么用递归实现遍历?
这三种遍历方式天然适合递归描述,只需要改变访问节点的顺序即可。
2.4 统一输出三种遍历结果
为了让程序更加模块化,我们将三种遍历结果的输出封装成一个统一的函数。这样做的原因主要还是为了避免重复代码,提高可维护性;同时也能统一输出格式,增强可读性。
2.5 释放整棵树的内存
最后,由于我们构建二叉树时会动态分配内存,所以必须显式地释放这些内存,否则会导致内存泄漏。
三、代码编写
3.1 头文件引入
在开始正式编码之前,我们需要先确定程序所需的基本结构和头文件支持。程序涉及字符串处理、输入输出、内存管理等多个方面,因此我们引入标准库中的 <stdio.h>
、<stdlib.h>
、<string.h>
和 <ctype.h>
头文件。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
3.2 数据结构定义
接着,我们需要表示二叉树的节点,所以应该定义一个名为 TreeNode
的结构体。每个节点包含一个字符型数据字段,以及指向左右子节点的指针。这样的结构非常贴合二叉树的定义。
#define MAX_INPUT_SIZE 100 // 输入字符串最大长度// 二叉树节点结构体
typedef struct TreeNode
{char data;struct TreeNode* left;struct TreeNode* right;
} TreeNode;
宏定义 MAX_INPUT_SIZE
是为了限制输入字符串的最大长度,防止缓冲区溢出,保证程序安全性。
3.3 遍历枚举定义
此外,我们为了标识不同的遍历方式,可以定义一个枚举类型 TraversalType
。使用枚举提高了程序的可读性,也方便后续判断使用哪种遍历方式。
// 三种二叉树遍历方式枚举
typedef enum TraversalType
{INORDER = 1, // 中序遍历PREORDER, // 先序遍历POSTORDER // 后序遍历
}TraversalType;
3.4 接收用户输入
接下来,我们来实现一个接收输入的函数,并进行有效性检查。考虑到用户可能输入错误格式的字符串,所以我们需要设计一个循环机制,直到输入合法为止。
首先我们可以输出明确的提示信息,告诉用户应该输入什么样的字符串,并提供示例。然后使用 fgets
读取输入,比 scanf
更加安全,不会导致缓冲区溢出。接着去除末尾的换行符,检查输入是否为空。如果为空,则提示用户重新输入。然后逐个字符检查是否为字母或 #
,如果不是,则认为输入不合法,提示用户重新输入,并清空输入数组。
在整个过程中,始终循环直到输入合法为止,确保后续操作顺利进行。
/*** 输入二叉树先序字符串** @param input 存储输入字符串的数组*/
void InputBinaryTreeString(char input[])
{int isValid = 0;while (!isValid) // 输入合法则结束{printf(" **提前说明**:\n\n\t请输入如 'ABD##E##CF###' 格式的字符串,其中 '#' 表示空节点\n");printf("\t输入后点击Enter回车,程序将输入根据用户输入字符串构建二叉树\n");printf("\t并输出该二叉树进行中序遍历、前序遍历和后序遍历后的结果。\n");printf("\n例如:\n 输入:\n\tABD##E##CF###\n");printf("\n 输出:\n\t中序遍历结果为:\tD B E A F C\n");printf("\t前序遍历结果为:\tA B D E C F\n");printf("\t后序遍历结果为:\tD E B F C A\n");printf("\n请输入先序遍历字符串构建二叉树:\t");/* 读取输入失败则退出 */if (fgets(input, MAX_INPUT_SIZE, stdin) == NULL){printf("错误:读取输入失败。\n");exit(EXIT_FAILURE);}/* 去除换行符 */input[strcspn(input, "\n")] = '\0';/* 检查输入是否为空 */if (strlen(input) == 0){printf("错误:输入不能为空,请重新输入。\n");continue;}/* 字符合法性检测:是否为字母或 '#' */isValid = 1;for (int i = 0; input[i] != '\0'; ++i){char ch = input[i];if (!(isalpha(ch) || ch == '#')){isValid = 0;break;}}/* 输入异常则清空重新输入 */if (!isValid){printf("错误:输入包含非法字符,请仅使用字母和 '#'。\n");printf("请重新输入。\n\n");memset(input, 0, MAX_INPUT_SIZE); // 清空输入缓冲区内容}}
}
3.5 二叉树的构建
接着,我们来构建二叉树,这是整个程序的核心逻辑之一。我们通过递归的方式,根据用户输入的字符串逐步恢复出原始的树结构。这里,我们需要传入参数,即 input
是原始输入字符串,index
是当前解析位置的指针,便于树的“生长”。
具体流程即,一开始取出一个字符 ch
,并将索引值增加 1。如果字符是 #
,表示这是一个空节点,直接返回 NULL
。否则,使用 malloc
分配一个新的 TreeNode
节点,设置其 data
字段为当前字符,然后递归调用函数分别构建其左子树和右子树。最终返回这个新创建的节点作为当前层的根节点。
需要注意的是,当 malloc
失败时,应立即报错并退出程序,防止继续运行造成不可预料的后果。
/*** 递归构建二叉树** @param input 字符串输入流* @param index 当前字符索引指针* @return 新建的子树根节点*/
TreeNode* BuildTree(const char* input, int* index)
{char ch = input[*index];(*index)++;if (ch == '#') {return NULL; // '#' 表示空节点}TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));if (!node){printf("错误:内存分配失败。\n");exit(EXIT_FAILURE);}node->data = ch;node->left = BuildTree(input, index);node->right = BuildTree(input, index);return node;
}
3.6 遍历方式的实现
前序遍历、中序遍历和后序遍历这三种遍历方式本质上都是通过递归来模拟树的访问顺序,它们之间的区别仅在于访问当前节点与递归访问子树的顺序不同:
遍历类型 | 访问顺序 | 特点说明 |
---|---|---|
前序遍历 | 根 → 左 → 右 | 适合构建树结构、复制树 |
中序遍历 | 左 → 根 → 右 | 适合排序输出(在 BST 中) |
后序遍历 | 左 → 右 → 根 | 适合释放资源、求表达式值 |
3.6.1 前序遍历
前序遍历的顺序的根左右,实际上这与我们构建二叉树时的顺序一致,因此非常适合用递归的方式实现。在递归过程中,我们先处理当前节点的数据,然后再依次递归访问左子树和右子树。
实现的逻辑大致为:如果当前节点为空(NULL),则直接返回;否则,首先打印当前节点的值;然后递归调用自身处理左子树;最后递归调用自身处理右子树。
这样就能按照“根左右”的顺序输出整棵树的所有节点。
/*** 前序遍历:根 -> 左 -> 右** @param root 树根节点*/
void PreorderTraversal(TreeNode* root)
{if (root != NULL){printf("%c ", root->data);PreorderTraversal(root->left);PreorderTraversal(root->right);}
}
注意事项
- 需要确保每次访问节点前都进行空指针判断,防止非法访问;
- 打印格式统一使用
"%c "
,即每个字符后面加一个空格,便于阅读;- 函数不返回任何值,仅用于输出结果。
3.6.2 中序遍历
3.6.3 后序遍历
中序遍历和后序遍历也是同理,只不过顺序不一样而已,则合理就不再赘述。
/*** 中序遍历:左 -> 根 -> 右** @param root 树根节点*/
void InorderTraversal(TreeNode* root)
{if (root != NULL){InorderTraversal(root->left);printf("%c ", root->data);InorderTraversal(root->right);}
}/*** 后序遍历:左 -> 右 -> 根** @param root 树根节点*/
void PostorderTraversal(TreeNode* root)
{if (root != NULL){PostorderTraversal(root->left);PostorderTraversal(root->right);printf("%c ", root->data);}
}
3.7 输出遍历结果
为了简化主函数中的重复调用逻辑,提高代码复用性,避免在主函数中多次调用相似的打印语句; 统一输出格式,提升可读性和一致性。我们将三种遍历方式封装在一个函数中,根据传入的枚举类型决定执行哪一种遍历。
使用 Switch分支语句传入的遍历类型,分别调用对应的遍历函数,并在前面加上描述信息即可。
/*** 输出二叉树遍历结果(中序、前序、后序)** @param root 指向树根节点的指针* @param type 遍历的方式枚举常量*/
void OutputTraverslResult(TreeNode* root, TraversalType type)
{switch (type){case INORDER:{printf("中序遍历结果为:\t");InorderTraversal(root);break;}case PREORDER:{printf("前序遍历结果为:\t");PreorderTraversal(root);break;}case POSTORDER:{printf("后序遍历结果为:\t");PostorderTraversal(root);break;}default:break;}printf("\n");
}
3.8 释放二叉树
由于我们在程序中使用了动态内存分配(malloc
),因此必须手动释放所有节点占用的内存空间,否则会导致内存泄漏。因为只有先释放左右子树,才能安全地释放当前节点,否则会出现野指针或未释放的情况,所以我们采用后序释放。
void FreeBinaryTree(TreeNode** root)
{if (*root != NULL){FreeBinaryTree(&(*root)->left);FreeBinaryTree(&(*root)->right);free(*root);*root = NULL;}
}
3.9 主函数测试
现在我们已经完成了所有功能模块的实现,可以将它们整合到主函数中,形成完整的程序流程。
主函数依次完成以下任务:打印欢迎信→获取用户输入→构建二叉树→分别执行三种遍历并输出结果→释放整棵树内存→返回 0 结束程序。这样的组织方式使得主函数逻辑清晰,便于维护和扩展。
int main(void)
{printf("\n\t=== 二叉树构造与遍历程序 ===\n\n");/* 定义根节点和输入数组,并初始化树根 */char input[MAX_INPUT_SIZE];TreeNode* root = NULL;/* 获取用户输入 */InputBinaryTreeString(input); /* 构建二叉树 */int index = 0;root = BuildTree(input, &index); /* 前中后序遍历 */OutputTraverslResult(root, INORDER);OutputTraverslResult(root, PREORDER);OutputTraverslResult(root, POSTORDER);/* 释放二叉树 */FreeBinaryTree(&root); return 0;
}
四、实验总结
本次实验围绕“递归构建与遍历二叉树”这一核心任务,完整实现了从用户输入解析、树结构构建、三种遍历输出到内存释放的全过程。我们熟悉了递归在二叉树操作中的应用,理解了先序字符串如何还原出一棵树结构,并成功实现了中序、前序、后序三种遍历方式的递归实现。
当然,个人认为上述代码仍有较大的优化空间,因此大家也不必局限于此,更多的是带着批判性思维去学习,并且尝试独立编写代码,这样才能真正提高我们的代码编写能力和逻辑思维能力!
以上便是本次文章的所有内容,欢迎各位朋友在评论区讨论,本人也是一名初学小白,愿大家共同努力,一起进步吧!
鉴于笔者能力有限,难免出现一些纰漏和不足,望大家在评论区批评指正,谢谢!