news 2026/10/10 7:36:59

对称二叉树怎么判断?从镜像原理到递归与迭代解法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
对称二叉树怎么判断?从镜像原理到递归与迭代解法

很多刚开始刷二叉树的人,遇到 101 这道“对称二叉树”时,都会觉得自己一眼就看懂了题目:左孩子等于右孩子呗。可真正在在线判题平台上一提交,才发现事情并没有那么简单。我见过不少同学第一版代码写成return root.left.val == root.right.val,样例能过,一跑到深层节点就失败;也有人老老实实把整棵树做一次层序遍历,再判断每一层是不是回文,虽然能过但总觉得绕。这篇文章我会从“镜像”这个词本身出发,讲清楚对称二叉树要比较的到底是什么,然后给出递归和迭代两套实现,并附上测试用例和从这道题延伸出去的几个变体。对刚接触二叉树的读者,我会尽量把每一步拆开讲;如果你已经有基础,也可以直接跳到第 2 章看代码,或跳到第 5 章看它和“相同的树”“翻转二叉树”的关系。

1. 对称到底在比什么:先把“镜像”两个字翻译成代码能判断的条件

1.1 一棵对称树的典型长相

先看一棵最经典的对称树:

1 / \ 2 2 / \ / \ 3 4 4 3

根节点是 1,左孩子是 2,右孩子也是 2。只看前面两层,很多人会以为“对称就是左孩子等于右孩子”。但继续往下看:左子树这边,2 的左孩子是 3、右孩子是 4;右子树那边,2 的左孩子是 4、右孩子是 3。注意,左子树的“右侧” 4,对应的是右子树的“左侧” 4;左子树的“左侧” 3,对应的是右子树的“右侧” 3。

换句话说,对称轴是从根节点 1 正中间竖直画下去的那条线。左边最靠左的节点,要跟右边最靠右的节点比;左边最靠右的节点,要跟右边最靠左的节点比。这两者之间是交叉对应的关系,不是同一边硬比。很多人第一次写错,就是因为在递归的时候把left.left和right.left放在一起比,或者把left.right和right.right放在一起比,这实际上是在比较两棵子树的“同位”,而不是“镜像”。

1.2 把“照镜子”的规则写下来

如果让我用一句话归纳判断规则,那就是:始终有两个指针,各自从左右两棵子树出发,按照“镜像位置”同步往下走。假设当前需要比较的两个节点分别是left和right,那么这组节点要满足三个条件:

  1. left.val必须等于right.val;
  2. left.left要和right.right继续满足镜像关系;
  3. left.right要和right.left继续满足镜像关系。

这背后的道理就是照镜子。两个人面对面站着,你的左手在对方看来是右手,你的右手在对方看来是左手。放在二叉树上,左边节点的左分支,对应的是右边节点的右分支;左边节点的右分支,对应的是右边节点的左分支。理解这个“交叉”关系是整道题的核心,也是后面递归代码里最难写对的那一行。

如果两个节点一个是空、一个不是空,那肯定不对称,直接返回false。如果两个都是空,说明已经到了叶子底部,这一组镜像关系成立,返回true。这两条边界条件加上刚才说的三条规则,其实就是完整的一层递归逻辑。

2. 递归解法:不要让主函数硬撑,拆出一个 isMirror 出来

2.1 递归参数为什么是两个而不是一个

很多新手一开始会想:既然题目只给了一个根节点root,那就写一个函数isSymmetric(root),在里面判断左子树是否对称、右子树是否对称。这个思路第一眼看上去没问题,但仔细一推就会发现问题:一棵整体对称的树,它的左子树单独拿出来不一定对称。

比如上面那棵树,根节点左子树的根是 2,它左孩子是 3、右孩子是 4,单独看左子树的话,3 和 4 并不对称。但整棵树因为存在右侧镜像的 4 和 3,所以整体是对称的。也就是说,不能把问题拆成“左子树对称且右子树对称”,而应该拆成“左子树和右子树互为镜像”。

这就是为什么需要一个额外的递归函数,参数是两个节点:一个来自左子树,一个来自右子树。比较的时候,永远是比较“一对镜像节点”,而不是比较某个子树自身。递归函数的签名可以设计成isMirror(left, right),主函数只需要做一件事:把root.left和root.right作为第一对镜像节点传进去。

2.2 终止条件与单层逻辑的代码化

下面是完整的递归解法,用 Python 写:

