继续更新拷打面试官算法系列:树!

***新加的:2025.8.15号发现的新题目,25年例行增加的面试101热题,自己刷题发现:
(1)之字打印二叉树遍历结果
(2)和为某一值的路径(一)
(3)对称二叉树
拷打面试官系列:TreeNode树结构算法,从“入门”到“走火入魔” 硬核教程!
总纲: 恭喜你,点开了这篇足以改变你算法认知,甚至职业生涯的文章。如果你还在纠结于前序、中序、后序遍历该怎么写,还在苦恼于递归调用看不懂,那么你来对地方了。本篇,我将带你彻底打穿树结构算法的第一关:遍历。我们不止会写出漂亮、正确的代码,更会深入到CPU层面,解密递归的本质,用“人话”告诉你为什么它能实现复杂功能。
第一章:站在巨人的肩膀上——递归三兄弟的优雅舞步
总: 树结构算法的入门,就是从“三大遍历”开始。它们不仅仅是算法,更是一种思想:分而治之。递归是实现这种思想最优雅的方式,但很多人把它当成一个“黑盒”。今天,我们就要掀开这个“黑盒”的盖子,让你看到里面到底藏着什么。
1.1 前序遍历:老大哥的霸气登场
思路分析: 前序遍历的精髓,在于它的“根-左-右”访问顺序。它就像一个脾气急躁的领导,一到场就先宣示主权(访问根节点),然后才开始处理左边的事情,最后再管右边。 递归实现,就是把这个思想复制粘贴到每一个子树上:
-
首先,判断当前节点
root是否为空。如果为空,说明到达了叶子节点的外部,直接返回,这是递归的终止条件。 -
如果不为空,就执行第一步:访问根节点。也就是把
root->val存入结果数组。 -
然后,执行第二步:递归处理左子树。把
root->left作为新的“根”节点,调用自己。 -
最后,执行第三步:递归处理右子树。把
root->right作为新的“根”节点,调用自己。
这种方式,把一个大问题(遍历整棵树),分解成了一个小问题(遍历一个子树),直到问题小到可以被直接解决(子树为空)。
代码实现: 我们来看你提供的代码,我帮你优化和补上了注释,并修正了一个常见的小bug。
#include <stdio.h>
#include <stdlib.h>
// TreeNode结构体定义,用于构建二叉树
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// 辅助递归函数,用于执行前序遍历
// @param root: 当前遍历的节点
// @param res: 存放遍历结果的数组指针
// @param returnSize: 结果数组的当前大小(指针传递,便于在递归中修改)
void doFuncPreorder(struct TreeNode *root, int *res, int *returnSize) {
// 递归终止条件:如果当前节点为空,直接返回,结束该分支的遍历
if (root == NULL) {
return;
}
// 1. 访问根节点:将当前节点的值存入结果数组
// 注意:(*returnSize)++ 是一个原子操作,先解引用获取 returnSize 的值,作为数组索引,
// 然后再对 returnSize 指向的内存进行自增,保证下一个元素能放在正确的位置。
// 你原来写的 (*returnSize++) 是有问题的,因为++的优先级更高,会先让指针移动。
res[(*returnSize)++] = root->val;
// 2. 递归遍历左子树
doFuncPreorder(root->left, res, returnSize);
// 3. 递归遍历右子树
doFuncPreorder(root->right, res, returnSize);
}
// 主函数:前序遍历的入口
// @param root: 二叉树的根节点
// @param returnSize: 返回数组的行数
// @return: 存储前序遍历结果的整型一维数组
int *preorderTraversal(struct TreeNode *root, int *returnSize) {
// 预估最大节点数,分配足够大的内存。
// 在实际面试中,需要考虑树的规模,动态调整。
// 如果不确定,可以先用一个较小值,如果超过,再重新分配,但这会增加复杂度。
// 稳妥起见,我们先分配一个足够大的空间。
int *res = (int *)malloc(1000000 * sizeof(int));
if (res == NULL) {
// 内存分配失败处理
*returnSize = 0;
return NULL;
}
// 初始化结果数组的大小为0
*returnSize = 0;
// 调用辅助函数开始遍历
doFuncPreorder(root, res, returnSize);
return res;
}
1.2 中序遍历:温柔的绅士风范
思路分析: 中序遍历的访问顺序是“左-根-右”。它像一个彬彬有礼的绅士,先去拜访左边邻居(左子树),回来之后再处理自己的事情(访问根节点),最后再去拜访右边邻居。这种遍历方式有一个非常特殊的性质:**对于二叉搜索树(BST)来说,中序遍历的结果是升序排列的!**这是面试中一个非常重要的考点,你必须烂熟于心。
代码实现: 有了前序的基础,中序的代码就是小菜一碟,只需要调整一下调用顺序就行了。
#include <stdio.h>
#include <stdlib.h>
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// 辅助递归函数:中序遍历
void doFuncInorder(struct TreeNode *root, int *res, int *returnSize) {
// 递归终止条件
if (root == NULL) {
return;
}
// 1. 递归遍历左子树
doFuncInorder(root->left, res, returnSize);
// 2. 访问根节点
res[(*returnSize)++] = root->val;
// 3. 递归遍历右子树
doFuncInorder(root->right, res, returnSize);
}
// 主函数:中序遍历的入口
int *inorderTraversal(struct TreeNode *root, int *returnSize) {
int *res = (int *)malloc(1000000 * sizeof(int));
if (res == NULL) {
*returnSize = 0;
return NULL;
}
*returnSize = 0;
doFuncInorder(root, res, returnSize);
return res;
}
1.3 后序遍历:乖孩子的最后告别
思路分析: 后序遍历的访问顺序是“左-右-根”。它像一个听话的孩子,先完成左边(左子树)和右边(右子树)的任务,最后才回到自己(根节点)这里交差。这种遍历在释放树节点、计算子树高度等场景中非常有用,因为它保证了在处理父节点时,它的所有子节点都已经被处理完毕。
代码实现: 同样的,我们只需要调整一下调用顺序。
#include <stdio.h>
#include <stdlib.h>
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// 辅助递归函数:后序遍历
void doFuncPostorder(struct TreeNode *root, int *res, int *returnSize) {
// 递归终止条件
if (root == NULL) {
return;
}
// 1. 递归遍历左子树
doFuncPostorder(root->left, res, returnSize);
// 2. 递归遍历右子树
doFuncPostorder(root->right, res, returnSize);
// 3. 访问根节点
res[(*returnSize)++] = root->val;
}
// 主函数:后序遍历的入口
int *postorderTraversal(struct TreeNode *root, int *returnSize) {
int *res = (int *)malloc(1000000 * sizeof(int));
if (res == NULL) {
*returnSize = 0;
return NULL;
}
*returnSize = 0;
doFuncPostorder(root, res, returnSize);
return res;
}
表格1-1:递归三兄弟的异同点
| 遍历方式 |
访问顺序 |
核心思想 |
适用场景 |
|---|---|---|---|
| 前序遍历 |
根 -> 左 -> 右 |
快速定位根节点,分治处理子树 |
复制整棵树,创建树的表达式 |
| 中序遍历 |
左 -> 根 -> 右 |
访问根节点被“夹”在左右子树之间 |
(最重要) 二叉搜索树的升序排列,将树拍平 |
| 后序遍历 |
左 -> 右 -> 根 |
依赖子树的结果,再处理父节点 |
释放树节点内存,计算子树高度、深度 |
第二章:告别“黑盒”——递归的本质与栈的秘密
总: 如果你只是会写上面的代码,那你还是个合格的面试者。但如果你想成为30k+的专家,你就必须理解:**递归不是魔法,它只是一个优雅的语法糖。在底层,编译器和操作系统是通过函数调用栈(Call Stack)**来模拟这个过程的。理解栈,就是理解递归的本质。
2.1 栈帧(Stack Frame)的创建与销毁
每一次函数调用,操作系统都会在内存的栈区为这个函数分配一个独立的栈帧。这个栈帧就像一个临时工作区,存放着:
-
函数的局部变量。
-
函数参数。
-
调用该函数的返回地址(告诉CPU函数执行完后该回到哪里)。
当我们调用doFuncPreorder(root->left)时,一个新的栈帧被创建,并压入栈顶。当这个函数执行完毕后,它的栈帧会被弹出,局部变量被销毁,CPU跳回到保存的返回地址继续执行。递归,就是把这个“压栈-出栈”的过程,自动、重复地执行。
2.2 暴力手撕:用C语言模拟递归栈
为了让你彻底明白这个原理,我们不依赖编译器,自己用一个显式栈(一个数组)来模拟递归过程,从而实现**迭代(非递归)**版的前序遍历。
思路分析:
-
我们创建一个数组来作为栈,并把根节点压入栈中。
-
进入一个循环,只要栈不为空,就一直执行。
-
在循环中,我们弹出栈顶元素,访问它(存入结果数组)。
-
然后,我们遵循“根-左-右”的原则,但由于栈是后进先出(LIFO)的,所以我们先将右子节点压入栈,再将左子节点压入栈。这样,左子节点就会在下一次循环中先被弹出并访问到。
-
循环直到栈为空,遍历结束。
代码实现: 这才是真正的“硬核”时刻,面试官最爱考这种手撸栈的解法。
#include <stdio.h>
#include <stdlib.h>
// 宏定义栈的大小,需要足够大以容纳所有节点
#define STACK_SIZE 1000
// TreeNode结构体定义,同上
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
};
// 栈结构体
typedef struct {
struct TreeNode* items[STACK_SIZE]; // 栈的存储数组
int top; // 栈顶指针
} Stack;
// 初始化栈
void initStack(Stack* s) {
s->top = -1;
}
// 检查栈是否为空
int isStackEmpty(Stack* s) {
return s->top == -1;
}
// 压栈
void push(Stack* s, struct TreeNode* item) {
if (s->top >= STACK_SIZE - 1) {
// 栈溢出处理,在实际项目中需要动态扩容
return;
}
s->items[++s->top] = item;
}
// 弹栈
struct TreeNode* pop(Stack* s) {
if (isStackEmpty(s)) {
return NULL;
}
return s->items[s->top--];
}
// 迭代(非递归)前序遍历
// @param root: 二叉树的根节点
// @param returnSize: 返回数组的行数
// @return: 存储前序遍历结果的整型一维数组
int *preorderTraversalIterative(struct TreeNode *root, int *returnSize) {
// 预估最大节点数,分配结果数组
int *res = (int *)malloc(1000000 * sizeof(int));
if (res == NULL) {
*returnSize = 0;
return NULL;
}
*returnSize = 0;
// 1. 如果树为空,直接返回
if (root == NULL) {
return res;
}
// 2. 初始化栈
Stack s;
initStack(&s);
// 3. 将根节点压入栈
push(&s, root);
// 4. 进入循环,直到栈为空
while (!isStackEmpty(&s)) {
// 5. 弹栈,获取当前节点
struct TreeNode* current = pop(&s);
// 6. 访问节点(根)
res[(*returnSize)++] = current->val;
// 7. 先压入右子节点,再压入左子节点
// 因为栈是 LIFO (后进先出),我们希望先处理左子树
if (current->right != NULL) {
push(&s, current->right);
}
if (current->left != NULL) {
push(&s, current->left);
}
}
return res;
}
对比与升华: | 特性 | 递归版 | 迭代版(显式栈) | | :--- | :--- | :--- | | 代码风格 | 简洁、优雅,符合直觉 | 复杂,需要手动管理栈 | | 内存管理 | 依赖系统栈,有栈溢出风险 | 动态分配内存,风险可控 | | 性能 | 隐藏了函数调用和返回的开销 | 避免了函数调用开销,通常更快 | | 本质 | 隐式使用系统栈 | 显式模拟系统栈 | | 面试意义 | 基础题,考察思维 | 进阶题,考察底层原理 |
结语:超越“代码”本身
老铁,通过这一篇,我们已经不仅仅是“写”了三个遍历算法,我们更重要的是理解了递归和栈的本质联系。当你下次再写递归代码时,脑子里一定要浮现出栈帧压入和弹出的动态过程。这是从“会写代码”到“理解代码”的质变,也是你拿下30k+ offer的底层内功。
在下一篇,我们将继续深入。你提供的levelOrder层序遍历,以及之字形和右视图,这些都和队列这个数据结构息息相关。我们将彻底搞清楚如何用队列来做树的广度优先搜索,并用硬核的代码把它们一一实现。
期待下次,我们继续!
第三章:从栈到队列——广度优先搜索的硬核基石
总: 如果说递归遍历(DFS)是纵向地“一头扎到底”,那么层序遍历(BFS)就是横向地“逐层推进”。这种思维模式的转变,需要我们更换一个底层工具:从栈(Stack)切换到队列(Queue)。队列的**先进先出(FIFO)**特性,完美契合了我们“先处理第一层,再处理第二层”的逻辑。
3.1 队列(Queue)的抽象与实现
在C语言中,我们没有内置的队列,这给了我们一个绝佳的机会,亲手实现一个。一个队列的核心操作有三个:
-
入队(Enqueue):在队列末尾添加一个元素。
-
出队(Dequeue):从队列头部移除并返回一个元素。
-
判空(isEmpty):检查队列是否为空。
为了简单高效,我们用一个循环数组来模拟队列。这比你的代码中直接使用一个固定大小的数组更优,因为它能循环利用空间,避免频繁的内存分配和移动。
图3-1:循环队列原理图
graph TD
A[队尾指针: rear] --> B[队头指针: front]
B --> C(出队)
A --> D(入队)
subgraph Q

最低0.47元/天 解锁文章
674

被折叠的 条评论
为什么被折叠?



