对于数据结构:链式二叉树的超详细保姆级解析—上
开篇介绍:
Hello 大家好!许久不见,不知道大家是否还记得上一篇博客中我们深入探讨的 “堆” 这种特殊数据结构?现在回想起来,上次和大家一起拆解堆的逻辑与物理结构的场景还历历在目,仿佛就发生在不久之前。
在上一篇内容里,我们通过大量的图例和代码示例,清晰地梳理了堆的核心特性:它的逻辑结构是完全二叉树,这意味着从根节点开始,每一层都遵循 “从左到右、连续无空缺” 的排列规则;而它的物理结构则是数组—— 我们通过 “父节点索引与子节点索引的数学关系”(如父节点索引 i 对应左子节点 2i+1、右子节点 2i+2),让线性的数组能完美映射完全二叉树的层级关系,既节省了存储空间,又保证了插入、删除操作的高效性。
相信经过上一轮的学习,大家对二叉树的 “结构特性” 和 “存储逻辑” 已经有了更深刻的理解,甚至能独立写出堆的初始化、堆化、TopK 问题求解等代码。但学习数据结构的核心,从来不是 “掌握某一种固定实现”,而是 “根据问题场景选择最合适的方案”—— 这就引出了一个关键问题:堆的数组存储方式,真的能适用于所有二叉树吗?
答案显然是否定的。我们必须明确一点:堆的数组存储,本质是为 “完全二叉树” 量身定制的。因为完全二叉树没有 “空缺的节点”,数组中的每一个下标都能对应到一个有效的节点,不会出现 “浪费存储空间” 的情况。可如果我们遇到的是非完全二叉树呢?
比如一棵 “左子树为空、右子树有节点” 的二叉树,或者一棵节点分布零散的二叉树。此时若强行用数组存储,就会出现大量 “无意义的占位符”—— 为了保证 “父节点与子节点的索引关系”,我们必须在数组中为空缺的节点预留位置,这会导致存储空间的严重浪费;更麻烦的是,当树的结构变得复杂时,数组的插入、删除操作会变得异常繁琐(比如在非末尾位置插入节点,可能需要移动大量元素)。
面对这种 “数组存储不适用” 的场景,我们该如何应对?其实答案早就藏在我们学过的基础数据结构里 —— 既然顺序表(数组)有局限性,那我们还有 “链表” 呀!
是的,今天这篇博客的核心,就是带大家学习一种全新的二叉树实现方式 ——链式二叉树。它不再依赖数组的线性存储,而是通过 “节点 + 指针(或引用)” 的方式,让每个节点主动 “链接” 到自己的左、右子节点,从而灵活地构建出任意结构的二叉树(无论是完全二叉树、满二叉树,还是非完全二叉树)。
接下来,我们就正式进入链式二叉树的学习,一步步拆解它的实现逻辑,让大家既能理解 “为什么要用链式结构”,也能掌握 “如何用链式结构实现二叉树”。
在这里,我直接总结一下递归的一个过程,接下来的话,非常关键,是我们理解递归的一个必须的知识点,无论如何,都请重视并牢牢记住:
首先,我们知道,一段代码的执行,是按照从上到下的顺序进行的,意思就是上面的代码还没执行完的话,那么就一定不会执行下面的代码,这个是编译器执行代码的本质,也是关键中的关键。
那么运用我们的这一些递归遍历中,又是怎么样的呢?
我们要知道,当递归碰到了要返回的条件之后,函数就得返回到上一个函数中的,是的,递归的关键来了,就是每次递归调用函数的时候,在没碰到返回条件之前,都是会将运行到的函数暂时压在一个栈中,然后去再进入新的一个栈,只有当碰到返回条件的时候,又或者是在这个栈中的函数运行到头了,那么这个栈才会结束,然后回到上一个栈,也就是上一个运行到一半的函数中,然后这个时候,在这一个函数中(即上一个运行到一半的函数),会接着对这个函数中进入递归的代码的下面进行运行,对于接下来的递归亦或者是往上的返回,都会一直重复这个操作,直到到了某个栈(其实就是某个函数中),递归的代码全部被运行完,这个时候,代码才会结束。
以上的内容,可以说是理解递归的重中之重,只有把上面所说的理解透,才能真正的了解到递归的过程。
我们可以从内存模型、函数调用机制和指令执行顺序三个维度,用 “可视化” 的方式拆解递归的每一个细节,以 factorial(3) 为例(n! = n × (n-1)!,终止条件 0! = 1),把递归的底层执行逻辑讲清楚。
一、前置知识:函数调用的底层原理
在理解递归前,必须先明确普通函数调用的内存机制:
- 栈(Stack):函数调用时,系统会在内存中开辟一块连续空间(栈),用于存储函数的参数、局部变量、返回地址(即函数执行完后要回到哪里继续执行)。
- 压栈(Push):调用函数时,系统会将上述信息打包成一个 “栈帧(Stack Frame)”,压入栈顶。
- 弹栈(Pop):函数执行完毕(遇到
return),栈顶的栈帧会被销毁,系统根据 “返回地址” 回到调用它的地方,继续执行后续代码。
简单说:每次函数调用 = 压栈,每次函数返回 = 弹栈,栈严格遵循 “先进后出” 原则。
二、递归执行的全流程拆解(以 factorial(3) 为例)
先给出完整代码:
int factorial(int n) { // 函数定义if (n == 0) {return 1; // 终止条件}return n * factorial(n - 1); // 递归调用
}// 主函数中调用:int result = factorial(3);
阶段 1:主函数调用 factorial(3)(初始压栈)
-
主函数执行到
int result = factorial(3);系统需要调用factorial(3),因此:- 创建栈帧 1(
factorial(3)的栈帧):- 参数
n = 3 - 局部变量:无(此函数无额外局部变量)
- 返回地址:主函数中
factorial(3)所在行的下一行(即拿到结果后要赋值给result)
- 参数
- 将栈帧 1 压入栈顶,此时栈状态:
栈顶 → 栈帧1:n=3,返回地址=主函数下一行 栈底
- 创建栈帧 1(
-
执行
factorial(3)的代码按顺序执行函数内代码:- 先判断
n == 0?3 == 0为假,跳过return 1。 - 执行
return 3 * factorial(2);:要计算这个表达式,必须先知道factorial(2)的结果。因此,factorial(3)的执行被暂停(当前刚执行到乘法运算符左侧,等待右侧结果)。
- 先判断
阶段 2:factorial(3) 调用 factorial(2)(第一次递归压栈)
-
调用
factorial(2)系统创建栈帧 2(factorial(2)的栈帧):- 参数
n = 2 - 局部变量:无
- 返回地址:
factorial(3)中return 3 * factorial(2);这一行(即拿到factorial(2)结果后,要继续计算3 * 结果) - 将栈帧 2 压入栈顶,此时栈状态:
栈顶 → 栈帧2:n=2,返回地址=factorial(3)的return行栈帧1:n=3,返回地址=主函数下一行 栈底
- 参数
-
执行
factorial(2)的代码- 判断
n == 0?2 == 0为假,跳过return 1。 - 执行
return 2 * factorial(1);:需先计算factorial(1),factorial(2)被暂停(等待factorial(1)的结果)。
- 判断
阶段 3:factorial(2) 调用 factorial(1)(第二次递归压栈)
-
调用
factorial(1)创建栈帧 3:- 参数
n = 1 - 返回地址:
factorial(2)中return 2 * factorial(1);这一行 - 压入栈顶,栈状态:
栈顶 → 栈帧3:n=1,返回地址=factorial(2)的return行栈帧2:n=2,返回地址=factorial(3)的return行栈帧1:n=3,返回地址=主函数下一行 栈底
- 参数
-
执行
factorial(1)的代码- 判断
n == 0?1 == 0为假,跳过return 1。 - 执行
return 1 * factorial(0);:需先计算factorial(0),factorial(1)被暂停。
- 判断
阶段 4:factorial(1) 调用 factorial(0)(第三次递归压栈,触发终止条件)
-
调用
factorial(0)创建栈帧 4:- 参数
n = 0 - 返回地址:
factorial(1)中return 1 * factorial(0);这一行 - 压入栈顶,栈状态:
栈顶 → 栈帧4:n=0,返回地址=factorial(1)的return行栈帧3:n=1,返回地址=factorial(2)的return行栈帧2:n=2,返回地址=factorial(3)的return行栈帧1:n=3,返回地址=主函数下一行 栈底
- 参数
-
执行
factorial(0)的代码- 判断
n == 0?0 == 0为真,执行return 1;。此时,factorial(0)执行完毕,准备返回结果。
- 判断
阶段 5:factorial(0) 返回,开始回溯(第一次弹栈)
-
factorial(0)弹栈- 栈帧 4 被销毁,返回值
1传递给 “返回地址” 指向的位置(即factorial(1)的return 1 * factorial(0);行)。 - 栈状态恢复为:
栈顶 → 栈帧3:n=1,返回地址=factorial(2)的return行栈帧2:n=2,返回地址=factorial(3)的return行栈帧1:n=3,返回地址=主函数下一行 栈底
- 栈帧 4 被销毁,返回值
-
恢复
factorial(1)的执行- 之前暂停在
return 1 * factorial(0);,现在拿到factorial(0)=1,继续计算:1 * 1 = 1。 - 执行
return 1;,factorial(1)执行完毕。
- 之前暂停在
阶段 6:factorial(1) 返回(第二次弹栈)
-
factorial(1)弹栈- 栈帧 3 被销毁,返回值
1传递给factorial(2)的return 2 * factorial(1);行。 - 栈状态:
栈顶 → 栈帧2:n=2,返回地址=factorial(3)的return行栈帧1:n=3,返回地址=主函数下一行 栈底
- 栈帧 3 被销毁,返回值
-
恢复
factorial(2)的执行- 计算
2 * 1 = 2,执行return 2;,factorial(2)执行完毕。
- 计算
阶段 7:factorial(2) 返回(第三次弹栈)
-
factorial(2)弹栈- 栈帧 2 被销毁,返回值
2传递给factorial(3)的return 3 * factorial(2);行。 - 栈状态:
栈顶 → 栈帧1:n=3,返回地址=主函数下一行 栈底
- 栈帧 2 被销毁,返回值
-
恢复
factorial(3)的执行- 计算
3 * 2 = 6,执行return 6;,factorial(3)执行完毕。
- 计算
阶段 8:factorial(3) 返回(第四次弹栈,递归结束)
factorial(3)弹栈- 栈帧 1 被销毁,返回值
6传递给主函数的int result = factorial(3);行,result被赋值为6。 - 栈为空,递归全过程结束。
- 栈帧 1 被销毁,返回值
三、递归的核心本质总结
-
递归 = 多次普通函数调用的叠加递归函数每次调用自己,和 “函数 A 调用函数 B,函数 B 调用函数 C” 的机制完全相同,只是调用的函数名称相同而已。
-
栈是递归的 “记忆载体”栈帧保存了每个递归调用的参数、暂停位置(返回地址),确保当内层递归返回时,外层函数能 “记住” 自己执行到哪一步,继续往下算。
-
终止条件是递归的 “出口”若无终止条件(如
n == 0时返回1),递归会无限创建栈帧,最终导致 “栈溢出”(Stack Overflow)错误。 -
执行流程 = “深入(压栈)→ 触底(终止条件)→ 回溯(弹栈)”
- 深入阶段:不断压栈,问题规模缩小(
3→2→1→0)。 - 回溯阶段:不断弹栈,利用子问题结果解决父问题(
0!→1!→2!→3!)。
- 深入阶段:不断压栈,问题规模缩小(
通过这个过程可以看到,递归的 “神奇” 之处其实是计算机通过栈结构,严谨地保存和恢复每个调用的状态,让 “自我调用” 能够正确传递结果并最终返回。理解了栈的操作,就理解了递归的本质。
我再以双重递归来讲述一下,假设是后序遍历,即
//(3)后序遍历(Postorder Traversal):
//访问根结点的操作发生在遍历其左右子树之后
//访问顺序为:左子树、右子树、根结点
void postorder(btn* root)
{//先设置递归返回条件//即走到了节点是为空,就得返回if (root == NULL){printf("N ");return;}//先去走左子树//走完左子树后,直接去走右子树,不用表达数据postorder(root->left);//走右子树//走完右子树之后,才能表达节点数据postorder(root->right);//因为是从左子树开始,所以我们得走完左子树后,再去走完右子树,最后才能表达数据//其实就是根结点在最后//所以我们表达也要在最后printf("%d ",root->data);
}
假设有这么一棵二叉树:
我们调用后序遍历,那么先到最上面的根节点1,诶,既没达到返回条件,也没达到函数末尾,所以把postorder(1)压栈,往下走到postorder(1)函数中的postorder(root->left),即postorder(2),再一看,依然既没达到返回条件,也没达到函数末尾,所以就进入postorder(2)函数中的postorder(root->left),即postorder(4),再一看,依然既没达到返回条件,也没达到函数末尾,所以就进入postorder(4)函数中的postorder(root->left),这个时候,即是postorder(NULL),达到返回条件,进行返回,返回到postorder(4)中,这个时候可不是一路返回到postorder(1),而是沿着postorder(4)继续往下走,即postorder(4)函数中的postorder(root->right),即进入到postorder(NULL),达到返回条件,进行返回,此时就又返回到了postorder(4)中了,然后这个时候依旧要沿着postorder(4)函数往下走,因为走完了postorder(4)函数中的postorder(root->right),所以接下来就要走postorder(4)函数中的printf,当表达完之后,postorder(4)函数也就运行到了函数末尾,OK,代表postorder(4)函数运行结束,回到postorder(2)函数中,
回到 postorder(2) 函数后,接着执行postorder(2)函数中的 postorder(root->right),也就是 postorder(NULL),达到返回条件,返回。然后执行 postorder(2) 函数里的 printf,输出节点 2 的值,之后 postorder(2) 函数运行结束,回到 postorder(1) 函数。
在 postorder(1) 函数中,继续执行 postorder(root->right),即 postorder(3)。进入 postorder(3) 函数后,先执行 postorder(root->left)(NULL),返回;再执行 postorder(root->right)(NULL),返回;接着执行 postorder(3) 里的 printf,输出节点 3 的值,postorder(3) 函数运行结束,回到 postorder(1) 函数。
最后执行 postorder(1) 函数里的 printf,输出节点 1 的值,整个后序遍历结束,最终输出顺序为 4、2、3、1。
节点结构:
那么我们知道,我们接下来要实现的链式二叉树,本质上是由一个个节点构成的,那么,我们的节点的里面的结构又是怎么样的呢?
首先,我们可以明确的是,节点里面一定要有一个存储数据的变量,这是毋庸置疑的,毕竟我们实现链式二叉树的本质还是去存储数据。
那么除了存储数据的变量之外,我们的节点结构里,还要有什么呢?
其实大家不难想到,因为我们是要实现二叉树,那么这也就意味着,一个父节点要有两个孩子(即使孩子为NULL),那么我们又怎么能让一个节点能够有两个孩子呢?其实意思就是要能够通过这个父节点去找到它的两个孩子节点。
说到这里,大家肯定就懂了,那不就是利用指针吗,是的,我们要在节点结构体中,再创建两个指针变量,一个用于指向它(即节点)的左孩子,我们叫left,另一个用于指向它(即节点)的右孩子,我们叫right。
如此一来,是不是就能实现能够通过这个父节点去找到它的两个孩子节点了,也就能够符合我们二叉树的模样。
到了这里,就没有什么可以再赘述的了,聪明的你肯定就能全明白了。
所以,我下面直接给出我们链式二叉树的节点结构体的创建:
//用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。
//通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,
//左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址typedef int name1;struct BinaryTreeNode
{name1 data;struct BinaryTreeNode* left;struct BinaryTreeNode* right;
};typedef struct BinaryTreeNode btn;
这已经是常规操作了,也不知道大家在瞬秒这个结构体的创建的时候,会不会想起自己当初手搓链表时的迷茫的,哈哈。
手动创建一个链式二叉树:
有了节点结构,接下来就要思考如何 “构建” 一棵链式二叉树。和堆的初始化(从数组批量构建)不同,链式二叉树的初始化通常是 “从根节点开始,逐个添加子节点”—— 因为它的结构不固定,无法像完全二叉树那样通过批量数据直接映射。
二叉树的创建方式比较复杂,为了更好的步入到二叉树内容中,我们先手动创建一棵链式二叉树,
那么其实手动创建一颗二叉树是很简单的,无非就是创建新节点,然后我们再去修改节点的left和right指针的指向,如此一来便能手动创建一颗二叉树来。
创建新节点,我们在对单向链表的解析中,已经讲解过了。至于修改指针指向,更是小儿科的难度。
只不过,我们在创建一颗二叉树之前,最好是先把图画出来,这样子我们在创建的时候,才会更加得心应手。
比如我们要创建这么一颗二叉树:

那么实现它的代码便如下:
//二叉树的创建方式比较复杂,为了更好的步入到二叉树内容中,
//我们先手动创建一棵链式二叉树//创建节点函数
btn* createnode(name1 x)
{btn* newnode = (name1*)malloc(sizeof(btn));if (newnode == NULL){perror("malloc newnode false");exit(1);}newnode->data = x;newnode->left = NULL;newnode->right = NULL;return newnode;
}//创建二叉树
btn* createbinarytree()
{btn* n1 = createnode(1);btn* n2 = createnode(2);btn* n3 = createnode(3);btn* n4 = createnode(4);btn* n5 = createnode(5);btn* n6 = createnode(6);n1->left = n2;n1->right = n4;n2->left = n3;n4->left = n5;n4->right = n6;//将根节点返回return n1;
}
以防大家不太明白,我提供一版详细注释的:
// 创建单个二叉树节点的函数
// 参数:x 是要存储在新节点中的数据值
// 返回值:指向新创建节点的指针(btn*类型)
btn* createnode(int x)
{// 1. 为新节点分配内存空间// malloc函数需要传入要分配的内存大小(单位:字节)// sizeof(btn)计算出一个btn结构体所需的字节数// (btn*)是将malloc返回的void*指针强制转换为btn*类型btn* newnode = (btn*)malloc(sizeof(btn));// 2. 检查内存分配是否成功// 当内存不足时,malloc会返回NULL,需要处理这种异常情况if (newnode == NULL){// perror函数用于打印最近一次系统调用的错误信息,这里会显示"malloc newnode false: 错误原因"perror("malloc newnode false");// exit(1)表示程序异常退出,1是退出码(非0表示异常)exit(1);}// 3. 初始化新节点的各个成员newnode->data = x; // 将参数x的值赋给新节点的数据域newnode->left = NULL; // 初始化左指针为NULL,表示暂时没有左子节点newnode->right = NULL; // 初始化右指针为NULL,表示暂时没有右子节点// 4. 返回创建好的节点指针return newnode;
}// 手动创建一棵完整的链式二叉树
// 返回值:指向二叉树根节点的指针(通过根节点可以访问整棵树)
btn* createbinarytree()
{// 第一阶段:创建所有需要的节点// 调用createnode函数分别创建存储1-6的节点btn* n1 = createnode(1); // 创建节点n1,数据为1btn* n2 = createnode(2); // 创建节点n2,数据为2btn* n3 = createnode(3); // 创建节点n3,数据为3btn* n4 = createnode(4); // 创建节点n4,数据为4btn* n5 = createnode(5); // 创建节点n5,数据为5btn* n6 = createnode(6); // 创建节点n6,数据为6// 第二阶段:建立节点之间的关联关系(构建树结构)// 根节点n1的左子节点是n2,右子节点是n4n1->left = n2; // n1的left指针指向n2n1->right = n4; // n1的right指针指向n4// 节点n2的左子节点是n3(n2的右子节点保持NULL,即没有右子节点)n2->left = n3; // n2的left指针指向n3// 节点n4的左子节点是n5,右子节点是n6n4->left = n5; // n4的left指针指向n5n4->right = n6; // n4的right指针指向n6// 修正后的二叉树结构如下(准确反映代码逻辑):// // n1(1) 第1层(根节点层)// / \// n2(2) n4(4) 第2层// / \ / \// n3(3) NULL n5(5) n6(6) 第3层// / \ / \ / \// NULL NULL NULL NULL NULL NULL 第4层(所有叶子节点的子节点均为NULL)// 第三阶段:返回根节点// 二叉树的操作通常从根节点开始,因此返回根节点指针return n1;
}
即如上,very easy。
对于链式二叉树的遍历类型:
在掌握了链式二叉树的节点结构,并且成功手动创建出一棵二叉树后,我们就能够着手对这棵二叉树进行操作了。而对二叉树的操作,往往离不开树的遍历—— 所谓遍历,就是按照某种规则 “访问” 树中所有节点,且每个节点仅被访问一次的过程。
按照遍历逻辑的不同,二叉树的遍历可分为 “递归结构遍历” 和 “非递归结构遍历”,其中最核心、最常用的包括以下四种:
一、基于递归结构的遍历(深度优先遍历,DFS)
递归遍历的核心逻辑是 “分治思想”:将 “遍历整棵树” 的问题,拆解为 “遍历左子树” 和 “遍历右子树” 两个子问题,直到遇到空节点(递归终止条件)。区别仅在于 “访问根节点” 的时机不同。
1. 前序遍历(Preorder Traversal,亦称先序遍历)
- 核心规则:访问根节点的操作,发生在遍历其左、右子树之前。
- 访问顺序:根节点 → 左子树 → 右子树
- 逻辑理解:先 “拿到” 当前树的根节点数据,再依次深入左、右子树重复该逻辑。例:对之前手动创建的二叉树(根 n1=1,左子 n2=2,右子 n4=4),前序遍历结果为:
1 → 2 → 3 → 4 → 5 → 6。
2. 中序遍历(Inorder Traversal)
- 核心规则:访问根节点的操作,发生在遍历其左、右子树之中(先遍历完左子树,再访问根节点,最后遍历右子树)。
- 访问顺序:左子树 → 根节点 → 右子树
- 逻辑理解:先深入左子树 “探底”,再返回访问当前根节点,最后处理右子树。例:对上述二叉树,中序遍历结果为:
3 → 2 → 1 → 5 → 4 → 6。注:中序遍历是二叉搜索树(BST)的关键遍历方式,其结果为 “升序序列”。
3. 后序遍历(Postorder Traversal)
- 核心规则:访问根节点的操作,发生在遍历其左、右子树之后。
- 访问顺序:左子树 → 右子树 → 根节点
- 逻辑理解:先把当前树的左、右子树全部处理完,最后再访问根节点。例:对上述二叉树,后序遍历结果为:
3 → 2 → 5 → 6 → 4 → 1。注:后序遍历常用于 “删除二叉树”(先删子节点,再删父节点,避免内存泄漏)。
二、基于非递归结构的遍历(广度优先遍历,BFS):层序遍历
层序遍历与递归遍历逻辑完全不同,它不依赖 “分治”,而是遵循 “广度优先” 规则 ——从上到下、从左到右,逐层访问树中的节点,如同 “按楼层顺序遍历大楼”。
层序遍历(Level-order Traversal)
- 核心规则:以 “层” 为单位遍历,先访问第 1 层(根节点),再访问第 2 层(根节点的左、右子节点),依次向下,同一层内按 “左→右” 顺序访问。
- 实现依赖:需要借助 “队列(Queue)” 数据结构(先进先出特性),确保节点按 “层” 的顺序出队访问。
- 具体步骤:
- 先将根节点入队;
- 若队列不为空,将队头节点出队并访问;
- 若出队节点有左子节点,将左子节点入队;
- 若出队节点有右子节点,将右子节点入队;
- 重复步骤 2-4,直到队列为空。
- 例:对上述二叉树,层序遍历结果为:
1 → 2 → 4 → 3 → 5 → 6(第 1 层:1;第 2 层:2、4;第 3 层:3、5、6)。
这四种遍历方式覆盖了二叉树操作的核心场景:递归遍历(前 / 中 / 后序)适合处理 “深度优先” 的需求(如二叉搜索树的有序查询),层序遍历适合处理 “广度优先” 的需求(如按层打印树结构、求树的最小深度等)。后续我们会通过代码实现,进一步理解它们的执行逻辑。
为什么要用递归去实现前、中、后序遍历:
在二叉树的前序、中序、后序遍历中,递归之所以成为最自然、最简洁的实现方式,本质上是因为二叉树的递归结构与遍历的递归逻辑形成了 “基因层面” 的契合。这种契合并非偶然,而是由二叉树的定义、遍历的本质以及递归的特性共同决定的。我们可以从以下五个维度,结合具体场景和对比分析,彻底理解这种 “天然适配” 的深层原因:
一、二叉树的定义自带递归 “基因”,为递归提供了 “原生土壤”
二叉树的数学定义是:“二叉树是有限个节点的集合,这个集合要么为空(空树),要么由一个根节点和两棵互不相交的左子树、右子树组成,且左子树和右子树本身也是二叉树”。这个定义包含三个关键的递归要素:
-
自相似的拆分性任何非空二叉树都可以拆分为 “根节点 + 左子树(二叉树) + 右子树(二叉树)”。以我们的示例树(根 1,左子树 2-3,右子树 4-5-6)为例:
- 整棵树 = 根 1 + 左子树(2-3) + 右子树(4-5-6)
- 左子树(2-3) = 根 2 + 左子树(3) + 右子树(空)
- 左子树(3) = 根 3 + 左子树(空) + 右子树(空)这种 “大结构包含小结构,小结构与大结构完全同质” 的特性,正是递归 “将大问题拆分为同类型小问题” 的核心要求。
-
明确的终止条件当子树为空(即节点的左 / 右指针为
NULL)时,拆分过程自然终止 —— 这恰好是递归的 “终止条件”。就像俄罗斯套娃拆到最后一个,里面再没有更小的娃娃,拆解就会停止。 -
无歧义的边界左子树和右子树 “互不相交”,确保了拆分后的子问题彼此独立,不会出现重叠或冲突 —— 这避免了递归过程中 “子问题纠缠” 的逻辑混乱。
这种定义上的递归特性,让二叉树从诞生起就为递归算法 “预留了接口”,就像一把锁天生适配对应的钥匙。
二、遍历逻辑与递归流程 “镜像同步”,执行过程零摩擦
遍历的本质是 “按规则访问所有节点,且每个节点仅访问一次”。前序(根→左→右)、中序(左→根→右)、后序(左→右→根)遍历的核心区别,仅在于 “访问根节点的时机”,而三者的整体逻辑都与递归流程形成 “镜像对应”:
以前序遍历为例,我们通过 “函数调用栈” 的变化,直观展示这种同步性:
假设遍历函数为Traverse(node),逻辑是:
- 访问
node(打印值); - 调用
Traverse(node.left); - 调用
Traverse(node.right)。
对示例树的执行过程如下:
-
初始调用:
Traverse(1)栈状态:[Traverse (1)]执行步骤 1:访问 1(输出 1);执行步骤 2:调用Traverse(2),栈变为 [Traverse (1), Traverse (2)]。 -
进入 Traverse (2)栈状态:[Traverse (1), Traverse (2)]执行步骤 1:访问 2(输出 2);执行步骤 2:调用
Traverse(3),栈变为 [Traverse (1), Traverse (2), Traverse (3)]。 -
进入 Traverse (3)栈状态:[Traverse (1), Traverse (2), Traverse (3)]执行步骤 1:访问 3(输出 3);执行步骤 2:调用
Traverse(3.left)(NULL),触发终止条件,返回;执行步骤 3:调用Traverse(3.right)(NULL),触发终止条件,返回;栈弹出Traverse(3),回到Traverse(2)。 -
回到 Traverse (2)栈状态:[Traverse (1), Traverse (2)]步骤 3:调用
Traverse(2.right)(NULL),触发终止条件,返回;栈弹出Traverse(2),回到Traverse(1)。 -
回到 Traverse (1)栈状态:[Traverse (1)]步骤 3:调用
Traverse(4),栈变为 [Traverse (1), Traverse (4)]。(后续遍历 4、5、6 的过程与上述逻辑完全一致,最终输出 1 2 3 4 5 6)
整个过程中:
- 递归的 “调用” 对应 “深入子树”,栈的深度随子树层级增加而增加;
- 递归的 “返回” 对应 “回溯到父节点”,栈的深度随子树处理完毕而减少;
- 访问根节点的时机(步骤 1 的位置)决定了遍历类型,只需调整步骤 1 的位置,就能轻松实现中序或后序遍历。
这种 “调用→深入→返回→回溯” 的流程,与遍历 “从根到子树、再回到父节点处理另一子树” 的逻辑完全同步,没有任何额外的 “适配成本”。
三、递归自动处理 “记忆与回溯”,避免手动控制的复杂性
非递归实现遍历的核心难点是 “如何记住待访问的节点” 和 “如何回溯到上一层”。递归通过编译器自动维护的 “调用栈”,完美解决了这两个问题:
-
自动记忆待处理节点递归调用时,编译器会将当前函数的状态(如参数、局部变量、返回地址)压入栈中。当需要处理右子树时,左子树的遍历状态已被自动 “存档”,无需手动记录。
例如遍历到节点 2 时,调用
Traverse(3)前,Traverse(2)的状态(包括 “接下来要处理右子树” 的信息)会被压入栈中。当Traverse(3)执行完毕,栈会自动弹出Traverse(2)的状态,程序知道 “该处理 2 的右子树了”。 -
自动控制回溯顺序栈的 “后进先出” 特性,确保了回溯顺序与遍历要求一致。例如前序遍历中,左子树必须在右子树之前处理,而递归调用时
Traverse(left)先于Traverse(right),栈会自然保证左子树处理完毕后再处理右子树。
如果用非递归实现前序遍历,开发者必须手动模拟这一过程:
// 非递归前序遍历伪代码
stack = [根节点1]
while stack 不为空:node = stack.pop() // 弹出栈顶节点访问nodeif node有右子树:stack.push(右子树) // 右子树先入栈(后处理)if node有左子树:stack.push(左子树) // 左子树后入栈(先处理)
这段代码需要手动控制入栈顺序(右→左)以保证遍历顺序(根→左→右),还需显式维护栈的入栈、出栈操作。如果是中序或后序遍历,还需要额外标记节点是否已被访问(如用哈希表记录),否则会重复访问根节点。
对比之下,递归代码无需关注这些细节:
// 递归前序遍历伪代码
def Traverse(node):if node为空: return访问node // 前序:根在最前Traverse(node.left)Traverse(node.right)
开发者只需描述 “遍历的规则”,无需关心 “如何实现遍历的流程”—— 这种 “关注点分离” 极大降低了思维负担。
四、递归代码与人类思维模式一致,可读性远超非递归
人类理解复杂结构的方式,往往是 “先把握整体,再深入局部”,这与递归的 “先处理当前根节点,再深入子树” 逻辑高度吻合。
以中序遍历(左→根→右) 为例,人类的思考过程是:“要遍历整棵树,先遍历左子树;左子树遍历完了,再访问根节点;最后遍历右子树。而遍历左子树的方式和遍历整棵树一样 —— 先遍历它的左子树,再访问它的根,最后遍历它的右子树。”
这段自然语言描述,几乎可以直接转化为递归代码:
def InOrder(node):if node为空: returnInOrder(node.left) // 先遍历左子树访问node // 再访问根节点InOrder(node.right) // 最后遍历右子树
而非递归的中序遍历代码(如下)则完全打破了这种思维连贯性:
// 非递归中序遍历伪代码
stack = []
current = 根节点1
while current不为空 or stack不为空:// 先走到最左节点while current不为空:stack.push(current)current = current.left// 弹出最左节点并访问current = stack.pop()访问current// 转向右子树current = current.right
这段代码需要用 “先走到最左、再弹栈访问、最后转向右” 的机械步骤模拟中序逻辑,与人类 “先左、再根、再右” 的自然思维差距较大,可读性显著降低。
递归的优势在于:代码逻辑与问题的自然描述完全一致,开发者无需 “翻译” 自己的思考过程,直接用代码 “复述” 逻辑即可。
五、生活化类比:“组织多人接力完成分层任务”
我们用 “多人接力整理图书馆” 的场景,类比递归遍历的优势:
假设图书馆的书架结构是 “总书架(根)→ 左分区(左子树)→ 右分区(右子树)”,每个分区又分为更小的子分区,直到分区为空(无书)。任务是 “按规则整理所有区域:先整理当前区域的索引牌,再整理左子分区,最后整理右子分区(对应前序遍历)”。
用 “递归方式”(多人接力):
- 你负责总书架:先整理总索引牌(访问根节点),然后对同事 A 说 “按同样规则整理左分区”(递归左子树),对同事 B 说 “等 A 整理完,你按同样规则整理右分区”(递归右子树)。
- 同事 A 负责左分区:先整理左分区索引牌(访问左子树根),再让下属 C 整理左分区的左子分区(递归左子树的左子树),以此类推。
- 任何一个人遇到空分区(无书),就向自己的上级汇报 “我这里搞定了”(递归终止),上级继续安排下一项任务。
整个过程中,你无需记住 “左分区有多少子分区”“谁正在整理哪个角落”,只需下达 “按规则处理左、右” 的指令,所有人自动协作完成任务。
用 “非递归方式”(单人操作):
你需要:
- 拿一个笔记本,记录 “待整理的区域”(手动维护栈)。
- 先整理总索引牌,然后在笔记本上写下 “右分区、左分区”(确保左分区先处理)。
- 从笔记本划掉 “左分区”,去整理它的索引牌,再写下它的 “右子分区、左子分区”。
- 重复步骤 3,直到笔记本上没有待整理区域。
这个过程中,你必须时刻关注笔记本的记录(避免遗漏),还要严格控制记录顺序(否则会打乱整理规则),稍有疏忽就会出错 —— 这就像非递归遍历需要手动维护栈和节点顺序,思维负担极大。
总结:递归是二叉树遍历的 “最优解”
递归与二叉树遍历的适配,是 “问题结构”“解决逻辑”“人类思维” 三者的完美统一:
- 二叉树的递归定义提供了 “拆分问题” 的天然框架;
- 递归的调用 - 返回机制与遍历的深入 - 回溯流程完全同步;
- 编译器自动维护的栈替代了繁琐的手动控制;
- 代码逻辑与人类自然思维高度一致,可读性极强。
这种全方位的契合,使得递归不仅是实现二叉树遍历的 “简便方法”,更是最能体现二叉树本质特性的 “自然选择”。理解了这一点,就能明白为什么几乎所有数据结构教材中,二叉树的遍历都会以递归为首选实现方式 —— 它不是技巧,而是对事物本质的直接映射。
对于链式二叉树的前序遍历:
那么知道了对于链式二叉树的遍历类型之后,我们这一部分就先来讲一下对于链式二叉树的前序遍历,其实是不怎么难的,但是由于用到了递归,就导致有些难理解,但是一旦理解透了,大家就会觉得,wos,原来这么简单。


我们以图中这棵二叉树(根节点为 1,左子树以 2 为根,右子树以 4 为根)为例,来详细讲解前序遍历(根→左子树→右子树)的执行步骤,再用生活化场景辅助理解。
一、前序遍历执行步骤
前序遍历的核心规则是:先访问当前根节点,再遍历左子树,最后遍历右子树,并且对左、右子树的遍历也遵循这个 “根→左→右” 的规则,直到遇到空节点才停止。
步骤 1:遍历整棵树的根节点(值为 1)
首先,我们从最顶层的根节点 1 开始。按照前序遍历规则,第一步就是访问根节点 1,所以我们先 “记录” 或 “处理” 节点 1。
步骤 2:遍历根节点的左子树(以 2 为根的子树)
接下来,要遍历根节点 1 的左子树。这棵左子树的根是节点 2,所以我们把 “以 2 为根的子树” 当作一棵新的二叉树,重复前序遍历的规则。
- 首先访问这棵子树的根节点 2。
- 然后,遍历节点 2 的左子树(以 3 为根的子树):同样,把 “以 3 为根的子树” 当作新的二叉树,先访问根节点 3。此时,节点 3 的左子树和右子树都是空的(没有子节点了),所以以 3 为根的子树遍历完毕,回到节点 2 的遍历流程。
- 接着,遍历节点 2 的右子树:节点 2 的右子树是空的,所以这部分没有可访问的节点,以 2 为根的左子树遍历完毕,回到整棵树(根为 1)的遍历流程。
步骤 3:遍历根节点的右子树(以 4 为根的子树)
现在,开始遍历根节点 1 的右子树。这棵右子树的根是节点 4,把 “以 4 为根的子树” 当作新的二叉树,执行前序遍历。
- 首先访问这棵子树的根节点 4。
- 然后,遍历节点 4 的左子树(以 5 为根的子树):把 “以 5 为根的子树” 当作新的二叉树,先访问根节点 5。节点 5 的左子树和右子树都是空的,所以以 5 为根的子树遍历完毕,回到节点 4 的遍历流程。
- 接着,遍历节点 4 的右子树(以 6 为根的子树):把 “以 6 为根的子树” 当作新的二叉树,先访问根节点 6。节点 6 的左子树和右子树都是空的,所以以 6 为根的子树遍历完毕,回到节点 4 的遍历流程,最终整棵树的右子树遍历完毕。
至此,整棵树的前序遍历结束,访问顺序为:1 → 2 → 3 → 4 → 5 → 6。
二、生活化例子:“家庭辈分介绍”
我们可以把这棵二叉树类比成一个家族的辈分结构,前序遍历就像是一场 “家族聚会时的成员介绍”,要按照 “先核心长辈,再左支亲属,最后右支亲属” 的顺序,把家族里的人物关系清晰地传递出来。
一、给每个节点赋予 “家族身份”
为了让例子更生动,我们给每个节点对应的家族成员设定具体的身份和故事感:
- 根节点 1:家族里的 “老爷爷”,是整个家族的大家长,德高望重,掌管着家族的核心事务,是所有晚辈的 “精神支柱”。
- 左子树的根节点 2:老爷爷的 “大儿子”,是家里的长子,性格沉稳,早早地承担起了协助家族管理的责任,是左支亲属的 “领头人”。
- 左子树的左子树(根节点 3):大儿子的 “大孙子”,是长子这一支里最年长的小辈,活泼可爱,是家族里的 “小开心果”,经常跟着爷爷(节点 2)学习家族的传统技艺。
- 右子树的根节点 4:老爷爷的 “小儿子”,是家里的幼子,头脑灵活,从事着新潮的行业,给家族带来了很多新鲜思想,是右支亲属的 “创意担当”。
- 右子树的左子树(根节点 5):小儿子的 “大孙女”,是幼子这一支里的第一个小辈,温柔乖巧,喜欢艺术,是家族里的 “小艺术家”。
- 右子树的右子树(根节点 6):小儿子的 “小孙女”,是幼子这一支里的第二个小辈,古灵精怪,充满好奇心,是家族里的 “小探索家”。
二、前序遍历:家族聚会的 “介绍流程”
前序遍历的核心是 “先介绍核心长辈→再介绍左支亲属(从长辈到晚辈层层深入)→最后介绍右支亲属(同样从长辈到晚辈层层深入)”,就像家族聚会时,主人会先把最重要的长辈介绍给客人,再依次介绍各支脉的亲属。
第一步:介绍 “核心长辈(根节点 1)”
聚会开始,主人首先把 ** 老爷爷(节点 1)** 拉到客人面前,郑重介绍:“您看,这是我们家的老爷爷,是家族的大家长!他年轻的时候为家族打拼,现在虽然上了年纪,但威望可高了,家里大小事都还会听听他的意见呢。”这一步对应前序遍历 “先访问根节点” 的规则 —— 根节点是整棵树的 “核心”,就像老爷爷是家族的 “核心”。
第二步:介绍 “左支亲属(从根节点 2 开始的子树)”
介绍完老爷爷,主人接着开始介绍左支亲属(以大儿子为核心的分支),因为左支是 “长房”,在传统家族观念里更受重视,要优先介绍。
子步骤 1:介绍 “左支的核心长辈(节点 2)”
主人把 ** 大儿子(节点 2)** 带到客人身边,介绍道:“这是老爷爷的大儿子,是家里的长子。您别看他现在看起来挺严肃,年轻的时候可能干了,跟着老爷爷一起操持家业,现在也帮着老爷爷管理家族里的老产业,是左支这边的领头人。”这一步对应 “访问左子树的根节点”—— 节点 2 是左子树的 “核心”,就像大儿子是左支的 “核心长辈”。
子步骤 2:介绍 “左支的晚辈(节点 3)”
介绍完大儿子,主人又把 ** 大孙子(节点 3)** 拉过来,笑着说:“这是长子的大孙子,是这一支的小辈。这孩子特别机灵,从小就跟着爷爷(节点 2)学木工,现在都能做出像模像样的小凳子了,是家族里的小开心果,大家都喜欢他。”此时,大孙子(节点 3)没有自己的子晚辈了(对应节点 3 的左、右子树为空),所以左支的晚辈介绍完毕,回到 “左支核心长辈(节点 2)” 的介绍流程。
子步骤 3:确认左支无其他晚辈
主人环顾大儿子家的方向,补充道:“大儿子家目前就这一个孙子,暂时没有其他更小的晚辈了。”这对应 “节点 2 的右子树为空”,所以左支的所有亲属介绍完毕,回到 “整棵家族树(根节点 1)” 的介绍流程。
第三步:介绍 “右支亲属(从根节点 4 开始的子树)”
左支介绍完毕,主人开始介绍右支亲属(以小儿子为核心的分支),因为右支是 “幼房”,按顺序排在左支之后。
子步骤 1:介绍 “右支的核心长辈(节点 4)”
主人把 ** 小儿子(节点 4)** 带到客人面前,介绍说:“这是老爷爷的小儿子,是家里的幼子。他和哥哥(节点 2)性格不太一样,脑子活络,大学毕业后就去大城市闯了,现在做互联网行业,给家族带来了不少新想法,是右支这边的创意担当。”这一步对应 “访问右子树的根节点”—— 节点 4 是右子树的 “核心”,就像小儿子是右支的 “核心长辈”。
子步骤 2:介绍 “右支的第一个晚辈(节点 5)”
介绍完小儿子,主人把 ** 大孙女(节点 5)** 领过来,温柔地说:“这是幼子的大孙女,是这一支的第一个小辈。这孩子喜欢画画,平时安安静静的,画的画可好看了,上次还在学校拿了奖,是家族里的小艺术家。”这一步对应 “访问右子树左子树的根节点”—— 节点 5 是右子树左子树的 “核心”,就像大孙女是右支左部分的 “代表晚辈”。
子步骤 3:介绍 “右支的第二个晚辈(节点 6)”
接着,主人又把 ** 小孙女(节点 6)** 抱过来,笑着介绍:“这是幼子的小孙女,是这一支的第二个小辈。这孩子跟姐姐(节点 5)性格相反,特别活泼,对什么都好奇,整天问东问西的,是家族里的小探索家。”此时,小孙女(节点 6)没有自己的子晚辈了(对应节点 6 的左、右子树为空),所以右支的晚辈介绍完毕,回到 “右支核心长辈(节点 4)” 的介绍流程。
子步骤 4:确认右支无其他晚辈
主人看了看小儿子家的方向,说:“小儿子家目前就这两个孙女,暂时也没有其他晚辈了。”这对应 “节点 4 的右子树后续无更多节点”,所以右支的所有亲属介绍完毕,整个家族的介绍流程也就结束了。
三、前序遍历的本质:“核心优先,分支递进”
通过家族聚会的例子能发现,前序遍历的逻辑和 “介绍家族成员” 的逻辑高度一致:
- 核心优先:先把最核心的 “根”(老爷爷)介绍清楚,让客人对 “家族的核心” 有认知。
- 分支递进:每介绍完一个核心(如大儿子、小儿子),就深入他的 “分支”(晚辈),把分支里的核心(如大孙子、大孙女)和细节(如小孙女)依次讲清楚,直到分支里没有更多成员(子树为空),再回到上一层核心,继续介绍其他分支。
这种 “先抓主干,再填细节” 的方式,能让听众(或代码的执行逻辑)快速建立 “整体→局部→更细局部” 的认知,这也是前序遍历在二叉树操作中(如 “复制二叉树”“打印树结构”)常用的原因 —— 先确定根,再构建 / 打印左右子树,逻辑清晰且自然。
所以,其实就是使用递归去一路走,然后遍历,将大问题化解为小问题。我先给出详细代码供大家参考:
//(1)前序遍历(Preorder Traversal 亦称先序遍历):
//访问根结点的操作发生在遍历其左右子树之前
//访问顺序为:根结点、左子树、右子树
void preorder(btn* root)
{//本质是使用递归,后面会有非递归//先设置递归返回条件//即走到了节点是为空,就得返回if (root == NULL){printf("N ");//让我们知道是空return;//因为是void,所以直接返回即可}//如果不是节点为空//我们就得先去走左边的子树//使用递归//直到左子树走完走到空,才返回//因为是从根节点出发,所以每次根节点的数据就得先表达//其实就是根结点在最前面//所以我们表达也要在最前面printf("%d ",root->data);//表达数据,让我们知道数据preorder(root->left);//接着再去走右子树//当左子树走完到空之后,就会返回到这里,接着走右子树preorder(root->right);}
再给大家详细图:

函数递归栈帧图:

再给上使用递归实现前序遍历的运行步骤:
我们以之前的二叉树(根节点 1,左子树 2-3,右子树 4-5-6)为例,详细用递归实现前序遍历(根→左→右)的完整过程,包括每一步的递归调用、栈状态变化和节点访问顺序。
一、前序遍历的递归函数逻辑
前序遍历的核心规则是 “先访问当前根节点,再递归遍历左子树,最后递归遍历右子树”,对应的递归函数逻辑如下:
// 函数功能:前序遍历以root为根的二叉树
// 访问节点的操作:打印节点数据
void PreOrder(BTNode* root) {if (root == NULL) {return; // 递归终止条件:空节点无需处理}// 1. 访问当前根节点(打印数据)printf("%d ", root->data);// 2. 递归遍历左子树(把左子树当作新的二叉树处理)PreOrder(root->left);// 3. 递归遍历右子树(把右子树当作新的二叉树处理)PreOrder(root->right);
}
二、详细执行步骤(结合示例树)
示例树结构如下(节点值即编号):
1 (根节点)/ \2 4 (第2层)/ / \3 5 6 (第3层)
很多人觉得递归抽象,其实只要搞懂 "函数调用栈" 的变化,就知道代码是怎么一步步执行的。咱们还是以 preorder (1)(调用根节点 1 的前序遍历)为例,拆解每一步的栈状态、执行操作和输出结果,连 "暂停" "返回" 的细节都讲清楚。
核心概念:函数调用栈
递归的时候,每次调用preorder函数,系统都会在 “函数栈” 里新增一个 “栈帧”—— 这个栈帧就像一张 “工作便签”,记录着当前函数的参数(比如root指向哪个节点)、局部变量、以及 “下一步要执行的代码位置”(断点)。当函数执行到return(比如遇到空节点),这张 “便签” 就会被撕掉(栈帧弹出),程序回到上一张便签的 “断点位置” 继续执行。
1. 初始调用:preorder(1)(根节点 1)
- 栈状态(从上到下是最近调用的函数,栈帧里记录 “函数名 + root 值 + 断点”):
[preorder(1):root=1,断点在"调用preorder(left)之后"] - 执行步骤:
- 检查
root=1不为空,不触发终止条件。 - 执行 “第一步:访问根节点”→ 打印
1→ 输出1。 - 执行 “第二步:递归左子树”→ 调用
preorder(root->left),也就是preorder(2)(节点 1 的左子树是 2)。 - 栈帧变化:把
preorder(1)的栈帧暂停(断点记在 “调用完 preorder (2) 后,要执行下一步调用 preorder (right)”),新增preorder(2)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(2):root=2,断点在"调用preorder(left)之后"]。
- 检查
- 当前输出:
1。
2. 执行preorder(2)(节点 2,节点 1 的左子树)
- 栈状态:
[preorder(1)(暂停), preorder(2):root=2,断点在"调用preorder(left)之后"] - 执行步骤:
- 检查
root=2不为空,不触发终止条件。 - 执行 “第一步:访问根节点”→ 打印
2→ 输出2。 - 执行 “第二步:递归左子树”→ 调用
preorder(root->left),也就是preorder(3)(节点 2 的左子树是 3)。 - 栈帧变化:暂停
preorder(2)(断点记在 “调用完 preorder (3) 后,要执行下一步调用 preorder (right)”),新增preorder(3)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(2)(暂停), preorder(3):root=3,断点在"调用preorder(left)之后"]。
- 检查
- 当前输出:
1 2。
3. 执行preorder(3)(节点 3,节点 2 的左子树)
- 栈状态:
[preorder(1)(暂停), preorder(2)(暂停), preorder(3):root=3,断点在"调用preorder(left)之后"] - 执行步骤:
- 检查
root=3不为空,不触发终止条件。 - 执行 “第一步:访问根节点”→ 打印
3→ 输出3。 - 执行 “第二步:递归左子树”→ 调用
preorder(root->left),也就是preorder(NULL)(节点 3 的左子树是空)。 - 栈帧变化:暂停
preorder(3)(断点记在 “调用完 preorder (NULL) 后,要执行下一步调用 preorder (right)”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(2)(暂停), preorder(3)(暂停), preorder(NULL):root=NULL]。
- 检查
- 当前输出:
1 2 3。
4. 执行preorder(NULL)(节点 3 的左子树)
- 栈状态:
[preorder(1)(暂停), preorder(2)(暂停), preorder(3)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(3)的 “断点位置”—— 也就是 “调用完 preorder (left) 之后,准备执行调用 preorder (right)”。
- 检查
- 当前输出:
1 2 3 N。
5. 回到preorder(3),继续执行(调用右子树)
- 栈状态:
[preorder(1)(暂停), preorder(2)(暂停), preorder(3):root=3,断点在"调用preorder(left)之后"] - 执行步骤:
- 执行 “第三步:递归右子树”→ 调用
preorder(root->right),也就是preorder(NULL)(节点 3 的右子树是空)。 - 栈帧变化:暂停
preorder(3)(断点记在 “调用完 preorder (NULL) 后,要执行 return”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(2)(暂停), preorder(3)(暂停), preorder(NULL):root=NULL]。
- 执行 “第三步:递归右子树”→ 调用
- 当前输出:
1 2 3 N。
6. 再执行preorder(NULL)(节点 3 的右子树)
- 栈状态:
[preorder(1)(暂停), preorder(2)(暂停), preorder(3)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(3)的 “断点位置”—— 也就是 “调用完 preorder (right) 之后”。 preorder(3)的所有步骤都已执行完毕,执行return,撕掉preorder(3)的栈帧(弹出栈),回到preorder(2)的 “断点位置”—— 也就是 “调用完 preorder (left) 之后,准备执行调用 preorder (right)”。
- 检查
- 当前输出:
1 2 3 N N。
7. 回到preorder(2),继续执行(调用右子树)
- 栈状态:
[preorder(1)(暂停), preorder(2):root=2,断点在"调用preorder(left)之后"] - 执行步骤:
- 执行 “第三步:递归右子树”→ 调用
preorder(root->right),也就是preorder(NULL)(节点 2 的右子树是空)。 - 栈帧变化:暂停
preorder(2)(断点记在 “调用完 preorder (NULL) 后,要执行 return”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(2)(暂停), preorder(NULL):root=NULL]。
- 执行 “第三步:递归右子树”→ 调用
- 当前输出:
1 2 3 N N。
8. 执行preorder(NULL)(节点 2 的右子树)
- 栈状态:
[preorder(1)(暂停), preorder(2)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(2)的 “断点位置”—— 也就是 “调用完 preorder (right) 之后”。 preorder(2)的所有步骤都已执行完毕,执行return,撕掉preorder(2)的栈帧(弹出栈),回到preorder(1)的 “断点位置”—— 也就是 “调用完 preorder (left) 之后,准备执行调用 preorder (right)”。
- 检查
- 当前输出:
1 2 3 N N N。
9. 回到preorder(1),继续执行(调用右子树)
- 栈状态:
[preorder(1):root=1,断点在"调用preorder(left)之后"] - 执行步骤:
- 执行 “第三步:递归右子树”→ 调用
preorder(root->right),也就是preorder(4)(节点 1 的右子树是 4)。 - 栈帧变化:暂停
preorder(1)(断点记在 “调用完 preorder (4) 后,要执行 return”),新增preorder(4)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4):root=4,断点在"调用preorder(left)之后"]。
- 执行 “第三步:递归右子树”→ 调用
- 当前输出:
1 2 3 N N N。
10. 执行preorder(4)(节点 4,节点 1 的右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4):root=4,断点在"调用preorder(left)之后"] - 执行步骤:
- 检查
root=4不为空,不触发终止条件。 - 执行 “第一步:访问根节点”→ 打印
4→ 输出4。 - 执行 “第二步:递归左子树”→ 调用
preorder(root->left),也就是preorder(5)(节点 4 的左子树是 5)。 - 栈帧变化:暂停
preorder(4)(断点记在 “调用完 preorder (5) 后,要执行下一步调用 preorder (right)”),新增preorder(5)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4)(暂停), preorder(5):root=5,断点在"调用preorder(left)之后"]。
- 检查
- 当前输出:
1 2 3 N N N 4。
11. 执行preorder(5)(节点 5,节点 4 的左子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(5):root=5,断点在"调用preorder(left)之后"] - 执行步骤:
- 检查
root=5不为空,不触发终止条件。 - 执行 “第一步:访问根节点”→ 打印
5→ 输出5。 - 执行 “第二步:递归左子树”→ 调用
preorder(root->left),也就是preorder(NULL)(节点 5 的左子树是空)。 - 栈帧变化:暂停
preorder(5)(断点记在 “调用完 preorder (NULL) 后,要执行下一步调用 preorder (right)”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4)(暂停), preorder(5)(暂停), preorder(NULL):root=NULL]。
- 检查
- 当前输出:
1 2 3 N N N 4 5。
12. 执行preorder(NULL)(节点 5 的左子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(5)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(5)的 “断点位置”—— 也就是 “调用完 preorder (left) 之后,准备执行调用 preorder (right)”。
- 检查
- 当前输出:
1 2 3 N N N 4 5 N。
13. 回到preorder(5),继续执行(调用右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(5):root=5,断点在"调用preorder(left)之后"] - 执行步骤:
- 执行 “第三步:递归右子树”→ 调用
preorder(root->right),也就是preorder(NULL)(节点 5 的右子树是空)。 - 栈帧变化:暂停
preorder(5)(断点记在 “调用完 preorder (NULL) 后,要执行 return”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4)(暂停), preorder(5)(暂停), preorder(NULL):root=NULL]。
- 执行 “第三步:递归右子树”→ 调用
- 当前输出:
1 2 3 N N N 4 5 N。
14. 再执行preorder(NULL)(节点 5 的右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(5)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(5)的 “断点位置”—— 也就是 “调用完 preorder (right) 之后”。 preorder(5)的所有步骤都已执行完毕,执行return,撕掉preorder(5)的栈帧(弹出栈),回到preorder(4)的 “断点位置”—— 也就是 “调用完 preorder (left) 之后,准备执行调用 preorder (right)”。
- 检查
- 当前输出:
1 2 3 N N N 4 5 N N。
15. 回到preorder(4),继续执行(调用右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4):root=4,断点在"调用preorder(left)之后"] - 执行步骤:
- 执行 “第三步:递归右子树”→ 调用
preorder(root->right),也就是preorder(6)(节点 4 的右子树是 6)。 - 栈帧变化:暂停
preorder(4)(断点记在 “调用完 preorder (6) 后,要执行 return”),新增preorder(6)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4)(暂停), preorder(6):root=6,断点在"调用preorder(left)之后"]。
- 执行 “第三步:递归右子树”→ 调用
- 当前输出:
1 2 3 N N N 4 5 N N。
16. 执行preorder(6)(节点 6,节点 4 的右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(6):root=6,断点在"调用preorder(left)之后"] - 执行步骤:
- 检查
root=6不为空,不触发终止条件。 - 执行 “第一步:访问根节点”→ 打印
6→ 输出6。 - 执行 “第二步:递归左子树”→ 调用
preorder(root->left),也就是preorder(NULL)(节点 6 的左子树是空)。 - 栈帧变化:暂停
preorder(6)(断点记在 “调用完 preorder (NULL) 后,要执行下一步调用 preorder (right)”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4)(暂停), preorder(6)(暂停), preorder(NULL):root=NULL]。
- 检查
- 当前输出:
1 2 3 N N N 4 5 N N 6。
17. 执行preorder(NULL)(节点 6 的左子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(6)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(6)的 “断点位置”—— 也就是 “调用完 preorder (left) 之后,准备执行调用 preorder (right)”。
- 检查
- 当前输出:
1 2 3 N N N 4 5 N N 6 N。
18. 回到preorder(6),继续执行(调用右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(6):root=6,断点在"调用preorder(left)之后"] - 执行步骤:
- 执行 “第三步:递归右子树”→ 调用
preorder(root->right),也就是preorder(NULL)(节点 6 的右子树是空)。 - 栈帧变化:暂停
preorder(6)(断点记在 “调用完 preorder (NULL) 后,要执行 return”),新增preorder(NULL)的栈帧压入栈。此时栈状态:[preorder(1)(暂停), preorder(4)(暂停), preorder(6)(暂停), preorder(NULL):root=NULL]。
- 执行 “第三步:递归右子树”→ 调用
- 当前输出:
1 2 3 N N N 4 5 N N 6 N。
19. 再执行preorder(NULL)(节点 6 的右子树)
- 栈状态:
[preorder(1)(暂停), preorder(4)(暂停), preorder(6)(暂停), preorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉preorder(NULL)的栈帧(弹出栈),回到preorder(6)的 “断点位置”—— 也就是 “调用完 preorder (right) 之后”。 preorder(6)的所有步骤都已执行完毕,执行return,撕掉preorder(6)的栈帧(弹出栈),回到preorder(4)的 “断点位置”—— 也就是 “调用完 preorder (right) 之后”。preorder(4)的所有步骤都已执行完毕,执行return,撕掉preorder(4)的栈帧(弹出栈),回到preorder(1)的 “断点位置”—— 也就是 “调用完 preorder (right) 之后”。preorder(1)的所有步骤都已执行完毕,执行return,撕掉preorder(1)的栈帧(弹出栈)。
- 检查
- 当前输出:
1 2 3 N N N 4 5 N N 6 N N。
20. 前序遍历结束
- 栈状态:空(所有栈帧都已弹出)
- 最终输出:
1 2 3 N N N 4 5 N N 6 N N - 核心访问顺序(去掉空节点标记 “N”):
1 2 3 4 5 6
啊呐,为了大家能够彻底理解透,我再给上我的手写笔记:

这一部分内容确实比较抽象,希望大家能结合例子去不断进行思考以及解析,大家可一定要加油呀。
对于链式二叉树的中序遍历:
讲完前序遍历,咱们接着来看中序遍历。其实中序遍历和前序遍历的核心逻辑很像,都是用递归把 “遍历整棵树” 拆成 “遍历子树” 的小问题,但关键区别就一个 ——访问根节点的时机变了。前序是 “先根后左右”,中序是 “先左、再根、最后右”。刚开始可能会有点绕,但跟着例子一步步走,你会发现:哦,原来和前序的逻辑是通的,只是调整了个顺序而已!
我们还是以之前的二叉树(根节点为 1,左子树以 2 为根,右子树以 4 为根)为例,先把树的结构再明确一下,方便后续对照:
1 (根节点)/ \2 4 (第2层)/ / \3 5 6 (第3层)
接下来,咱们从 “执行步骤”“生活化例子”“代码实现”“递归栈帧细节” 四个部分,把中序遍历彻底讲透。
一、中序遍历的执行步骤(核心:左→根→右)
中序遍历的规则是:先递归遍历当前根节点的左子树,再访问当前根节点,最后递归遍历当前根节点的右子树。而且对左、右子树的遍历,也完全遵循 “左→根→右” 的规则,直到遇到空节点才停止。
咱们一步一步拆解整个过程:
步骤 1:从整棵树的根节点 1 开始,先遍历左子树(以 2 为根的子树)
中序遍历的第一步不是访问根节点 1,而是先 “钻” 进它的左子树 —— 因为规则要求 “先左”。这棵左子树的根是节点 2,所以我们把 “以 2 为根的子树” 当作一棵新的二叉树,重复中序规则。
子步骤 1.1:遍历节点 2 的左子树(以 3 为根的子树)
同样,先不访问节点 2,而是先钻它的左子树(根是节点 3),继续按 “左→根→右” 处理:
- 先遍历节点 3 的左子树:节点 3 的左子树是空(
NULL),触发递归终止条件,直接返回。 - 此时,节点 3 的左子树遍历完了,按规则 “再根”—— 访问节点 3,记录 / 打印
3。 - 再遍历节点 3 的右子树:节点 3 的右子树也是空(
NULL),触发终止条件,返回。 - 至此,“以 3 为根的子树” 遍历完毕,回到节点 2 的遍历流程。
子步骤 1.2:访问节点 2,再遍历它的右子树
- 节点 2 的左子树已经遍历完,按规则 “再根”—— 访问节点 2,记录 / 打印
2(累计输出:3 2)。 - 接着遍历节点 2 的右子树:节点 2 的右子树是空(
NULL),触发终止条件,返回。 - 至此,“以 2 为根的左子树” 遍历完毕,回到整棵树(根为 1)的遍历流程。
步骤 2:访问整棵树的根节点 1
- 根节点 1 的左子树已经遍历完,按规则 “再根”—— 访问节点 1,记录 / 打印
1(累计输出:3 2 1)。
步骤 3:遍历根节点 1 的右子树(以 4 为根的子树)
根节点 1 的左子树和自身都处理完了,最后按规则 “再右”—— 遍历它的右子树(根是节点 4),继续按 “左→根→右” 处理:
子步骤 3.1:遍历节点 4 的左子树(以 5 为根的子树)
- 先遍历节点 5 的左子树:节点 5 的左子树是空(
NULL),触发终止条件,返回。 - 节点 5 的左子树遍历完,按规则 “再根”—— 访问节点 5,记录 / 打印
5(累计输出:3 2 1 5)。 - 再遍历节点 5 的右子树:节点 5 的右子树是空(
NULL),触发终止条件,返回。 - 至此,“以 5 为根的子树” 遍历完毕,回到节点 4 的遍历流程。
子步骤 3.2:访问节点 4,再遍历它的右子树(以 6 为根的子树)
- 节点 4 的左子树遍历完,按规则 “再根”—— 访问节点 4,记录 / 打印
4(累计输出:3 2 1 5 4)。 - 接着遍历节点 4 的右子树(根是节点 6):
- 先遍历节点 6 的左子树:空(
NULL),返回。 - 访问节点 6,记录 / 打印
6(累计输出:3 2 1 5 4 6)。 - 再遍历节点 6 的右子树:空(
NULL),返回。
- 先遍历节点 6 的左子树:空(
- 至此,“以 6 为根的子树” 遍历完毕,回到节点 4 的遍历流程。
步骤 4:整棵树遍历结束
“以 4 为根的右子树” 遍历完毕,整个中序遍历流程结束。最终的访问顺序是:3 → 2 → 1 → 5 → 4 → 6。
二、生活化例子:“家族财产清点”
还是用之前的 “家族辈分” 类比,但这次场景换成 “家族财产清点”—— 中序遍历就像清点规则:“先清点长辈的左支亲属家的财产,再清点长辈自己的财产,最后清点长辈的右支亲属家的财产”。这样能确保 “先理清分支,再汇总核心”,避免遗漏。
1. 先给节点对应的家族身份再明确一遍
- 根节点 1:老爷爷(家族大家长,有核心财产:祖传的老宅子和账本)
- 左子树根 2:大儿子(老爷爷的左支,管家族老产业:城郊的果园)
- 左子树的左子树 3:大孙子(大儿子的左支,管果园的采摘队和仓库)
- 右子树根 4:小儿子(老爷爷的右支,管家族新产业:城里的便利店)
- 右子树的左子树 5:大孙女(小儿子的左支,管便利店的进货和库存)
- 右子树的右子树 6:小孙女(小儿子的右支,管便利店的收银和客户服务)
2. 中序遍历:财产清点的流程
第一步:先清点老爷爷的左支(大儿子家)
清点员拿着账本说:“先不碰老爷爷的核心财产,先去大儿子家理清分支 —— 按规矩,左支要先清。”
- 先去大孙子家(大儿子的左支):大孙子管的采摘队和仓库没再分下属(左、右子树为空),清点员当场核对库存:“采摘队工具 3 套,仓库苹果 300 斤”,记下来 “3 号财产(大孙子):工具 3 套 + 苹果 300 斤”。
- 回到大儿子家:大孙子的财产清完了,再清大儿子的果园:“果园果树 200 棵,年度收益 2 万元”,记下来 “2 号财产(大儿子):果树 200 棵 + 收益 2 万”(累计清单:3 号、2 号)。
- 再看大儿子家的右支:大儿子没其他分管亲属(右子树为空),左支清点完毕,清点员带着左支清单回到老爷爷家。
第二步:清点老爷爷的核心财产
清点员对着左支清单确认无误后,说:“左支都清完了,现在汇总老爷爷的核心财产。” 核对老宅子和账本:“老宅子 1 套,家族总存款 10 万元”,记下来 “1 号财产(老爷爷):老宅子 1 套 + 存款 10 万”(累计清单:3 号、2 号、1 号)。
第三步:最后清点老爷爷的右支(小儿子家)
清点员收好核心财产账本,说:“核心清完了,再去小儿子家清右支 —— 同样先从左支开始。”
- 先去大孙女家(小儿子的左支):大孙女管的进货和库存没下属,清点员核对:“进货渠道 5 个,库存零食 50 箱”,记下来 “5 号财产(大孙女):渠道 5 个 + 零食 50 箱”(累计清单:3 号、2 号、1 号、5 号)。
- 回到小儿子家:大孙女的财产清完了,再清小儿子的便利店:“便利店门店 1 家,月度流水 3 万元”,记下来 “4 号财产(小儿子):门店 1 家 + 流水 3 万”(累计清单:3 号、2 号、1 号、5 号、4 号)。
- 再去小孙女家(小儿子的右支):小孙女管的收银和客服没下属,清点员核对:“收银机 2 台,会员客户 100 人”,记下来 “6 号财产(小孙女):收银机 2 台 + 会员 100 人”(累计清单:3 号、2 号、1 号、5 号、4 号、6 号)。
第四步:清点结束
小儿子家清完了,清点员把所有清单汇总:“3 号(大孙子)→2 号(大儿子)→1 号(老爷爷)→5 号(大孙女)→4 号(小儿子)→6 号(小孙女)”—— 这个汇总顺序,就是中序遍历的顺序。
3. 中序遍历的本质:“先分支后核心,层层汇总”
从财产清点的例子能看出来,中序遍历的逻辑是 “先把左边的分支理清楚,再处理当前核心,最后处理右边的分支”。这种 “先分支后核心” 的方式,特别适合需要 “先汇总下属数据,再处理上级数据” 的场景 —— 比如二叉搜索树(BST)的中序遍历,结果就是从小到大的有序序列,本质就是 “先找左支最小的,再找当前节点,最后找右支更大的”。就像清点财产时,先清完下属的分支,再汇总上级的核心,才不会算错总账。
三、中序遍历的递归代码实现
中序遍历的代码和前序几乎一样,只是把 “访问根节点” 的代码,从 “递归左子树” 前面,挪到了 “递归左子树” 后面。咱们直接上代码,关键地方加了注释,还补充了空节点打印(方便看到遍历路径):
// 1. 先定义二叉树节点结构体(和前序遍历一致,确保代码可运行)
typedef struct BinaryTreeNode {int data; // 节点存储的数据(比如财产编号)struct BinaryTreeNode* left; // 指向左子节点的指针(左支亲属)struct BinaryTreeNode* right; // 指向右子节点的指针(右支亲属)
} btn; // 结构体别名,简化后续使用// 2. 中序遍历函数:参数是当前子树的根节点,功能是按“左→根→右”遍历并打印
void inorder(btn* root) {// 递归终止条件:如果当前节点是空(没有亲属/财产),直接返回if (root == NULL) {printf("N "); // 打印"N",明确标记空节点,方便跟踪遍历路径return;}// 核心逻辑:左→根→右// 1. 第一步:先递归遍历当前根节点的左子树(先清左支)inorder(root->left);// 2. 第二步:左子树遍历完,再访问当前根节点(再清核心)printf("%d ", root->data); // 打印节点数据,模拟“记录财产”// 3. 第三步:最后递归遍历当前根节点的右子树(最后清右支)inorder(root->right);
}
代码里的关键变化,就是printf("%d ", root->data)的位置 —— 前序是在inorder(root->left)前面,中序是在中间。就这一个位置调整,遍历顺序就从 “根→左→右” 变成了 “左→根→右”,特别直观。比如咱们的示例树,调用inorder(1)后,输出就是N 3 N 2 N 1 N 5 N 4 N 6 N (其中 “N” 是空节点的标记,去掉后核心顺序就是3 2 1 5 4 6)。
四、中序遍历的递归栈帧细节(彻底理解 “怎么跑的”)
很多人觉得递归抽象,其实只要搞懂 “函数调用栈” 的变化,就知道代码是怎么一步步执行的。咱们还是以inorder(1)(调用根节点 1 的中序遍历)为例,拆解每一步的栈状态、执行操作和输出结果,连 “暂停”“返回” 的细节都讲清楚。
核心概念:函数调用栈
递归的时候,每次调用inorder函数,系统都会在 “函数栈” 里新增一个 “栈帧”—— 这个栈帧就像一张 “工作便签”,记录着当前函数的参数(比如root指向哪个节点)、局部变量、以及 “下一步要执行的代码位置”(断点)。当函数执行到return(比如遇到空节点),这张 “便签” 就会被撕掉(栈帧弹出),程序回到上一张便签的 “断点位置” 继续执行。
1. 初始调用:inorder(1)(根节点 1)
- 栈状态(从上到下是最近调用的函数,栈帧里记录 “函数名 + root 值 + 断点”):
[inorder(1):root=1,断点在“调用inorder(left)之后”] - 执行步骤:
- 检查
root=1不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
inorder(root->left),也就是inorder(2)(节点 1 的左子树是 2)。 - 栈帧变化:把
inorder(1)的栈帧暂停(断点记在 “调用完 inorder (2) 后,要执行下一步打印 root->data”),新增inorder(2)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(2):root=2,断点在“调用inorder(left)之后”]。
- 检查
- 当前输出:无(还没到打印节点的步骤)。
2. 执行inorder(2)(节点 2,节点 1 的左子树)
- 栈状态:
[inorder(1)(暂停), inorder(2):root=2,断点在“调用inorder(left)之后”] - 执行步骤:
- 检查
root=2不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
inorder(root->left),也就是inorder(3)(节点 2 的左子树是 3)。 - 栈帧变化:暂停
inorder(2)(断点记在 “调用完 inorder (3) 后,要执行下一步打印 root->data”),新增inorder(3)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(2)(暂停), inorder(3):root=3,断点在“调用inorder(left)之后”]。
- 检查
- 当前输出:无。
3. 执行inorder(3)(节点 3,节点 2 的左子树)
- 栈状态:
[inorder(1)(暂停), inorder(2)(暂停), inorder(3):root=3,断点在“调用inorder(left)之后”] - 执行步骤:
- 检查
root=3不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
inorder(root->left),也就是inorder(NULL)(节点 3 的左子树是空)。 - 栈帧变化:暂停
inorder(3)(断点记在 “调用完 inorder (NULL) 后,要执行下一步打印 root->data”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(2)(暂停), inorder(3)(暂停), inorder(NULL):root=NULL]。
- 检查
- 当前输出:无。
4. 执行inorder(NULL)(节点 3 的左子树)
- 栈状态:
[inorder(1)(暂停), inorder(2)(暂停), inorder(3)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(3)的 “断点位置”—— 也就是 “调用完 inorder (left) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N。
5. 回到inorder(3),继续执行
- 栈状态:
[inorder(1)(暂停), inorder(2)(暂停), inorder(3):root=3,断点在“调用inorder(left)之后”] - 执行步骤:
- 执行第二步:访问根节点 3,打印
3→ 输出3。 - 执行第三步:递归右子树 → 调用
inorder(root->right),也就是inorder(NULL)(节点 3 的右子树是空)。 - 栈帧变化:暂停
inorder(3)(断点记在 “调用完 inorder (NULL) 后,要执行 return”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(2)(暂停), inorder(3)(暂停), inorder(NULL):root=NULL]。
- 执行第二步:访问根节点 3,打印
- 当前输出:
N 3。
6. 再执行inorder(NULL)(节点 3 的右子树)
- 栈状态:
[inorder(1)(暂停), inorder(2)(暂停), inorder(3)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(3)的 “断点位置”—— 也就是 “调用完 inorder (right) 之后”。 inorder(3)的所有步骤都已执行完毕,执行return,撕掉inorder(3)的栈帧(弹出栈),回到inorder(2)的 “断点位置”—— 也就是 “调用完 inorder (left) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N 3 N。
7. 回到inorder(2),继续执行
- 栈状态:
[inorder(1)(暂停), inorder(2):root=2,断点在“调用inorder(left)之后”] - 执行步骤:
- 执行第二步:访问根节点 2,打印
2→ 输出2。 - 执行第三步:递归右子树 → 调用
inorder(root->right),也就是inorder(NULL)(节点 2 的右子树是空)。 - 栈帧变化:暂停
inorder(2)(断点记在 “调用完 inorder (NULL) 后,要执行 return”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(2)(暂停), inorder(NULL):root=NULL]。
- 执行第二步:访问根节点 2,打印
- 当前输出:
N 3 N 2。
8. 执行inorder(NULL)(节点 2 的右子树)
- 栈状态:
[inorder(1)(暂停), inorder(2)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(2)的 “断点位置”—— 也就是 “调用完 inorder (right) 之后”。 inorder(2)的所有步骤都已执行完毕,执行return,撕掉inorder(2)的栈帧(弹出栈),回到inorder(1)的 “断点位置”—— 也就是 “调用完 inorder (left) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N 3 N 2 N。
9. 回到inorder(1),继续执行
- 栈状态:
[inorder(1):root=1,断点在“调用inorder(left)之后”] - 执行步骤:
- 执行第二步:访问根节点 1,打印
1→ 输出1。 - 执行第三步:递归右子树 → 调用
inorder(root->right),也就是inorder(4)(节点 1 的右子树是 4)。 - 栈帧变化:暂停
inorder(1)(断点记在 “调用完 inorder (4) 后,要执行 return”),新增inorder(4)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4):root=4,断点在“调用inorder(left)之后”]。
- 执行第二步:访问根节点 1,打印
- 当前输出:
N 3 N 2 N 1。
10. 执行inorder(4)(节点 4,节点 1 的右子树)
- 栈状态:
[inorder(1)(暂停), inorder(4):root=4,断点在“调用inorder(left)之后”] - 执行步骤:
- 检查
root=4不为空,不触发终止条件。 - 执行第一步:递归左子树 → 调用
inorder(root->left),也就是inorder(5)(节点 4 的左子树是 5)。 - 栈帧变化:暂停
inorder(4)(断点记在 “调用完 inorder (5) 后,要执行打印 root->data”),新增inorder(5)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4)(暂停), inorder(5):root=5,断点在“调用inorder(left)之后”]。
- 检查
- 当前输出:
N 3 N 2 N 1。
11. 执行inorder(5)(节点 5,节点 4 的左子树)
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(5):root=5,断点在“调用inorder(left)之后”] - 执行步骤:
- 检查
root=5不为空,不触发终止条件。 - 执行第一步:递归左子树 → 调用
inorder(root->left),也就是inorder(NULL)(节点 5 的左子树是空)。 - 栈帧变化:暂停
inorder(5)(断点记在 “调用完 inorder (NULL) 后,要执行打印 root->data”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4)(暂停), inorder(5)(暂停), inorder(NULL):root=NULL]。
- 检查
- 当前输出:
N 3 N 2 N 1。
12. 执行inorder(NULL)(节点 5 的左子树)
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(5)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(5)的 “断点位置”—— 也就是 “调用完 inorder (left) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N 3 N 2 N 1 N。
13. 回到inorder(5),继续执行
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(5):root=5,断点在“调用inorder(left)之后”] - 执行步骤:
- 执行第二步:访问根节点 5,打印
5→ 输出5。 - 执行第三步:递归右子树 → 调用
inorder(root->right),也就是inorder(NULL)(节点 5 的右子树是空)。 - 栈帧变化:暂停
inorder(5)(断点记在 “调用完 inorder (NULL) 后,要执行 return”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4)(暂停), inorder(5)(暂停), inorder(NULL):root=NULL]。
- 执行第二步:访问根节点 5,打印
- 当前输出:
N 3 N 2 N 1 N 5。
14. 再执行inorder(NULL)(节点 5 的右子树)
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(5)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(5)的 “断点位置”—— 也就是 “调用完 inorder (right) 之后”。 inorder(5)的所有步骤都已执行完毕,执行return,撕掉inorder(5)的栈帧(弹出栈),回到inorder(4)的 “断点位置”—— 也就是 “调用完 inorder (left) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N 3 N 2 N 1 N 5 N。
15. 回到inorder(4),继续执行
- 栈状态:
[inorder(1)(暂停), inorder(4):root=4,断点在“调用inorder(left)之后”] - 执行步骤:
- 执行第二步:访问根节点 4,打印
4→ 输出4。 - 执行第三步:递归右子树 → 调用
inorder(root->right),也就是inorder(6)(节点 4 的右子树是 6)。 - 栈帧变化:暂停
inorder(4)(断点记在 “调用完 inorder (6) 后,要执行 return”),新增inorder(6)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4)(暂停), inorder(6):root=6,断点在“调用inorder(left)之后”]。
- 执行第二步:访问根节点 4,打印
- 当前输出:
N 3 N 2 N 1 N 5 N 4。
16. 执行inorder(6)(节点 6,节点 4 的右子树)
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(6):root=6,断点在“调用inorder(left)之后”] - 执行步骤:
- 检查
root=6不为空,不触发终止条件。 - 执行第一步:递归左子树 → 调用
inorder(root->left),也就是inorder(NULL)(节点 6 的左子树是空)。 - 栈帧变化:暂停
inorder(6)(断点记在 “调用完 inorder (NULL) 后,要执行打印 root->data”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4)(暂停), inorder(6)(暂停), inorder(NULL):root=NULL]。
- 检查
- 当前输出:
N 3 N 2 N 1 N 5 N 4。
17. 执行inorder(NULL)(节点 6 的左子树)
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(6)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(6)的 “断点位置”—— 也就是 “调用完 inorder (left) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N 3 N 2 N 1 N 5 N 4 N。
18. 回到inorder(6),继续执行
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(6):root=6,断点在“调用inorder(left)之后”] - 执行步骤:
- 执行第二步:访问根节点 6,打印
6→ 输出6。 - 执行第三步:递归右子树 → 调用
inorder(root->right),也就是inorder(NULL)(节点 6 的右子树是空)。 - 栈帧变化:暂停
inorder(6)(断点记在 “调用完 inorder (NULL) 后,要执行 return”),新增inorder(NULL)的栈帧压入栈。此时栈状态:[inorder(1)(暂停), inorder(4)(暂停), inorder(6)(暂停), inorder(NULL):root=NULL]。
- 执行第二步:访问根节点 6,打印
- 当前输出:
N 3 N 2 N 1 N 5 N 4 N 6。
19. 再执行inorder(NULL)(节点 6 的右子树)
- 栈状态:
[inorder(1)(暂停), inorder(4)(暂停), inorder(6)(暂停), inorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉inorder(NULL)的栈帧(弹出栈),回到inorder(6)的 “断点位置”—— 也就是 “调用完 inorder (right) 之后”。 inorder(6)的所有步骤都已执行完毕,执行return,撕掉inorder(6)的栈帧(弹出栈),回到inorder(4)的 “断点位置”—— 也就是 “调用完 inorder (right) 之后”。inorder(4)的所有步骤都已执行完毕,执行return,撕掉inorder(4)的栈帧(弹出栈),回到inorder(1)的 “断点位置”—— 也就是 “调用完 inorder (right) 之后”。inorder(1)的所有步骤都已执行完毕,执行return,撕掉inorder(1)的栈帧(弹出栈)。
- 检查
- 当前输出:
N 3 N 2 N 1 N 5 N 4 N 6 N。
20. 中序遍历结束
- 栈状态:空(所有栈帧都已弹出)
- 最终输出:
N 3 N 2 N 1 N 5 N 4 N 6 N - 核心访问顺序(去掉空节点标记 “N”):
3 2 1 5 4 6
五、总结:中序遍历的核心要点
通过上面的详细拆解,我们可以总结出中序遍历的几个核心要点:
- 遍历顺序:严格遵循 “左→根→右”,先处理左子树,再访问当前节点,最后处理右子树。
- 递归本质:利用二叉树的递归结构,将 “遍历整棵树” 拆成 “遍历左子树 + 访问根 + 遍历右子树”,子树的遍历逻辑与整棵树完全一致。
- 栈的作用:递归调用时,函数栈自动记录 “未完成的父节点操作”,确保左子树遍历完后能回到父节点,继续处理右子树。
- 终止条件:遇到空节点(
NULL)时递归终止,避免无限循环,这是递归能正常结束的关键。 - 应用场景:中序遍历特别适合需要 “先汇总下属数据,再处理上级数据” 的场景,比如二叉搜索树(BST)的中序遍历结果是有序序列。
掌握了中序遍历,再理解后序遍历就会很容易 —— 后序遍历只是把 “访问根节点” 的时机调整到了 “遍历右子树之后”,核心逻辑和递归思想与中序遍历完全一致。
先给上完整代码:
//(2)中序遍历(Inorder Traversal):
//访问根结点的操作发生在遍历其左右子树之中(间)
//访问顺序为:左子树、根结点、右子树
void inorder(btn* root)
{//如果能理解前序遍历的话//那么中序遍历也就不会多难//只不过是顺序的改变了//先设置递归返回条件//即走到了节点是为空,就得返回if (root == NULL){printf("N ");return;}//先去走左子树,走完之后,表达了所有左子树的节点的数据,之后,我们才去走右子树inorder(root->left);//因为是从左子树开始,所以我们得走完左子树后,再去表达数据//其实就是根结点在中间//所以我们表达也要在中间printf("%d ",root->data);//走完左子树之后,开始走右子树inorder(root->right);
}
依旧再给上我的手写解析:

怎么样大家,经过上面前序遍历的解析,我相信大家理解中序遍历会轻松很多。
对于链式二叉树的后序遍历:
讲完前序和中序遍历,咱们最后来看后序遍历。其实后序遍历和前序、中序的核心逻辑完全一致,都是用递归把 “遍历整棵树” 拆成 “遍历子树” 的小问题,但关键区别依然是 ——访问根节点的时机变了。前序是 “先根后左右”,中序是 “先左再根最后右”,而后序则是 “先左、再右、最后根”。掌握了前两种遍历,理解后序会非常轻松,因为本质逻辑是相通的,只是调整了访问根节点的顺序而已!
我们还是以之前的二叉树(根节点为 1,左子树以 2 为根,右子树以 4 为根)为例,先把树的结构再明确一下,方便后续对照:
1 (根节点)/ \2 4 (第2层)/ / \3 5 6 (第3层)
接下来,咱们从 “执行步骤”“生活化例子”“代码实现”“递归栈帧细节” 四个部分,把后序遍历彻底讲透。
一、后序遍历的执行步骤(核心:左→右→根)
后序遍历的规则是:先递归遍历当前根节点的左子树,再递归遍历当前根节点的右子树,最后访问当前根节点。而且对左、右子树的遍历,也完全遵循 “左→右→根” 的规则,直到遇到空节点才停止。
咱们一步一步拆解整个过程:
步骤 1:从整棵树的根节点 1 开始,先遍历左子树(以 2 为根的子树)
后序遍历的第一步不是访问根节点 1,而是先 “钻” 进它的左子树 —— 因为规则要求 “先左”。这棵左子树的根是节点 2,所以我们把 “以 2 为根的子树” 当作一棵新的二叉树,重复后序规则。
子步骤 1.1:遍历节点 2 的左子树(以 3 为根的子树)
同样,先不访问节点 2,而是先钻它的左子树(根是节点 3),继续按 “左→右→根” 处理:
- 先遍历节点 3 的左子树:节点 3 的左子树是空(
NULL),触发递归终止条件,直接返回。 - 再遍历节点 3 的右子树:节点 3 的右子树也是空(
NULL),触发终止条件,返回。 - 此时,节点 3 的左、右子树都遍历完了,按规则 “最后根”—— 访问节点 3,记录 / 打印
3。 - 至此,“以 3 为根的子树” 遍历完毕,回到节点 2 的遍历流程。
子步骤 1.2:遍历节点 2 的右子树,再访问节点 2
- 节点 2 的左子树已经遍历完,按规则 “再右”—— 遍历节点 2 的右子树:节点 2 的右子树是空(
NULL),触发终止条件,返回。 - 此时,节点 2 的左、右子树都遍历完了,按规则 “最后根”—— 访问节点 2,记录 / 打印
2(累计输出:3 2)。 - 至此,“以 2 为根的左子树” 遍历完毕,回到整棵树(根为 1)的遍历流程。
步骤 2:遍历根节点 1 的右子树(以 4 为根的子树)
根节点 1 的左子树已经处理完,按规则 “再右”—— 遍历它的右子树(根是节点 4),继续按 “左→右→根” 处理:
子步骤 2.1:遍历节点 4 的左子树(以 5 为根的子树)
- 先遍历节点 5 的左子树:节点 5 的左子树是空(
NULL),触发终止条件,返回。 - 再遍历节点 5 的右子树:节点 5 的右子树是空(
NULL),触发终止条件,返回。 - 此时,节点 5 的左、右子树都遍历完了,按规则 “最后根”—— 访问节点 5,记录 / 打印
5(累计输出:3 2 5)。 - 至此,“以 5 为根的子树” 遍历完毕,回到节点 4 的遍历流程。
子步骤 2.2:遍历节点 4 的右子树(以 6 为根的子树)
- 节点 4 的左子树已经遍历完,按规则 “再右”—— 遍历节点 4 的右子树(根是节点 6):
- 先遍历节点 6 的左子树:空(
NULL),返回。 - 再遍历节点 6 的右子树:空(
NULL),返回。 - 此时,节点 6 的左、右子树都遍历完了,按规则 “最后根”—— 访问节点 6,记录 / 打印
6(累计输出:3 2 5 6)。
- 先遍历节点 6 的左子树:空(
- 至此,“以 6 为根的子树” 遍历完毕,回到节点 4 的遍历流程。
子步骤 2.3:访问节点 4
- 节点 4 的左、右子树都遍历完了,按规则 “最后根”—— 访问节点 4,记录 / 打印
4(累计输出:3 2 5 6 4)。 - 至此,“以 4 为根的右子树” 遍历完毕,回到整棵树(根为 1)的遍历流程。
步骤 3:访问整棵树的根节点 1
- 根节点 1 的左、右子树都已经遍历完,按规则 “最后根”—— 访问节点 1,记录 / 打印
1(累计输出:3 2 5 6 4 1)。
步骤 4:整棵树遍历结束
整个后序遍历流程结束。最终的访问顺序是:3 → 2 → 5 → 6 → 4 → 1。
二、生活化例子:“家族财产清点(最终汇总版)”
还是用之前的 “家族辈分” 类比,但这次场景换成 “家族财产清点的最终汇总”—— 后序遍历就像清点规则:“先清点长辈的左支亲属家的财产,再清点长辈的右支亲属家的财产,最后才汇总长辈自己的核心财产”。这样能确保 “先理清所有分支细节,最后再汇总核心”,避免汇总后又发现分支遗漏。
1. 先给节点对应的家族身份再明确一遍
- 根节点 1:老爷爷(家族大家长,有核心财产:祖传的老宅子和账本)
- 左子树根 2:大儿子(老爷爷的左支,管家族老产业:城郊的果园)
- 左子树的左子树 3:大孙子(大儿子的左支,管果园的采摘队和仓库)
- 右子树根 4:小儿子(老爷爷的右支,管家族新产业:城里的便利店)
- 右子树的左子树 5:大孙女(小儿子的左支,管便利店的进货和库存)
- 右子树的右子树 6:小孙女(小儿子的右支,管便利店的收银和客户服务)
2. 后序遍历:财产清点的最终汇总流程
第一步:先清点老爷爷的左支(大儿子家)
清点员拿着账本说:“先不碰老爷爷的核心财产,先去大儿子家理清所有分支细节 —— 按规矩,左支要先清,而且要清完所有子分支才能汇总。”
- 先去大孙子家(大儿子的左支):大孙子管的采摘队和仓库没再分下属(左、右子树为空),清点员当场核对库存:“采摘队工具 3 套,仓库苹果 300 斤”,记下来 “3 号财产(大孙子):工具 3 套 + 苹果 300 斤”。
- 回到大儿子家:大孙子的财产清完了,再看大儿子家的右支:大儿子没其他分管亲属(右子树为空)。此时大儿子家的所有分支都清完了,才汇总大儿子的果园:“果园果树 200 棵,年度收益 2 万元”,记下来 “2 号财产(大儿子):果树 200 棵 + 收益 2 万”(累计清单:3 号、2 号)。
- 左支清点完毕,清点员带着左支清单回到老爷爷家。
第二步:清点老爷爷的右支(小儿子家)
清点员收好左支清单,说:“左支清完了,再去小儿子家清所有右支分支 —— 同样要清完所有子分支才能汇总。”
- 先去大孙女家(小儿子的左支):大孙女管的进货和库存没下属,清点员核对:“进货渠道 5 个,库存零食 50 箱”,记下来 “5 号财产(大孙女):渠道 5 个 + 零食 50 箱”。
- 再去小孙女家(小儿子的右支):小孙女管的收银和客服没下属,清点员核对:“收银机 2 台,会员客户 100 人”,记下来 “6 号财产(小孙女):收银机 2 台 + 会员 100 人”。
- 回到小儿子家:大孙女和小孙女的财产都清完了,才汇总小儿子的便利店:“便利店门店 1 家,月度流水 3 万元”,记下来 “4 号财产(小儿子):门店 1 家 + 流水 3 万”(累计清单:3 号、2 号、5 号、6 号、4 号)。
- 右支清点完毕,清点员带着右支清单回到老爷爷家。
第三步:最后汇总老爷爷的核心财产
清点员对着左、右支的详细清单逐一确认无误后,说:“所有分支都清完了,现在才能汇总老爷爷的核心财产。” 核对老宅子和账本:“老宅子 1 套,家族总存款 10 万元”,记下来 “1 号财产(老爷爷):老宅子 1 套 + 存款 10 万”(累计清单:3 号、2 号、5 号、6 号、4 号、1 号)。
第四步:清点结束
所有分支和核心都清完了,清点员把最终清单汇总:“3 号(大孙子)→2 号(大儿子)→5 号(大孙女)→6 号(小孙女)→4 号(小儿子)→1 号(老爷爷)”—— 这个汇总顺序,就是后序遍历的顺序。
3. 后序遍历的本质:“先所有分支,最后核心”
从财产清点的例子能看出来,后序遍历的逻辑是 “先把左边的所有分支理清楚,再把右边的所有分支理清楚,最后才处理当前核心”。这种 “先分支后核心” 的方式,特别适合需要 “先完成所有子任务,再处理父任务” 的场景 —— 比如文件系统的删除操作(必须先删除所有子文件 / 文件夹,才能删除父文件夹)、表达式树的求值(必须先计算左右子表达式,才能计算父表达式)等。就像清点财产时,必须先清完所有下属的分支,才能汇总上级的核心,否则可能会遗漏或重复计算。
三、后序遍历的递归代码实现
后序遍历的代码和前序、中序几乎一样,只是把 “访问根节点” 的代码,从 “递归左子树” 前面(前序)或中间(中序),挪到了 “递归右子树” 后面。咱们直接上代码,关键地方加了注释,还补充了空节点打印(方便看到遍历路径):
// 1. 先定义二叉树节点结构体(和前序、中序遍历一致,确保代码可运行)
typedef struct BinaryTreeNode {int data; // 节点存储的数据(比如财产编号)struct BinaryTreeNode* left; // 指向左子节点的指针(左支亲属)struct BinaryTreeNode* right; // 指向右子节点的指针(右支亲属)
} btn; // 结构体别名,简化后续使用// 2. 后序遍历函数:参数是当前子树的根节点,功能是按“左→右→根”遍历并打印
void postorder(btn* root) {// 递归终止条件:如果当前节点是空(没有亲属/财产),直接返回if (root == NULL) {printf("N "); // 打印"N",明确标记空节点,方便跟踪遍历路径return;}// 核心逻辑:左→右→根// 1. 第一步:先递归遍历当前根节点的左子树(先清左支)postorder(root->left);// 2. 第二步:再递归遍历当前根节点的右子树(再清右支)postorder(root->right);// 3. 第三步:最后访问当前根节点(最后清核心)printf("%d ", root->data); // 打印节点数据,模拟“记录财产”
}
代码里的关键变化,就是printf("%d ", root->data)的位置 —— 前序是在postorder(root->left)前面,中序是在中间,后序是在最后。就这一个位置调整,遍历顺序就从 “根→左→右” 变成了 “左→右→根”,特别直观。比如咱们的示例树,调用postorder(1)后,输出就是N N 3 N 2 N N 5 N N 6 N 4 1 (其中 “N” 是空节点的标记,去掉后核心顺序就是3 2 5 6 4 1)。
四、后序遍历的递归栈帧细节(彻底理解 “怎么跑的”)
后序遍历的递归栈帧变化和前序、中序类似,只是 “访问根节点” 的时机不同。咱们还是以postorder(1)(调用根节点 1 的后序遍历)为例,拆解每一步的栈状态、执行操作和输出结果,连 “暂停”“返回” 的细节都讲清楚。
核心概念:函数调用栈
递归的时候,每次调用postorder函数,系统都会在 “函数栈” 里新增一个 “栈帧”—— 这个栈帧就像一张 “工作便签”,记录着当前函数的参数(比如root指向哪个节点)、局部变量、以及 “下一步要执行的代码位置”(断点)。当函数执行到return(比如遇到空节点),这张 “便签” 就会被撕掉(栈帧弹出),程序回到上一张便签的 “断点位置” 继续执行。
1. 初始调用:postorder(1)(根节点 1)
- 栈状态(从上到下是最近调用的函数,栈帧里记录 “函数名 + root 值 + 断点”):
[postorder(1):root=1,断点在“调用postorder(left)之后”] - 执行步骤:
- 检查
root=1不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
postorder(root->left),也就是postorder(2)(节点 1 的左子树是 2)。 - 栈帧变化:把
postorder(1)的栈帧暂停(断点记在 “调用完 postorder (2) 后,要执行下一步调用 postorder (right)”),新增postorder(2)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(2):root=2,断点在“调用postorder(left)之后”]。
- 检查
- 当前输出:无(还没到打印节点的步骤)。
2. 执行postorder(2)(节点 2,节点 1 的左子树)
- 栈状态:
[postorder(1)(暂停), postorder(2):root=2,断点在“调用postorder(left)之后”] - 执行步骤:
- 检查
root=2不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
postorder(root->left),也就是postorder(3)(节点 2 的左子树是 3)。 - 栈帧变化:暂停
postorder(2)(断点记在 “调用完 postorder (3) 后,要执行下一步调用 postorder (right)”),新增postorder(3)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(2)(暂停), postorder(3):root=3,断点在“调用postorder(left)之后”]。
- 检查
- 当前输出:无。
3. 执行postorder(3)(节点 3,节点 2 的左子树)
- 栈状态:
[postorder(1)(暂停), postorder(2)(暂停), postorder(3):root=3,断点在“调用postorder(left)之后”] - 执行步骤:
- 检查
root=3不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
postorder(root->left),也就是postorder(NULL)(节点 3 的左子树是空)。 - 栈帧变化:暂停
postorder(3)(断点记在 “调用完 postorder (NULL) 后,要执行下一步调用 postorder (right)”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(2)(暂停), postorder(3)(暂停), postorder(NULL):root=NULL]。
- 检查
- 当前输出:无。
4. 执行postorder(NULL)(节点 3 的左子树)
- 栈状态:
[postorder(1)(暂停), postorder(2)(暂停), postorder(3)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(3)的 “断点位置”—— 也就是 “调用完 postorder (left) 之后,准备执行调用 postorder (right)”。
- 检查
- 当前输出:
N。
5. 回到postorder(3),继续执行(调用右子树)
- 栈状态:
[postorder(1)(暂停), postorder(2)(暂停), postorder(3):root=3,断点在“调用postorder(left)之后”] - 执行步骤:
- 执行 “第二步:递归右子树”→ 调用
postorder(root->right),也就是postorder(NULL)(节点 3 的右子树是空)。 - 栈帧变化:暂停
postorder(3)(断点记在 “调用完 postorder (NULL) 后,要执行下一步打印 root->data”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(2)(暂停), postorder(3)(暂停), postorder(NULL):root=NULL]。
- 执行 “第二步:递归右子树”→ 调用
- 当前输出:
N。
6. 再执行postorder(NULL)(节点 3 的右子树)
- 栈状态:
[postorder(1)(暂停), postorder(2)(暂停), postorder(3)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(3)的 “断点位置”—— 也就是 “调用完 postorder (right) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N N。
7. 回到postorder(3),继续执行(访问根节点)
- 栈状态:
[postorder(1)(暂停), postorder(2)(暂停), postorder(3):root=3,断点在“调用postorder(right)之后”] - 执行步骤:
- 执行 “第三步:访问根节点”→ 打印
3→ 输出3。 postorder(3)的所有步骤都已执行完毕,执行return,撕掉postorder(3)的栈帧(弹出栈),回到postorder(2)的 “断点位置”—— 也就是 “调用完 postorder (left) 之后,准备执行调用 postorder (right)”。
- 执行 “第三步:访问根节点”→ 打印
- 当前输出:
N N 3。
8. 回到postorder(2),继续执行(调用右子树)
- 栈状态:
[postorder(1)(暂停), postorder(2):root=2,断点在“调用postorder(left)之后”] - 执行步骤:
- 执行 “第二步:递归右子树”→ 调用
postorder(root->right),也就是postorder(NULL)(节点 2 的右子树是空)。 - 栈帧变化:暂停
postorder(2)(断点记在 “调用完 postorder (NULL) 后,要执行下一步打印 root->data”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(2)(暂停), postorder(NULL):root=NULL]。
- 执行 “第二步:递归右子树”→ 调用
- 当前输出:
N N 3。
9. 执行postorder(NULL)(节点 2 的右子树)
- 栈状态:
[postorder(1)(暂停), postorder(2)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(2)的 “断点位置”—— 也就是 “调用完 postorder (right) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N N 3 N。
10. 回到postorder(2),继续执行(访问根节点)
- 栈状态:
[postorder(1)(暂停), postorder(2):root=2,断点在“调用postorder(right)之后”] - 执行步骤:
- 执行 “第三步:访问根节点”→ 打印
2→ 输出2。 postorder(2)的所有步骤都已执行完毕,执行return,撕掉postorder(2)的栈帧(弹出栈),回到postorder(1)的 “断点位置”—— 也就是 “调用完 postorder (left) 之后,准备执行调用 postorder (right)”。
- 执行 “第三步:访问根节点”→ 打印
- 当前输出:
N N 3 N 2。
11. 回到postorder(1),继续执行(调用右子树)
- 栈状态:
[postorder(1):root=1,断点在“调用postorder(left)之后”] - 执行步骤:
- 执行 “第二步:递归右子树”→ 调用
postorder(root->right),也就是postorder(4)(节点 1 的右子树是 4)。 - 栈帧变化:暂停
postorder(1)(断点记在 “调用完 postorder (4) 后,要执行下一步打印 root->data”),新增postorder(4)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4):root=4,断点在“调用postorder(left)之后”]。
- 执行 “第二步:递归右子树”→ 调用
- 当前输出:
N N 3 N 2。
12. 执行postorder(4)(节点 4,节点 1 的右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4):root=4,断点在“调用postorder(left)之后”] - 执行步骤:
- 检查
root=4不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
postorder(root->left),也就是postorder(5)(节点 4 的左子树是 5)。 - 栈帧变化:暂停
postorder(4)(断点记在 “调用完 postorder (5) 后,要执行下一步调用 postorder (right)”),新增postorder(5)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4)(暂停), postorder(5):root=5,断点在“调用postorder(left)之后”]。
- 检查
- 当前输出:
N N 3 N 2。
13. 执行postorder(5)(节点 5,节点 4 的左子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(5):root=5,断点在“调用postorder(left)之后”] - 执行步骤:
- 检查
root=5不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
postorder(root->left),也就是postorder(NULL)(节点 5 的左子树是空)。 - 栈帧变化:暂停
postorder(5)(断点记在 “调用完 postorder (NULL) 后,要执行下一步调用 postorder (right)”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4)(暂停), postorder(5)(暂停), postorder(NULL):root=NULL]。
- 检查
- 当前输出:
N N 3 N 2。
14. 执行postorder(NULL)(节点 5 的左子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(5)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(5)的 “断点位置”—— 也就是 “调用完 postorder (left) 之后,准备执行调用 postorder (right)”。
- 检查
- 当前输出:
N N 3 N 2 N。
15. 回到postorder(5),继续执行(调用右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(5):root=5,断点在“调用postorder(left)之后”] - 执行步骤:
- 执行 “第二步:递归右子树”→ 调用
postorder(root->right),也就是postorder(NULL)(节点 5 的右子树是空)。 - 栈帧变化:暂停
postorder(5)(断点记在 “调用完 postorder (NULL) 后,要执行下一步打印 root->data”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4)(暂停), postorder(5)(暂停), postorder(NULL):root=NULL]。
- 执行 “第二步:递归右子树”→ 调用
- 当前输出:
N N 3 N 2 N。
16. 再执行postorder(NULL)(节点 5 的右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(5)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(5)的 “断点位置”—— 也就是 “调用完 postorder (right) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N N 3 N 2 N N。
17. 回到postorder(5),继续执行(访问根节点)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(5):root=5,断点在“调用postorder(right)之后”] - 执行步骤:
- 执行 “第三步:访问根节点”→ 打印
5→ 输出5。 postorder(5)的所有步骤都已执行完毕,执行return,撕掉postorder(5)的栈帧(弹出栈),回到postorder(4)的 “断点位置”—— 也就是 “调用完 postorder (left) 之后,准备执行调用 postorder (right)”。
- 执行 “第三步:访问根节点”→ 打印
- 当前输出:
N N 3 N 2 N N 5。
18. 回到postorder(4),继续执行(调用右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4):root=4,断点在“调用postorder(left)之后”] - 执行步骤:
- 执行 “第二步:递归右子树”→ 调用
postorder(root->right),也就是postorder(6)(节点 4 的右子树是 6)。 - 栈帧变化:暂停
postorder(4)(断点记在 “调用完 postorder (6) 后,要执行下一步打印 root->data”),新增postorder(6)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4)(暂停), postorder(6):root=6,断点在“调用postorder(left)之后”]。
- 执行 “第二步:递归右子树”→ 调用
- 当前输出:
N N 3 N 2 N N 5。
19. 执行postorder(6)(节点 6,节点 4 的右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(6):root=6,断点在“调用postorder(left)之后”] - 执行步骤:
- 检查
root=6不为空,不触发终止条件。 - 执行 “第一步:递归左子树”→ 调用
postorder(root->left),也就是postorder(NULL)(节点 6 的左子树是空)。 - 栈帧变化:暂停
postorder(6)(断点记在 “调用完 postorder (NULL) 后,要执行下一步调用 postorder (right)”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4)(暂停), postorder(6)(暂停), postorder(NULL):root=NULL]。
- 检查
- 当前输出:
N N 3 N 2 N N 5。
20. 执行postorder(NULL)(节点 6 的左子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(6)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(6)的 “断点位置”—— 也就是 “调用完 postorder (left) 之后,准备执行调用 postorder (right)”。
- 检查
- 当前输出:
N N 3 N 2 N N 5 N。
21. 回到postorder(6),继续执行(调用右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(6):root=6,断点在“调用postorder(left)之后”] - 执行步骤:
- 执行 “第二步:递归右子树”→ 调用
postorder(root->right),也就是postorder(NULL)(节点 6 的右子树是空)。 - 栈帧变化:暂停
postorder(6)(断点记在 “调用完 postorder (NULL) 后,要执行下一步打印 root->data”),新增postorder(NULL)的栈帧压入栈。此时栈状态:[postorder(1)(暂停), postorder(4)(暂停), postorder(6)(暂停), postorder(NULL):root=NULL]。
- 执行 “第二步:递归右子树”→ 调用
- 当前输出:
N N 3 N 2 N N 5 N。
22. 再执行postorder(NULL)(节点 6 的右子树)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(6)(暂停), postorder(NULL):root=NULL] - 执行步骤:
- 检查
root=NULL,触发终止条件,执行printf("N ")→ 输出N。 - 执行
return,撕掉postorder(NULL)的栈帧(弹出栈),回到postorder(6)的 “断点位置”—— 也就是 “调用完 postorder (right) 之后,准备执行打印 root->data”。
- 检查
- 当前输出:
N N 3 N 2 N N 5 N N。
23. 回到postorder(6),继续执行(访问根节点)
- 栈状态:
[postorder(1)(暂停), postorder(4)(暂停), postorder(6):root=6,断点在“调用postorder(right)之后”] - 执行步骤:
- 执行 “第三步:访问根节点”→ 打印
6→ 输出6。 postorder(6)的所有步骤都已执行完毕,执行return,撕掉postorder(6)的栈帧(弹出栈),回到postorder(4)的 “断点位置”—— 也就是 “调用完 postorder (right) 之后,准备执行打印 root->data”。
- 执行 “第三步:访问根节点”→ 打印
- 当前输出:
N N 3 N 2 N N 5 N N 6。
24. 回到postorder(4),继续执行(访问根节点)
- 栈状态:
[postorder(1)(暂停), postorder(4):root=4,断点在“调用postorder(right)之后”] - 执行步骤:
- 执行 “第三步:访问根节点”→ 打印
4→ 输出4。 postorder(4)的所有步骤都已执行完毕,执行return,撕掉postorder(4)的栈帧(弹出栈),回到postorder(1)的 “断点位置”—— 也就是 “调用完 postorder (right) 之后,准备执行打印 root->data”。
- 执行 “第三步:访问根节点”→ 打印
- 当前输出:
N N 3 N 2 N N 5 N N 6 N 4。
25. 回到postorder(1),继续执行(访问根节点)
- 栈状态:
[postorder(1):root=1,断点在“调用postorder(right)之后”] - 执行步骤:
- 执行 “第三步:访问根节点”→ 打印
1→ 输出1。 postorder(1)的所有步骤都已执行完毕,执行return,撕掉postorder(1)的栈帧(弹出栈)。
- 执行 “第三步:访问根节点”→ 打印
- 当前输出:
N N 3 N 2 N N 5 N N 6 N 4 1。
26. 后序遍历结束
- 栈状态:空(所有栈帧都已弹出)
- 最终输出:
N N 3 N 2 N N 5 N N 6 N 4 1 - 核心访问顺序(去掉空节点标记 “N”):
3 2 5 6 4 1
五、总结:后序遍历的核心要点
通过上面的详细拆解,我们可以总结出后序遍历的几个核心要点:
- 遍历顺序:严格遵循 “左→右→根”,先处理左子树,再处理右子树,最后访问当前节点。
- 递归本质:利用二叉树的递归结构,将 “遍历整棵树” 拆成 “遍历左子树 + 遍历右子树 + 访问根”,子树的遍历逻辑与整棵树完全一致。
- 栈的作用:递归调用时,函数栈自动记录 “未完成的父节点操作”,确保左、右子树都遍历完后能回到父节点,最后访问父节点。
- 终止条件:遇到空节点(
NULL)时递归终止,避免无限循环,这是递归能正常结束的关键。 - 应用场景:后序遍历特别适合需要 “先完成所有子任务,再处理父任务” 的场景,比如文件系统删除(先删子文件 / 文件夹)、表达式树求值(先算子表达式)、拓扑排序等。
掌握了前序、中序、后序三种遍历,你会发现它们的核心逻辑完全一致 —— 都是 “递归拆解 + 栈帧管理”,区别只是 “访问根节点的时机” 不同。这种 “统一逻辑 + 微小差异” 的特点,正是二叉树遍历的精髓所在。记住:前序(根→左→右)、中序(左→根→右)、后序(左→右→根),核心都是 “递归”,差异只是 “访问根的时机”。
OK,依旧是先给出详细代码:
//(3)后序遍历(Postorder Traversal):
//访问根结点的操作发生在遍历其左右子树之后
//访问顺序为:左子树、右子树、根结点
void postorder(btn* root)
{//先设置递归返回条件//即走到了节点是为空,就得返回if (root == NULL){printf("N ");return;}//先去走左子树//走完左子树后,直接去走右子树,不用表达数据postorder(root->left);//走右子树//走完右子树之后,才能表达节点数据postorder(root->right);//因为是从左子树开始,所以我们得走完左子树后,再去走完右子树,最后才能表达数据//其实就是根结点在最后//所以我们表达也要在最后printf("%d ",root->data);
}
再给上我的手写解析:

