刷到代码随想录系列的 Day16,我突然有种被打通任督二脉的感觉。这一天不是简单堆了三道二叉树题,而是把二叉树最核心的三个能力点在同一个下午集中轰炸了一遍:226 翻转二叉树练的是遍历顺序和递归的配合,101 对称二叉树考的是递归函数如何设计入参、如何构建镜像比较的语义,104 二叉树的最大深度则把递归的返回值传递和层序迭代的层计数拉到同一张桌子上对比。很多刷题的人到了二叉树就头晕,因为在数组、链表上还能靠直觉硬算,但树的题目每一步都要求你先想清楚结构关系、再动手写代码,这个思维转换很多人没跨过去。
这篇文章不是抄一遍题解,而是把我自己刷这三道题时踩过的坑、绕过的弯、以及后来才悟明白的细节全部摊开。不管你是刚开始刷二叉树的新手,还是准备面试想系统过一遍树的题型,只要把这三道题吃透,后续遇到路径总和、树的直径、最近公共祖先这类进阶题,你都会发现底层的“递归三要素”和“遍历顺序决定权”是同一套东西。
1. 三道题放在一起的底层逻辑
1.1 考察的知识点高度重合
很多人把这三道题当作三个孤立的任务去刷,刷一道忘一道,这是最大的误区。你仔细看,它们本质上都是“二叉树遍历顺序”的变种:翻转二叉树的本质是在每个节点上执行左右孩子交换这一“访问动作”,而这个动作放在前序、后序都成立;对称二叉树是要对两棵子树做镜像比较,本质上是同步遍历两棵树,只不过入参从一个节点变成了两个节点;二叉树最大深度的递归解法,本质上又是后序遍历的深度累加。
所以我在刷完这三道题之后最大的感受是:二叉树题目看起来千变万化,但只要把“遍历顺序”和“对节点的访问时机”吃透,很多题的框架是共享的。你甚至可以把这三道题的递归模板放在一起对比,会发现相似度非常高,差异只在抽出的那个“访问动作”上。这就是为什么代码随想录要把它们排在连续几天里,目的就是让你在重复的训练中形成肌肉记忆。
1.2 贯穿始终的主线思维:递归三要素
代码随想录反复强调递归三要素:参数与返回值、终止条件、单层递归逻辑。这三道题每一道都能完美套用这个框架,而且套着套着你就会发现递归不再玄学。
翻转二叉树的递归三要素是:返回值是根节点,终止条件是 root 为空,单层逻辑是交换 root 的左右孩子然后分别递归处理。
对称二叉树的递归三要素是:入参变成两个节点(左子树的某个节点和右子树镜像位置的某个节点),终止条件要处理两节点为空的各种组合,单层逻辑是比较外侧一对和内侧一对子节点。
二叉树最大深度的递归三要素是:返回值是当前子树的高度,终止条件是 nil 节点返回 0,单层逻辑是取左右子树深度的较大者再加一。
这三道题如果分别用三要素拆解,写出来的代码结构几乎同源。你真正需要训练的,是根据题目要求快速写出三要素的能力,而不是背代码。这也是我在刷题后期才明白的:面试官考递归题目,真正想看的就是你有没有把问题拆成子问题的思维。
1.3 学习顺序与精力分配建议
三道题的难度排序建议先刷翻转二叉树,再刷最大深度,最后刷对称二叉树。为什么这么排?因为翻转最简单,它就是递归三要素在树上的第一个应用,能帮你建立“当前节点做什么、然后交给递归做什么”的直觉。最大深度稍微进阶一点,因为它的返回值要参与计算,但思路仍然是一元递归,只有一个搜索方向。对称二叉树是这三道里思维难度最高的,因为递归函数的入参变成了两个节点,而且要交叉比较,很多人第一次接触会懵:为什么需要比较 left.Left 和 right.Right?
按这个顺序刷,你会感觉难度是逐级爬坡而不是陡增。反过来先刷对称二叉树,大概率第一天就被劝退了。
2. 226. 翻转二叉树:递归序的第一次正面交锋
2.1 题面理解与暴力思路
题目描述很简单:给定一棵二叉树,把它完整镜像翻转,也就是每个节点的左右子树都交换。很多人第一次看到这题觉得不需要递归——直接 BFS 遍历每个节点,交换左右指针不就行了?这个思路完全可行,但面试官通常会追问一句“能不能用递归写”,因为递归写法才是这题真正的考点。
在写递归之前,你得先想清楚一个关键问题:这个题目到底在“遍历”的过程中做什么操作?二叉树的遍历无非是前序、中序、后序三种,区别在于对当前节点的处理时机。翻转二叉树的核心操作是把当前节点的左右指针交换,那么你只需要决定:先交换再递归,还是先递归再交换?两个都行,但都只对特定遍历顺序成立,这就为后面的坑埋下伏笔。
2.2 递归实现:先换还是先下钻
先看先交换的版本,也就是前序遍历:
func invertTree(root *TreeNode) *TreeNode { if root == nil { return nil } root.Left, root.Right = root.Right, root.Left invertTree(root.Left) invertTree(root.Right) return root }这段代码的逻辑非常干净:当前节点先把自己的左右孩子交换掉,然后递归去翻转左子树和右子树。你可能会疑惑:交换之后递归调用传来的 root.Left 到底是原来的左子树还是右子树?答案是原来的右子树,因为先交换了嘛。但这不影响结果,因为反正两棵子树都要翻转变换,顺序无所谓。
再看后序遍历的版本,也就是先递归再交换:
func invertTree(root *TreeNode) *TreeNode { if root == nil { return nil } left := invertTree(root.Left) right := invertTree(root.Right) root.Left = right root.Right = left return root }这个版本先把左右子树分别翻转好,再把当前节点的左右指针指向翻转后的子树。两版结果完全一样。真正要命的是中序遍历版本,很多人会踩坑,我放在后面小节专门讲。
注意:以上两个版本都是正确的,因为它们都满足“每个节点恰好被访问一次并交换一次”的原则。区别只在于交换动作发生在递归下潜之前还是回溯之后。
2.3 层序迭代解法:用队列做镜像翻转
递归解法干净,但面试官经常追问“递归会不会栈溢出?你写一个迭代版本”。树高很大的时候(比如一条链状树),递归深度可能达到 O(n),要是树有几万层,程序直接栈溢出崩溃。这时候层序迭代就派上用场了:
func invertTree(root *TreeNode) *TreeNode { if root == nil { return nil } queue := []*TreeNode{root} for len(queue) > 0 { size := len(queue) for i := 0; i < size; i++ { node := queue[0] queue = queue[1:] node.Left, node.Right = node.Right, node.Left if node.Left != nil { queue = append(queue, node.Left) } if node.Right != nil { queue = append(queue, node.Right) } } } return root }这里有个细节值得注意:层序解法里,交换动作是在出队时做的,而且交换后入队的顺序其实已经不影响了——因为两棵子树都要处理。初学者容易纠结“先入左还是先入右”,对这题真的无所谓,你只要保证当前节点的两个孩子被交换过就行。但换成层序求其他结果时,入队顺序就可能影响结果,比如后面要讲的对称二叉树。
2.4 易错点与实测细节
翻转二叉树里最经典的一个坑就是中序遍历。很多人会写出这样的代码:
func invertTree(root *TreeNode) *TreeNode { if root == nil { return nil } invertTree(root.Left) // 递归左子树 root.Left, root.Right = root.Right, root.Left // 交换 invertTree(root.Left) // 递归“左子树” return root }看起来好像没问题:先翻左,交换,再翻新的左(即原来的右)。但你仔细模拟就会发现,这个写法会让部分节点被重复翻转。假设一棵只有三个节点的树:根节点 1,左孩子 2,右孩子 3。中序写法先递归左子树(翻转以 2 为根的子树),然后交换 2 和 3,此时根节点的左孩子变成了 3,右孩子变成了 2;再递归左子树,也就是翻转以 3 为根的子树。结果是根节点的左子树被翻了两次,右子树一次没翻,整个结果的镜像关系完全错乱。
所以我的建议是:如果代码模板选用了中序遍历,直接把交换去掉,改成“连环赋值”的写法也能达到翻转效果,但那样更难理解。翻转二叉树的正确打开方式就是前序或后序,中序版本直接放弃,不要为了一时逞能写一个容易出错的版本。
还有一个低级的坑:递归函数最后忘记返回 root。翻转操作是原地修改指针,但调用方需要拿到根节点,不返回 root 整个函数就白写了。这种低级错误在面试高压场景下很容易出现,我建议你刷完一道题就把返回值习惯性写上。
3. 101. 对称二叉树:递归函数的语义设计
3.1 对称的本质:两棵子树互为镜像
先理解什么叫做一棵二叉树对称。从根节点往下看,不是左右孩子相等就叫对称,而是左子树的左孩子要对应右子树的右孩子,左子树的右孩子要对应右子树的左孩子。整个比较关系是交叉的、镜像的。
换句话说,你不能只写一个递归函数去看 root.Left 和 root.Right 是否相等,因为这一步只是第一层判断。真正需要的是递归比较:左子树的左孩子与右子树的右孩子、左子树的右孩子与右子树的左孩子。这个“交叉比较”是整道题的灵魂。
所以这道题的难点在于:你不应该把递归函数设计成“判断一棵树是否对称”,而应该设计成“判断两棵树是否互为镜像”。这个语义上的转变是很多人卡住的根本原因。你是以一个节点的视角写递归,还是以两个节点的视角写递归,决定了整个代码的结构。
3.2 递归解法:双参数入参的设计
这里我把递归逻辑完整展开。先看主函数和辅助函数:
func isSymmetric(root *TreeNode) bool { if root == nil { return true } return compare(root.Left, root.Right) } func compare(left, right *TreeNode) bool { if left == nil && right == nil { return true } if left == nil || right == nil { return false } if left.Val != right.Val { return false } return compare(left.Left, right.Right) && compare(left.Right, right.Left) }主函数很简单,把 root 的左右子树交给 compare 函数。compare 函数接收两个节点,它们的语义很明确:“这两个节点在我们幻想的两棵树中处于镜像对称的位置”。如果 left 是原树中某个节点的左孩子,那么 right 就是对应位置原树右孩子的节点。
终止条件分三种情况:
| 场景 | left | right | 结果 |
|---|---|---|---|
| 两个都为空 | nil | nil | true |
| 一个为空 | nil | 非nil / 非nil | nil |
| 值不等 | 非nil | 非nil | false |
只有两个都非空且值相等时,才继续递归。单层递归的事情是:当前两个节点值相等,继续往下一层比较它们的孩子。因为镜像关系是交叉的,所以下一次比较的是 left.Left 和 right.Right(外侧),以及 left.Right 和 right.Left(内侧),两者同时成立才算对称。
我第一次写这道题时,犯了用 compare(left.Left, right.Left) 去比较的错误,结果发现左链和右链比了半天,逻辑完全不对。所以要记住这个交叉规则:镜像对称的递归比较,永远是左对右、右对左。
3.3 迭代解法:用队列两两入队出队
递归解法下潜到叶子节点就回溯,但迭代解法需要你手动维护一个“待比较配对”的队列。核心思想是:把应该互相比对的两个节点相邻放入队列,每次出队两个节点进行比对。
func isSymmetric(root *TreeNode) bool { if root == nil { return true } queue := []*TreeNode{root.Left, root.Right} for len(queue) > 0 { left := queue[0] right := queue[1] queue = queue[2:] if left == nil && right == nil { continue } if left == nil || right == nil || left.Val != right.Val { return false } queue = append(queue, left.Left, right.Right, left.Right, right.Left) } return true }这里有一个新手最容易踩的坑:把 nil 节点直接跳过不放进队列。如果我这样写,那 left 为 nil、right 非 nil 的情况就永远判断不到,程序会错误地返回 true。所以队列里的 nil 元素不是垃圾数据,而是用来占位和触发终止条件的哨兵。你在调试时如果发现对称判断漏判了,先检查是不是 nil 没入队。
另外一个细节是入队顺序。你看我的代码:先放入 left.Left 和 right.Right,再放入 left.Right 和 right.Left。这个顺序保证了每两个相邻的元素就是需要比较的镜像对。如果顺序写错,比如先放 left.Left 再放 left.Right,比较对象就被打乱了。这种 bug 特别隐蔽,你只看队列里的元素会觉得每一个都差不多,但实际上配对关系已经全乱了。
3.4 边界条件与常见错误
对称二叉树这道题的边界条件主要集中在根节点为空、单节点树、左右子树高度不同三种情况。根节点为空直接返回 true,这是惯例,不算什么坑。单节点树只有一个节点,也返回 true。真正容易错的是左右子树高度不同的情况,比如一棵树只有左子树没有右子树,这时候 compare(root.Left, root.Right) 就会走到“一个为空一个非空”的分支,返回 false,逻辑没问题。
还有一个很常见的错误是在递归函数里忘了 || 和 && 的短路顺序。比如有人会把三个 if 合并成:
if left == nil || right == nil || left.Val != right.Val { return false }这段代码的问题是:当 left 为 nil、right 非 nil 时,走到 left.Val 会 panic,因为 left 是空指针。所以必须先判断 left == nil || right == nil,再判断值是否相等,顺序不能乱。Go 里 || 的短路求值会保证 left.Val 在 left 非 nil 时才会被求值,但如果你把 left.Val 的判断放在 left==nil 前面,就直接 panic 了。这类空指针崩溃在面试白板代码中简直是送命题。
4. 104. 二叉树的最大深度:从递归到层序的思维切换
4.1 深度概念与题面理解
二叉树的最大深度定义是根节点到最远叶子节点的最长路径上的节点数。注意是节点数,不是边的数量。所以空树的深度是 0,只有一个根节点的树深度是 1,根节点有一个左孩子的树深度是 2。这个“节点数”而不是“边数”的细节,是很多人一开始容易忽略的,尤其是后续做二叉树的直径、平衡二叉树判断时,这个计数口径会直接影响公式推导。
4.2 后序递归求解:把问题拆给左右孩子
先看递归解法的代码:
func maxDepth(root *TreeNode) int { if root == nil { return 0 } leftDepth := maxDepth(root.Left) rightDepth := maxDepth(root.Right) if leftDepth > rightDepth { return leftDepth + 1 } return rightDepth + 1 }这段代码完美的演示了什么是后序递归:先递归左子树得到左子树深度,再递归右子树得到右子树深度,最后回到当前节点时,取两者较大值加一作为当前子树的深度。为什么必须用后序?因为父节点想知道自己的深度,必须依赖子节点返回的深度结果,这是典型的自底向上的信息收集过程。如果用前序,你还没拿到孩子的深度就去算当前深度,算出来的一定是错的。
你可以在心里模拟一下递归调用栈:叶子节点 nil 返回 0,所以叶子节点那一层的 leftDepth 和 rightDepth 都是 0,返回 1。再往上一层,拿到左右子树各自的深度 1,取大加一返回 2。以此类推,一层一层把答案传回根节点。递归的关键就是相信子问题会正确返回结果,你只需要处理“当前层+1”这个关系。
4.3 层序迭代求解:BFS天然适合算深度
迭代解法我强烈推荐直接用层序遍历,因为 BFS 天然是一层一层扫的,每次完整扫描完一层,深度加一就行。代码也很直观:
func maxDepth(root *TreeNode) int { if root == nil { return 0 } depth := 0 queue := []*TreeNode{root} for len(queue) > 0 { size := len(queue) depth++ for i := 0; i < size; i++ { node := queue[0] queue = queue[1:] if node.Left != nil { queue = append(queue, node.Left) } if node.Right != nil { queue = append(queue, node.Right) } } } return depth }这里必须注意一个细节:为什么内层循环要用 size := len(queue) 固定长度,而不是直接 for len(queue) > 0 一直弹?因为一次外循环恰恰对应“当前这一层”的所有节点;如果你不固定 size,就会把下一层的节点也一起弹出,深度计数就不准了。这个 size 固定的技巧在层序遍历相关题目中非常常见,比如求每层平均值、找每层最大值,都是基于这个模板改的。
我建议把层序迭代版本练到可以盲写,因为它的变体在二叉树题目里出现频率极高。你甚至可以把它当成一个标准模板背下来,遇到求深度、求层数、按层输出的题目直接套。
4.4 扩展题:N叉树最大深度与最小深度的坑
把最大深度从二叉树扩展到 N 叉树,思路完全一样,只是从递归两个子节点改成遍历所有孩子节点。Go 代码大概长这样:
func maxDepth(root *Node) int { if root == nil { return 0 } max := 0 for _, child := range root.Children { depth := maxDepth(child) if depth > max { max = depth } } return max + 1 }这个扩展很简单,真正的坑是“最小深度”。很多人觉得最大深度的递归模板把 max 换成 min 不就行了?大错特错。二叉树的最小深度是指根节点到最近叶子节点的最短路径上的节点数,这里的重点在“叶子节点”。如果一棵树只有左子树没有右子树,直接用 min(leftDepth, rightDepth)+1 会在某个没有右孩子的非叶子节点上错误地返回 1,但事实上这个节点不是叶子节点,真正的最近叶子距离是 2。正确做法是要单独判断:如果左子树为空,就只递归右子树;如果右子树为空,就只递归左子树;两者都为空才返回 1。很多面试题就是在最大深度的基础上加上这个弯子来考察你有没有真正理解“叶子节点”的定义。
4.5 三者对比:什么时候用递归,什么时候用迭代
这三道题刷完以后,你应该形成自己的判断标准。我列一个简单的对照表:
| 判断维度 | 递归解法 | 迭代解法 |
|---|---|---|
| 代码可读性 | 高,接近数学归纳法 | 低,需要维护队列/栈 |
| 空间复杂度 | O(H),H为树高 | O(W),W为最大层宽 |
| 最坏空间 | 链状树 O(n) | 满二叉树最底层 O(n) |
| 栈溢出风险 | 树很深时存在 | 不存在 |
| 适用场景 | 子问题结构清晰的题目 | 层序处理、深度计数 |
我的个人习惯是:如果题目问的答案是整棵树的某种聚合值(深度、节点数、直径),优先用递归写,因为思路最清晰;如果面试官特别追问性能,或者题目要求按层输出、层序遍历,那层序迭代就是标准答案。两道题都能写,是二叉树进阶的基本功。
5. 实战复盘:刷完这三道题必须想明白的几件事
5.1 递归终止条件的“最小问题”验证法
我刷题时养成了一个习惯:递归函数写完后,先用最小问题验证终止条件。什么叫最小问题?就是一棵空树、一个节点的树、两个节点的树。分别代入递归函数,看会不会出现空指针、死循环或者逻辑错误。
以对称二叉树为例,最小问题验证法就是拿一棵只有左孩子没有右孩子的树去跑 compare 函数。遍历到第二个节点时,left 是非 nil 的叶子节点,right 是 nil,这时候必须命中终止条件返回 false,而不是继续递归访问 left.Left 导致 panic。如果递归函数里先判断值相等还是先判断 nil 的顺序错了,这个最小问题立刻暴露。三个条件“left 和 right 都为空、只有一个为空、值不相等”的顺序,就是递归函数的安全边界。
5.2 遍历顺序选择的黄金法则
我在刷完这几道题后总结了一个简单的法则:如果当前节点的处理结果依赖于左右子树返回的信息,用后序;如果当前节点的处理动作发生在递归下潜之前,并且不需要子树的信息,用前序;中序在二叉树问题中主要用于 BST 相关(中序有序性),日常翻转、求深度这类题目里中序往往不是最优选,甚至像翻转二叉树那样直接出错。
举个例子你就明白了:求深度必须先把左右子树挖到底,拿到结果才能算当前层深度,所以是后序。翻转二叉树直接交换左右指针,把交换动作放在递归下潜前或回溯后都行,但不能放在中序位置。对称二叉树的 compare 其实也是后序的变体,它先比对当前两个节点,再递归下去,回来后把两边的结果做 && 合并。
5.3 调试二叉树代码的实用技巧
二叉树代码出 bug 的时候,靠 print 打日志往往手忙脚乱,我建议你准备一个把树转成字符串的工具函数,这在本地调试时非常管用:
func printTree(root *TreeNode, level int) { if root == nil { return } fmt.Printf("%*s%d\n", level*2, "", root.Val) printTree(root.Left, level+1) printTree(root.Right, level+1) }比如翻转二叉树代码写完,你可以先打印原始树,输出翻转后的树,用缩进层次直观地看出左右子树是否真的交换了;对称二叉树的递归比较函数出问题时,在 compare 函数开头打印 left 和 right 的值,就能看出递归顺序到底是交叉比较还是同侧比较。
还有一个技巧是从小数据测起,别一上来就构造一棵 10 层的大树。先用最简单的 3 节点树、5 节点树手推一遍递归顺序,确认无误再测复杂用例。很多递归逻辑的问题在 3 节点树上就能暴露。
5.4 复杂度分析必须动手算
很多人刷题只看代码对错,不分析复杂度,面试时被追问就卡壳。这三道题的递归解法时间复杂度都是 O(n),n 是节点数,因为每个节点恰好被访问一次。空间复杂度就不是很多人口中轻飘飘的 O(1) 了,递归解法最坏情况下要 O(H),其中 H 是树高;当树退化成一条链时,H 等于 n,空间复杂度变成 O(n)。这个点经常被忽略,部分资料甚至误导说递归空间是 O(1)。迭代解法用队列存储,空间复杂度取决于最大层宽,满二叉树的最底层节点数大约是 n/2,所以最坏也是 O(n)。
我建议你养成一个习惯:每写完一道树题,在注释里写上时间复杂度和空间复杂度,以及最坏情况何时发生。这不仅能帮你应付面试追问,更重要的是逼自己想清楚算法到底消耗了多少资源。
三道题刷完,最大的收获不是会写三道题,而是你终于敢面对象限更广的二叉树题目了。后续的路径总和、二叉树最近公共祖先、二叉搜索树的各种操作,本质上都逃不出递归三要素、遍历顺序、空间复杂度这三个核心词的组合。真正把这几道题吃透,再往后刷到树的进阶题时,你会明显发现自己不再对着题目发呆,而是能直接开始拆解递归逻辑了。