from typing import Optional class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right class Solution: def isSymmetric(self, root: Optional[TreeNode]) -> bool: if root is None: return True return self.isMirror(root.left, root.right) def isMirror(self, left: Optional[TreeNode], right: Optional[TreeNode]) -> bool: if left is None and right is None: return True if left is None or right is None: return False if left.val != right.val: return False return ( self.isMirror(left.left, right.right) and self.isMirror(left.right, right.left) )

递归函数四个出口,按顺序分别是:

  • 两个节点都是空:说明这组镜像关系走到了头,成立;
  • 一个空一个非空:结构不对称,直接失败;
  • 两个都不空但值不相等:数值不对称,直接失败;
  • 两个都不空且值相等:继续递归检查它们的镜像孩子。

代码里最关键的一行就是最后那个and。left.left要和right.right比较,left.right要和right.left比较。一旦把这两个对应关系写反,整道题就废了。

2.3 手推一个递归过程

拿上面那棵[1,2,2,3,4,4,3]的树来说,主函数会先调isMirror(2, 2)。这两节点值相等,于是递归调用isMirror(3, 3)和isMirror(4, 4)。isMirror(3, 3)继续检查 3 的左孩子和 3 的右孩子,发现两边都为空,返回true。isMirror(4, 4)同理返回true。最后两个true通过and汇总,根节点返回true。

如果某一个位置出现了空与非空的错位,递归会沿着对应分支一路走到底,然后返回false。因为and的特性,只要有一组镜像失败,整个结果立即失败,不会继续做无意义的比较。

2.4 复杂度分析

时间复杂度是O(n),其中n是二叉树节点总数。每个节点在递归过程中最多被访问一次,因为它只会出现在一组镜像节点对里。最坏情况下,比如一棵完全不对称但前面几层恰好相等的树,可能会访问很多节点才返回false,但整体仍然不会超过O(n)。

空间复杂度和递归深度有关。完全二叉树的高度是O(log n),但题目没说一定是平衡树,如果树退化成一条链,递归深度会达到O(n),这时系统调用栈压力会比较大。在面试中一定要把“递归空间复杂度最坏是 O(n)”这句话说出来,不能只背一个平均情况。

3. 迭代解法:用队列把“镜像对”按顺序排好

3.1 为什么直接做层序遍历不够直接

有些同学会想:那我用层序遍历,把每一层的节点值收集成一个数组,判断这个数组是不是回文不就行了?这个思路能解部分情况,但要小心,如果只收集非空节点的值,会出现误判。比如树长成这样:

1 / \ 2 2 \ \ 3 3

只看非空节点,第一层是[2, 2],第二层是[3, 3],两层都像是回文,但实际整棵树并不对称。因为左边多出的 3 在右侧对应的位置应该是 3 没错,但镜像关系要求左子树最右边对右子树最左边,这里的结构明显是“同向偏”,不是镜像。

如果你真的要用层序遍历,就得把空节点也考虑进去,用类似null占位符的方式还原完整结构,代码会变得很啰嗦。更直接的做法是:不用分层,而是维护一个队列,每次从队列里取两个节点出来,成对比较。这一对节点天然就是“待比较的镜像位置”,不需要额外整理层级。

3.2 队列成对出队,空节点也照样入队

迭代解法如下:

from collections import deque from typing import Optional class Solution: def isSymmetric(self, root: Optional[TreeNode]) -> bool: if root is None: return True queue = deque([root.left, root.right]) while queue: left = queue.popleft() right = queue.popleft() if left is None and right is None: continue if left is None or right is None: 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

初始时队列里放入root.left和root.right,也就是第一对镜像节点。之后每次循环弹出两个,如果两个都为空,说明这对节点不需要再往下扩展,直接跳过;如果一空一非空,说明结构不对称;如果值不等,说明数值不对称。

最需要注意的是最后四行入队顺序。我习惯先放入left.left和right.right,再放入left.right和right.left,这样队列里的元素天然成了两两一组。你当然可以调换两组的先后顺序,但不能打乱组内配对。如果把left.left、left.right、right.left、right.right这样一股脑放进去,下一次弹出时就会把left.left和left.right错误地配成一对,整个比较逻辑就乱了。

3.3 空节点必须进队列的原因

再强调一个很容易忽略的点:空节点也要入队。有人看到节点为空就直接跳过不处理,等于把一个位置的空缺信息丢掉了。

用上面那颗“同向偏”的树举例。初始队列是[左2, 右2],弹出后,左 2 的左孩子是空、右孩子是 3;右 2 的左孩子也是空、右孩子是 3。如果遇到空节点就跳过不入队,那么下一次队列里可能只剩[3, 3],看起来相等,最终错误地返回true。实际上应该把None也当成普通节点放进队列,让空位置和非空位置形成“一空一非空”的对比,这样才能准确判断结构是否错位。

