力扣热题100里,101. 对称二叉树是一道绕不开的基础题。很多人第一次做它,觉得比反转二叉树难一点,比前序中序后序的遍历又简单一点,但真要动手写,却经常在递归函数的入参和边界条件上栽跟头。这道题本质上考的是“镜像对应”这个概念,和“判断两棵树是否相同”有细微差别,反而成了面试官最爱埋雷的地方。无论是准备秋招春招,还是单纯想巩固二叉树递归、迭代遍历的基本功,这道题都值得认真吃透。下面我把自己的解题过程、踩过的坑、以及不同写法之间的优劣整理出来,给刷题的你做一个可以直接照抄的参考。
1. 题目理解与核心思路拆解
1.1 题目描述到底在问什么
力扣101的题目很短:给你一个二叉树的根节点 root,检查它是否轴对称。对称二叉树也叫镜像二叉树,意思是以根节点为中心线,整棵树左右两边完全镜像。比如根节点只有一个,那这棵树天然对称;如果根节点有左孩子和右孩子,那么左孩子的值要等于右孩子的值,同时左孩子的左子树要镜像等于右孩子的右子树,左孩子的右子树要镜像等于右孩子的左子树。
这里有个特别容易搞混的点:它和“判断两棵树完全相同”不是一回事。判断相同树,是左孩子对应左孩子,右孩子对应右孩子;而对称二叉树是左孩子的左子树要对应右孩子的右子树,左孩子的右子树要对应右孩子的左子树,整个对应关系是交叉的。我第一次做的时候就是没转过这个弯,直接用“两棵树相同”的逻辑去套,结果遇到非对称的用例就会报错。
所以解题第一步不是急着写代码,而是把“对称”翻译成递归或迭代时能用的条件。根节点不用比较,因为它只有自己一个点,没有镜像对象。真正要比较的是它下面分裂出来的左右两侧子树:把它们看成两棵树,判断这两棵树是不是互为镜像。
1.2 为什么这道题值得刷三遍
这道题在力扣上是“简单”难度,但它的价值远不止简单题。首先,它是热题100的成员,在很多公司的笔试面试中出现频率很高,尤其是字节、腾讯这类爱考基础数据结构的团队。其次,递归和迭代两种解法正好覆盖了二叉树题目的两大通用技巧:递归三要素,以及用队列或者栈模拟遍历过程。把这题吃透了,后面做“相同的树”“二叉树的镜像”“翻转二叉树”等题目会顺手很多。
另外,这道题也经常被拿来当面试开场题。面试官不会只让你说一个解,而是会问“你还会别的写法吗”“两者的空间复杂度分别是什么”。如果你只会递归,或者只会迭代,答得就不够饱满。所以我的建议是:递归、迭代、层序三种思路都自己写一遍,写完之后再复盘,收获会大得多。
2. 递归解法:最简单的对称判断
2.1 递归的拆解思路
递归解法的核心,是定义一个函数用来比较两个节点是否互为镜像。这个函数不能只接收 root,因为根节点没有镜像点,真正需要比较的是 root.left 和 root.right 这两棵子树。所以我们要额外写一个辅助函数,入参是两个节点 left 和 right,返回一个布尔值,表示以 left 为根的子树和以 right 为根的子树是否互为镜像。
递归的终止条件有三层,缺一不可:
- 如果 left 和 right 都是空,说明两侧都到头了,返回 True。
- 如果 left 和 right 中只有一个为空,说明结构不对称,返回 False。
- 如果 left.val != right.val,说明节点值不匹配,返回 False。
通过这三层之后,说明当前这两个节点本身是匹配的。接下来要递归判断它们的子树是否镜像:left.left 和 right.right 是否镜像,left.right 和 right.left 是否镜像。只有这两组同时成立,整体才算对称。
这里有个小技巧:递归函数的返回值应该写成isSymmetricHelper(left.left, right.right) and isSymmetricHelper(left.right, right.left)。注意中间用 and,而不是 or,因为必须两个方向都满足。如果写成 or,那只要有一个方向对称就返回 True,整个逻辑就废了。
2.2 代码实现与复杂度分析
用 Python 写最简洁的版本:
class Solution: def isSymmetric(self, root: TreeNode) -> bool: if not root: return True return self.is_mirror(root.left, root.right) def is_mirror(self, left: TreeNode, right: 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 self.is_mirror(left.left, right.right) and \ self.is_mirror(left.right, right.left)这段代码有几点值得学习。第一,入口函数负责处理空树或单节点的情况,if not root直接返回 True,避免后面访问 root.left 报空指针。第二,辅助函数独立,职责清晰。第三,递归调用时用反斜杠换行,让对应关系一目了然。
时间复杂度是 O(n),因为每个节点最多被访问一次,这里的 n 是二叉树节点总数。空间复杂度是 O(n),这里的 n 理解成递归深度更准确,最坏情况下树退化成一条链,递归深度达到 n,栈空间就是 O(n)。如果是一棵完全二叉树,递归深度是 log n,空间复杂度可以认为是 O(log n),但力扣官方给的最坏情况空间复杂度仍然是 O(n)。
2.3 递归解法的易错点
第一个易错点是只判断左子树和右子树的值,忽略了结构。比如左子树有两个孩子,右子树只有一个孩子,即便某个方向值碰巧一样,整体也不可能对称。所以递归终止条件里的“一个为空另一个非空”必须单独写,不能靠后面的值比较来兜底。
第二个易错点是递归调用传错参数。很多人会把is_mirror(left.left, left.right)写出来,这样等于拿同一个节点的左右孩子去比较,完全跑偏了。写递归的时候,心里要明确:当前比较的 left 和 right 是两个镜像节点,它们的孩子要继续按照交叉方式配对。
第三个易错点是忘记考虑 root 为 None 的情况。力扣的层序遍历序列可以包含空值,但 root 本身可能是 None,这时候整棵树没有节点,按照定义是对称的,所以要返回 True。很多解法省略了这个判断,直接访问 root.left 就会抛 AttributeError。
3. 迭代解法:用队列/栈模拟层序比较
3.1 迭代思路:把镜像节点成对压入
递归虽然简洁,但面试官经常追问“能不能不用递归”。答“能”之后,就需要用迭代。迭代的核心思想是手动维护一个队列或者栈,代替递归时的系统调用栈。
思路是这样的:初始时把 root.left 和 root.right 这一对节点压入队列。然后每次从队列中弹出两个节点,进行比较。比较逻辑和递归完全一样:两个都空,继续;一个空一个非空,返回 False;值不相等,返回 False。然后按照镜像对应的顺序,把left.left和right.right压入队列,再把left.right和right.left压入队列。注意要确保它们成对弹出,所以每次压入两组,每组两个。
这里有一个特别重要的细节:空节点也要压入队列。不能因为节点为 None 就不压,否则队列里弹出的顺序会错乱,导致结构不对称的树也被判断成对称。很多初次写迭代解法的人在这里翻车:只把非空节点压进去,结果丢失了空位信息,误判了结构。
3.2 代码实现(队列版本)
from collections import deque class Solution: def isSymmetric(self, root: TreeNode) -> bool: if not root: return True queue = deque() queue.append(root.left) queue.append(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 True因为每次弹出两个,压入四个,循环中每次 popleft 两次,逻辑上是成对的。使用 deque 而不是 list,是因为 deque 的 popleft 是 O(1) 复杂度,如果直接用 Python list 的 pop(0) 是 O(n) 复杂度,会拖慢整体性能,面试时提一句这个细节会显得很专业。
这个解法的时间复杂度同样是 O(n),空间复杂度是 O(n)。队列中最多同时存在两层的节点数量级,最坏情况下是 O(n)。
3.3 迭代 vs 递归:考察角度与性能对比
很多人在面试时会被问“递归和迭代哪个好”。我的回答是:它们没有绝对的好坏,递归代码更少、可读性更强;迭代避免了递归深度过深导致的栈溢出风险,更安全。
看表格更清楚:
| 维度 | 递归解法 | 迭代解法(队列) |
|---|---|---|
| 代码量 | 约10行 | 约15行 |
| 逻辑直观性 | 高,直接对应数学定义 | 中,需要手动压入出队 |
| 空间复杂度 | O(n)(最坏递归深度) | O(n)(队列节点数) |
| 栈溢出风险 | 树高过深时有风险 | 无 |
| 面试考察点 | 递归三要素、分治思想 | 队列/栈模拟、成对比较意识 |
实际刷题时,我建议两种都要掌握。如果面试官让你优化,通常希望你能从递归改成迭代,因为系统栈深度是有限的,极端情况下递归可能爆栈。不过在实际面试中,只要你先写出递归,再主动补充迭代解法,就已经超出大部分候选人的预期了。
除了队列,也可以使用栈。用栈的思路和队列基本一样,只是把先进先出改成后进先出,代码上的区别在于用list.append和list.pop(),并且初始压栈时也压入 root.left 和 root.right。因为比较节点是成对取出,栈的顺序不会影响正确性,只要保证每次取出的两个节点是应该比较的那对即可。我给一个栈版本供参考:
class Solution: def isSymmetric(self, root: TreeNode) -> bool: if not root: return True stack = [root.left, root.right] while stack: right = stack.pop() left = stack.pop() if not left and not right: continue if not left or not right: return False if left.val != right.val: return False stack.append(left.left) stack.append(right.right) stack.append(left.right) stack.append(right.left) return True注意这个版本的弹出顺序:先弹出的是最后压入的 right 和 left,但是它们原本就是成对的,所以没问题。压栈时也要按照成对的顺序压入,否则会比较错。
4. 实战中常见的坑与排查技巧
4.1 边界条件与空指针处理
我在实际刷题和帮别人 review 代码时,发现最常见的错误集中在边界条件上。这里整理几个高频考察点:
- 空树 root = None,返回 True。
- 只有一个节点,返回 True,因为只有一个点必然对称。
- 根节点只有一个孩子,比如 root.left 有值,root.right 为空,返回 False。
- 值重复但结构不对的情况,比如左子树是 [2, 3, None],右子树是 [2, None, 3],虽然值都是 2/3,但结构不对称,必须返回 False。
递归和迭代的边界处理其实是一样的:先判空,再判一个空,再判值。这个顺序不能乱。如果把“值不相等”放在“一个空一个非空”之前,遇到空节点访问 val 就会报错。
4.2 如何快速定位错误输出
如果你写出来的代码在某个用例上报错,除了反复看代码,更高效的方式是打印调试。我给一个方法:在递归函数开头加一个打印,把 left 和 right 的值打出来(注意判空),这样能看到递归实际比较的节点顺序。比如:
def is_mirror(self, left, right): if left and right: print(f"比较 {left.val} 和 {right.val}") else: print(f"比较 {left} 和 {right}")通过打印结果,你可以直观地看到节点配对是否交叉对应。如果打印出来是 left.left 和 left.right 在比较,那就是递归参数写错了。如果打印结果显示一侧已经为空,另一侧还有节点,那就是结构不对称,程序早退,也能帮你确认逻辑走到哪个分支。
迭代解法的调试思路是给队列里的每个节点做一个标记,比如用(节点, 位置)的形式,但这样会占用额外空间。我更推荐用一个简单办法:先写一个层序遍历辅助函数,把每一层的节点按顺序打印出来,然后用视觉反馈发现对称破缺的地方。不过实际刷题时很少用这么重的调试方式,通常只要理清“成对比较”的思路,问题就能定位到相应代码段。
4.3 变形题与延伸思路
对称二叉树不是孤立的知识点,它和很多题目是亲戚。比如力扣100“相同的树”,递归函数是isSameTree(p, q),入参也是两个节点,但比较的是 p.left 和 q.left、p.right 和 q.right,方向是顺着的。而我们的对称二叉树,需要比较left.left 和 right.right、left.right 和 right.left,方向是交叉的。这两个题对比着看,能更清楚地区分“单向对应”和“镜像对应”。
还有力扣226“翻转二叉树”,翻转后的结果如果和原树相同,那原树就是对称的。不过这并不适合直接作为解题思路,因为翻转再比较需要复制一棵树,复杂度更高。但面试时你可以把这个关联关系说出来,展示你对题目之间的联系有思考。
力扣572“另一棵树的子树”也用到了递归比较两棵树的逻辑,虽然判断的是子树关系,但基础比较函数和“相同的树”几乎一样。所以吃透对称二叉树,其实是为后续一堆二叉树递归题打底子。
5. 刷题心得与扩展建议
5.1 我刷这道题的真实过程
我最初是在一个面试模拟题单里遇到它的,当时只写出了递归,还要面试官提示才恶补了迭代。后来我把这道题反复做了三遍,每次隔两个星期重做一次,直到拿到题目不需要思考就能准确写出递归。到第三遍时,我开始琢磨队列版本里的空节点到底该不该压入,然后自己去验证了一个用例:左子树是 [2, None, 3],右子树是 [2, 3, None]。
这棵树节点都是 2、3,但结构不对称,因为左子树的右孩子是 3,右子树却只有左孩子是 3。如果队列里不压入空节点,两个 3 可能会被误认为是对称的,但压入空节点后,left.right=3和right.right=None一对,会立刻返回 False。所以迭代版本中压入空节点的这个操作,是保证结构判断正确的关键,这一点我在面试时也不止一次拿出来讲过。
5.2 面试时怎么答出亮点
如果面试官让你做这题,我的建议是:先用 30 秒理清思路,然后完整地说一遍“递归法”的思路,包括入参为什么是两个节点、终止条件为什么有三层、递归调用为什么要交叉对应。写代码时要注意代码风格,变量名起得语义清晰,比如is_mirror比f好得多。
写完递归后,可以主动说“我还能写一个迭代版本,用队列层次遍历的方式实现,避免了递归深度问题”。然后迅速写一遍。写完后如果还有时间,可以补充一句“两种解法的复杂度都是 O(n) 时间、O(n) 空间,但迭代的栈空间是堆内存分配的,递归是系统栈,实际操作上迭代更稳健”。这会让面试官觉得你不是背题,而是真的理解底层差异。
如果面试官进一步问“能不能做到 O(1) 空间”,那正常情况下比较难,因为二叉树本身结构决定至少需要遍历所有节点。你可以坦诚地说“在不修改树结构的前提下,无法做到严格 O(1) 空间,但可以尝试 Morris 遍历相关做法,不过这里不适合”。一般来说,问到这个深度已经很少。
5.3 下一步练习建议
刷完对称二叉树,建议按这个顺序加深:100 相同的树 -> 101 对称二叉树 -> 226 翻转二叉树 -> 102 二叉树的层序遍历 -> 104 二叉树的最大深度。这几道题把递归、遍历、队列都用到了,而且互相之间有很强的关联性。
如果你在准备热题 100,那么对称二叉树是必刷的第 101 题,但后面还有二叉树的中序遍历、从前序与中序遍历序列构造二叉树、二叉树展开为链表等题目,它们需要的预处理和递归思路更高阶。把 101 题的基础打牢,再做那些题的时候,至少不会因为“两棵树比较”这类基础操作卡壳。
说到底,对称二叉树考的本质就是:你能不能把一个看起来是“整体”的题目,拆成一对节点、两两比较的子问题。能拆解出来,递归和迭代都顺手;拆不出来,背再多题解也会忘。我在实际刷题时最大的感受是,这一类基础题,一定不要只写一遍就过,隔几天重新写一遍,用迭代解法写一次,会发现自己对二叉树结构的理解又深了一层。
最后再分享一个小习惯:每次提交通过后,去力扣题解区翻一翻其他语言的写法,尤其是官方解法。哪怕你不熟悉那个语言,也能看到不同人对同一逻辑的表述方式。对称二叉树这道题,有个用 C++ 的解法用了 lambda 表达式写递归,很惊艳,我当时看完觉得递归还能这么封装,挺有意思的。刷题最大意义不在于背答案,而是在一次次对比中,找到属于自己的那套解题语言。