ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

二叉树最近公共祖先详解:从递归后序遍历到迭代优化

二叉树最近公共祖先详解:从递归后序遍历到迭代优化 1. 题目到底在问什么先别急着上手写代码刷LeetCode热题100hot100的时候很多人做到236这道二叉树的最近公共祖先LCALowest Common Ancestor就卡住了。卡住的原因不是看不懂题目而是想不明白一棵树里找两个节点的公共祖先为什么要用这么绕的写法或者说大家心里其实没搞清楚“最近公共祖先”这个定义到底意味着什么直接就开始递归套模板了套出来对了也不知道为什么对错了更不知道错在哪里。先花两分钟把问题定义掰扯清楚。给定一棵二叉树以及两个节点p和q要求返回这两个节点的最近公共祖先。这里的“最近”指的是在从根节点到p和q的所有公共祖先中深度最大的那个。更直白地说就是这两个节点往上走第一次相遇的那个节点。如果p本身就是q的祖先那么p就是答案反过来如果q是p的祖先q就是答案。举一个具体的例子一棵树根节点是3左子树是5右子树是15的左子树是6右子树是21的左子树是0右子树是8如果找节点5和节点1的最近公共祖先一眼就能看出来是3因为只有3这个位置能让它们俩“会合”。如果找节点6和节点2的最近公共祖先答案就不是3而是5因为6和2都挂在5下面5是它们往上最早相遇的地方。一个容易踩的误区是把“祖先”理解成只能是祖父、曾祖父这种隔着好几层的节点。实际上在二叉树定义里一个节点的祖先包括它上方的所有节点而“最近公共祖先”允许的一个关键情况是一个节点自己可以是自己的祖先。LeetCode对这题的说明里专门强调了这个边界题目本身对这句话写得很明确“一个节点也可以是它自己的祖先”。这句话不是废话很多人在后序解法里递归边界处理出错就是因为没想明白这一点。从hot100的角度来看这道题之所以必刷是因为它同时考察了三样东西对二叉树遍历顺序的理解尤其是“先处理子树、再处理当前节点”的后序遍历。对递归返回值含义的设计能力递归函数返回什么、什么时候返回空、什么时候返回非空这比代码本身重要得多。对空间换时间、剪枝优化等常见策略的敏感度因为这道题既能用递归轻松解又能用哈希表记录父节点迭代解还能对二叉搜索树场景做极致的剪枝优化。所以这道题不是一个孤立的题目它实际上是把树的遍历、递归设计、边界处理、复杂度分析这几块内容串起来了。把这题吃透二叉树这块很多所谓的中等题就不再有神秘感了。2. 递归解法的核心思路用后序遍历收集信息2.1 为什么后序遍历是天然的解法直接给出LeetCode上最经典的递归解法可能只有十几行但这十几行背后是有逻辑的不是背下来就完事。先确认一个基本前提我们要找的最近公共祖先本质上是一个“汇聚点”。两个节点从自己所在的位置向上走第一次碰到的那个节点就是答案。要让程序找到这个汇聚点最自然的信息传递方式是从下往上汇报左右子树先各自告诉我你那一侧有没有我要找的p或q如果两边都有那当前节点就是公共祖先如果只有一边有就把这个信息继续往上传递如果两边都没有就返回空。这个过程对应到二叉树的遍历顺序恰好就是后序遍历左子树、右子树、当前节点。因为只有先把子树的情况摸清楚才能决定当前节点应该向上汇报什么。很多同学第一次写这道题用的是前序遍历思路也就是先看当前节点是不是p或q然后再去子树里找。这种思路当然也能写出解法但它找到的不一定是“最近”的公共祖先你得额外记录深度、比较深度代码量会明显变多。后序遍历天然地避开了这个问题因为它把判断放在了子树信息之后天然满足“从下往上找”的需求。2.2 递归函数设计一次想明白返回值的含义写递归题最忌讳的是“边写边猜”。动手之前先定义好递归函数的返回值代表什么。对于这道题可以这样定义函数lowestCommonAncestor(root, p, q)返回的是“以root为根的这棵子树里p和q的最近公共祖先如果这个子树里只找到了p或q中的一个就返回找到的那个节点如果一个都没找到就返回null”。这个定义有点像是绕口令但它非常关键。它允许返回值表达三层意思找到了LCA、找到了p或q之一、什么都没找到。在一个递归函数里用一个引用来表达三种状态就可以省去额外维护全局变量或状态数组的麻烦。核心代码可以写为def lowestCommonAncestor(root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: if root is None: return None if root p or root q: return root left lowestCommonAncestor(root.left, p, q) right lowestCommonAncestor(root.right, p, q) if left is not None and right is not None: return root if left is not None: return left if right is not None: return right return None这段代码的逻辑展开来是这样的边界一如果root为空说明这棵子树里不可能有p或q返回空。边界二如果root就是p或者q直接返回root不用再去子树里找了。这里隐含了一个重要判断如果p是q的祖先当递归走到p这个节点时直接返回p这个p会被一层层传回去最终成为答案。递归部分分别去左子树和右子树找。合并判断如果左右两边都返回了非空节点说明p和q各在一边当前节点就是最近的公共祖先。这里为什么一定是“最近”因为这是递归自底向上第一次出现左右都非空的情况不会有比当前节点更深的公共祖先了。如果只有一边非空说明两个节点都在那一边或者只找到了其中一个把非空结果继续向上传递。这个解法的关键在于“left和right都非空时返回root”这个判断。整个过程实际上是在“收集线索”递归不是从上到下直接找答案而是从叶子开始一层一层确认信息的。2.3 复杂度分析和代码的可读性优化时间复杂度是O(n)因为每个节点都会被访问一次空间复杂度是O(n)最坏情况下树退化成链表递归调用栈深度达到n层。有些同学觉得最后的if left is not None、if right is not None、return None三连有点啰嗦可以精简一点def lowestCommonAncestor(root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: if not root or root p or root q: return root left lowestCommonAncestor(root.left, p, q) right lowestCommonAncestor(root.right, p, q) if not left: return right if not right: return left return root这个精简版的思路是如果左子树啥也没找到那结果要么在右子树里要么右子树为空就返回空如果右子树也没找到那只能是当前节点了。这个写法我称它为“优先排除法”因为它不像上一个版本那样“都找到了才返回root”而是把所有不满足的情况先排除掉剩下的就是答案。注意面试或写题时一定要想清楚自己用的是哪个版本的语义。leetcode的官方题解更接近第一个版本第二个版本虽然简洁但对递归返回值定义的理解要求更高。3. 别只知道递归迭代解法与父指针思路3.1 从递归到迭代的思路转换递归解法虽然优雅但并不是所有场景都适合。当树的深度特别大时递归可能触发栈溢出runtime error。这时候就需要一个基于迭代的解法用哈希表存储每个节点的父节点。这种思路非常直白模拟的就是“往上走”的过程第一步遍历整棵树层序、前序都行用一个哈希表记录每个节点的父节点。第二步从p节点开始沿着父指针往上走把经过的所有节点都放进一个集合。第三步从q节点开始也沿着父指针往上走遇到的第一个已经在集合里的节点就是p和q的最近公共祖先。这个思路的好处是符合直觉不用纠结递归的返回语义只需依靠哈希表的快速查找。3.2 具体的迭代代码实现def lowestCommonAncestor(root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: parent {root: None} stack [root] while stack: node stack.pop() if node.left: parent[node.left] node stack.append(node.left) if node.right: parent[node.right] node stack.append(node.right) ancestors set() while p: ancestors.add(p) p parent[p] while q: if q in ancestors: return q q parent[q] return None需要强调的是这种做法的前提是“整棵树都被遍历过”所以第一个while会一直走到栈为空确保p和q的父指针信息都存在。实际写的时候可以加一个“如果parent里已经有p和q了就可以提前结束”的优化省一点时间但因为是O(n)复杂度差别不大。哈希表迭代解法的空间复杂度也是O(n)因为要存储每个节点的父指针。相比递归它的优势是栈溢出的风险低代码的逻辑也更好解释但劣势是要多写不少代码。3.3 两种解法的取舍刷hot100的过程中很多人会纠结到底用递归还是迭代。我的建议是把递归解法作为第一选择因为代码短、语义清晰、面试时容易讲明白。迭代解法作为备选方案当面试官追问“如果树的深度特别大怎么办”的时候能拿出来展示自己对栈溢出问题的思考。在工程化场景里迭代解法更接近我们处理真实树形结构的做法因为真实项目里的树深度不可控递归确实要谨慎一些。但这是题外话了刷题阶段优先掌握两种解法性价比最高。4. 针对不同树形态的变体与优化4.1 二叉搜索树利用有序性质做剪枝如果题目给的是一棵二叉搜索树BST解法可以大幅优化。BST的核心性质是左子树所有节点的值小于根节点右子树所有节点的值大于根节点。利用这个性质每次只需要往一个方向走时间复杂度可以从O(n)降到O(h)其中h是树的高度。思路很简单如果p和q的值都小于root的值说明两个节点都在左子树里答案在左子树。如果p和q的值都大于root的值说明两个节点都在右子树里答案在右子树。如果一个小于root一个大于root说明这两个节点分布在两侧当前节点就是最近的公共祖先。对应的递归代码def lowestCommonAncestorBST(root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: if p.val root.val and q.val root.val: return lowestCommonAncestorBST(root.left, p, q) if p.val root.val and q.val root.val: return lowestCommonAncestorBST(root.right, p, q) return root同样的逻辑可以改成迭代连栈都不用就是一路往下走def lowestCommonAncestorBST(root: TreeNode, p: TreeNode, q: TreeNode) - TreeNode: cur root while cur: if p.val cur.val and q.val cur.val: cur cur.left elif p.val cur.val and q.val cur.val: cur cur.right else: return cur return None这种写法在hot100里虽然只会以变体形式出现但思路非常值得掌握因为它体现的核心是“如何利用数据结构的特性来降复杂度”。4.2 扩展到p和q可能是同一个节点的情况一些题目变体会让p和q指向同一个节点。这种情况下答案自然就是p自己。递归解法里if root p or root q: return root这个判断天然覆盖了这种情况不需要额外写特殊逻辑。但是如果p和q是相同的值但不同的节点题目里通常会明确说节点是唯一的所以不会出现这种歧义那就需要额外处理。实际工程中树节点的比较通常用引用或唯一ID而不是值这个边界要想清楚。4.3 更一般的场景找多个节点的最近公共祖先如果题目从两个节点扩展成k个节点传统的两两LCA就不够看了。这时候有个更理论的解法对于一组节点它们的最近公共祖先是“整棵树中深度最深的、能够覆盖所有k个节点的子树根节点”。实现方式可以递归判断当前节点的子树是否包含所有目标节点如果是就往子树里走如果不是就返回当前节点。这个思路在代码上并不复杂但实际操作中我建议直接用“自底向上计数”的方式维护一个计数器只有当子树中目标节点数量等于k时才更新答案。这样能避免对每个节点都做一次集合交集运算性能更好。4.4 线索二叉树对LCA问题的启发有些资料提到线索二叉树Threaded Binary Tree这是二叉树的一种存储优化方式通过利用空指针指向中序遍历的前驱或后继节点来加速遍历。很多同学会问线索二叉树能不能用来优化LCA我试过这个方向结论是不太划算。线索二叉树优化的是“遍历”本身让中序遍历不需要栈或递归。但LCA问题的核心是“找汇聚点”需要的是从下往上的路径信息而不是单纯地遍历顺序。即使有了线索也得额外维护深度或父指针才能定位LCA并不比普通做法省事。所以看到相关热词时心里有数就行不需要花太多时间在这条路上。5. 实战中常见的运行时错误与排查技巧5.1 为什么写二叉树程序时总是报运行时错误我相信很多刷hot100的人都有过这种体验思路好像是对的一提交就是runtime error让人又气又急。二叉树问题里的运行时错误原因其实高度集中排查起来是有固定套路的。最常见的三种原因空指针访问。最常见没有判断root为None就直接访问root.val或root.left。递归深度过大导致栈溢出。当二叉树退化成一条链时递归深度等于节点数上万层的深度足以撑爆调用栈。全局变量或引用参数在多测试用例之间没有重置。LeetCode执行多次测试时如果代码里有类级别的全局变量上一次的测试值会影响下一次。排查方法也很简单先在本地IDE里手动构造一个空树、一个只有根节点的树、一个只有左子树的链式树这三种极端情况分别跑一遍。80%的运行时错误都能通过这三个自测用例暴露出来。5.2 手写二叉树时的自查清单很多初学者在本地练习写二叉树程序时经常遇到“编译过了但程序崩了”的情况。这里有张自查清单每次写完代码对着过一遍访问节点字段前是否确认了这个节点不为空递归函数里是否写了递归终止条件终止条件是不是覆盖了root None如果修改了树的结构父节点的引用是否同步更新了构造测试用例时是不是真的构建出了预期的树形结构左右子树有没有挂反是否误用了is和来比较值或者比较节点在Python里is比较的是对象身份比较的是值写错会导致完全不同的结果。关于第四点我这里多说一句因为手写二叉树时很容易踩坑。假设你要构造一棵树根节点是1左孩子是2右孩子是3root TreeNode(1) root.left TreeNode(2) root.right TreeNode(3)看起来没问题。但如果你的测试代码是print(root.left root.right)输出False很正常因为两个不同的对象即使值相同也不相等。但如果你不小心写成了root.left TreeNode(2)然后又执行root.left root.right那左孩子就丢了。这些细节都是手写测试用例时的重灾区值得多花一分钟检查。5.3 递归思路反向怎么办一个调试技巧递归代码不像迭代代码那样可以一步步跟踪调试起来很麻烦。我自己常用的一个技巧是在小纸上手动构造一棵只有5到7个节点的树然后在递归函数里加打印语句输出当前节点的值、left的返回值、right的返回值观察它是否符合预期。对于236这道题可以这样临时改造调试代码def lowestCommonAncestor(root, p, q): if not root or root p or root q: print(fbase case: node {root.val}) return root left lowestCommonAncestor(root.left, p, q) right lowestCommonAncestor(root.right, p, q) print(fnode {root.val}, left{left.val if left else None}, right{right.val if right else None}) if not left: return right if not right: return left return root打印出来的每一行都对应一次递归调用可以很直观地看到信息是如何自底向上汇聚的。加完打印之后你可能会发现一个有意思的现象大部分节点返回的都是None只有少数节点返回了非空值而LCA是第一个左右都非空的节点。这个现象说明代码确实是按后序逻辑工作的。提示调试完之后一定要记得把print删掉否则提交LeetCode时会输出额外内容某些平台会把它当作错误结果处理。5.4 一个面试追问的坑如何证明答案是“最近”的很多面试官让你做这题不会只满足于AC还会追问你怎么证明这个算法找到的是最近的公共祖先而不是随便一个公共祖先这是考查候选人对算法正确性的理解。标准的证明思路是假设递归函数返回的是子树内p和q的最近公共祖先或者只找到的一个节点。对于当前节点root如果p和q分别在左右子树中假设root不是最近公共祖先那么必定存在一个深度更大的节点同时是p和q的祖先但这个节点一定位于左子树或右子树的某一侧。可如果这个节点在左子树里说明q也应该在左子树这与“q在右子树”矛盾。反证法成立。如果p或q中有一个是当前节点那当前节点自然是它们能在这一侧相遇的最深位置。这个证明并不复杂但能把逻辑讲清楚的人不多。6. 热词背后从236到二叉树知识网络的构建6.1 为什么hot100里二叉树权重这么高很多初学者会有一个困惑hot100里为什么有大量二叉树题目而在实际开发中直接手写二叉树的机会似乎并不多。这里需要看清一个事实二叉树题目练的并不仅仅是二叉树本身。它练的是递归设计能力、指针引用操作能力、边界条件意识以及把复杂问题拆解成子问题的能力。说透了二叉树是训练这些通用能力的完美载体。你去做图的深度优先搜索去看线段树和并查集会发现很多代码结构和二叉树递归如出一辙。所以不要抱着“学会了这题就行”的心态。236这道题只是一个切入口它把树的遍历、递归返回值设计、状态合并、复杂度分析这些知识点串了起来算是二叉树这个知识网络的枢纽节点。把它彻底搞透再去刷110平衡二叉树、124二叉树中的最大路径和、543二叉树的直径会感觉顺很多。6.2 二叉树的遍历和深度的关联热词里出现了“二叉树的遍历”和“二叉树的深度”这两个概念与236的关系非常密切。后序遍历是递归解法的基础而树的深度决定了递归栈的最大层数。很多人在算深度的时候会写这样的代码def maxDepth(root): if not root: return 0 return 1 max(maxDepth(root.left), maxDepth(root.right))这个代码的逻辑和236的递归逻辑有异曲同工之处都是先处理子树再合并结果。区别只在于一个是返回深度数值一个是返回节点引用。如果你能把这两段代码对照着看你会发现树的递归题其实就两种核心模式返回值是数值型的深度、直径、路径和返回值是节点型的LCA、搜索节点、翻转后的根节点。抓住这个规律树的题就不再是一题一题地背了。6.3 搜索二叉树BST在LCA问题中的特殊地位热词里还有“搜索二叉树”这正好对应4.1节讲过的BST优化版本。需要补充一个小点当题目明确说明是BST时p和q的大小区间可以直接用来判断方向这种做法不需要同时知道p和q的整棵祖先路径。你可以当作一道独立的新题来做它就是leetcode 235。但做的时候一定要意识到它和236的题目设定有微妙差异BST版本要求树满足有序性质而普通二叉树版本对树的结构没有任何额外要求。所以从刷题顺序来看建议先做236把通用解法搞清楚再做235的BST剪枝优化。这样一个题目就能吃透两个算法思想。6.4 是不是所有LCA变体都要掌握坦白说LCA问题往深了做可以非常难。比如树上倍增法Binary Lifting可以在O(log n)时间内回答任意两个节点的LCA查询这需要预处理每个节点的第2^k级祖先。这个技巧在算法竞赛里很常用但在hot100的范围内不太会出现。以hot100刷题为目标的话掌握普通二叉树的递归与迭代解法就足够了。但如果后续要打竞赛或者深入图论树上倍增是值得了解的进阶内容。它本质上是一个动态规划表格up[node][j]表示从node向上走2^j步到达的祖先节点递推公式是up[node][j] up[up[node][j-1]][j-1]。这个递推式非常优美用今天的LCA题目作为起点去学习接受成本会低很多。7. 复盘提交多次才明白的细节我最早做这道题的时候其实也踩过一些坑现在回头看都是一些很小的点但确实能让人卡很久。最后分享几个真实经验第一Python里比较节点对象时用和is都要谨慎。LeetCode的TreeNode类默认会根据对象地址来判断是否相等但在某些实现里Python的会递归比较字段这是一个很大的坑。实际写题时直接用root is p or root is q最安全因为题目给的p和q就是树中真实节点的引用用is比较身份语义最准确。第二递归解法的终止条件里if not root or root p or root q三个条件只要满足一个就直接返回root。很多人写到这里会担心如果root等于p但q其实在root的子树里那直接返回root会漏掉q吗不会漏。因为此时p就是q的祖先p本身就是正确结果直接返回没问题。这个逻辑想通了整个递归就迎刃而解。第三提交代码前把debugprint删掉。说起来有点好笑但我在早期真干过这种事提交之后输出一大堆调试信息白白多了很多WA记录。第四如果实在理解不了递归过程不要硬看代码动手画一棵7个节点的树按照代码的执行顺序手动模拟一遍。模拟两三次之后那种“悬空”的感觉自然就消失了。学习树的递归题笔和纸永远是最好的工具。这篇就把236背后涉及的思路、细节、变体和常见坑过了一遍。二叉树这块确实是hot100的重头戏把这道题吃透了后面很多树的题目都会顺畅不少。
返回列表