1. 先搞清楚这道题到底在考什么
1.1 题目定位与常见误区
对称二叉树这道题在 LeetCode 上编号 101,属于二叉树章节的经典入门题,同时也是“热门 100 题”里的常客。我印象里几乎所有大厂面试题库里都有它,而且往往不是考你能不能写出来,而是考你能不能讲明白为什么是这么写的。
题目本身很简单:给定一棵二叉树的根节点 root,判断它是否轴对称。这里先问自己一个问题:什么叫“对称”?很多人第一反应是“左右子树相等”,这个想法其实有偏差。真正的对称是指:把整棵树沿着根节点的中线对折,左右两部分能完全重合。顺着这个定义想,你要比较的并不是根节点的左右子树是否完全一致,而是它们是否互为镜像。
互为镜像意味着什么?假设左子树有一个节点 A,右子树对应的镜像位置也应该有一个节点 B,A 和 B 的值相等,而且 A 的左孩子要对应 B 的右孩子,A 的右孩子要对应 B 的左孩子。这个“交叉对应”关系是整个题目的题眼,后面递归和迭代两种解法都是围绕着这个核心展开的。
这个误区我见过太多次。初学者会写成判断 root.left 和 root.right 是不是完全相同,也就是递归地比较 left.left 与 right.left、left.right 与 right.right。这样写对一棵完全对称的树可能碰巧通过,但遇到稍微不对称的用例就会翻车,因为相同子树和镜像子树的对应关系是不同的。面试时如果说出这个误区,面试官往往会追问“那你觉得对称的定义是什么”,所以先把定义理清楚,比直接背模板重要得多。
1.2 两条解题路线:递归和迭代
这道题官方题解给的是递归法,用递归写出来非常简洁,核心就三行逻辑。但如果面试官接着问“你能不用递归写一遍吗”,就轮到迭代法上场了。迭代法的本质是用显式的队列或栈模拟递归过程,实现原理不复杂,但细节坑不少。
两条路线分别对应两种不同的理解方式。递归法直观、好写,适合在面试中快速给出解答;迭代法则体现了“手动维护调用栈”的能力,在实际工程中递归深度受限时更有意义。我不建议死记其中一种写法,更推荐把两种都练熟,面试时根据上下文选择展示哪一种。
我个人的刷题习惯是:先花几分钟在纸上画出几个对称和不对称的例子,包括空树、单节点、深度为 2 的对称树、以及一个容易混淆的“值不对称但结构对称”的案例,把这些例子跑通之后再写代码。手写用例这一步看起来很基础,但真的能帮你避开“看起来对但实际错”的尴尬。
2. 递归解法:三行核心逻辑背后的判断标准
2.1 递归函数的定义与终止条件
写递归第一步是定义清楚函数语义。对于这道题,我建议不要直接在 isSymmetric 里同时处理左右子树,而是单独定义一个辅助函数 isMirror(left, right),它专门用来判断两棵子树是否互为镜像。
isMirror 的语义要说清楚:传入两个节点,判断以这两个节点为根的子树是否互为镜像。注意是“镜像”而不是“相等”,这一点贯穿始终。
终止条件分三种情况:
- 两个节点都为 null,说明这组镜像位置是空的,对称成立,返回 True。
- 一个为 null 另一个不为 null,说明结构不对称,返回 False。
- 两个都不为 null 但值不同,返回 False。
这三种情况写全之后就进入递归部分。很多代码模板会把“值不同返回 False”和“结构不对称返回 False”分开写,看起来啰嗦,但可读性更强,也更容易排查问题。把它们合并成一条条件语句虽然可以缩短代码,但面试时其实不太推荐,因为牺牲了“讲清楚”的机会。
这里还有一个容易被忽略的边界:如果整棵树本身就是空的,即 root 为 null,题目默认返回 True。这一点在 LeetCode 的测试用例里是有的,但如果你直接调用 isMirror(root.left, root.right) 而没有对 root 本身做判空,null 的 left 属性就会抛空指针异常。很多人在这个细节上翻车。
2.2 镜像比较的配对规则
递归主体代码只有一行核心逻辑:isMirror(left.left, right.right) and isMirror(left.right, right.left)。就把这句话写在纸上,对照一棵树看一遍,就能理解为什么是交叉配对而不是顺向配对。
画一棵对称的二叉树:
1 / \ 2 2 / \ / \ 3 4 4 3左子树的左孩子是 3,右子树的右孩子是 3,它们要对应比较;左子树的右孩子是 4,右子树的左孩子是 4,它们要对应比较。这就是 left.left 对 right.right、left.right 对 right.left 的来源。
为什么不能顺向配?因为“镜像”的本质就是反转。想象两面镜子对称放在一起,镜子里左侧景物对应出来是另一侧的右侧,这个生活常识其实就是代码逻辑的最好类比。如果你用顺向配对去判断,上面这棵对称树反而会返回 False,因为 left.left 是 3,right.left 也是 3,单独看这一对能对上,但 left.right 是 4,right.right 是 3,就穿帮了。
2.3 时间复杂度与空间复杂度分析
时间复杂度容易分析:每个节点最多被访问一次,所以是 O(n),n 是树中节点总数。递归次数不是 n 次,但每个节点参与的递归调用是常数次,整体还是线性。
空间复杂度需要分情况。递归栈的深度等于树的高度:如果树是平衡的,高度为 O(log n);如果树退化成链状,比如每个节点只有一个左孩子,高度就是 O(n),这种情况下递归深度会非常大,实际运行时可能触发栈溢出。这也是我当时刷题时第一次意识到“递归虽好,深度受限”这个问题的场景。
对称二叉树这道题如果树很深,迭代法在空间上其实更可控,因为队列里最多同时存在一层节点,对于一棵宽度很大的树可能比递归占用更多内存,但至少不会因为深度而爆栈。所以两种方法各有利弊,这也是我把代码和复杂度一起整理进笔记的原因。
3. 迭代解法:队列模拟镜像比较
3.1 为什么用队列而非直接层序遍历
迭代法的思路是:把需要比较的两个节点成对放入队列,每次从队首取出两个节点进行比较,再把它们的孩子按照“镜像配对”的规则依次入队。本质上这就是手动的 BFS,不过一次处理一对节点,而且存储的顺序是配对后的顺序。
为什么用队列而不是直接用层序遍历?因为层序遍历拿到的是一层从左到右的节点列表,你还需要再对列表做一次前后反转对比。虽然也能做,但多了中间存储,代码也更绕。而队列的“先进先出”天然适合成对处理,每次取出两个来比较,随后两个孩子节点再成对进去,保证比较顺序始终是正确的镜像顺序。
有人会用栈代替队列做迭代,这其实也可以。栈版本可以理解为 DFS 版的迭代,顺序不同,但配对规则不变。只要保证“取出的两个节点是逻辑上互为镜像的一对”,用队列还是用栈只是遍历顺序的差别,结果是一样的。把这一点搞明白,迭代法就算真的会了。
3.2 入队顺序的对称性设计
入队顺序是整个迭代法的灵魂,写错一个顺序就会全盘皆输。正确顺序是:
- left.left
- right.right
- left.right
- right.left
每次从队列中取出最前面的两个节点,它们就是一对镜像节点。这样依次入队的好处是:取出节点时永远能拿到配对正确的一对,不会出现左右错位的比较。
有一个记忆技巧:把入队顺序看成“先外后内、左右交替”。外是指 left.left 和 right.right,这两个是靠近树外侧的孩子;内是指 left.right 和 right.left,这两个是靠近中轴的孩子。第一对和第二对分别对应“外侧镜像”和“内侧镜像”,跟递归里 isMirror(left.left, right.right) and isMirror(left.right, right.left) 的顺序保持一致。
3.3 栈版本与队列版本的等价性
如果把队列换成栈,代码几乎一样,只需要把所有 pop 操作从“队首弹出”改为“栈顶弹出”。队列版本每次取的是最先入队的配对节点,栈版本每次取的是最后入队的配对节点。两者的差异只在遍历顺序,而不在比较逻辑。
我在实际练习中推荐先用队列版本,因为它更直观,也更容易和层序遍历、腐烂的橘子这类 BFS 题目建立类比。等队列版本写顺手了,再顺手把 pop 换掉试试栈版本,你会发现其实代码只需要改一个函数名。这种小改造练习对理解 DFS 和 BFS 的差别很有帮助,比单纯抄代码印象深刻得多。
4. 核心代码实现与边界情况处理
4.1 Python 与 Java 代码参考
这里给出一份 Python 递归版、一份 Python 迭代版和一份 Java 递归版,按照我自己的编码习惯,加上了判空和注释。你可以把它作为刷题笔记直接记录。
递归版本(Python):
class Solution: def isSymmetric(self, root: Optional[TreeNode]) -> bool: if not root: return True def is_mirror(left: Optional[TreeNode], right: Optional[TreeNode]) -> bool: if not left and not right: return True if not left or not right: return False if left.val != right.val: return False return is_mirror(left.left, right.right) and is_mirror(left.right, right.left) return is_mirror(root.left, root.right)迭代版本(Python,使用队列):
class Solution: def isSymmetric(self, root: Optional[TreeNode]) -> bool: if not root: return True from collections import deque queue = deque([root.left, root.right]) while queue: left = queue.popleft() right = queue.popleft() if not left and not right: continue if not left or not right: return False if left.val != right.val: return False queue.append(left.left) queue.append(right.right) queue.append(left.right) queue.append(right.left) return TrueJava 递归版:
class Solution { public boolean isSymmetric(TreeNode root) { if (root == null) { return true; } return isMirror(root.left, root.right); } private boolean isMirror(TreeNode left, TreeNode right) { if (left == null && right == null) { return true; } if (left == null || right == null) { return false; } if (left.val != right.val) { return false; } return isMirror(left.left, right.right) && isMirror(left.right, right.left); } }4.2 五个最容易踩的坑
第一个坑是只判断了“值相等”却漏了“结构对称”。比如左子树比右子树多一个节点,某组镜像位置一个是节点一个是 null,值比较根本轮不到执行就返回了错误结果。所以结构判断必须先于值判断。
第二个坑是迭代时忘了在循环里取出节点后检查是否为 null。Python 的 deque 允许存储 None,取出来时如果不判空就直接访问 .val 或 .left,就会抛 AttributeError。这也是为什么我上面代码在每次取出两个节点后,先做两个都是 None 的短路判断。
第三个坑是入队顺序写成 left.left、left.right、right.left、right.right。这样取出时比较的却是 left.left 和 left.right,完全不是镜像关系。我调试时最喜欢用题目自带的那棵对称树跑这段“错误逻辑”,很快就能看出来不对。
第四个坑是递归版本里把终止条件写成 if not left or not right: return False,然后又加一个 if left.val != right.val。顺序没问题,但要注意一旦 left 或 right 有一个为 null,后面的值判断就永远不该执行。如果先取值再判空就崩了。
第五个坑是对于 root 本身为空的情况不处理。很多在线评测对空树的预期是 true,但如果你不做判空直接调用 root.left,就会抛空指针。我在面试时见过有候选人因为这个边界条件卡住,所以每次刷树题我都把“根节点为空”这几个字写在笔记第一行。
4.3 空树与单节点树的情况
先说空树:root 为 null 时直接返回 true,因为空树没有任何可以不对称的地方。这个判断要放在整个函数最开始,避免后续访问 left 或 right 时报错。
单节点树即 root 只有一个节点,没有左右孩子。此时 isMirror(root.left, root.right) 传入两个 null,在递归法中两个都为 null 返回 true,在迭代法中两个都为 null 则 continue,循环结束,同样返回 true。所以单节点树天然对称,这个结果符合直觉。
还有一种情况是部分节点为空。比如左子树有左孩子但右子树没有对应的右孩子,镜像位置上一个是节点一个是 null,递归很快会在终止条件处返回 false。写代码时只要保证每次递归调用的参数传递正确,就不需要额外针对这种场景写特殊判断,递归会自动处理。
5. 同类题目横向拓展与刷题建议
5.1 对称二叉树与 BFS 类题目(腐烂橘子)
刷完对称二叉树之后回头看,会发现迭代法里的队列思想和 LeetCode 994 腐烂的橘子非常像。腐烂的橘子的核心是从所有初始腐烂橘子出发,用 BFS 模拟每一分钟向外扩散一圈,每一层扩展的时间计数就是分钟数。它同样使用队列保存当前层的节点,处理完一层再进入下一层,这正是对称二叉树迭代版在做的事情。
差别在于对称二叉树每次取两个节点做镜像配对,而腐烂橘子每次取一个格子向四个方向扩散。但共同点都是“队列 + 分层状态管理”。如果你能把对称二叉树的迭代逻辑吃透,再去做腐烂橘子就会有一种熟悉的亲切感。我个人的经验是,二叉树题和图题在 BFS 思路上是相通的,练好一个,另一类就能顺带入门。
5.2 从递归到迭代:栈类题目(基本计算器)
对称二叉树递归法转迭代法,关键就是显式地用数据结构模拟函数调用栈。这个思路跳出去看,和 LeetCode 224 基本计算器里用栈存数字和运算符号有异曲同工之妙。一个是把递归调用改成手动栈,一个是用栈维护处理优先级,两者都在回答同一个问题:如何用数据结构控制执行顺序。
我在刷题时发现,面试官喜欢把“递归改迭代”当作追问,大多数二叉树题目都能通过显式栈改成 DFS 迭代版。对称二叉树刚好是一个特别好的练习素材,因为它递归逻辑只有三行,改写成迭代不会有太多干扰因素,专注力可以全部放在“显式栈如何模仿隐式栈”上。练好这一题,再去处理更复杂的非递归遍历就会轻松不少。
5.3 刷题笔记方法论:如何吃透一题带动十题
最后聊聊我自己的刷题方法,因为这道对称二叉树其实很适合用来练“一题多吃”。我有一个习惯,每刷完一道题就问自己三个问题:这道题最核心的判定条件是什么?能不能用另一种遍历方式重写?改一改题目条件会变成哪道题?
对称二叉树的核心判定是“镜像配对关系”,这是递归和迭代共用的灵魂。你可以尝试把题目改成“判断两棵树是否相同”,对应 LeetCode 100,代码只需要把 isMirror 里的交叉配对改成顺向配对。再改成“判断二叉树是否自顶向下对称”或者“按层输出镜像结构”,对应的又是另一批题目。
从对称二叉树出发,可以自然延伸到相同树(100)、翻转二叉树(226)、二叉树的层序遍历(102)、以及腐烂的橘子(994)。这种连锁刷题的方式比闷头按题号刷效率高很多,因为你在每一道新题上都能看到上一道题的影子,知识就这样串起来了。