昨天我们把函数递归的基础打扎实了------阶乘、求和、斐波那契、汉诺塔,一路推演过来。今天进入递归最经典的应用场景:二叉树的遍历。我们把「普通递归」和「树递归」放到一起对比,帮你把中间那条线连起来,彻底搞醒豁。
零、二叉树是什么?------ 三分钟速通数据结构
在讲遍历之前,先把「二叉树」这个数据结构搞清楚。第一次接触「二叉树」?正常,咱们从最简单的概念开始。
直观理解
二叉树就像一棵倒挂的树------根在上,叶子在下。每个节点最多有两个分叉,左边那个叫左子树,右边那个叫右子树。
A ← 根节点(Root),整棵树的起点
/ \
B C ← B 是 A 的左子节点,C 是 A 的右子节点
/ \ \
D E F ← D、E、F 是叶子节点(下面没有节点了)
几个基本名词先记到:
- 根节点:最顶上的那个,整棵树只有唯一一个根
- 子节点:某个节点下面连着的节点(B、C 是 A 的子节点)
- 叶子节点:下面没有子节点的节点(D、E、F 就是叶子)
- 父节点:上面连着的那个(A 是 B、C 的父节点)
用 C 语言定义节点
c
typedef struct TreeNode
{
int data; // 节点存的数据
struct TreeNode* left; // 指向左子节点的指针
struct TreeNode* right; // 指向右子节点的指针
} TreeNode;
一个节点就三样东西:自己的数据 、左孩子在哪 、右孩子在哪 。而每个子树本身又是一个二叉树------这就是递归结构的本质:树由子树组成,子树也是树。
⚠️ 提醒 :
struct TreeNode* left里必须写struct,因为typedef还没生效,编译器不认识TreeNode。初学这里经常报错,先记住就行。
一、「普通递归」和「树递归」有啥不一样?
在上篇文章里,我们学的递归都是这样的:
c
// 阶乘递归:每次只调一次自己
int fact(int n) {
if (n == 0) return 1; // 出口
return n * fact(n - 1); // 只调一次 fact
}
每次调用只会递归一次,函数调用链是一条直线往下走。
但树的递归不一样------一个节点有两个分支 ,所以一次函数调用里要递归两次:
c
void preorder(TreeNode* root) {
if (root == NULL) return;
printf("%d ", root->data);
preorder(root->left); // 第一次递归:走左子树
preorder(root->right); // 第二次递归:走右子树
}
这就产生了质变------普通递归是 「一条线」 ,树递归是 「一张网」 。
| 对比维度 | 普通递归(阶乘/求和/Fib) | 树递归(遍历) |
|---|---|---|
| 每次调几次自己 | 1 次 | 2 次(左 + 右) |
| 出口条件 | 具体数值(n0, n1) | 指针为空(root==NULL) |
| 调用链形状 | 一条直线 | 树状分叉 |
| 典型问题 | 阶乘、Fib、汉诺塔 | 遍历、查找、计算深度 |
| 思维难度 | ⭐⭐ | ⭐⭐⭐⭐ |
💡 关键认知:树递归里的每个节点,都要把「自己做的事」做完、再把「左子树的事」和「右子树的事」也做完,才能返回。这就是为什么树递归的调用栈比普通递归复杂得多------后面手写推演的时候你会看到。
二、二叉树的三种遍历方式
遍历二叉树,就是按照某种规则「访问」树中的每一个节点。根据先处理根还是后处理根,分为三种:
| 遍历方式 | 英文名 | 访问顺序 | 一句话记法 |
|---|---|---|---|
| 先根遍历 | Preorder | 根 → 左 → 右 | 进门先打卡 |
| 中根遍历 | Inorder | 左 → 根 → 右 | 逛完左边再打卡 |
| 后根遍历 | Postorder | 左 → 右 → 根 | 全部逛完才打卡 |
本篇聚焦先根遍历 和后根遍历,这两者恰好形成鲜明对比。搞懂它们,中根自然也就通了。
为什么递归天然适合遍历树?
答案很简单:二叉树本身就是递归定义的------
二叉树 = 根节点 + 左子树(也是一棵二叉树) + 右子树(也是一棵二叉树)
遍历一棵树 = 访问根 + 遍历左子树 + 遍历右子树。而「遍历左子树」和「遍历右子树」又是同样的问题,只是规模更小------完美匹配上篇文章讲的递归两个条件(出口 + 递推公式)。
三、先根遍历(Preorder Traversal)
规则
访问根节点 → 递归遍历左子树 → 递归遍历右子树
用一句话记住:「到自己家了,先打个卡,再去左右邻居家串门。」
代码实现
c
void preorder(TreeNode* root)
{
if (root == NULL) // 递归出口:空树,直接返回
{
return;
}
printf("%d ", root->data); // ① 先访问根节点
preorder(root->left); // ② 递归遍历左子树
preorder(root->right); // ③ 递归遍历右子树
}
三行核心逻辑,极其简洁。
手动推演执行过程
以这棵树为例:
1
/ \
2 3
/ \ \
4 5 6
执行 preorder(节点1):
preorder(1)
root != NULL,打印 1 → 输出: "1 "
调用 preorder(2)
preorder(2)
root != NULL,打印 2 → 输出: "1 2 "
调用 preorder(4)
preorder(4)
root != NULL,打印 4 → 输出: "1 2 4 "
调用 preorder(NULL) → 直接返回
调用 preorder(NULL) → 直接返回
返回
调用 preorder(5)
preorder(5)
root != NULL,打印 5 → 输出: "1 2 4 5 "
调用 preorder(NULL) → 直接返回
调用 preorder(NULL) → 直接返回
返回
返回
调用 preorder(3)
preorder(3)
root != NULL,打印 3 → 输出: "1 2 4 5 3 "
调用 preorder(NULL) → 直接返回
调用 preorder(6)
preorder(6)
root != NULL,打印 6 → 输出: "1 2 4 5 3 6 "
调用 preorder(NULL) → 直接返回
调用 preorder(NULL) → 直接返回
返回
返回
返回
最终输出:1 2 4 5 3 6
先根遍历的特征
- 根节点总是第一个被访问
- 遍历顺序反映的是「深度优先、根优先」的逻辑
- 应用场景:复制一棵树(先建根节点再递归建子树)、序列化树结构、输出目录树
四、后根遍历(Postorder Traversal)
规则
递归遍历左子树 → 递归遍历右子树 → 访问根节点
用一句话记住:「先去左右邻居家串门,串完了回自己家再打卡。」
代码实现
c
void postorder(TreeNode* root)
{
if (root == NULL) // 递归出口:空树,直接返回
{
return;
}
postorder(root->left); // ① 递归遍历左子树
postorder(root->right); // ② 递归遍历右子树
printf("%d ", root->data); // ③ 最后访问根节点
}
和先根遍历相比,仅仅是 printf 的位置从第一行移到了最后一行。就这一行位置的变化,输出结果彻底不同。
手动推演执行过程
还是那棵树:
1
/ \
2 3
/ \ \
4 5 6
执行 postorder(节点1):
postorder(1)
调用 postorder(2)
postorder(2)
调用 postorder(4)
postorder(4)
调用 postorder(NULL) → 直接返回
调用 postorder(NULL) → 直接返回
打印 4 → 输出: "4 "
返回
调用 postorder(5)
postorder(5)
调用 postorder(NULL) → 直接返回
调用 postorder(NULL) → 直接返回
打印 5 → 输出: "4 5 "
返回
打印 2 → 输出: "4 5 2 "
返回
调用 postorder(3)
postorder(3)
调用 postorder(NULL) → 直接返回
调用 postorder(6)
postorder(6)
调用 postorder(NULL) → 直接返回
调用 postorder(NULL) → 直接返回
打印 6 → 输出: "4 5 2 6 "
返回
打印 3 → 输出: "4 5 2 6 3 "
返回
打印 1 → 输出: "4 5 2 6 3 1 "
最终输出:4 5 2 6 3 1
后根遍历的特征
- 根节点总是最后一个被访问
- 必须先处理完所有子节点,才能处理父节点
- 应用场景:删除一棵树(必须先删子节点再删父节点,否则子节点就丢了)、计算目录大小(先算子目录再汇总)
五、先根 vs 后根:一行之差,天壤之别
把两段代码放在一起对比:
c
// 先根遍历:根 → 左 → 右
void preorder(TreeNode* root)
{
if (root == NULL) return;
printf("%d ", root->data); // ← 根在这里,第一个执行
preorder(root->left);
preorder(root->right);
}
// 后根遍历:左 → 右 → 根
void postorder(TreeNode* root)
{
if (root == NULL) return;
postorder(root->left);
postorder(root->right);
printf("%d ", root->data); // ← 根在这里,最后一个执行
}
同一棵树 [1,2,3,4,5,null,6] 的输出对比:
| 遍历方式 | 输出结果 | 根节点位置 |
|---|---|---|
| 先根遍历 | 1 2 4 5 3 6 |
第一个 |
| 后根遍历 | 4 5 2 6 3 1 |
最后一个 |
这个对比完美体现了上一篇文章的结论:递归语句前面的代码在递推阶段执行,后面的代码在回归阶段执行。 先根遍历在递推阶段就打印,后根遍历要等回归阶段才打印。
⚠️ 提醒 :很多兄弟伙会搞混「中根」和「后根」。记一个口诀:「先根是进门打卡,中根是左边看完打卡,后根是走完两边再打卡。」或者更粗暴------先根看代码顺序(printf → left → right),后根把 printf 丢最后。
六、完整可运行代码
为了方便你动手验证,这里给出一份完整的代码------手动构建一棵树并分别执行先根和后根遍历:
c
#include <stdio.h>
#include <stdlib.h>
typedef struct TreeNode
{
int data;
struct TreeNode* left;
struct TreeNode* right;
} TreeNode;
// 创建一个新节点
TreeNode* createNode(int data)
{
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
node->data = data;
node->left = NULL;
node->right = NULL;
return node;
}
// 先根遍历
void preorder(TreeNode* root)
{
if (root == NULL) return;
printf("%d ", root->data);
preorder(root->left);
preorder(root->right);
}
// 后根遍历
void postorder(TreeNode* root)
{
if (root == NULL) return;
postorder(root->left);
postorder(root->right);
printf("%d ", root->data);
}
int main()
{
// 手动构建树:
// 1
// / \
// 2 3
// / \ \
// 4 5 6
TreeNode* root = createNode(1);
root->left = createNode(2);
root->right = createNode(3);
root->left->left = createNode(4);
root->left->right = createNode(5);
root->right->right = createNode(6);
printf("先根遍历: ");
preorder(root);
printf("\n");
printf("后根遍历: ");
postorder(root);
printf("\n");
return 0;
}
运行结果:
先根遍历: 1 2 4 5 3 6
后根遍历: 4 5 2 6 3 1
🎓 面试技巧 :如果你能手写这段代码并且把执行过程讲清楚,基本上递归遍历这块就能过面试关了。面试官经常追问:「如果把
printf换到中间,输出是什么?」------答案就是中根遍历4 2 5 1 3 6。
七、为什么递归遍历不会无限循环?
可能有兄弟伙会问:递归会调用自己,树的遍历也在不停调用 preorder,凭什么不会死循环?
答案藏在递归出口里:
c
if (root == NULL) return;
树的每一个分支最终都会走到叶子节点。叶子节点的 left 和 right 都是 NULL。递归走到 NULL 时直接 return,不会再往下调------这就是触底反弹。
递归深度 = 树的高度,而不是节点数量。 只要树的高度可控(一般不会超过几十层),就不会栈溢出。
八、从递归遍历到算法思维
二叉树递归遍历是理解很多高级算法的基础:
- DFS(深度优先搜索) 本质上就是先根遍历的泛化
- 回溯算法 的核心结构就是「递推 → 处理 → 回归」,和后根遍历的执行流程一致
- 动态规划 的「自顶向下 + 记忆化」也可以类比为树的递归遍历
所以说,搞懂二叉树的递归遍历,就等于打开了算法世界的大门。 这是从「写代码」到「写算法」的关键一步。
九、经典面试题精讲 🎯
二叉树递归遍历是校招面试的高频考点,下面三道题,手写代码的概率极高。
面试题一:求二叉树的最大深度
题目描述:给定一棵二叉树,求它的最大深度(从根节点到最远叶子节点的路径上的节点数)。
LeetCode 104,几乎所有厂的面试都出过。
思路分析:
树的深度 = max(左子树深度, 右子树深度) + 1(根自己算一层)。这本身就是个递推公式。
c
int maxDepth(TreeNode* root)
{
if (root == NULL) // 出口:空树深度为 0
return 0;
int leftDepth = maxDepth(root->left); // 左子树深度
int rightDepth = maxDepth(root->right); // 右子树深度
return (leftDepth > rightDepth ? leftDepth : rightDepth) + 1; // 取大的 +1
}
以我们那棵树 [1,2,3,4,5,null,6] 为例:
maxDepth(1)
left = maxDepth(2)
left = maxDepth(4) → 1
right = maxDepth(5) → 1
return max(1,1)+1 = 2
right = maxDepth(3)
left = maxDepth(NULL) → 0
right = maxDepth(6) → 1
return max(0,1)+1 = 2
return max(2,2)+1 = 3
最大深度 = 3。
🎓 面试技巧:这道题本质就是后根遍历------先算子节点深度,再汇总到父节点。掌握后根遍历,这一题就能秒。
面试题二:判断两棵二叉树是否相同
题目描述 :给定两棵二叉树的根节点 p 和 q,判断它们是否完全相同(结构相同且每个节点的值相同)。
LeetCode 100,经典到不能再经典。
思路分析:
两棵树相同,当且仅当:当前节点值相等 + 左子树相同 + 右子树相同。
c
bool isSameTree(TreeNode* p, TreeNode* q)
{
// 出口 1:两个都为空 → 相同
if (p == NULL && q == NULL)
return true;
// 出口 2:一个空一个不空 → 不相同
if (p == NULL || q == NULL)
return false;
// 出口 3:当前节点值不同 → 不相同
if (p->data != q->data)
return false;
// 递推:左右子树都相同才行
return isSameTree(p->left, q->left) && isSameTree(p->right, q->right);
}
关键点 :这里的递归是同时遍历两棵树,同步比较。注意三个出口条件缺一不可------这是面试官最爱挑刺的地方。
面试题三:根据遍历结果重建二叉树
题目描述:给定一棵二叉树的先根遍历序列和中根遍历序列(数组形式),要求重建这棵二叉树。
LeetCode 105,字节、阿里高频原题。
示例:
- 先根:
[3, 9, 20, 15, 7] - 中根:
[9, 3, 15, 20, 7]
重建结果:
3
/ \
9 20
/ \
15 7
思路分析:
- 先根遍历的第一个数一定是根节点(这是先根的核心性质)
- 在中根遍历中找到这个根节点的位置,左边全是左子树,右边全是右子树(这是中根的核心性质)
- 对左右子树分别递归执行同样的操作
c
// 假设 preorder[] 是先根序列, inorder[] 是中根序列
// preLeft/preRight: 先根序列的左右边界
// inLeft/inRight: 中根序列的左右边界
TreeNode* buildTree(int* preorder, int preLeft, int preRight,
int* inorder, int inLeft, int inRight)
{
if (preLeft > preRight) // 出口:空区间
return NULL;
int rootVal = preorder[preLeft]; // 先根第一个就是根
TreeNode* root = createNode(rootVal);
// 在中根序列里找根的位置
int rootIndex = inLeft;
while (inorder[rootIndex] != rootVal)
rootIndex++;
int leftSize = rootIndex - inLeft; // 左子树节点个数
// 递归构建左右子树
root->left = buildTree(preorder, preLeft + 1, preLeft + leftSize,
inorder, inLeft, rootIndex - 1);
root->right = buildTree(preorder, preLeft + leftSize + 1, preRight,
inorder, rootIndex + 1, inRight);
return root;
}
🎓 面试技巧 :这道题的突破口就一句话------「先根给根,中根分左右」。面试官问「为什么必须知道两种遍历才能重建」,标准答案是:只用先根无法区分左右子树,只用中根无法确定根。先根 + 中根组合才能唯一确定一棵二叉树。
📝 本节知识点速查
| 知识点 | 一句话记住 |
|---|---|
| 二叉树节点结构 | data + left 指针 + right 指针 |
| 普通递归 vs 树递归 | 普通递归每次调自己 1 次(一条线),树递归调 2 次(一张网) |
| 先根遍历(Preorder) | 根 → 左 → 右,printf 在最前面 |
| 后根遍历(Postorder) | 左 → 右 → 根,printf 在最后面 |
| 中根遍历(Inorder) | 左 → 根 → 右,printf 在中间 |
| 递归出口 | root == NULL 时 return,叶子节点的左右孩子触发 |
| 先根应用 | 树的复制与序列化 |
| 后根应用 | 树的删除(先删子节点再删父节点)、计算深度 |
| 最大深度 | 后根遍历:max(左深度, 右深度) + 1 |
| 判断相同树 | 同时遍历两棵树:值相同 + 左相同 + 右相同 |
| 重建二叉树 | 「先根给根,中根分左右」,先根 + 中根唯一确定树 |
| 递归深度 | 等于树的高度,不是节点数量 |
| 核心差异 | 先根在递推阶段打印,后根在回归阶段打印 |
结语
从昨天的「一条线递归」(阶乘、Fib),到今天的「一张网递归」(二叉树遍历),你其实已经完成了递归思维里最关键的一次跃迁。
普通递归 → 树递归,就是把「一条路走到黑」升级成「分岔路口每条路都走一遍」。 这个思维一旦形成,后面学 DFS、回溯、分治,全部是顺水推舟。
把先根和后根的推演过程自己手写一遍,三道面试题的代码亲手敲一遍------递归在你手里就不是「玄学」而是实打实的工具了。