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

文章详情

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

对称二叉树LeetCode 101全解:从递归到迭代的镜像判断

对称二叉树LeetCode 101全解:从递归到迭代的镜像判断 力扣hot100刷到第34题对称二叉树原题编号是LeetCode 101。这道题在热题100里属于“看着简单写起来容易翻车”的代表。我第一次做的时候只判断了根节点的左右孩子值是否相等还自信满满地提交结果直接WA。后来我仔细画了几棵树的镜像图才真正搞明白“对称”不是“相等”而是要模拟照镜子的效果。无论你是刚开始刷题的新手还是准备面试的老手这道题都值得认真拆解。它小但五脏俱全递归、迭代、DFS、BFS、空节点处理、边界条件全都能在里面练到。我打算从题目本质讲到三种写法再到调试技巧和延伸思考尽量把每个环节掰开揉碎。1. 对称二叉树凭什么进hot1001.1 原题到底在问什么力扣101题的题面极短给你一棵二叉树的根节点root检查它是否轴对称。这里的轴对称是指以根节点所在位置为对称轴整棵树左右完全镜像。注意镜像不会改变节点值的对应方式但会改变“左右”的对应方向。举个例子下面这棵树就是对称的1 / \ 2 2 / \ / \ 3 4 4 3从根节点1出发左侧2的左孩子是3右侧2的右孩子也是3左侧2的右孩子是4右侧2的左孩子也是4。镜像关系一一对应。再看另一棵树1 / \ 2 2 \ \ 3 3根节点左右孩子都是2看起来很像对称。但左侧2的右孩子是3而右侧2的右孩子也是3。按照镜像规则左侧2的右孩子3应该对应右侧2的左孩子可右侧2的左孩子是空。因此这棵树不对称。很多错误答案就出在这种“差一层”的例子上。1.2 它被选进热题100的原因hot100是力扣筛选出来的高频面试题集。一道题能进hot100通常要么是面试常客要么是算法技巧的“浓缩胶囊”。对称二叉树两者都占。面试里树的题目出现频率很高而对称二叉树作为树的基础综合题正好能考察候选人三项能力第一能不能理解“镜像”这种空间概念第二能不能设计出正确的递归出口第三遇到递归不方便时能不能切换到迭代思路。这几点恰好也是很多候选人最薄弱的地方。从刷题训练角度看这道题难度不高但可拓展性很强。你可以轻松把它改成“判断两棵树是否互为镜像”“判断N叉树是否对称”“判断某层是否回文”等变体。所以它不只是一个题更是一组树类问题的“元模型”。刷hot100如果只追求数量遇到这题很容易滑过去但我建议你停下来把它当作一个可以反复咀嚼的基础题。1.3 它在“树类问题”中的定位二叉树的经典操作大概可以分成几类遍历前中后序、层序、属性统计深度、节点数、路径搜索路径和、最近公共祖先、以及结构判断相同树、对称树、平衡树、完全树。对称二叉树属于“结构判断”这一类而且它处在最基础的位置。如果你能把对称判断的递归模型写熟那么后面遇到“判断两棵树是否相同”LeetCode 100、“翻转二叉树”LeetCode 226、“合并二叉树”LeetCode 617时会有一种强烈的熟悉感。因为这些题的核心都是“成对处理节点”只是在比较条件和递归参数上有所不同。所以我经常把对称二叉树称为“镜像思维”的元题先把这道题吃透其他结构判断类题目会事半功倍。2. 解对称问题的第一性原理2.1 对称的本质是“镜像位置的节点相等”为什么直接比较根节点的左右子树不等于判断对称因为“相等”是两个结构完全一致方向相同而“对称”是两个结构互为镜像方向相反。你在镜子前举起左手镜子里的“你”举起的是右手。树的镜像也是这个道理左侧节点的左孩子对应右侧节点的右孩子左侧节点的右孩子对应右侧节点的左孩子。这个定义决定了我们做比较时必须把比较的指针设计成“交叉”形态一个指针走向左另一个指针必须走向右。很多同学写错代码就是因为没有建立这种交叉意识把递归写成了“p.left对应q.left”那是在判断相同树不是对称树。所以请记住一句话对称判断永远是一左一右对应。2.2 把镜像关系转化成递归模型如果我们定义函数f(p, q)表示“节点p和节点q是否互为镜像”那么它的逻辑这样展开若p和q都为空说明两边都不存在节点镜像成立返回True若p和q只有一方为空说明镜像位置缺失返回False若p.val不等于q.val值不一致返回False若以上都满足继续检查p.left与q.right是否镜像以及p.right与q.left是否镜像两者都成立时返回True。把这个递归模型画成代码就是主函数返回f(root.left, root.right)。为什么参数是root.left和root.right因为整棵树的对称性完全取决于根节点左右两个孩子是否互为镜像。根节点本身的值不需要参与比较因为不管根节点的值是什么它都是自己。2.3 空节点也是重要的信息在二叉树问题里null节点经常被忽略但对称判断恰恰依赖空节点。试想[1,2,2,null,3,null,3]这棵树如果不把null当作一种位置信息只看值两侧都有2和3可能会误判为对称。实际上左侧2的左孩子是null右侧2的右孩子是3位置上根本不匹配。所以写代码时处理空节点必须放在最前面而且两个空节点与一个空节点要区分开。我的习惯是写两个ifif not p and not q返回Trueif not p or not q返回False。千万不要合并成一个if not p or not q那样两个空和单空都会被当成False导致空树或者空节点场景出错。开发时你会觉得多写一个if很啰嗦但恰恰是这个if保住了边界案例。3. 三种核心实现递归、栈模拟、队列层序3.1 递归解法最直观也最容易写错递归版本的代码上面已经给出这里再强调几个细节。Python中TreeNode的定义通常是class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right力扣的Python3环境下直接使用Optional[TreeNode]表示可能为空没有问题。写递归时我建议把check函数放在isSymmetric内部这样可以直接引用不需要额外传参代码也更紧凑。这里还要注意递归的“短路”特性。Python的and会从左往右计算如果第一个递归调用返回False右侧调用就不会执行。这个特性在性能上是有利的因为一旦发现某处不对称可以提前终止。但同时也意味着你不能依赖第二个递归调用做某些副作用操作。当然纯粹的对称判断不需要副作用所以没问题。3.2 迭代解法用栈模拟递归面试官让你别用递归时迭代解法就派上用场。核心思想是手动维护一个栈栈里放的是“待比较的节点对”。每次从栈顶弹出一对节点按照递归相同的三个判断来处理class Solution: def isSymmetric(self, root: Optional[TreeNode]) - bool: if root is None: return True stack [(root.left, root.right)] while stack: left, right stack.pop() 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 stack.append((left.left, right.right)) stack.append((left.right, right.left)) return True注意压栈顺序先压(left.left, right.right)再压(left.right, right.left)。这里顺序不关键但配对一定不能错。有些人把第二个pair写成(left.right, right.right)那就变成比较同一个方向了整棵树的对称结构会被破坏。我在面试现场见过候选人犯这个错他反复提交都过不了实际上就是因为左右交叉写错了。3.3 队列写法层序验证对称队列写法和栈几乎一样只是把pop改成popleft先比较的节点对先入队。这种写法的优势是它天然对应层序遍历的思路适合回答“能不能用BFS做”的追问。代码如下from collections import deque class Solution: def isSymmetric(self, root: Optional[TreeNode]) - bool: if root is None: return True q deque() q.append((root.left, root.right)) while q: left, right q.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 q.append((left.left, right.right)) q.append((left.right, right.left)) return True有人会问为什么不直接把每一层的节点值收集起来然后判断是否回文那种做法需要额外记录null的位置否则像[1,2,2,null,3,null,3]这种例子第二层的值列表是[2,2]是回文但第三层的值列表[null,3,null,3]就不是回文逻辑可行但实现起来要小心。成对比较的写法更直接也避免了层分隔符的问题。3.4 三种实现对比实现方式数据结构时间复杂度空间复杂度优点注意点递归系统栈O(n)O(n)最坏代码最简洁递归深度可能过大迭代栈自定义栈O(n)O(n)无递归深度风险配对关系要写对队列BFS队列O(n)O(n)层级感直观空节点处理需严谨实际面试中三种写法只要会两种就够但最好都能理解。因为面试官一追问“会不会迭代”你如果只会递归就得现场想半天。4. 实操全过程从读题到AC的完整动作4.1 先手动画图别急着写代码我刷很多树题都有个习惯先在纸上画一棵3层以上的二叉树然后给需要比较的节点对上色。以对称树[1,2,2,3,4,4,3]为例我从根节点下面的两个2开始画两条比较线左侧2的左孩子3去比较右侧2的右孩子3左侧2的右孩子4去比较右侧2的左孩子4。画完之后你会发现比较路径像两条交叉的弧线。这个“交叉弧线”就是递归参数的直接来源左侧往左走右侧必须往右走。如果画的是相同树比较弧线方向是平行的。一个简单的图就能避免一半以上写错递归参数的问题。4.2 准备一套完整的测试用例力扣的默认测试用例只有简单树但你要应对隐藏用例就得自己多准备一些。我本地一般至少测试下面这些输入输入期望输出说明NoneTrue空树TreeNode(1)True单节点[1,2,2,3,4,4,3]True标准对称树[1,2,2,None,3,None,3]False深层不对称[1,2,2,2,None,2]False一层对称但深层不对称[1,2,2,3,None,None,3]True第三层只在边缘有节点且对称把表里的用例写成代码逐个跑一遍比在力扣上提交几十次有效得多。尤其第三个用例可以验证你的代码是否会错误地只比较到某一层就结束。4.3 复杂度分析与边界条件为什么要分析复杂度因为面试官问完“怎么做”之后大概率会问“时间和空间复杂度”。递归写法的空间复杂度不是O(1)而是O(h)h是树高因为在递归过程中系统会保存每个栈帧。最坏情况下树退化成链表h等于n所以空间复杂度是O(n)。迭代写法用栈或队列保存待比较节点对最多存的节点对数量也是O(n)。时间复杂度则无论哪种写法都是O(n)因为每个节点最多被访问一次。边界条件方面除了空树和单节点还有一类容易被忽略节点值为0的情况。如果你在比较条件里写了p.val and q.val遇到val0会出bug因为Python里0为False。正确写法是p.val ! q.val不要用and或or做值判断。另外值的类型是整数不要假设为正数这虽然不影响算法但测试用例覆盖负值和0能让你更早发现类似问题。4.4 提交时的性能与优化观察我在力扣上提交过几版。第一版用递归执行时间约36ms内存消耗16.9MB。第二版用栈迭代执行时间约28ms内存18.1MB。差异不大都在正常范围。如果你发现自己的代码超时大概率不是这题的复杂度问题而是某处造成了死循环。最可能的原因是迭代写法中遇到两个空节点时continue写成了return False或者根本没有写continue导致空节点对无限入栈。另一种可能是你往栈里塞了多余的节点对比如本来两个节点都为空时你还继续把它们的空孩子塞进去这样虽然最终能结束但会白白多处理很多轮影响性能。4.5 一个实际的调试记录我记得有一次写迭代版本时把检查顺序写成了“先比较值再检查空节点”。这在遇到空节点时直接抛异常。当时我很疑惑为什么样例不通过。后来我在本地打了一行print打印每轮弹出的left和right立刻发现有一轮弹出的是(None, TreeNode)程序在left.val处就炸了。把空节点检查提到值比较之前后问题立刻解决。这个顺序问题也提醒我在链表、树的迭代写法中访问字段之前必须先确认对象非空。空安全是一个程序员的基本功而这道题正好能练到。5. 常见问题与排查技巧实录5.1 只判断了根节点左右孩子是否相等有很多初学同学会这样写if root.left.val root.right.val: return True这样写最大的问题是只看了一层完全没有递归或循环。碰到三层以上的对称树比如[1,2,2,3,4,4,3]第一层确实相等程序直接返回True看起来“对了”但换成[1,2,2,3,4,3,4]两侧孩子都是2和3、4乱序第一层也相等程序照样返回True实际这棵树不对称。要避免这个错误就要知道“对称”是一个递归定义必须往下钻到每一层。5.2 递归参数写反或终止条件缺失递归写法常见的两类问题参数写反以及终止条件不完整。参数写反的结果是把对称问题变成相同问题前面已经反复提过。终止条件不完整的情况通常是只写了p.val ! q.val时返回False而漏掉一个为空的情况。假设p不为空q为空那么访问q.val就会抛NullPointerException。所以我的建议是三个终止条件的顺序固定为“都空 - 单空 - 值不等”写熟练之后闭着眼睛都能写对。5.3 迭代写法中的空指针和死循环栈写法和队列写法中最危险的是空指针。有些同学判断两个节点都为空后没有continue而是继续往下走了。这时如果left和right都为空下一步比较val就会出错。正确的做法是两个都空说明这个镜像位置不需要再比较直接继续下一对。另外不要在压栈前把null删掉因为null也是占位信息删掉会导致树结构失真。比如某侧节点为空但对应另一侧有值如果不把空节点作为一对压入栈中就可能漏掉不对称的结论。5.4 快速定位问题的手段打印、画图、比对如果你对为什么WA没思路推荐三层调试法。第一层是纸上模拟拿着错误样例手动列出所有需要比较的节点对。第二层是在check函数入口打印p和q的值注意先判空再打印观察实际比较序列是否和纸上的列表一致。第三层是引入一个正确的参考实现比如把递归版本的输出和你的迭代版本输出做diff逐步缩小差异范围。这个方法不仅适用于对称二叉树处理大多数树类题都有效。尤其面对hot100这种题量大的题库找到自己的调试节奏比死记题解更重要。5.5 常见问题速查表症状可能原因修复方向只判断了左右孩子相等就返回没有递归/迭代到深层使用check(p,q)整体判断递归调用后出现空指针缺少“单空”终止条件在值比较之前加空判断处理到一半返回错误True参数交叉写错p.left必须配q.right迭代死循环空节点对没有continuecontinue放到第一个if里层序思路漏判没有把null节点放进队列保留null作为占位信息6. 由这道题延伸出去的思考6.1 与翻转二叉树联动镜像对称和翻转二叉树关系密切。翻转一棵树就是把每个节点的左右孩子交换。如果你把一棵树的左子树翻转再判断它是否和右子树完全相同也能判断原树是否对称。但这么做会修改原树所以不推荐在生产代码里这样做。不过面试时你可以主动提到这个思路展示你对概念之间关系的理解。更实用的联动是做对称二叉树之前先做一遍翻转二叉树的递归和迭代版本你会发现两者的递归结构非常相似只是一个在交换一个在比较。6.2 与相同树的递归模型对比LeetCode 100“相同的树”是判断两棵树结构相同且节点值相同。它的递归模型是def isSameTree(p, q): if not p and not q: return True if not p or not q: return False return p.val q.val and isSameTree(p.left, q.left) and isSameTree(p.right, q.right)对比对称二叉树的递归模型区别只在递归调用参数相同树比较同侧对称树比较交叉侧。把这两个模型并排写在笔记本上你就能深刻理解“递归函数比较的是哪两个节点”这件事。做hot100时我经常把看起来相似的题并到一张图里用这种方式对抗遗忘。6.3 面试中的扩展问法面试官大概率不会只问你原题。常见扩展包括改成判断一棵N叉树是否对称。处理思路是对每个节点的子节点列表从两端向中间两两比较同时交叉比较子节点的对应关系。如何输出一棵树的所有对称路径可以结合DFS和路径记录。如何在不使用递归的情况下证明两棵树对称迭代版本就是答案。如何判断一棵二叉树是否“中心对称”不要求根节点居中而是整体看起来像某个中心这种问题已经超越了原题一般会结合旋转或变换属于开放讨论。这些扩展并不需要你全部背下来但至少要对“镜像比较”这个核心概念有自己的理解。掌握了交叉配对的思想遇到任何变体都不会慌。6.4 我做这道题的一点真实体会我现在刷题复盘时经常会回想第一次错在“只比较了根节点左右孩子”的那个时刻。那是一次很典型的教训没有理解题意就上手。从那以后我养成了先画图再写代码的习惯。对称二叉树这道题也让我意识到递归的关键不在于写得多短而在于把“每一层要做什么”想清楚。当你觉得递归难懂时就把函数参数改成“当前两个待比较的节点”用中文默念它们之间的关系代码自然就出来了。最后再分享一个小技巧每做完一道树题我会顺手把它和“遍历顺序”挂个钩。比如对称二叉树适合用后序思想来理解——先处理左右子树再汇总结果。说不上这个方法对所有人有效但对我来说把树题当作不同遍历顺序的练习以后很多题都变得有章法了。希望这篇拆解也能给你带来同样的感觉。
返回列表