先聊个现象:你在牛客、知乎、掘金上搜“力扣”,翻来覆去就是那几道题被反复推荐,翻转二叉树(Invert Binary Tree)稳稳占着一个坑位。这题看着简单到不像话——不就是把每个节点的左右子树换一下吗?
但恰恰是这道“简单题”,背后藏着一整套二叉树递归、遍历、迭代的底层思维,而且它还有个非常出圈的故事:Homebrew 作者 Max Howell 当年在 Google 面试时就是倒在这道题上,被刷掉之后发了条推文吐槽“Google 90% 的工程师都用我写的软件,但我不懂翻转二叉树”。这事情当年在开发者圈子里掀起过不小的讨论。
所以你今天点进这篇,不管是为了准备大厂面试、刷力扣热题 100,还是纯粹想把二叉树吃透,这道题都值得掰开揉碎了玩一遍。我会从题目本身的逻辑讲起,把递归、迭代、层序、前序中序后序这些写法全部过一遍,再聊聊我在实际面试和刷题过程中的一些体会,最后给一套二叉树类题目的通用打法。
1. 内容整体设计与思路拆解
1.1 先看懂题目在问什么
力扣原题编号 226,题目描述非常短:给你一棵二叉树的根节点 root,翻转这棵二叉树,并返回其根节点。
“翻转”是什么意思?看个例子就明白了。输入一棵树:
code复制 4
/ \
2 7
/ \ / \
1 3 6 9
翻转之后变成:
code复制 4
/ \
7 2
/ \ / \
9 6 3 1
本质上就是:对于二叉树中的每一个节点,把它的左子树和右子树交换位置。注意,不是简单地把节点的值交换,而是把整棵子树都换过去,子树的内部结构也要跟着一起翻转。
这个操作在图像处理里也有对应——镜面翻转。你可以把二叉树想象成一张对称的树形结构,翻转就是沿着根节点画一条垂直中线,把左右两边对折过去。理解了这一点,题目就已经解了一半。
1.2 为什么这道题被奉为经典
先说一个很多人容易忽略的点:翻转二叉树的代码量非常少,主流的递归写法核心逻辑只有三行。但也正因为代码少,很多初学者看一眼题解就觉得自己会了,结果面试手写的时候,不是忘了终止条件,就是把交换逻辑放在错误的位置。
这道题之所以经典,有几个原因:
第一,它是二叉树递归思维的“最小单元”。二叉树的绝大多数题目——求深度、判断对称、路径求和、最近公共祖先——都建立在“能递归处理左右子树”这个基本动作上。翻转二叉树就是把这个动作拆到最干净:对每个节点做一件事,然后交给递归去处理子节点。
第二,它考察的是“递归三部曲”的熟练度。很多同学刷二叉树题感觉很飘,看到题目能想出思路,但一写代码就卡壳。原因就是没有把递归的固定套路吃透:确定递归函数的参数和返回值、确定终止条件、确定单层递归的逻辑。翻转二叉树恰好是练习这三部曲的最佳载体。
第三,它有着极高的面试出场率。不管是校招、社招,还是各个大厂的中高级岗位面试,算法题环节经常会出现这道题,甚至会被作为“热身题”或“加试题”。你别看它简单,真要不假思索地写出来,而且能解释清楚每一种写法的时间和空间复杂度,面试官对你的基础功印象会非常加分。
1.3 刷题前的必要知识储备
在正式上手之前,先确保你对二叉树的基础概念不陌生。
二叉树的节点定义在力扣的 JavaScript(或者你用的其他语言)环境里通常是这样的:
javascript复制function TreeNode(val, left, right) {
this.val = (val === undefined ? 0 : val);
this.left = (left === undefined ? null : left);
this.right = (right === undefined ? null : right);
}
这其实就是个典型的链表节点变体:一个数据域 val,两个指针域 left 和 right,分别指向左子节点和右子节点。为空的位置用 null 表示。
你需要具备的基础知识包括:
- 二叉树的深度优先遍历(DFS):前序遍历(根左右)、中序遍历(左根右)、后序遍历(左右根)
- 二叉树的广度优先遍历(BFS):层序遍历,一层一层从上往下扫
- 递归的基本原理:函数调用栈、递推过程、回溯过程
如果你上面这些概念还不太熟,也没关系,接下来的内容会带着你边做边把这些点全部串起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 递归解法:三行代码背后的运行逻辑
递归解法是翻转二叉树最直接的实现方式,同时也是理解这道题的最佳切入点。
思路非常清晰:
- 如果当前节点是空节点(null),直接返回 null
- 递归地翻转当前节点的左子树
- 递归地翻转当前节点的右子树
- 交换当前节点的左右子树
用 JavaScript 写出来就是这样:
javascript复制var invertTree = function(root) {
// 终止条件:节点为空,没什么可翻的,直接返回
if (root === null) {
return null;
}
// 递归处理左右子树
const left = invertTree(root.left);
const right = invertTree(root.right);
// 交换当前节点的左右子树
root.left = right;
root.right = left;
return root;
};
代码就这么点,但很多人写的时候会出各种问题,比如交换顺序放在了递归调用之前,或者没有把递归结果保存下来就直接赋值。下面我用一棵三层的树完整走一遍递归过程,帮你看清楚每一帧栈里发生了什么。
还是拿这个例子:
code复制 4
/ \
2 7
/ \ / \
1 3 6 9
调用 invertTree(root),root 是值为 4 的节点。
第一层递归(处理节点 4):先保存节点 4 的左子树翻转结果。于是递归调用 invertTree(节点 2)。
第二层递归(处理节点 2):同样先保存节点 2 的左子树翻转结果。递归调用 invertTree(节点 1)。
第三层递归(处理节点 1):节点 1 的左孩子为 null,右孩子为 null。先递归 invertTree(null) 得到 null,再递归 invertTree(null) 得到 null。此时交换节点 1 的左右子树,两个都是 null,交换前后没有变化。返回节点 1。
回到第二层(处理节点 2):此时 left 变量拿到的是翻转后的节点 1(其实是它本身),接着递归 invertTree(节点 3)。节点 3 的处理和节点 1 完全一样,返回节点 3。然后做交换:节点 2 原本左孩子是节点 1,右孩子是节点 3,交换后变为左孩子节点 3,右孩子节点 1。返回节点 2。
回到第一层(处理节点 4):此时 left 拿到的是翻转后的根为节点 2 的子树,也就是:
code复制 2
/ \
3 1
接着递归 invertTree(节点 7),它会做同样的操作,把节点 7 的左右孩子从 (6, 9) 变成 (9, 6),返回根为节点 7 的子树:
code复制 7
/ \
9 6
最后在节点 4 上交换:原本左子树根是节点 2,右子树根是节点 7,交换后变成左子树根为节点 7,右子树根为节点 2。
整棵树就完成了翻转。
有个细节值得注意:上面的写法是先把左右子树递归调用的结果保存到 left 和 right 变量里,最后再统一交换。这样写最不容易出错,因为你在交换之前就已经把两边的翻转结果都算出来了。
还有一种常被提到的写法是:
javascript复制var invertTree = function(root) {
if (root === null) return null;
const temp = root.left;
root.left = invertTree(root.right);
root.right = invertTree(temp);
return root;
};
这种写法本质上也是后序遍历逻辑:先把右子树翻转好赋给左孩子,再把原来左子树(用 temp 暂存)翻转后赋给右孩子。两种写法都对,但第一种的可读性和逻辑清晰度更高,面试推荐优先写第一种。
2.2 递归的时间复杂度与空间复杂度分析
很多面经里都会追问复杂度,这个必须能答得上来。
时间复杂度是 O(n),n 是二叉树节点的个数。因为每个节点恰好被访问一次,做常数次操作(递归调用、交换指针),所以总时间与节点数线性相关。
空间复杂度是 O(h),h 是二叉树的高度。递归调用栈的深度由树的高度决定。最坏情况下,二叉树退化为一条链(比如每个节点都只有左孩子),高度为 n,那么空间复杂度退化为 O(n),这时候递归写法有栈溢出风险。最好情况下(平衡二叉树),高度为 log n,空间复杂度是 O(log n)。
这里要记住一个重要结论:递归的空间复杂度不是盲目按 O(n) 算的,它取决于递归深度,即树高。你可以在面试时说清楚这一点,面试官会认为你真正理解递归的本质。
2.3 迭代解法:不靠递归也能翻转树
如果面试官追加一句“递归太简单了,你用迭代实现一下”,你要是没准备就容易卡住。迭代解法的本质是:用显式的栈或队列来模拟递归函数调用栈,手动控制遍历顺序。
一种思路是使用前序遍历的迭代版本:先处理当前节点(交换左右孩子),再把非空的孩子节点压入栈中,循环直到栈为空。
javascript复制var invertTree = function(root) {
if (root === null) return null;
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
// 交换当前节点的左右子树
const temp = node.left;
node.left = node.right;
node.right = temp;
// 注意这里压栈的顺序:先压左再压右,或者先压右再压左都行
// 因为不管遍历顺序如何,我们都会在出栈时交换每个节点
if (node.left !== null) {
stack.push(node.left);
}
if (node.right !== null) {
stack.push(node.right);
}
}
return root;
};
用一个具体的例子模拟一下:初始栈里只有根节点 4。弹出节点 4,交换左右孩子,此时节点 4 的左孩子变为 7,右孩子变为 2。接着把节点 7 和节点 2 都压入栈。注意压入的顺序不影响最终结果,因为后面弹出时都会执行交换操作。假设先压左孩子 7,再压右孩子 2,那么栈顶是 2。弹出节点 2,交换它的左右孩子,子树内部完成翻转。然后继续处理节点 7。最终整棵树翻转完成。
这个写法的时间复杂度同样是 O(n),空间复杂度是 O(h),栈中最多同时存放树的一层节点加当前路径上的节点,最坏情况下仍是 O(n)(链式结构),最好情况下是 O(log n)(平衡树)。不过迭代写法的实际空间占用比递归略多一点点,因为栈对象本身有额外的开销。
2.4 层序解法:BFS 也能轻松搞定翻转
还有一种写法是用队列做层序遍历(BFS),一层一层地处理。这个思路特别直观:对于当前层的每个节点,交换它的左右孩子,然后把非空的孩子节点加入队列,继续处理下一层。
javascript复制var invertTree = function(root) {
if (root === null) return null;
const queue = [root];
while (queue.length > 0) {
const size = queue.length;
for (let i = 0; i < size; i++) {
const node = queue.shift();
// 交换左右孩子
const temp = node.left;
node.left = node.right;
node.right = temp;
// 把下一层的节点加入队列
if (node.left !== null) {
queue.push(node.left);
}
if (node.right !== null) {
queue.push(node.right);
}
}
}
return root;
};
这个写法和前序迭代的区别在于处理顺序:BFS 是按层级从上到下逐层交换,而 DFS 迭代是沿着一条路径走到最深处再回溯交换。但两者的结果完全一致,因为翻转操作的顺序并不影响最终结果——每个节点的左右子树最终都被交换了,不管先换谁后换谁。
有些同学会担心:如果先交换了根节点的左右孩子,那么后续遍历的时候会不会把已经交换过的子树再翻回去?不会。因为每个节点在入队/入栈之前,它的左右子树的内部结构可能已经被交换过,但当我们处理到这个节点时,交换的是它的两个孩子的位置。第二次访问同一个节点时才会出现“翻回去”的问题,而我们的遍历保证每个节点只被处理一次(入队/入栈后移除),所以不会有问题。
这里有个细节需要特别注意:使用数组的 shift() 方法在 JavaScript 里时间复杂度是 O(n),因为数组移除首元素后所有元素要前移一位。所以上面的层序解法严格来说最坏情况下的时间复杂度不是真正的 O(n)(如果算上 shift 的移动开销,整体会变成 O(n²))。更好的做法是用一个索引指针模拟队列,或者直接用链表/真正的队列来实现。在力扣的测试用例长度下,shift 的性能影响很小,但面试时如果能主动提这一点,会显得你的代码素养非常扎实。
优化后的层序写法:
javascript复制var invertTree = function(root) {
if (root === null) return null;
const queue = [root];
let head = 0;
while (head < queue.length) {
const size = queue.length;
for (let i = head; i < size; i++) {
const node = queue[head++];
const temp = node.left;
node.left = node.right;
node.right = temp;
if (node.left !== null) {
queue.push(node.left);
}
if (node.right !== null) {
queue.push(node.right);
}
}
}
return root;
};
用 head 指针模拟出队,彻底避免了 shift 的性能问题。这也是刷题时很多高手会采用的技巧。
3. 实操过程与核心环节实现
3.1 从零开始:在力扣上提交第一版递归解法
直接打开力扣第 226 题,语言选择 JavaScript,编辑器里默认就有 TreeNode 的定义。把下面的代码粘贴进去,点“执行”再点“提交”。
javascript复制/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val === undefined ? 0 : val);
* this.left = (left === undefined ? null : left);
* this.right = (right === undefined ? null : right);
* }
*/
/**
* @param {TreeNode} root
* @return {TreeNode}
*/
var invertTree = function(root) {
if (root === null) return null;
const left = invertTree(root.left);
const right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
};
提交之后你大概率会直接通过。这时候别急着切题,花几分钟把这道题吃透,比快速刷十道简单题有价值得多。
新手最容易犯的一个错误是:把交换逻辑写成这样——
javascript复制// 错误示范
var invertTree = function(root) {
if (root === null) return null;
root.left = invertTree(root.right);
root.right = invertTree(root.left);
return root;
};
这个写法的问题是:当你执行 root.left = invertTree(root.right) 之后,root.left 已经被覆盖成了翻转后的原右子树。此时再执行 root.right = invertTree(root.left),这里的 root.left 已经不是原来的左子树了,而是刚赋值过来的翻转右子树。结果是整棵树被错误地复制了一份,原来的左子树彻底丢了。
这个坑特别隐蔽,即使是有经验的开发者,稍不留神也会踩进去。我的建议是:在递归处理二叉树的左右子树时,如果需要同时用到左右子树的原值,先用临时变量存下来,或者像我推荐的第一种写法那样,先递归完两边再把结果存到变量里,最后统一赋值。
3.2 本地环境调试:造一棵树跑通全流程
力扣上做题可以直接看题目给的测试用例,但如果你想深入理解翻转过程,本地跑一遍会更直观。下面给一份可以在 Node.js 环境直接运行的完整示例代码。
javascript复制// 定义二叉树节点
function TreeNode(val, left, right) {
this.val = (val === undefined ? 0 : val);
this.left = (left === undefined ? null : left);
this.right = (right === undefined ? null : right);
}
// 翻转二叉树(递归解法)
function invertTree(root) {
if (root === null) return null;
const left = invertTree(root.left);
const right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
}
// 层序遍历打印二叉树(方便查看结果)
function levelOrderPrint(root) {
if (root === null) return [];
const result = [];
const queue = [root];
while (queue.length > 0) {
const size = queue.length;
const level = [];
for (let i = 0; i < size; i++) {
const node = queue.shift();
level.push(node === null ? null : node.val);
if (node !== null) {
queue.push(node.left);
queue.push(node.right);
}
}
result.push(level);
}
return result;
}
// 构建示例树:4, 2, 7, 1, 3, 6, 9
const root = new TreeNode(4);
root.left = new TreeNode(2);
root.right = new TreeNode(7);
root.left.left = new TreeNode(1);
root.left.right = new TreeNode(3);
root.right.left = new TreeNode(6);
root.right.right = new TreeNode(9);
console.log('翻转前:');
console.log(JSON.stringify(levelOrderPrint(root)));
const inverted = invertTree(root);
console.log('翻转后:');
console.log(JSON.stringify(levelOrderPrint(inverted)));
运行这段代码,输出应该是:
code复制翻转前:
[[4],[2,7],[1,3,6,9]]
翻转后:
[[4],[7,2],[9,6,3,1]]
打印的每一层都是从左到右排列的节点值,可以看到第二层和第三层的顺序完全反过来了。这就是翻转的直观效果。
3.3 多种解法对比:哪种最适合面试手写
把上面对比汇总成一张表格,方便你根据自己的情况选择:
| 解法 | 核心思路 | 时间复杂度 | 空间复杂度 | 代码量 | 面试推荐度 |
|---|---|---|---|---|---|
| 递归(后序) | 先翻转子树再交换 | O(n) | O(h) | 最少 | 非常推荐 |
| 递归(前序) | 先交换再翻转子树 | O(n) | O(h) | 较少 | 推荐 |
| 迭代(前序栈) | 用栈模拟深度遍历 | O(n) | O(h) | 中等 | 推荐 |
| 迭代(层序队列) | 用队列逐层处理 | O(n) | O(w),w为树最大宽度 | 中等 | 视情况 |
我的建议是:优先掌握第一种递归写法,这是理解这道题最快的方式,也是面试时最稳的方案。然后掌握栈迭代的写法,因为很多二叉树题目(前序、中序、后序遍历)都会用到栈模拟,这是你的“第二武器”。队列层序的写法可以作为扩展了解,它在处理二叉树右视图、层序遍历等题目时非常有用。
关于这四种写法,还有一个共性值得注意:它们都是在“访问到每个节点时交换左右孩子”,区别只是访问顺序不同。用一句话概括就是——翻转二叉树 = 遍历整棵树 + 在遍历过程中交换每个节点的左右孩子。只要你能遍历一棵二叉树(不管用递归还是迭代、深度优先还是广度优先),你就能翻转它。
3.4 完整跑通:手把手带你做一次代码走查
写完代码之后,一定要有一个自查的流程。我一般会做三件事:对着空树验证边界、对着简单树手动模拟、对着题目给的例子跑一遍。
先看空树:invertTree(null) 直接返回 null。这个分支一定要有,否则访问 null.left 会直接报错。有些同学觉得这行代码多余,其实在面试里,这个边界处理恰恰是考察点之一。
再看只有一个根节点的树:invertTree(节点1),左右孩子都是 null,递归两边返回 null,交换后还是它本身。没毛病。
最后是题目给的例子:前面已经模拟过了,结构正确。
代码走查还有一个更高级的技巧:对于递归代码,可以在递归函数入口打印日志,观察每个节点的处理顺序。比如:
javascript复制var invertTree = function(root) {
if (root === null) return null;
console.log('正在处理节点:', root.val);
const left = invertTree(root.left);
const right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
};
跑一次你会发现,处理顺序是 4 → 2 → 1 → 3 → 7 → 6 → 9。这就是后序遍历的顺序:先处理左子树,再处理右子树,最后处理当前节点。这个观察对你理解递归帮助非常大。
4. 常见问题与排查技巧实录
4.1 典型报错与逻辑Bug排查对照表
在我带过的学员和朋友交流中,翻转二叉树最常见的报错和 Bug 集中在下面几种情况:
| 问题现象 | 原因分析 | 解决方案 |
|---|---|---|
| TypeError: Cannot read properties of null | 递归终止条件缺失或写错 | 检查 if (root === null) return null 是否在函数最前面 |
| 翻转后结果和预期不一致,左子树丢失 | 交换顺序错误,覆盖了原始左子树 | 用临时变量保存左子树,或者先递归再统一交换 |
| 内存溢出/栈溢出 | 递归深度过大,树退化为链表 | 改用迭代写法(栈/队列) |
| 翻转后只有根节点变了,子树内部没变 | 只在根节点交换了左右孩子,没有递归处理子树 | 确认递归调用覆盖了每个节点的左右子树 |
| 力扣编译报错,函数签名不对 | 语言语法或函数名不匹配 | 仔细阅读题目给的函数模板,不要改动函数名 |
前三种问题是出现频率最高的。特别是第二种,遇到“左子树丢失”的情况,我建议你第一时间检查是不是把递归调用和赋值写在了同一行,导致一个变量被覆盖后才参与后续计算。
4.2 易错点深度解析:为什么我的递归把树复制了一份
这个 bug 值得单独拎出来讲透,因为很多人在面试手写时容易紧张,一紧张就容易犯这个错误。错误代码我再贴一遍:
javascript复制var invertTree = function(root) {
if (root === null) return null;
root.left = invertTree(root.right);
root.right = invertTree(root.left);
return root;
};
分析一下会发生什么:
假设当前节点是节点 4,它有左孩子 2 和右孩子 7。
执行 root.left = invertTree(root.right)。此时 root.right 是节点 7,递归翻转整棵以节点 7 为根的子树,得到一个翻转后的树(9 和 6 交换位置)。这个新树的根节点还是 7,把它赋给 root.left。现在节点 4 的左指针指向了原来右子树的根 7。
执行 root.right = invertTree(root.left)。但注意,root.left 现在已经不是原来的节点 2 了,而是刚赋值的翻转后的节点 7!所以这行代码会再次翻转以节点 7 为根的子树,得到一棵新的树,然后赋给 root.right。
此时 root 的两个子树全都是从同一个原始右子树复制过来的,原来的左子树(节点 2 那棵)凭空消失了。如果子树内部有相同的结构还好,万一结构不同,最终结果就是错误的。
这个 bug 的根源在于:修改了 root.left 之后,原来的引用就丢了。所以你不论用什么语言,只要是在需要同时使用两个原值做交换的场景,必须先保存其中一个值,或者确保在修改之前已经拿到了两个所需的值。
这种“先保存后交换”的思路不仅适用于这道题,在很多需要交换/覆盖的场景里都是通用原则——比如翻转数组、交换链表节点,甚至操作数据库记录时都可能会遇到。养成这个习惯,能帮你避免一大类隐蔽 bug。
4.3 面试现场:遇到追问怎么办
翻转二叉树这道题本身不难,但面试官经常会加戏追问。我整理几个高频追问和参考回答思路,你可以提前准备一下。
追问一:“你能说说递归和迭代在这里的区别吗?”
回答思路:递归是函数自身调用自身,利用系统调用栈保存状态,代码简洁但受限于栈深度;迭代是用显式的数据结构(栈或队列)模拟遍历过程,代码稍长但可控性更强。在翻转二叉树这个问题上,两者的时间复杂度和空间复杂度基本一致,只是实现机制不同。如果树的高度很大,递归可能栈溢出,迭代更安全。
追问二:“如果树特别大,内存放不下怎么办?”
回答思路:这道题问的是海量数据下的思路。可以回答:如果单棵树无法全部加载到内存,需要采用外部存储和分治策略,比如把树按子树拆分存储到磁盘,分批加载子树进行翻转后再写回。或者讨论使用分布式计算框架来处理大规模树结构。不过在面试算法题阶段,通常期望的回答是分析递归写法的空间复杂度瓶颈在哪里,以及如何用迭代来降低栈深度风险。
追问三:“这题和前序遍历、中序遍历有什么关系?”
回答思路:如果要写一个统一的框架,翻转二叉树就是遍历到每个节点时执行相同的“交换左右孩子”操作。不管用的是前序、中序、后序、层序,只要保证每个节点都被访问到且只访问一次,最终结果都一样。甚至可以构造一种特殊的“翻转遍历顺序”,比如先交换再递归处理,在代码层面就等于前序遍历版本。
追问四:“如果树里有环(即某个节点的子节点指向了它的祖先节点),你的代码会怎样?”
回答思路:会死循环。因为递归没有终止条件,会一直沿着环走下去直到栈溢出。不过二叉树定义上不允许有环,所以这个情况在题目中是假设不存在的。但实际工程中,如果处理的数据来自不可靠来源,需要先做环检测。这个追问一般是想考察你的边界思维和对二叉树性质的理解。
4.4 避坑指南:刷题环境里的常见陷阱
力扣刷题有一些隐藏的坑,特别是用 JavaScript 做题时,我踩过的和见过别人踩的归纳如下。
第一,不要修改 TreeNode 的定义。有些同学为了自己方便,会在提交代码里改动节点结构,比如增加一个 parent 指针。力扣的判题系统会使用它自己的 TreeNode 定义,你修改后可能提交时直接编译错误。
第二,全局变量问题。如果使用 JavaScript 在力扣上做题,注意不要在函数外部声明全局变量。多个测试用例会在同一个执行上下文中运行,全局变量可能跨用例残留,导致结果错乱。
第三,注意返回值的类型。这道题要求返回翻转后的根节点 root。递归解法中,函数返回的是处理后的当前节点。迭代解法则直接返回原始的 root(因为我们在原地修改了每个节点的指针)。如果你不小心返回了一个中间节点,OJ 会判定错误,而且报错信息可能让你一头雾水。
第四,不同浏览器里的执行环境差异。力扣的 JavaScript 运行环境是 Node.js,支持 ES6+ 语法。但在某些公司的笔试平台里,可能只支持 ES5,这时用 const、let、箭头函数等就要谨慎了。我习惯在笔试前先看一遍平台支持的语言特性,免得因为语法兼容性问题浪费时间。
5. 从翻转二叉树到力扣热题100的进阶路线
5.1 从这道题延伸出的必刷题清单
翻转二叉树做完之后,强烈建议趁热打铁做下面这几道题,它们之间关联非常紧密,能帮你把二叉树的基础打得更扎实。
第一道是力扣 101 题“对称二叉树”。这道题判断一棵树是否是对称的,思路和翻转二叉树的模板高度重合——你可以把一棵树想象成镜像翻转后和原树比较。核心是把对称性转化成“左子树的左子树与右子树的右子树是否相同”的递归判断。
第二道是力扣 104 题“二叉树的最大深度”。递归三要素在这道题里体现得淋漓尽致:终止条件是空节点返回 0,单层逻辑是取左右子树最大深度加一。做完这道题,你对“后序遍历”的理解会加深一层——必须先知道子树的深度,才能算出当前节点的深度。
第三道是力扣 102 题“二叉树的层序遍历”。如果你之前用的是队列解法做翻转二叉树,那这道题你几乎可以秒杀。它就是 BFS 的核心模板,后续的“二叉树的右视图”(199 题)、“二叉树的锯齿形层序遍历”(103 题)都是它的变体。
第四道是力扣 236 题“二叉树的最近公共祖先”。这是二叉树后序遍历的经典应用,也是大厂面试高频题。做完翻转二叉树,再配合最大深度和对称二叉树,你已经能把“递归处理左右子树再汇总结果”这个模式练得比较熟了,做最近公共祖先前会更有底气。
这几道题刷下来,二叉树的基础框架就算是立住了。这也是“力扣热题 100”里二叉树板块最核心的一道链路。
5.2 力扣热题100的正确刷题姿势
说到力扣热题 100,很多同学都有一个误区:觉得把它全部刷完就能稳进大厂。这其实是对“刷题”这两个字的误解。
热题 100 之所以叫“热题”,是因为它的考察频率高、覆盖面广、难度分布合理,是很好的学习材料,而不是说“刷完就万事大吉”。我见过太多同学,每天定计划刷五道题,一个月后热题 100 刷完了,回头做一道简单题还是卡壳。为什么?因为他们只是在“背答案”,没有真正理解题目背后的算法思维。
正确的刷题姿势应该是:每道题至少保证三种收获——理解题目的数学模型、掌握至少两种解法(一优一劣对比)、能说出时间复杂度和空间复杂度。如果是经典题,还要能举一反三,知道这道题的变体有哪些。
拿翻转二叉树来说,你不能只记住递归三行代码,还要能回答:为什么用后序遍历?前序遍历行不行?迭代怎么写?空间复杂度是多少?如果你能把这些都说得清清楚楚,这才算真正“刷过”这道题。
我提倡一种“主题式刷题法”:按数据结构或算法主题来刷,而不是按题号顺序刷。比如你花了三天时间专门刷二叉树相关题目,从翻转二叉树开始,到对称、深度、层序、最近公共祖先,把这一整块吃透。之后再刷链表、数组、动态规划。这样形成的知识网络是结构化的,面试的时候从一个点能牵连出一大片,远比零散地刷几十道题高效。
5.3 为什么大厂面试还爱用这种“简单题”
这道题在中国互联网大厂的面试里出现频率不低,原因很现实:它能在很短的时间内考察一个候选人多项核心能力。
第一,考察代码基本功。三行递归代码,写起来很简单,但很容易在细节上翻车——比如 null 判断、变量覆盖、返回值。这些细节往往能反映一个候选人的编码习惯是否严谨。我作为面试官时,如果候选人能一次写对,我就知道他的基础是扎实的。
第二,考察对递归的理解深度。很多人递归只会套模板,不懂底层调用栈的变化。面试官问一句“你的递归深度是多少”,就能筛掉一批只会背题的人。反过来,如果你能主动解释递归的栈帧变化、空间复杂度与树高的关系,面试官会对你刮目相看。
第三,考察边界意识。空树、只有一个节点的树、极端不平衡的树——这些边界情况在面试中很容易被忽视,但恰恰是工程实践中最容易出问题的地方。代码能不能处理边界,在面试官眼里直接等于你的工程质量意识。
所以说,这道题虽然题目简单,但面试价值一点都不低。下次再有人说“这题这么简单怎么会考”,你可以用上面的角度回他:题目简单不等于面试容易通过,能把简单题彻底讲透才是真本事。
6. 最后的实战细节与个人心得
6.1 自己动手画图,比看十遍题解有用
最后分享一个我个人的刷题习惯:每遇到一道二叉树相关的题,不管多简单,我都会在纸上画一遍递归调用的过程。
画法很简单:画出原始树的结构,然后用箭头标出每一次递归调用的走向。比如翻转二叉树,从根节点开始,标出“左子树递归 → 右子树递归 → 交换”。递归返回时用虚线箭头标回去。整张图画完,你对这道题的理解会从“背代码”提升到“理解代码”。
这个方法看起来笨,但极其有效。我在准备面试那段时间,桌子上堆了一沓 A4 纸,全是各种树的递归调用图。后来面试官问我算法题时,我甚至可以在脑子里直接“跑”一遍递归,连每一步栈帧的变化都看得很清楚。这种能力不是靠看题解能得到的,必须亲自动手画,让大脑的视觉皮层和逻辑中枢协同工作,记忆才最深。
6.2 语言选择:用你最擅长的语言刷,还是用目标公司的语言刷
关于刷题语言,我的建议是:如果你时间充足,用目标公司的核心语言刷;如果时间紧张,用你最顺手的语言刷。
面试时的算法题,核心考察的是解题思维,不是语言技巧,所以用自己最熟练的语言写代码,能大大降低因语法错误导致的尴尬。但是注意,大厂面试官在追问时可能要求你用某种特定语言讲解,所以至少要能把自己写的代码用目标语言说清楚思路。
以翻转二叉树为例,如果用 Python,递归写法和 JavaScript 几乎一致:
python复制def invertTree(self, root):
if not root:
return None
root.left, root.right = self.invertTree(root.right), self.invertTree(root.left)
return root
这里 Python 的多重赋值天然避免了变量覆盖的问题,因为右边先计算再统一赋值。如果你用 C++ 或 Java,需要注意临时变量的使用,这和 JavaScript 里的注意点是一样的。
语言不是障碍,算法思维才是关键。千万不要因为换了语言就不会写题。
6.3 真的把这道题吃透之后,你会收获什么
做完这道题,不要急着跳走,花五分钟做一次复盘:先默写一遍递归解法,再默写一遍栈迭代解法。如果两遍都能一次写对,再去把对称二叉树题目做一遍。你会发现思路几乎是相通的。
我个人在实际操作中的体会是:翻转二叉树就像算法世界里的“hello world”——它不是终点,而是一个宣告“我开始真正理解二叉树了”的起点。这道题给我的最大收获不是代码本身,而是它逼着我搞懂了递归的每一帧调用过程。
如果你现在刷到这道题,请多花点时间在递归过程的推演上。把栈帧图画清楚,把交换时机弄明白,把“为什么先递归再交换”刻进脑子里。这份基本功会在后续你刷每一道二叉树题目时,持续地回馈你。