用 Python 的collections.deque实现时,popleft()是O(1)操作。如果自己用列表pop(0),每次弹出都要移动整个列表,最坏情况下会变成O(n^2),这一点在写代码时也要注意。

4. 用四组测试用例把两种解法都验一遍

4.1 典型完全对称树

标准例子是[1,2,2,3,4,4,3],它对应:

1 / \ 2 2 / \ / \ 3 4 4 3

两种解法都应该返回true。递归解法会依次比较(2, 2)、(3, 3)、(4, 4);迭代解法会按照镜像对不断入队出队,最终队列清空。写到这里建议你用断点或者手动模拟跑一遍,这对理解“镜像交叉”关系很有帮助。

4.2 左右孩子值相同但结构不对称的陷阱

[1,2,2,null,3,null,3]是我个人很推荐的一个反例。它的结构是:

1 / \ 2 2 \ \ 3 3

左右子树的根节点都是 2,但两边都往右长,形成的是“同向偏”。从队列迭代的视角看,第一对(2, 2)没发现问题,继续入队时会把左 2 的左孩子None和右 2 的右孩子3放进队列。下一次弹出立刻发现一个空、一个非空,返回false。

这个用例能拦住很多“只比较节点值”的错误写法。如果只写return root.left.val == root.right.val,这棵树会直接通过前面两层,但深层的结构错位完全没被检查到。

4.3 空树和单节点边界

空树返回true,这在题目描述里是明确规定的:空二叉树是对称的。单节点树也只有根节点,没有左右子树需要比较,同样返回true。

这两类边界看似简单,但经常被忽略。特别是用递归解法的时候,如果主函数没有先判断root is None,直接访问root.left就会报空指针异常。在面试现场,空指针异常会让印象分大打折扣。

4.4 测试用例汇总

测试用例结构特点预期结果
[]空树true
[1]单节点true
[1,2,2,3,4,4,3]完全镜像对称true
[1,2,2,null,3,null,3]同向偏,结构错位false
[1,2,2,3,4,4,3,5,6,7,8,8,7,6,5]更深的完全对称树true

最后一行的用例可以自己画一画,它会同时考验递归函数里两条递归分支是否正确:left.left对right.right,以及left.right对right.left。如果只写对了一条,这种深层用例就会翻车。

5. 从第 101 题往外看:同一套递归思路能解哪些二叉树问题

5.1 变体一:相同的树

如果题目改成“判断两棵二叉树是否完全相同”,递归逻辑几乎一模一样,只是把“镜像交叉”改成“同位比较”。判断相同树的递归写法是:

class Solution: def isSameTree(self, p, q): if p is None and q is None: return True if p is None or q is None: return False if p.val != q.val: return False return ( self.isSameTree(p.left, q.left) and self.isSameTree(p.right, q.right) )

对比一下就能发现,对称二叉树的递归返回条件里是left.left对right.right、left.right对right.left;相同树的递归返回条件里是p.left对q.left、p.right对q.right。两者只差一个“对应关系”,其余所有结构,包括空节点判断和值判断,都完全相同。

5.2 变体二:翻转二叉树

翻转二叉树的常规做法也是递归:

class Solution: def invertTree(self, root): if root is None: return None root.left, root.right = ( self.invertTree(root.right), self.invertTree(root.left), ) return root

这道题和对称二叉树的关系很有意思:一棵二叉树如果对称,那么把它的左子树翻转之后,应该和右子树完全相同。换句话说,第 101 题本质上可以转化为“翻转左子树,然后判断是否与右子树相同”。这也是为什么刷题时建议把“相同的树”“翻转二叉树”“对称二叉树”三题放在一起做。它们共享同一个递归模板,区别只在孩子节点的对应关系上。

5.3 判断两棵树关系的通用递归套路

通过这三道题,可以总结出一个很好用的递归套路。凡是判断两棵树之间某种关系的题目,都可以按以下步骤思考:

  1. 先处理空节点:两边都空怎么返回,一边空怎么返回;
  2. 再比较当前节点的值;
  3. 最后确定下一步要比的是哪些对应关系。

难就难在第三步。相同树比较的是“同位”,对称树比较的是“镜像交叉”,翻转树是把“返回左子树”和“返回右子树”调换。只要画一张小图,把对应关系标出来,递归函数就不容易写错。这个思路也能迁移到更多题目上,比如判断某棵树是否是另一棵树的子树、判断两棵树是否镜像等。

6. 复盘这道题时怎么讲给面试官:表达顺序与三个易翻车点

