一篇吃透树的遍历:递归与迭代的完整拆解
摘要:树的遍历是数据结构的必修课。本文从二叉树的基本概念出发,用递归和迭代两种方式实现前序、中序、后序、层序遍历,每一步都配有详细的执行过程拆解,帮你彻底理解四种遍历的规律与差异。
📑 目录
- 树的基本要点
- 四种遍历方式概述
- 递归法:最自然的实现
- 迭代法:用栈和队列模拟递归
- 递归与迭代的对比总结
- 互动讨论
树的基本要点
二叉树是每个节点最多有两个子树的树结构。在开始遍历之前,先明确几个基本概念:
- 根节点:树的顶层节点,没有父节点。
- 叶子节点:没有子节点的节点。
- 左子树 / 右子树:根节点下方的左右分支,本身也是一棵二叉树。
- 深度:从根节点到某个节点的路径长度。
二叉树的定义本身就是递归的:一棵二叉树由根节点、左子树、右子树三部分组成,而左右子树也是二叉树。这个递归结构决定了递归是处理树最自然的思维方式。
为了便于理解后续的遍历过程,先定义一棵示例树:
text
markdown
1
/ \
2 3
/ \ \
4 5 6
- 根节点是 1
- 2 和 3 是 1 的左右孩子
- 4 和 5 是 2 的左右孩子
- 6 是 3 的右孩子
四种遍历方式概述
| 遍历方式 | 访问顺序 | 核心规律 |
|---|---|---|
| 前序遍历 | 根 → 左 → 右 | 根节点最先访问 |
| 中序遍历 | 左 → 根 → 右 | 根节点在中间访问 |
| 后序遍历 | 左 → 右 → 根 | 根节点最后访问 |
| 层序遍历 | 从上到下,从左到右 | 按层逐级访问 |
对于上面的示例树,四种遍历的结果:
- 前序:1 → 2 → 4 → 5 → 3 → 6
- 中序:4 → 2 → 5 → 1 → 3 → 6
- 后序:4 → 5 → 2 → 6 → 3 → 1
- 层序:1 → 2 → 3 → 4 → 5 → 6
递归法:最自然的实现
由于树的定义本身就是递归的,递归遍历代码极其简洁------直接按照访问顺序调用自身即可。
前序遍历(递归)
核心逻辑:对于当前节点,先访问它自己(记录值),再递归访问左子树,最后递归访问右子树。
c
scss
void preorderRecur(struct TreeNode* root, int* result, int* returnSize) {
if (!root) return;
result[(*returnSize)++] = root->val; // 1. 先访问根
preorderRecur(root->left, result, returnSize); // 2. 再遍历左子树
preorderRecur(root->right, result, returnSize); // 3. 最后遍历右子树
}
执行过程(示例树) :
text
scss
preorderRecur(1)
→ 记录 1
→ preorderRecur(2)
→ 记录 2
→ preorderRecur(4)
→ 记录 4
→ preorderRecur(NULL) 返回
→ preorderRecur(NULL) 返回
→ preorderRecur(5)
→ 记录 5
→ preorderRecur(NULL) 返回
→ preorderRecur(NULL) 返回
→ preorderRecur(3)
→ 记录 3
→ preorderRecur(NULL) 返回
→ preorderRecur(6)
→ 记录 6
→ preorderRecur(NULL) 返回
→ preorderRecur(NULL) 返回
结果:[1, 2, 4, 5, 3, 6]
中序遍历(递归)
核心逻辑:对于当前节点,先递归访问左子树,再访问自己,最后递归访问右子树。
c
scss
void inorderRecur(struct TreeNode* root, int* result, int* returnSize) {
if (!root) return;
inorderRecur(root->left, result, returnSize); // 1. 先遍历左子树
result[(*returnSize)++] = root->val; // 2. 再访问根
inorderRecur(root->right, result, returnSize); // 3. 最后遍历右子树
}
执行过程(示例树) :
text
scss
inorderRecur(1)
→ inorderRecur(2)
→ inorderRecur(4)
→ inorderRecur(NULL) 返回
→ 记录 4
→ inorderRecur(NULL) 返回
→ 记录 2
→ inorderRecur(5)
→ inorderRecur(NULL) 返回
→ 记录 5
→ inorderRecur(NULL) 返回
→ 记录 1
→ inorderRecur(3)
→ inorderRecur(NULL) 返回
→ 记录 3
→ inorderRecur(6)
→ inorderRecur(NULL) 返回
→ 记录 6
→ inorderRecur(NULL) 返回
结果:[4, 2, 5, 1, 3, 6]
后序遍历(递归)
核心逻辑:对于当前节点,先递归访问左子树,再递归访问右子树,最后访问自己。
c
scss
void postorderRecur(struct TreeNode* root, int* result, int* returnSize) {
if (!root) return;
postorderRecur(root->left, result, returnSize); // 1. 先遍历左子树
postorderRecur(root->right, result, returnSize); // 2. 再遍历右子树
result[(*returnSize)++] = root->val; // 3. 最后访问根
}
执行过程(示例树) :
text
scss
postorderRecur(1)
→ postorderRecur(2)
→ postorderRecur(4)
→ postorderRecur(NULL) 返回
→ postorderRecur(NULL) 返回
→ 记录 4
→ postorderRecur(5)
→ postorderRecur(NULL) 返回
→ postorderRecur(NULL) 返回
→ 记录 5
→ 记录 2
→ postorderRecur(3)
→ postorderRecur(NULL) 返回
→ postorderRecur(6)
→ postorderRecur(NULL) 返回
→ postorderRecur(NULL) 返回
→ 记录 6
→ 记录 3
→ 记录 1
结果:[4, 5, 2, 6, 3, 1]
层序遍历(递归)
层序遍历的递归实现与前三者不同------它不是按"访问顺序"递归,而是按"层级"递归:先处理第 0 层,再处理第 1 层,依此类推。递归函数需要额外接收一个参数 level,表示当前节点所在的层数。
核心逻辑:递归遍历整棵树,将每个节点放入对应层级的数组中。
c
scss
void levelOrderRecurHelper(struct TreeNode* root, int level, int*** result, int* returnSize, int** returnColumnSizes) {
if (!root) return;
if (level >= *returnSize) {
*result = (int**)realloc(*result, (level + 1) * sizeof(int*));
(*returnColumnSizes) = (int*)realloc(*returnColumnSizes, (level + 1) * sizeof(int));
(*returnColumnSizes)[level] = 0;
(*result)[level] = NULL;
(*returnSize)++;
}
(*returnColumnSizes)[level]++;
(*result)[level] = (int*)realloc((*result)[level], (*returnColumnSizes)[level] * sizeof(int));
(*result)[level][(*returnColumnSizes)[level] - 1] = root->val;
levelOrderRecurHelper(root->left, level + 1, result, returnSize, returnColumnSizes);
levelOrderRecurHelper(root->right, level + 1, result, returnSize, returnColumnSizes);
}
执行过程(示例树) :
text
ini
levelOrderRecurHelper(1, level=0)
→ 存入 result[0][0] = 1
→ 递归左子树(2, level=1)
→ 存入 result[1][0] = 2
→ 递归左子树(4, level=2)
→ 存入 result[2][0] = 4
→ 递归右子树(5, level=2)
→ 存入 result[2][1] = 5
→ 递归右子树(3, level=1)
→ 存入 result[1][1] = 3
→ 递归右子树(6, level=2)
→ 存入 result[2][2] = 6
结果:[[1], [2, 3], [4, 5, 6]]
注意:递归层序遍历需要在递归过程中动态扩展二维数组,在实现上比前三种复杂一些。在实际面试中,如果要求层序遍历,面试官通常期望看到迭代法(队列实现),因为它更直观。因此层序遍历我会在迭代法中重点展开。
递归法总结:
| 遍历 | 核心动作顺序 | 代码行数 |
|---|---|---|
| 前序 | 访问 → 左 → 右 | 最少 |
| 中序 | 左 → 访问 → 右 | 最少 |
| 后序 | 左 → 右 → 访问 | 最少 |
| 层序 | 按层存储 | 较多(需动态扩维) |
迭代法:用栈和队列模拟递归
递归的本质是函数调用栈 ------每一次递归调用都会在栈上压入一个帧。用显式栈可以模拟递归过程,同时避免递归过深导致的栈溢出风险。
四种遍历在迭代实现中分别使用两种数据结构:前、中、后序用栈 (LIFO),层序用队列(FIFO)。
前序遍历(迭代)
核心逻辑:利用栈的"后进先出"特性。先把根节点入栈,然后循环弹出栈顶节点并访问,访问后将右孩子和左孩子依次入栈。因为栈是后进先出,所以先入右孩子、再入左孩子,弹出时左孩子会先被处理,正好符合"先左后右"的顺序。
c
arduino
int* preorderTraversal(struct TreeNode* root, int* returnSize) {
struct TreeNode** stack = (struct TreeNode**)malloc(100 * sizeof(struct TreeNode*));
int* result = (int*)malloc(100 * sizeof(int));
int re = 0, size = 0;
if (root) stack[size++] = root;
while (size > 0) {
struct TreeNode* q = stack[--size]; // 弹出栈顶节点
result[re++] = q->val; // 访问
if (q->right) stack[size++] = q->right; // 先压右孩子
if (q->left) stack[size++] = q->left; // 再压左孩子
}
*returnSize = re;
return result;
}
执行过程(示例树) :
| 步骤 | 栈内容(从顶到底) | 弹出 | 结果 |
|---|---|---|---|
| 1 | [1] |
1 | [1] |
| 2 | [3, 2] |
2 | [1, 2] |
| 3 | [3, 5, 4] |
4 | [1, 2, 4] |
| 4 | [3, 5] |
5 | [1, 2, 4, 5] |
| 5 | [3] |
3 | [1, 2, 4, 5, 3] |
| 6 | [6] |
6 | [1, 2, 4, 5, 3, 6] |
结果:[1, 2, 4, 5, 3, 6]
为什么先压右再压左:栈是后进先出的结构,如果先压左再压右,右孩子会先被弹出,导致访问顺序变成"根 → 右 → 左",不符合前序遍历的"根 → 左 → 右"。所以必须反过来------先压右、再压左。
中序遍历(迭代)
核心逻辑:中序遍历的迭代需要"一路向左走到底",把路径上的所有节点入栈,然后依次弹出访问,再转向右子树。
c
ini
int* inorderTraversal(struct TreeNode* root, int* returnSize) {
struct TreeNode** stack = (struct TreeNode**)malloc(100 * sizeof(struct TreeNode*));
int* result = (int*)malloc(100 * sizeof(int));
int top = 0, size = 0;
struct TreeNode* q = root;
while (q || top > 0) {
while (q) { // 一路向左走到底
stack[top++] = q;
q = q->left;
}
q = stack[--top]; // 弹出栈顶(最左节点)
result[size++] = q->val; // 访问
q = q->right; // 转向右子树
}
*returnSize = size;
return result;
}
执行过程(示例树) :
text
ini
初始:q = 1, stack = []
第1轮外循环:
内循环:1入栈 → q=2 → 2入栈 → q=4 → 4入栈 → q=NULL
弹出4 → result=[4] → q=5(4的右孩子,实际上为NULL)
第2轮外循环:
内循环:q=NULL,跳过
弹出2 → result=[4,2] → q=5
第3轮外循环:
内循环:5入栈 → q=NULL
弹出5 → result=[4,2,5] → q=NULL(5的右孩子)
第4轮外循环:
弹出1 → result=[4,2,5,1] → q=3
第5轮外循环:
内循环:3入栈 → q=6 → 6入栈 → q=NULL
弹出6 → result=[4,2,5,1,6] → q=NULL
第6轮外循环:
弹出3 → result=[4,2,5,1,6,3] → q=NULL
结果:[4, 2, 5, 1, 3, 6]
关键理解:中序遍历访问节点的时机是"左子树全部处理完、准备处理当前节点"的时候。所以先一路向左把路径存起来,当走到 NULL 时,说明当前节点的左子树已经处理完毕,可以弹出并访问当前节点,然后转向右子树。
后序遍历(迭代)
核心逻辑:使用两个栈。第一个栈用于"反向"遍历------先根、再右、再左,得到的顺序正好是前序遍历的镜像。第二个栈用于反转这个顺序,最终得到正确的后序遍历(左 → 右 → 根)。
c
ini
int* postorderTraversal(struct TreeNode* root, int* returnSize) {
struct TreeNode** stack1 = (struct TreeNode**)malloc(100 * sizeof(struct TreeNode*));
struct TreeNode** stack2 = (struct TreeNode**)malloc(100 * sizeof(struct TreeNode*));
int* result = (int*)malloc(100 * sizeof(int));
int top1 = 0, top2 = 0, size = 0;
if (root) stack1[top1++] = root;
while (top1 > 0) {
struct TreeNode* q = stack1[--top1]; // 从 stack1 弹出
stack2[top2++] = q; // 压入 stack2
if (q->left) stack1[top1++] = q->left; // 先压左(和前序相反)
if (q->right) stack1[top1++] = q->right; // 再压右
}
while (top2 > 0)
result[size++] = stack2[--top2]->val; // 从 stack2 弹出即为后序
*returnSize = size;
return result;
}
执行过程(示例树) :
第一步:stack1 遍历,stack2 收集
| 步骤 | stack1 弹出 | stack2 入栈 | stack1 入栈(先左后右) |
|---|---|---|---|
| 1 | 1 | stack2=1 | 入左2、右3 → stack1=3,2 |
| 2 | 2 | stack2=1,2 | 入左4、右5 → stack1=3,5,4 |
| 3 | 4 | stack2=1,2,4 | 无 → stack1=3,5 |
| 4 | 5 | stack2=1,2,4,5 | 无 → stack1=3 |
| 5 | 3 | stack2=1,2,4,5,3 | 入右6 → stack1=6 |
| 6 | 6 | stack2=1,2,4,5,3,6 | 无 → stack1=\[\] |
第二步:从 stack2 依次弹出
text
ini
stack2: [1, 2, 4, 5, 3, 6](从栈顶到栈底)
弹出顺序:6 → 3 → 5 → 4 → 2 → 1
结果:[4, 5, 2, 6, 3, 1]
结果:[4, 5, 2, 6, 3, 1]
为什么这样能工作:stack1 的遍历顺序是"根 → 左 → 右"(先压左再压右,所以右先出)。stack2 的入栈顺序是 1、2、4、5、3、6,从栈顶弹出时正好反转,得到"左 → 右 → 根"的后序顺序。
层序遍历(迭代)
核心逻辑:使用队列(FIFO),而不是栈。每次从队头取出一个节点访问,将其左右孩子依次放入队尾。队列的"先进先出"特性天然保证按层逐级处理。
c
ini
int** levelOrder(struct TreeNode* root, int* returnSize, int** returnColumnSizes) {
struct TreeNode** queue = (struct TreeNode**)malloc(2000 * sizeof(struct TreeNode*));
int** result = (int**)malloc(2000 * sizeof(int*));
*returnColumnSizes = (int*)malloc(2000 * sizeof(int));
int f = 0, t = 0, level = 0, times = 0;
if (root) {
queue[t++] = root;
times++;
}
while (f < t) {
int num = times; // 当前层的节点数
times = 0; // 重置,用于计数下一层
(*returnColumnSizes)[level] = num;
result[level] = (int*)malloc(num * sizeof(int));
struct TreeNode* q;
for (int i = 0; i < num; i++) {
q = queue[f++]; // 出队
result[level][i] = q->val;
if (q->left) {
queue[t++] = q->left;
times++;
}
if (q->right) {
queue[t++] = q->right;
times++;
}
}
level++;
}
*returnSize = level;
return result;
}
times 变量的关键作用:
times 记录的是当前层 的节点数量。在处理完当前层后,times 会被更新为下一层的节点数,用于控制下一轮的循环次数。这保证每一层的数据被正确地划分到各自的数组中。
执行过程(示例树) :
text
ini
初始:队列 [1], times=1, level=0
第1轮(level=0):num=1, times=0
出队 1 → result[0][0]=1
入队 2、3 → queue=[2,3], times=2
第2轮(level=1):num=2, times=0
出队 2 → result[1][0]=2
入队 4、5 → queue=[3,4,5], times=2
出队 3 → result[1][1]=3
入队 6 → queue=[4,5,6], times=3
第3轮(level=2):num=3, times=0
出队 4 → result[2][0]=4
出队 5 → result[2][1]=5
出队 6 → result[2][2]=6
结果:[[1], [2, 3], [4, 5, 6]]
迭代法总结:
| 遍历 | 数据结构 | 核心操作 | 关键点 |
|---|---|---|---|
| 前序 | 栈 | 弹出即访问,先压右再压左 | 入栈顺序决定访问顺序 |
| 中序 | 栈 | 一路向左入栈,弹出访问后转右 | 左子树入栈时机 |
| 后序 | 双栈 | 第一个栈遍历,第二个栈反转 | 两个栈接力完成反转 |
| 层序 | 队列 | 出队访问,入队孩子 | times 控制分层 |
递归与迭代的对比总结
| 维度 | 递归法 | 迭代法 |
|---|---|---|
| 代码量 | 少,简洁优雅 | 多,需要手动维护栈/队列 |
| 可读性 | 高,直接对应数学定义 | 中,需要理解栈/队列的运作 |
| 空间复杂度 | O(h),h 为树高(调用栈) | O(h) 或 O(n)(显式栈/队列) |
| 风险 | 树过高时可能栈溢出 | 无栈溢出风险 |
| 适用场景 | 理解遍历逻辑、树不太深 | 生产环境、需要严格控制的场景 |
四种遍历的规律速记:
| 遍历 | 口诀 | 迭代核心 |
|---|---|---|
| 前序 | 根左右 | 先压右再压左 |
| 中序 | 左根右 | 一路向左入栈 |
| 后序 | 左右根 | 双栈反转 |
| 层序 | 从上到下,从左到右 | 队列 + times 分层 |
互动讨论
- 前序遍历中,为什么先压右孩子再压左孩子? 如果反过来会怎样?
- 中序遍历的迭代实现中,
while (q || top > 0)这个条件为什么是||而不是&&? - 后序遍历能否只用单栈实现? 如果可以,思路是什么?
- 层序遍历中
times变量的作用是什么? 如果不用times,还有什么方法可以分层? - 递归遍历和迭代遍历,你更倾向在面试中使用哪一种? 为什么?
📌 一点心得:递归是理解树遍历最自然的入口,迭代是生产环境更可靠的选择。把两者都吃透了,才算真正掌握了树的遍历。