不用引入太多背景,直接说结论:力扣第101题“对称二叉树”是一道非常典型的二叉树递归/迭代练习题,也是面试里出现频率很高的基础题。很多人在刚接触二叉树时,被遍历、深度、翻转这些概念绕晕,等做到“对称”这道题时又开始混淆“左右子树相等”和“镜像对称”的区别。这篇文章我直接把这道题从头拆到尾,从题目本质、递归解法的思考方式、迭代解法的队列写法,到容易踩的坑和可以延伸的知识点,一次性讲透。
先明确我要解决的问题:给定一棵二叉树的根节点root,检查它是否轴对称。所谓轴对称,就是这棵树从中间画一条竖线,左右两侧是镜像关系。注意,不是左子树和右子树完全相同,而是左子树的左孩子要对应右子树的右孩子,左子树的右孩子要对应右子树的左孩子。这个对应关系是整道题的灵魂。
1. 题目拆解:对称的本质是“镜像比较”
1.1 从一棵树到两棵树的转化
第一次看到这道题,很多人的第一反应是:我能不能把左子树翻转一下,然后和右子树比较?这是一个很自然的思路,但实际操作起来会多出额外步骤,比如要先写一个翻转树的函数,然后再写一个判断两棵树是否相同的函数,两个函数叠在一起,逻辑上绕了一圈。
更直接的思路是:既然对称是镜像关系,那我不需要真的翻转,而是同时遍历左子树和右子树,每次比较两个对应位置的节点。也就是说,把“判断一棵树是否对称”拆成“判断两棵树是否镜像对称”。
这个转化非常关键。它把一个看似只涉及单棵树的问题,变成了双树比较问题。很多二叉树的题目都是这个套路,比如判断两棵树是否相同,也是同时遍历两棵树。对称只是在这个基础上,把左右孩子的访问顺序换了一下。
1.2 镜像比较的三个条件
对于两棵子树p和q,它俩互为镜像,必须同时满足三个条件:
p.val == q.val,也就是当前节点的值相等。p.left和q.right互为镜像,即 p 的左孩子要对应 q 的右孩子。p.right和q.left互为镜像,即 p 的右孩子要对应 q 的左孩子。
为什么是交叉对应?因为镜像嘛。你对着镜子举左手,镜子里的人举的是右手。所以左对右,右对左,这就是镜像的核心。
有了这三个条件,写递归就水到渠成了。递归函数接收两个节点,先判断值,再递归判断它们的孩子。终止条件是:两个节点都为空,返回 true;一个为空一个不为空,返回 false。
1.3 力扣原题给出的小陷阱
原题给的例子很简单:
1 / \ 2 2 / \ / \ 3 4 4 3这个是对称的。但很多人看例子会误以为“只要左右孩子值相等就行”,于是写出了只比较root.left.val == root.right.val就返回 true 的代码。这在大数据量下一定错。
还有一种常见误解,是把对称理解为“左子树的先序遍历结果等于右子树的后序遍历结果”。这个思路在特定条件下能工作,但需要考虑空节点的表示,而且实现起来不如直接递归直观。我建议初学者先掌握递归和迭代两种标准解法,再考虑这些花式技巧。
2. 递归解法:短路思想与边界条件的处理
2.1 核心代码与逐行解释
递归解法是这道题最简洁的写法。我用 Python 写了一遍,结构非常清晰:
class Solution: def isSymmetric(self, root: Optional[TreeNode]) -> bool: if not root: return True def check(p: Optional[TreeNode], q: Optional[TreeNode]) -> bool: if not p and not q: return True if not p or not q: return False if p.val != q.val: return False return check(p.left, q.right) and check(p.right, q.left) return check(root.left, root.right)这段代码里有几个细节值得展开讲:
第一个细节是if not root: return True。空树算不算对称?在力扣的定义里,空树是对称的。这符合数学上“空集满足任意性质”的约定,在递归时也天然成立,因为根节点为空时,左右子树都不存在。
第二个细节是把递归函数定义在isSymmetric内部。这样可以方便地访问根节点的左右孩子,也避免了额外传参。当然你也可以定义成外部函数,传入root.left和root.right,效果一样。
第三个细节是递归的终止条件顺序。先判断两个都空,再判断一个空,最后才比较值。这个顺序不能乱。如果先比较值,空节点会直接报错。在 Python 里对None取.val会抛AttributeError,所以在任何递归里,空节点判断都要放在最前面。
第四个细节是check(p.left, q.right) and check(p.right, q.left)这个表达式。Python 的and是短路求值的,也就是说,如果左边返回 False,右边根本不会执行。这在递归里天然形成剪枝,一旦发现不对称,立刻终止递归,不会白白遍历整棵树。
2.2 递归过程的手动模拟
为了彻底理解递归的执行过程,我拿上面那个对称的例子手动走一遍。
根节点是 1,进入check(root.left, root.right),也就是check(节点2, 节点2)。
第一步,两个节点都不为空,值都是 2,相等。
第二步,递归调用check(节点2.left, 节点2.right),也就是check(节点3, 节点4)。两个节点值分别为 3 和 4,不相等,返回 False。
等等,这个例子里节点3和4都是叶子节点,正常情况下左边是3右边是4,不对称吗?注意,我这里用的是力扣例子里的结构:
1 / \ 2 2 / \ / \ 3 4 4 3根节点 1 的左孩子的左孩子是 3,根节点 1 的右孩子的右孩子是 3。所以在check(root.left, root.right)里,p.left是左侧的 3,q.right是右侧的 3,两个值相等。而p.right是左侧的 4,q.left是右侧的 4,两个值也相等。只有当树变成:
1 / \ 2 2 / \ / \ 3 4 3 4这种情况下,左侧的 3 对应右侧的左孩子 3,但镜像对应的是右侧的右孩子 4,才会立刻发现不对称。
我在实际模拟时经常把指向搞混,后来总结了一个口诀:递归时只关心“当前这一层传入的两个节点”,剩下的交给下一层。只要记住了左对右、右对左,递归的调用关系就不会错。
2.3 递归的时间与空间复杂度分析
时间复杂度是 O(n),n 是二叉树的节点数。因为每个节点最多被访问一次,最坏情况下(对称时),递归会遍历整棵树的所有节点。如果不对称,可能会提前终止,但最坏复杂度仍然是 O(n)。
空间复杂度是 O(n) 的额外空间,因为递归调用栈的深度取决于树的高度。最坏情况是树退化成链表,高度为 n,递归栈会压入 n 层。最好的情况是平衡树,高度为 log(n),空间复杂度就是 O(log n)。面试时如果被问到空间复杂度,要把这两种情况都说清楚,只答 O(n) 或 O(log n) 都不完整,考官想听的是你理解树高度对递归栈的影响。
3. 迭代解法:队列实现层序式成对校验
3.1 为什么递归不够,还要学迭代
递归解法虽然简洁,但也有天然短板。当树的深度很大时,递归调用栈可能会溢出,这是函数调用机制决定的。比如树退化成一条链,深度达到几万层,Python 默认的递归深度限制是 1000 左右,直接 RecursionError。
迭代解法则没有这个顾虑,它用显式的队列或栈来模拟递归过程,不依赖系统调用栈。这也是面试官喜欢追问的第二问:“能不能不用递归实现?”如果只会递归写法,面试印象分会打折扣。
3.2 队列模拟的核心思路
迭代解法的思路是:把需要比较的两个节点成对放入队列,每次从队首取出两个节点进行比较,然后把它们的镜像子节点成对入队。
具体步骤如下:
- 初始化队列,将
root.left和root.right成对入队。 - 从队列中取出两个节点
u和v。 - 如果
u和v都为空,跳过本轮循环。 - 如果
u或v只有一个为空,返回 False。 - 如果
u.val != v.val,返回 False。 - 将
u.left与v.right成对入队,再将u.right与v.left成对入队。 - 循环直到队列为空,返回 True。
注意入队的顺序必须是两两一组,不能只入队一个节点。我用 Python 实现时习惯用collections.deque,因为列表的pop(0)是 O(n) 的,而deque的popleft()是 O(1) 的。
from collections import deque class Solution: def isSymmetric(self, root: Optional[TreeNode]) -> bool: if not root: return True queue = deque([root.left, root.right]) while queue: u = queue.popleft() v = queue.popleft() if not u and not v: continue if not u or not v: return False if u.val != v.val: return False queue.append(u.left) queue.append(v.right) queue.append(u.right) queue.append(v.left) return True这段代码里最需要注意的是第六步的入队顺序。u.left必须和v.right配对,u.right必须和v.left配对,这是镜像的核心。如果写成了u.left和v.left配对,那就是在判断两棵树是否相等,而不是是否镜像。这个错误非常隐蔽,尤其在队列很长时很难一眼看出来,我自己就曾经在这种地方排查了很久。
3.3 用栈替代队列:殊途同归
队列解法用到了先进先出的特性,但实际上用栈也能实现,只要保持成对弹入弹出的顺序不变即可。如果你把上面的queue换成普通的 list,用append和pop()模拟栈,效果完全一样。
这是因为我们只关心“成对比较”这个约束,不关心比较的先后顺序。无论是 BFS 式的层序比较,还是 DFS 式的深度优先比较,只要每对节点是镜像对应的,最终结果都一样。
在面试时主动提一句“队列和栈都可以,因为比较顺序不影响结果”,会显得你对问题的理解更深一层。这也是从“会做题”到“懂原理”的一个小分水岭。
3.4 迭代解法的复杂度对比
迭代解法的时空复杂度都是 O(n)。时间上每个节点入队出队一次;空间上队列最多同时存储两层的节点,最坏情况下(完全二叉树)最后一层有约 n/2 个节点,所以空间复杂度也是 O(n)。
和递归相比,迭代的优势在于没有调用栈溢出的风险。劣势是代码相对啰嗦,而且容易在入队顺序上写错。我觉得两者没有绝对的优劣,关键是理解各自的适用场景:面试时首选递归,写起来快;实际工程里如果树可能很深,用迭代更稳。
4. 常见误判与调试技巧实录
4.1 误区一:直接比较左右子树先序遍历序列
想过用序列化来偷懒的朋友应该不少。思路是:把左子树做一次先序遍历,把右子树做一次后序遍历,如果序列相同就对称。
这个思路理论上说得通,但实现时有个大坑:空节点怎么表示。如果不用特殊字符标记空节点,光靠节点值序列,很多树会得到相同的序列。比如下面的两棵树:
1 1 / \ / \ 2 2 2 2 / \ 3 3它们的节点值序列都是 1, 2, 3, 2 或 1, 2, 2, 3,单靠值序列根本区分不开。所以如果非要用序列化,一定要用例如'#'来代表空节点,并且保证左右子树的遍历方向相反。这个问题在讨论“对称树”这类问题时很容易被忽略,但实际写代码时一定会踩坑。
4.2 误区二:只做层序遍历并检查每层回文
另一个常见的思路是:用层序遍历收集每一层的节点值,然后检查每一层是不是回文数组。
这个思路能通过一些测试用例,但会在一种情况下出问题:空节点的位置没有被保留。比如下面的树:
1 / \ 2 2 \ \ 3 3第一层是 [1],第二层是 [2, 2],第三层如果只收集非空值是 [3, 3],看起来是回文,但这棵树实际上不是对称的,因为左边 3 是右孩子,右边 3 也是右孩子,位置不对应。
解决办法是层序遍历时保留空节点的占位符,比如用None表示空,然后再检查回文。这样可行,但实现起来比迭代解法更繁琐,而且队列里可能塞入大量空节点,空间利用率不高。我觉得作为思路拓展可以聊一聊,但不建议作为面试时的首选解法。
4.3 实际调试中我常用的三个小技巧
技巧一是多用“不对称”的例子来验证。比如只修改树中一个节点的值,确保代码能检测出来。很多人测试时只拿对称的例子跑,一遍通过就以为没问题,结果换了个用例就挂。我习惯准备至少三个用例:对称树、非对称树、空树。
技巧二是打印递归的调用对。在递归函数入口加一行print(p.val if p else None, q.val if q else None),可以看到每层比较的是哪两个节点。这一步对排查镜像对应关系极有帮助。
技巧三是在迭代解法中,不要把成对的节点塞进队列后忘了校验队列长度。因为每次循环取出两个节点,如果入队时不小心只塞了一个,队列长度变成奇数,就会在下一次popleft()时抛异常。比较好的习惯是每次入队都成对append,循环体内先判断len(queue) >= 2再做操作。
5. 从对称二叉树延伸:二叉树的深度、遍历与搜索树的交叉联想
5.1 对称与遍历:不改变不破坏原有结构
学完对称二叉树,再回头看热搜词里的“二叉树的遍历”,你会发现它们其实是同一套思维体系。遍历的核心是“访问顺序”,对称的核心是“比较顺序”。如果你能把二叉树的先序、中序、后序、层序遍历都写熟练,对称题里的递归和队列写法就只是换了个用途而已。
比如迭代解法中我用队列成对取节点,本质就是层序遍历的变形。层序遍历每次取一个节点并访问它的左右孩子,对称迭代每次取两个节点并比较它们的镜像子节点。如果你对层序遍历的模板足够熟悉,这个变形不需要死记硬背,现场推就能推出来。
“二叉树的深度”这个热搜词也和对称有关联。对称树不一定平衡,但平衡树大概率看起来更对称。深度本身不直接参与对称判断,但递归解法的空间复杂度来自深度,所以理解深度对分析递归栈有帮助。
5.2 对称与搜索二叉树:验证顺序的另一个方向
搜索二叉树(BST)定义的是节点值的排序关系:左孩子小于根,右孩子大于根。对称二叉树定义的是结构关系,两者不冲突但也不等价。一棵 BST 完全可能不对称,比如:
5 / \ 3 8 / \ 1 4按 BST 规则是合法的,但左右子树并不镜像对称。
如果把这两类问题放在一起复习,你会发现二叉树的题目基本都是三板斧:递归函数的设计、遍历顺序的选择、边界条件的处理。对称二叉树同时用到了递归和层序迭代,是一道很好的综合训练题。
5.3 变体题:怎么判断一棵树的子树是否对称
面试官经常会在原题基础上加问一句:“如果给你一个根节点,如何找到这棵树中最大的对称子树?”
这个问题的思路是:遍历每个节点,以该节点为根判断是否对称,同时记录最大深度。判断函数可以直接复用原题的isSymmetric,整体时间复杂度是 O(n^2)。如果追求更优解,可以用后序遍历加上子树哈希的手段来降低重复计算,但这是比较进阶的玩法,初学者先掌握 O(n^2) 的暴力解法即可。
这类变体题的价值在于:它逼着你把一个函数抽象成可复用的工具函数。原题里check(p, q)比较的是两个节点,放到变体里就变成了一个独立的判断逻辑,被反复调用。很多人拿到变体题就懵,本质上是没有意识到“原题已经被我写成了一个完整的函数”。
6. 实际面试中怎么回答这道题才能加分
6.1 从“背题”到“讲题”
力扣上有不少人是靠背答案过题的,但面试官早就免疫了。你光背出递归代码,他马上追问迭代写法;你写出迭代,他又问复杂度;你答出复杂度,他还能问“如果树非常大,递归栈会怎样”。这也是我反复强调要理解原理的原因。
我建议的回答顺序是:先说对称的定义(左对右、右对左),再说递归解法(三个终止条件加一个递归式),接着补充迭代解法(队列成对比较),最后主动分析复杂度。每一步都用一句人话解释“为什么”,面试官会明显感受到你是真懂。
6.2 一道题背后的知识网络
这道题虽然简单,但牵扯出来的知识点其实覆盖了二叉树的大部分基础:递归思想的落地、队列的运用、树的深度、空节点的处理、复杂度分析。如果你刚刚开始刷二叉树,把这道题吃透,再去做“相同的树”“翻转二叉树”“二叉树的最大深度”,会顺畅许多,因为它们共用同一套思维模型。
就我自己的刷题体验来说,最容易提升效率的方法不是刷题数量,而是每做完一道题,花十分钟想一想:这道题和之前哪道题相似?代码的骨架能不能抽出来?换一个条件会变成什么题?“对称二叉树”就是一个特别适合做这种思考的样本,因为它简单到不吓人,又复杂到足够承载递归、迭代、边界处理和复杂度分析四块内容。
7. 最后的实操建议与个人体会
我刷这道题的时候,最深的体会是:递归代码写得快,不代表理解到位。评判标准是你能不能在没有提示的前提下,把递归改成迭代。改完之后,再试试用层序遍历回文检测法做一遍,虽然不推荐面试使用,但能帮你深刻理解空节点占位的重要性。
做这道题时还建议准备几个容易出错的用例,包括只有根节点、节点值都为负数、左右子树高度差很大等情况。把这些用例跑通,基本上代码的鲁棒性就过关了。我个人习惯在做完题后额外写一个“临时修改某个节点值”的小函数,用来快速生成不对称的测试数据,这样就不用每次手动构造一棵新树了。
最后再分享一个调试小细节:如果你用的是 Python,递归函数里要避免直接使用全局变量来传参,尽量把需要比较的数据都放在函数参数里,否则在多层递归时容易出现状态污染。队列迭代也一样,不要在一个 while 循环里改动其他无关变量,保持每个循环周期独立。把这类工程习惯带到刷题里,时间长了你会发现,自己的代码质量和调 bug 速度都会上一个台阶。