6.1 表达顺序建议

如果是在面试场景里讲这道题,我建议按“定义 -> 递归 -> 迭代 -> 复杂度”的顺序说。

先解释:对称二叉树就是左右子树互为镜像,不是简单的左孩子等于右孩子。然后给出递归函数,重点强调交叉匹配的对应关系,不是left.left和right.left比,而是left.left和right.right比。接着再补一句:递归本质上是利用系统栈做隐式遍历,也可以用显式队列改成迭代版本。最后把所有复杂度结论一次性讲清楚:时间O(n),递归空间O(h),最坏O(n),迭代队列最坏空间也是O(n)。

这么讲的好处是逻辑链条完整,面试官能顺着你的思路理解代码,而不是听你背答案。

6.2 三个容易翻车的点

第一个翻车点:递归函数里漏掉left.val != right.val的判断。如果只递归比较孙子节点,不比较当前两个节点的值,那么左右根节点值不相等也可能返回true。这是很隐蔽的 bug,调试时很难一眼看出来。

第二个翻车点:迭代解法遇到空节点就直接continue。空节点代表着结构信息,跳过它会导致“一空一非空”的结构不对称被掩盖。必须把空节点也当成入队对象,通过下一轮弹出的(None, 非空)组合来识别结构错位。

第三个翻车点:把递归写成return isSymmetric(root.left) and isSymmetric(root.right)。这种写法判断的是左子树自身对称且右子树自身对称,而不是左右子树互为镜像。很多树的左右子树自身都不对称,但整体是对称的,所以这种写法会误判。

6.3 我的实际复盘体会

这道题我自己第一次写的时候,也栽在了“同向偏”那个用例上。当时觉得递归终止条件写得没问题,但提交之后才发现left.left和right.left这两条分支被我搞混了。后来我养成一个习惯:遇到二叉树关系类题目,先不写代码,先画一棵三层以上的树,把比较路径用箭头标出来。箭头从哪到哪画清楚了,递归函数里那行and就绝对不会写错。

另外我还建议把递归和迭代各写一遍。递归其实是在用系统栈比较镜像对,迭代是在用队列比较镜像对,两者的核心都是“成对比较”。如果你能自己把递归版本改写成迭代版本,说明你是真的理解了这题的比较逻辑,而不是只记住了某一种写法。这道题做透之后,再去做“相同的树”“翻转二叉树”,会明显感觉到思路被打通了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 7:36:27

Java并发编程核心:从JMM内存模型到Happens-Before规则实战解析

Java 多线程开发里,最折磨人的从来不是锁写错了,而是一段“看起来完全没问题”的代码在并发下突然翻车。三年前我负责一个网关服务,后台线程定期刷新上游节点健康状态,业务线程读这个状态决定路由。上线后某个区域偶尔把请求打到已…

作者头像 李华
网站建设 2026/10/10 7:36:13

OpenAI dots云端AI编码代理:异步常驻工位实操与避坑指南

昨天群里有人转了一张图,标题是“OpenAI 给 AI 发了张工位”。我一开始以为是整活,仔细看完才发现,dots 这个产品真的就是把一个 AI 编码代理常驻在云端,让它自己值班。熟悉 Codex 的朋友应该记得那行welcome to codex——以前是我…

作者头像 李华
网站建设 2026/10/10 7:35:51

二级倒立摆LQR控制仿真全流程:从非线性建模到Simulink闭环实现

二级倒立摆,这个课题我在学生时代折腾过很久,工作之后再看身边的同事做机器人腿部平衡、无人机吊舱稳定这类项目,本质上都绕不开同一套东西:非线性建模、局部线性化、状态反馈控制、仿真闭环验证。这篇文章就把我实际做过的“二级…

作者头像 李华
网站建设 2026/10/10 7:35:50

风光储交直流微电网孤岛Vf控制建模与仿真实践

风光储微电网做得多了以后,你会发现真正考验功力的往往不是并网状态下的PQ控制,而是孤岛模式下的电压频率支撑。我去年在做一套园区级风光储交直流微电网仿真平台时,把光伏、风电、储能都接进同一个网络,直流母线750V、交流母线38…

作者头像 李华
网站建设 2026/10/10 7:35:40

政企智能体落地实战:从POC到生产的技术选型与容错控制

智能体这词在政企圈子里这两年被反复提起,真正落地过的人都知道,它和消费级玩具Agent完全是两回事。我过去一段时间里经手过三个政企智能体项目,分别落在制造业质检、能源行业一线维修支持、政务窗口材料预审三个场景。每个项目都从POC一直推…

作者头像 李华