多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

对称二叉树判断详解:递归与迭代解法全解析

对称二叉树判断详解:递归与迭代解法全解析 几乎每个准备算法面试的人都会撞上这道题LeetCode上的编号是101剑指Offer里也有它很多大厂笔试和面试环节都直接拿它当热身题。题目本身描述得很简单——给定一棵二叉树检查它是否是镜像对称的也就是绕着根节点看左右两边是否互为镜像。但就是这么一道“简单题”我在实际面试候选人和带新人刷题的时候发现真正一次写对的人非常少。很多人在理解“对称”这两个字上就掉坑了递归函数写得不对迭代解法更是没思路。这篇文章我就把判断对称二叉树这件事掰开揉碎讲清楚包括递归和迭代两种解法的完整思路、代码实现、容易踩的坑以及相关的变体题目和排查经验。1. 先把“对称二叉树”的定义彻底想明白1.1 初学时段最常见的一个误区我见过不少同学一看到这道题第一反应是判断左子树和右子树是否各自对称。他们会写出类似isSymmetric(root.left) isSymmetric(root.right)的代码跑几个对称的例子发现没问题但一旦遇到非对称的树立刻翻车。问题出在哪因为一棵树整体对称完全不要求它的左子树单独对称也不要求右子树单独对称。举个例子一棵左子树长成“之”字形、右子树正好是它的镜像的树整体是对称的但左子树本身可能完全不对称。这个直觉上的误会是这道题最大的拦路虎所以做题第一步不是急着写代码而是把“镜像对称”这个概念抠清楚。1.2 镜像关系的递归定义对称二叉树的准确定义是根节点的左子树和右子树互为镜像。什么叫“互为镜像”不是两棵树长得一样而是左子树沿着某个轴翻转之后能和右子树重合。用递归的语言描述更严谨一些两棵二叉树t1和t2互为镜像需要同时满足三个条件t1和t2的根节点值相等t1的左子树与t2的右子树互为镜像t1的右子树与t2的左子树互为镜像。看到这个定义就明白了这是一个天然适合递归的问题。它不需要你比较同一棵树的两边而是需要你同步比较两颗不同子树上的对称位置节点。这个“同步”是关键词先比左子树的左孩子和右子树的右孩子再比左子树的右孩子和右子树的左孩子每一层都要交叉着匹配而不是平行着比较。用一棵具体的树说话。假设根节点是1左孩子是2右孩子也是2左孩子的左孩子是3右孩子的左孩子是3那么这棵树是否对称答案是false因为左子树的左孩子3应该和右子树的右孩子去比但右子树的右孩子是空的空和非空不匹配。但如果左孩子的右孩子是3右孩子的左孩子是3左孩子的左孩子是4右孩子的右孩子是4也就是形如[1,2,2,4,3,3,4]的层序序列这才是一棵真正的对称树。很多人凭视觉直观觉得“第一层相等、第二层相等、第三层相等”就够了其实层序遍历逐层对称加上每一层内部的有序性才构成完整的镜像条件。2. 递归解法用函数签名表达“镜像”关系2.1 为什么需要辅助函数这道题在LeetCode上只给你一个函数isSymmetric(root)如果你试图在这个函数内部递归调用它自己你会发现很难写。原因在于isSymmetric(root)的入参是一棵子树的根节点你要判断的是这棵子树根节点的左孩子和右孩子是否互为镜像这天然就是“两棵树是否互为镜像”的比较逻辑。所以一个更干净的做法是抽出一个辅助函数isMirror(t1, t2)专门用来判断两棵树是否互为镜像然后把根节点的左右孩子传进去。这个解法的核心其实就是一个函数签名的事。isMirror(t1, t2)接收两个节点比较它们的值然后递归比较t1.left与t2.right以及t1.right与t2.left。因为“镜像”本质上是交叉比较所以每一次递归都隐含着一次“翻转”的视角这是理解整个代码的关键。2.2 递归代码与逐步走读先给出Python实现这个版本最直观后面我会再给出C版本对比def isSymmetric(root): if not root: return True return isMirror(root.left, root.right) def isMirror(t1, t2): if t1 is None and t2 is None: return True if t1 is None or t2 is None: return False if t1.val ! t2.val: return False return isMirror(t1.left, t2.right) and isMirror(t1.right, t2.left)有几点需要特别说明。第一空指针的判断顺序不能乱先判断“两者都空”返回True再判断“其中一方为空”返回False这个顺序能保证代码安全不会在下一步访问空指针的属性。第二值比较放在结构判断之后因为如果结构已经不对称了就没有必要再取val否则可能引发空指针异常。第三递归的终止条件除了空节点之外还要在值不相等时立刻剪枝返回False这样能避免无意义的深层递归。用一棵非对称树[1,2,2,3,null,null,3]节点按层序编号来模拟执行。首先isMirror(2, 2)两个节点值相等继续递归isMirror(2的左孩子3, 2的右孩子null)这里第一个条件中左节点非空、右节点为空走t1 is None or t2 is None分支返回False。因为最后一行是and连接只要第一个递归调用返回False第二个递归调用根本不会执行函数提前结束。这就是短路求值在实际算法里的意义它让不匹配的子树能快速失败。2.3 复杂度分析与面试追问递归解法的时间复杂度是O(n)因为最坏情况下需要遍历二叉树上的所有节点其中n是节点总数。空间复杂度取决于递归深度在最坏情况下一棵严重偏斜的树会让递归栈深度达到O(n)而平均情况下二叉树的高度是O(log n)所以空间复杂度是O(h)其中h是树高。面试官问到递归解法之后大概率会追问一个问题如果这棵树特别深递归会不会出问题这个问题的答案和你使用的编程语言以及递归深度限制有关。以Python为例默认的递归深度限制通常在1000层左右如果树的高度超过这个值就会抛出RecursionError。你在本地刷题时可能很少遇到这么深的树但面试官问的不是“能不能跑”而是“你有没有意识到递归栈的隐患”。这时候你如果能主动给出迭代解法的思路会是一个很加分的表现。3. 迭代解法用队列同步比对绕开递归栈3.1 队列比对的思路来源迭代解法最常见的做法是用队列。这个思路可以从层次遍历迁移过来层次遍历用队列按层取出节点而对称判断则需要在每次出队时同时取出两个节点这两个节点就是一对应该在镜像位置上相等的节点。队列里初始放入根节点的左右孩子之后每次从队首弹出两个节点进行比较再把它们各自的孩子按交叉顺序入队。关键点在于“交叉顺序”这四个字。比较完一对节点后需要把第一个节点的左孩子和第二个节点的右孩子成对入队再把第一个节点的右孩子和第二个节点的左孩子成对入队。这个顺序和递归中isMirror(t1.left, t2.right)的交叉逻辑完全一致只是把递归调用栈换成了显式队列。用生活化的类比来说递归是“程序帮你记账”每次递归调用都压栈保存中间状态迭代则是“你自己记账”在队列里显式维护下一轮要比较的所有节点对。3.2 Python队列实现与细节处理from collections import deque def isSymmetric(root): if not root: return True queue deque() queue.append((root.left, root.right)) while queue: left, 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, right.right)) queue.append((left.right, right.left)) return True这段代码有几个细节值得展开。第一为什么遇到“两者都空”时是continue而不是直接返回True因为队列里可能还有其他节点对等待处理你只确认了当前这一对是匹配的不代表整棵树已经检查完。第二入队的顺序决定了比较的顺序如果你不小心把(left.left, right.left)放进队列那比较的就是同一侧的两个节点结果必然错误。第三用元组成对入队是一种比较清爽的写法也可以用两个队列分别存节点但那样代码会啰嗦不少。从可读性角度我更推荐元组或小数组的方式。我再用一个具体例子验证迭代过程。考虑对称树[1,2,2,3,4,4,3]。初始化队列[(2,2)]。第一轮弹出(2,2)值相等入队(3,3)和(4,4)。第二轮弹出(3,3)值相等它们的左右孩子都是空入(null, null)和(null, null)。第三轮弹出(4,4)同理。之后连续弹出四对空节点对全部continue队列清空返回True。可以看到迭代过程中节点对总是“镜像位置上”的一对完全复刻了递归的交叉比较逻辑。3.3 C版本与递归版本对比C实现同样直接这里用queue来存储节点对bool isSymmetric(TreeNode* root) { if (!root) return true; queuepairTreeNode*, TreeNode* q; q.push({root-left, root-right}); while (!q.empty()) { auto [left, right] q.front(); q.pop(); if (!left !right) continue; if (!left || !right || left-val ! right-val) return false; q.push({left-left, right-right}); q.push({left-right, right-left}); } return true; }对比两个版本的递归与迭代解法可以列个表看各自特点维度递归解法迭代解法核心机制系统调用栈显式队列空间复杂度O(h)h为树高O(n)最坏时队列存放接近整棵树的节点风险点深度过大可能栈溢出无栈溢出风险但内存可能更高代码量较短逻辑直接稍长需要维护队列适合场景树深度可控时面试追问或树高度不确定时迭代解法在最坏情况下的空间占用确实可能比递归大因为队列里可能同时存在同一层的很多节点对。但它的优势也很明显不受递归深度限制逻辑可以一步步断点调试。我在实际面试中见过不少候选人递归写得飞快但被追问“如果树有十万层怎么办”时卡壳这时候迭代解法就是救场的钥匙。4. 实操环节从边界条件到测试用例设计4.1 必须考虑的边界条件写任何二叉树题边界条件都是一个完整的得分点。判断对称二叉树这道题我开始敲代码之前会先在脑子里过一遍这些场景空树root为None直接返回True。这一点面试时很容易被忽略空树没有左右孩子它当然是对称的。只有一个根节点返回True。单个节点没有子树可比较天然对称。左右孩子存在但值不相等返回False。这是最简单的非对称场景。某个子树缺失比如左子树存在、右子树为空返回False。这是结构上的不对称。结构对称但值不对称比如[1,2,2,3,4,4,5]最后一个节点的值应该是3却写成了5判断时在第三层的某一对节点上就会触发值比较失败。关于边界条件的细节root为None时递归版本里要先判断否则直接访问root.left就是空指针异常。迭代版本同样要先处理空树。这两个判断是所有解法共通的起手式别把简单题做漏了。4.2 构造一套可复用的测试用例我习惯在本地建一个公共测试集因为这个题本身函数签名简单适合直接验证。我常用这样几个用例用例1: [] - True 用例2: [1] - True 用例3: [1,2,2,3,4,4,3] - True 用例4: [1,2,2,null,3,null,3] - False 用例5: [1,2,2,3,null,null,3] - True 用例6: [1,2,2,3,4,null,null,null,null,3] - False第三用例是教科书级的对称树层序从左到右读过去每一层都是回文结构同时还要满足交叉位置的节点存在性一致。第五用例是容易看走眼的一个左子树的右孩子是3右子树的左孩子也是3整棵树依然是对称的这里“3”出现在不同的相对位置但因为是镜像关系所以成立。我在带新人时经常用这个用例来检验对方是否真正理解交叉比较的逻辑很多人在纸上画半天最后恍然大悟——原来不是层序回文那么浅层的事情。4.3 本地调试时的一个实用技巧刚才说到测试用例顺便分享一个排查技巧当你看不出某棵树为什么不对称时自己把树的每一层按照从左到右打印出来再手动检查每一层是否回文这通常能快速定位不对称发生的位置。但要注意层序回文只是必要条件不是充分条件。比如[1,2,2,#,3,#,3]层序是[1,2,2,#,3,#,3]第二层2,2回文第三层#,3,#,3不是回文显然不对称。但也有层序回文却不对称的情况吗有比如结构上层序回文、但某个节点的左右孩子存在性不对称导致它的层序遍历带空占位后不仅不守恒反而暴露出问题。所以更好的调试方式是把空节点也用占位符打印出来检查整层的占位序列是否关于中点对称并且每一对对称位置上的节点父结构也满足交叉关系。这个方法在调试时很管用尤其是在使用层序遍历验证对称性的代码里。5. 从对称题延伸出去高频变体与周边知识点5.1 判断两棵树是否互为镜像对称二叉树判定的核心子问题其实就是“判断两棵二叉树是否互为镜像”。我在面试中经常把这道题当引子然后追问候选人能不能把isMirror这个辅助函数单独拿出来作为一个新题写一遍。比如题目改成“给定两颗二叉树判断它们是否互为镜像”入参就是两个根节点直接复用isMirror(t1, t2)。这个变体看起来简单但它考察的是对递归定义的迁移能力。你如果只是背了对称二叉树的解法没有理解镜像的真义遇到这个变体可能会重新陷入“先序遍历相同”“中序遍历相同”之类的误区而那些方法都会踩到结构重合但对称位置不匹配的坑。所以真正掌握这道题不是背代码而是理解“递归交叉比较”这个底层模式。5.2 翻转二叉树与对称性的关联又想起另一道题——翻转二叉树LeetCode 226也有人叫它“反转二叉树”。如果一棵二叉树的左右孩子全部交换得到的是一棵镜像树。那么一个有意思的推论是一棵树是对称二叉树当且仅当它翻转之后和自身相等。这个命题等价性可以帮助你从另一个角度理解题目甚至在某些变体题里派上用场。实际操作中如果你已经写好了翻转二叉树的函数再写一个判断两棵树是否相等的函数把两者拼起来也能解决对称判断只是多了一次遍历和一次比较时间上还是O(n)但常数因子更大不推荐面试时这么写。不过在心里记住这层关系确实能加深对树结构操作的理解。5.3 对称性的实际应用场景可能有人会问这种纯算法题除了面试真的会出现在工程里吗举几个我在工作中碰到过的场景。第一个是前端树组件的校验一些UI组件的树形数据如果要求对称布局后端返回的数据结构需要先做合法性校验第二个是语法分析里的抽象语法树某些编译器优化阶段会检查AST节点是否满足某种镜像等价关系比如交换律表达式的模式匹配第三个是图像处理里的镜像检测虽然图像不是二叉树但类似的分治递归思路常常迁移到图像金字塔的比较中。这些场景的共同点是你需要比较一棵树在某种几何操作下是否保持不变性而“对称”只是最简单的一种不变性。写在最后的个人体会做了这么多年算法教学和面试评审我最大的体会是一道题的价值不在于它本身有多难而在于你能不能从中提炼出通用的解题范式。对称二叉树这道题本质上考察的是你能否把“两个节点的关系模式”递归地定义清楚然后用递归或队列把它实现出来。这种“定义清楚关系再选择实现手段”的思路放在很多树上题目里都适用比如判断两棵树是否相同、判断子树、求树的最大深度全都是同一条思路线。还有一点想提醒大家写这种题时不要在代码结构上过度炫技把递归函数命名清楚、把空判断顺序摆正、把交叉比较写对就已经拿满了分数。我见过很多候选人写一长串花哨的写法最后却因为一个and写成了or直接错掉。简单、直接、可验证永远是算法代码的第一追求。
返回列表