怎么样大家,其实到了现在,就感觉轻松很多了。
三十年经验,三十秒传授:
那么大家,看完了上面的前、中、后序遍历,之后,大家是不是感觉会有点迷茫,就是对于代码如何书写,好像掌握了,又好像没掌握,那么,有没有什么小窍门能够帮助我们去顺利的写出前、中、后序遍历的代码呢?
其实是很简单的,首先,前、中、后序遍历的递归返回条件都是当遇到节点为空时,就进行返回,然后就要在下面调用递归,左子树呀,右子树呀,巴拉巴拉,那么去递归左子树和右子树,这个大家也轻而易举。
唯一的难点就是,我们的表达数据,要放在哪里呢?
诶其实很简单,因为我们知道,其实表达数据或者是取出数据,本质上是对根节点的操作,大家首先必须明确一点,根节点就是有左右孩子的节点,即使它的左右孩子是NULL。
所以,我们的表达数据要放在哪里,就取决于我们的根节点要什么时候去走,
对于前序遍历而言,由于根节点最先访问,所以就要放在递归左子树和右子树之前。
而对于中序遍历而言,由于根节点是在左子树之后,右子树之前去访问,所以就得将其放在递归左子树和递归右子树之中。
那么对于后序遍历而言,由于根节点最后访问,所以就要放在递归左子树和右子树之后。
怎么样大家,是不是一下就悟了呢?是的,其实就是这么简单。
那么讲解完了前中后序遍历,之后,我们不妨做几道题目来帮助我们加强掌握。
我就只讲解一下前序遍历的题目,至于中序遍历和后序遍历,由于和前序遍历差不多,所以我就不多讲解。
先给上三题链接:
144. 二叉树的前序遍历 - 力扣(LeetCode)
https://leetcode.cn/problems/binary-tree-preorder-traversal/description/94. 二叉树的中序遍历 - 力扣(LeetCode)
https://leetcode.cn/problems/binary-tree-inorder-traversal/description/145. 二叉树的后序遍历 - 力扣(LeetCode)
https://leetcode.cn/problems/binary-tree-postorder-traversal/description/
144. 二叉树的前序遍历:
题目我们就不看了,就是要求我们要前序遍历罢了,那本题大家可能有点蒙的点在于:
![]()
首先,题目要求我们返回一个数组,然后这个数组是存储着我们前序遍历的数据。
那么对于这个函数的第二个形参,它是什么意思呢?returnsize?翻译一下就是返回的数据个数,哦,这下好像就有点明白了,这个参数的意思是我们要返回数组中,是有几个数据的意思。
诶,但是它为什么要在形参中,不应该我们自己在函数内部创建吗?
其实,这是leetcode的正常操作,就是题目是会给我们这个变量,但是实际上它是无值的,需要我们自己去为其赋值,这也是为什么传过来的是整型指针,就是这么牛波一哈哈。
那么之所以要给我们这个变量,其实就是为了避免我们创建的数组长度过长导致浪费,所以,我们要先求出二叉树节点的个数,然后再把其赋值给这个变量,关于如何求二叉树节点的个数,这个我们下一篇会讲到。
在知道了二叉树节点个数之后,我们就可以开始执行前序遍历了,那么大家这个时候要在哪里进行前序遍历的递归呢?
这个函数?
nonono,肯定不行,因为这个函数要求返回整型指针,但是我们递归的时候,返回一个整型指针?总感觉怪怪的,而且我们上面的代码中,函数返回类型都是void,所以,我们肯定不能直接在这个函数内进行递归。
于是,我们就得创建一个函数去执行前序遍历,而这个函数要实现的功能就是将根节点的数据存进数组中,并且要实现按顺序的存储。
所以,我们这个函数就需要三个参数,一个是二叉树的最上面的根节点,一个是负责存储数据的数组,还有一个就是实现按顺序的存储的参数sign,每次存储完之后,就sign++,如此一来就能按顺序存储。
这边需要注意一下,我们传进sign要用指针类型,因为这样子才能彻彻底底在递归中改变sign。
那么这个时候,可能就要有小伙伴有问题了,那我怎么在前序遍历中去把数据存储进数组中呢?哦呦大家,这个简直是简单了,我们只需要把上面代码的
printf("%d ",root->data);
改为:
ret[(*(psign))++]=root->val;
就行啦,真的很简单。
至于在中后序遍历中,也是一样的,下面我就给出完整代码:
/*** Definition for a binary tree node.* struct TreeNode {* int val;* struct TreeNode *left;* struct TreeNode *right;* };*/
/*** Note: The returned array must be malloced, assume caller calls free().*/int binarytreesize(struct TreeNode* root)
{if(root==NULL){return 0;}return binarytreesize(root->left)+binarytreesize(root->right)+1;
}void preorder(struct TreeNode* root,int* ret,int* psign)
{if(root==NULL){return;}ret[(*(psign))++]=root->val;preorder(root->left,ret,psign);preorder(root->right,ret,psign);
}int* preorderTraversal(struct TreeNode* root, int* returnSize) {//根节点,左子树,右子树*returnSize=binarytreesize(root);int* ret=(int*)malloc(sizeof(int)*(*returnSize));if(root==NULL){return NULL;//因为我们这个函数的返回类型要是指针类型}int sign=0;preorder(root,ret,&sign);return ret;
}
再给上详细注释:
/*** Definition for a binary tree node.* 二叉树节点的结构体定义:* 每个节点包含三个部分:* 1. 存储的数据(val)* 2. 指向左子节点的指针(left)* 3. 指向右子节点的指针(right)*/
struct TreeNode {int val; // 节点所存储的整数数据struct TreeNode *left; // 指向当前节点左子节点的指针,若不存在左子节点则为NULLstruct TreeNode *right; // 指向当前节点右子节点的指针,若不存在右子节点则为NULL
};/*** Note: The returned array must be malloced, assume caller calls free().* 注意事项:* 本函数返回的数组必须通过malloc动态分配内存,* 调用本函数的代码需要负责通过free()释放该内存,* 以避免内存泄漏。*//*** 函数功能:计算二叉树的节点总数* 实现思路:利用二叉树的递归性质,树的总节点数 = 左子树节点数 + 右子树节点数 + 1(当前节点)* 参数说明:* root:指向二叉树根节点的指针,若树为空则为NULL* 返回值:* 整数,代表二叉树中节点的总数量,空树返回0*/
int binarytreesize(struct TreeNode* root)
{// 递归终止条件:如果当前节点为NULL(空节点),则该分支节点数为0if(root == NULL){return 0;}// 递归计算:// 1. 先计算左子树的节点数(binarytreesize(root->left))// 2. 再计算右子树的节点数(binarytreesize(root->right))// 3. 加上当前节点本身(1)return binarytreesize(root->left) + binarytreesize(root->right) + 1;
}/*** 函数功能:前序遍历的递归辅助函数,负责将遍历结果存入数组* 前序遍历规则:先访问当前根节点,再递归遍历左子树,最后递归遍历右子树(根→左→右)* 参数说明:* root:当前正在遍历的子树的根节点指针* ret:用于存储遍历结果的数组,数组大小已提前通过binarytreesize计算好* psign:指向一个整数的指针,用于记录当前应该存储数据的数组索引位置* 使用指针的原因是为了在递归调用中共享同一个索引变量*/
void preorder(struct TreeNode* root, int* ret, int* psign)
{// 递归终止条件:如果当前节点为NULL,说明已无节点可遍历,直接返回if(root == NULL){return;}// 第一步:访问当前根节点// 将当前节点的val值存入ret数组的psign指向的索引位置// (*psign)++ 是后置自增操作:先使用当前索引值,再将索引加1// 例如:初始sign=0时,先执行ret[0] = root->val,再将sign变为1ret[(*psign)++] = root->val;// 第二步:递归遍历左子树// 对当前节点的左子树执行相同的前序遍历操作preorder(root->left, ret, psign);// 第三步:递归遍历右子树// 左子树遍历完成后,再对当前节点的右子树执行相同的前序遍历操作preorder(root->right, ret, psign);
}/*** 函数功能:二叉树的前序遍历主函数,返回前序遍历结果数组* 参数说明:* root:指向二叉树根节点的指针,若树为空则为NULL* returnSize:输出参数(指针),用于将结果数组的长度返回给调用者* 返回值:* 指向存储前序遍历结果的动态数组的指针,空树返回NULL*/
int* preorderTraversal(struct TreeNode* root, int* returnSize) {// 步骤1:计算二叉树的节点总数,确定结果数组的长度// 将计算结果存入returnSize指向的变量,供调用者获取数组长度*returnSize = binarytreesize(root);// 步骤2:根据节点总数动态分配内存,创建存储结果的数组// 数组类型为int,大小为节点总数 * 每个int的字节数int* ret = (int*)malloc(sizeof(int) * (*returnSize));// 特殊情况处理:如果根节点为NULL(空树),直接返回NULL// 因为空树没有任何节点需要存储,无需执行遍历if(root == NULL){return NULL; // 返回空指针表示遍历结果为空}// 步骤3:定义索引变量sign,初始值为0// 用于记录当前应该将节点值存入数组的位置int sign = 0;// 步骤4:调用递归辅助函数进行前序遍历// 将遍历结果存入ret数组,通过&sign传递索引变量的地址preorder(root, ret, &sign);// 步骤5:返回存储好遍历结果的数组指针return ret;
}
到了这里,我们的解析也就暂时告一段落了。
结语:
在系统且细致地讲解了链式二叉树的前序、中序、后序这三种遍历方式后,我们已然能够全方位且透彻地领悟它们各自所具备的鲜明特点以及深层逻辑。
前序遍历,秉持 “根 - 左 - 右” 的执行顺序,宛如为我们迅速描绘出树的 “骨架”。从根节点启程,依次深入左子树、右子树,使得我们可以率先把控树的整体结构脉络。在实际应用场景中,像创建树的副本这类操作,前序遍历就能直接发挥作用,高效地完成树结构的复制工作。
中序遍历遵循 “左 - 根 - 右” 的次序,仿佛是给树开展了一次 “由内到外” 的精细剖析。特别值得注意的是,对于二叉搜索树来说,中序遍历所得到的节点值会呈现出严格的升序排列。这一极具价值的特性,让中序遍历在有序数据的检索场景中,能够快速定位目标数据;同时,在二叉搜索树的验证操作里,也起着不可或缺的关键作用,助力我们从有序性的维度去理解和运用树的结构。
后序遍历依照 “左 - 右 - 根” 的顺序推进,更着重于 “从细节到整体” 的逻辑流程,先深入处理左右子节点,之后再回溯到父节点。这种遍历方式在涉及资源释放、树的删除操作等场景时,展现出了独特的重要性。例如,当我们需要删除一棵二叉树时,后序遍历能够确保在删除父节点之前,先妥善地处理好其左右子树的相关资源,从而有效避免出现资源泄漏等问题,保障操作的安全性与完整性。
这些遍历方法,绝非仅仅是停留在理论层面的抽象概念,它们是我们探索链式二叉树结构与数据的重要且实用的工具。当我们熟练且精准地掌握了这些遍历方式,便能够为后续更为复杂的二叉树操作筑牢坚实的根基。比如,在基于遍历的特定节点查找中,我们可以根据不同遍历的顺序特点,高效地定位到目标节点;而在树的序列化与反序列化操作中,借助遍历的顺序规则,能够实现树结构与线性数据之间的相互转换,进而达成数据的持久化存储和网络传输等功能。可以说,掌握好这些遍历方式,能让我们在二叉树的学习与应用之路上,走得更为长远、顺畅,不断挖掘出二叉树在数据结构领域的更多价值与潜力。
