刷题记录做到二叉树这一节的时候,“最近公共祖先”是我每次都会单独拿出来写复盘的一道题。不是因为它难,而是这个题目把树的递归结构、后序遍历和返回值设计三件事一次考全了,Java实现踩坑的地方又特别典型。这篇我用自己的梳理方式讲讲思路、代码和一些容易翻车的细节,适合正在刷力扣的Java选手,也适合准备面试前临时抱佛脚的朋友。
1. 这道题到底想考什么
1.1 题面与“最近公共祖先”的精确定义
先说个题号的事。标题里标的是Lc339,但我在力扣上翻这道题的时候,标准编号其实是236,剑指Offer里对应的是68-II。整理笔记的时候手滑标错号了,不影响题目内容,后面我所有思路和代码都围绕“二叉树的最近公共祖先”这个题面展开。
题面很简洁:给定一棵二叉树的根节点root,再给两个节点p和q,要求返回这两个节点的最近公共祖先。公共祖先的定义是:如果节点x同时是p和q的祖先,那么x就是它们的公共祖先,而最近公共祖先则是所有公共祖先里离p和q最近、离根节点最远的那一个。
这里有个特别容易忽略的细节:一个节点算不算自己的祖先?题目里的定义是算的。也就是说,如果p本身就是q的祖先,那答案就是p;反过来也一样。很多第一次做这道题的人在这里卡住,其实只要把“自己也是自己的祖先”这个前提焊死在脑子里,边界情况就清晰了。
还有一层隐含条件:题目默认p和q一定存在于这棵二叉树中,且树中节点的值不重复。这个假设不是废话,它决定了我们不需要额外写“找不到怎么办”的分支逻辑,也让递归写法可以简化到一个非常干净的程度。
1.2 三种基础场景:一眼看懂答案形态
我把这道题的答案形态分成三种场景,画一遍心里就踏实了。
场景一:p和q分别位于某个节点的左子树和右子树。这时候这个节点就是最近公共祖先。比如根节点3,p在左子树5,q在右子树1,左右子树各找到一个目标,那答案就是根节点3。
场景二:p是q的祖先。比如p就是节点5,q在5的右子树里变成节点4,那最近公共祖先并不是5和4共同的那棵更小的子树,而是5本身,因为5直接覆盖了q。递归终止条件里“root等于p或q就直接返回root”处理的就是这种情况。
场景三:p和q在同一侧子树里。比如p和q都在root的左子树,那答案一定不在右子树,右子树递归会返回null。此时只要把左子树的递归结果继续向上抛,最终会在某个“两侧各一个命中点”的节点处停下来。
这三种场景不是三种解法,而是同一个递归逻辑在不同形态下的表现。理解它们,比背代码重要得多。
1.3 为什么递归是首选解法
这道题不递归也能做,最常见的暴力思路是:先遍历整棵树,用一个HashMap记录每个节点的父指针,然后从p开始沿着父指针链往上走,把经过的节点全记到HashSet里;再从q开始往上走,遇到的第一个已经在Set里的节点就是答案。
这个做法的思路非常直白,但问题是它需要额外开辟O(n)的空间存父指针,而且代码量明显更大。面试场景下,递归解法几乎是一句话方案:后序遍历左右子树,哪个方向有结果就往哪边走,两边都有结果就说明当前节点是答案。树本身就是递归定义的结构,左子树和右子树依然是树,用递归处理天然契合。
另外,很多人在学习递归时会觉得“想不清楚就写不出来”,而这道题恰好是能帮你建立递归直觉的经典样本。它不需要你在脑子里跟踪整棵树的全部状态,只需要你相信递归函数返回值的语义,然后处理当前这一层。
2. 递归三部曲:返回值的语义决定一切
2.1 递归函数只回答一个问题
写递归最怕的是让函数同时做好几件事。我见过不少解法,让递归函数一边判断“子树里有没有p”,一边判断“有没有q”,还要顺便维护一个全局答案变量。这种写法不是不行,但在面试现场特别容易把自己绕晕,状态一多就漏分支。
这道题更合适的设计思路是:让递归函数只回答一个问题——“当前这棵子树里,p和q的最近公共祖先是谁,或者p和q中任意一个有没有被找到”。
具体来说,递归函数的返回值有且只有三种含义:
- 返回null,说明这棵子树里既没有p也没有q;
- 返回p或q,说明这棵子树里至少找到了p或q中的一个;
- 返回其它节点,说明这个节点就是当前子树范围内的最近公共祖先。
把这个语义先写死在注释里,再往下写代码,逻辑会顺很多。这也是我之前踩坑后养成的一个习惯:递归函数的第一行注释先写返回值含义,而不是直接写if条件。
2.2 终止条件与单层逻辑的取舍
确定好返回值语义之后,递归三部曲就清晰了。
终止条件有两个:root为null时返回null,root等于p或q时返回root。这两个条件缺一不可。root为null表示递归走到了叶子节点的孩子,这层没有目标,返回null告诉上层“这边没人”。root等于p或q表示已经找到了其中一个目标,直接返回即可,没必要继续往下递归。
单层处理逻辑就是后序遍历的经典三步:先递归左子树,再递归右子树,最后处理当前节点。这里我用left和right接收左右子树的返回值,然后分情况讨论:
- 如果left和right都不为空,说明p和q分别被发现在当前节点的左右两侧,当前节点就是最近公共祖先;
- 如果left和right中有一个为空,说明两个目标都在非空的那一侧,把非空结果继续往上抛;
- 如果两个都为空,返回null。
这里有个思维上的小坎儿:为什么两边都不空时,当前节点一定是“最近”的公共祖先,而不是更上层的某个节点?因为我们是自底向上递归的,叶子节点先被处理,一旦某个节点发现左右各有一个命中,它就是递归过程中碰到的第一个满足条件的节点。从下往上第一个符合条件的,就是最近的,这就是后序遍历顺序带来的天然保证。
2.3 为什么“从下往上第一个命中点”就是最近答案
很多人会纠结一个问题:如果p和q在同一侧,那递归结果一路往上抛,最后抛到根节点时,左右子树只有一边非空,返回的自然是那个非空结果,这没问题。但如果p和q在两侧,比如p在左子树深处,q在右子树深处,那么递归会先处理左子树找到p,再处理右子树找到q,然后回到两者共同的那个父节点,发现left和right都非空,于是返回这个父节点。
关键点在于:这个判断是在递归返回的过程中完成的,不是先从上到下扫描一遍再另做处理。离p和q越近的节点,越早被检查。举个例子,如果p在左子树,q在右子树,而它们的最近公共祖先是节点5,那么节点5的递归调用会先执行,在它返回之前,它的父节点、祖父节点都还没轮到检查;等节点5返回自己之后,更高层只会看到一个非空结果,然后继续原样上抛,不会再改变答案。
这就保证了我们拿到的一定是最深的、也是最近的公共祖先。理解了这一层,你就不会再问“为什么答案不会变成根节点”这种问题了。
3. Java 完整实现与单测结果
3.1 标准力扣签名与代码
力扣给出的方法是public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q),TreeNode结构大家都很熟悉,就三个字段:值、左孩子、右孩子。下面是我的Java实现,核心代码只有十几行。
class Solution { public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { // 递归终止条件:空节点,或者遇到了p/q中的任意一个 if (root == null || root == p || root == q) { return root; } // 后序遍历:先递归处理左右子树 TreeNode left = lowestCommonAncestor(root.left, p, q); TreeNode right = lowestCommonAncestor(root.right, p, q); // 单层逻辑:两边都有结果,说明当前节点就是答案 if (left != null && right != null) { return root; } // 只有一边有结果,把结果往上传递 return left != null ? left : right; } }这段代码看起来短,但我建议把它当成模板记住,不是为了面试背题,而是因为它的返回值设计非常典型,后面遇到很多树相关的题目都能迁移。
3.2 手工构造测试树并验证结果
空谈复杂度容易虚,我直接搭一棵标准测试树来跑。力扣示例给的是[3,5,1,6,2,0,8,null,null,7,4],结构是根节点3,左孩子5,右孩子1;5的左孩子6、右孩子2;1的左孩子0、右孩子8;2的左孩子7、右孩子4。
我用同样的结构写了一个可运行的main方法,顺便验证题目里的两个经典Case:p=5、q=1时结果应该是3;p=5、q=4时结果应该是5。
public class LcaTest { static class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int val) { this.val = val; } } public static void main(String[] args) { // 构造测试树 [3,5,1,6,2,0,8,null,null,7,4] TreeNode root = new TreeNode(3); TreeNode p5 = new TreeNode(5); TreeNode q1 = new TreeNode(1); TreeNode n6 = new TreeNode(6); TreeNode n2 = new TreeNode(2); TreeNode n0 = new TreeNode(0); TreeNode n8 = new TreeNode(8); TreeNode n7 = new TreeNode(7); TreeNode n4 = new TreeNode(4); root.left = p5; root.right = q1; p5.left = n6; p5.right = n2; q1.left = n0; q1.right = n8; n2.left = n7; n2.right = n4; Solution solution = new Solution(); TreeNode ans1 = solution.lowestCommonAncestor(root, p5, q1); TreeNode ans2 = solution.lowestCommonAncestor(root, p5, n4); TreeNode ans3 = solution.lowestCommonAncestor(root, n4, n4); System.out.println("p=5, q=1 -> " + ans1.val); System.out.println("p=5, q=4 -> " + ans2.val); System.out.println("p=4, q=4 -> " + ans3.val); } static class Solution { public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { if (root == null || root == p || root == q) { return root; } TreeNode left = lowestCommonAncestor(root.left, p, q); TreeNode right = lowestCommonAncestor(root.right, p, q); if (left != null && right != null) { return root; } return left != null ? left : right; } } }跑出来三个结果分别是3、5、4。第三个用例是我额外加的,p和q是同一个节点时,最近公共祖先就是它自己,这正好呼应了“自己可以是自己的祖先”这个定义。
3.3 代码逐行走读:从调用栈看答案诞生
只看代码不追调用栈,遇到变体题还是会慌。我拿第二个Case,也就是p=5、q=4,手动走一遍关键路径。
递归从root=3开始。先递归左子树5,left这条线继续往下走:5的左子树6返回null,5的右子树2继续递归;2的左子树7返回null,2的右子树4命中q,返回4;此时节点2收到left=null、right=4,返回4给节点5;节点5收到left=null、right=4,同时节点5本身就是p,它在最开始的终止条件判断中应该直接返回5,所以这里实际路径更短:当递归进入节点5时,因为root == p成立,直接向上返回5。
然后回到root=3,左子树递归结果是5,右子树递归去找q=4。右子树1的左孩子0、右孩子8都没有命中,整棵右子树返回null。此时root=3这里left=5、right=null,按代码逻辑返回left也就是5,于是答案就是5。
注意我上面说的“节点5直接返回”这个行为:终止条件是先于左右递归判断的,所以当p或q在子树的根节点位置时,下面整层都不会被遍历。这也解释了为什么p和q是祖先关系时,代码能保持O(n)的复杂度,甚至有时会比O(n)还快一点,因为找到目标后就不再往下走了。
4. 复杂度分析、易错点与高频变体
4.1 时间复杂度和栈空间的真实消耗
时间复杂度是O(n),最坏情况下每个节点都会被访问一次。但这里要分两种情况说:如果树是平衡的,递归会快速收敛,很多分支因为找不到p和q而剪枝;如果树退化成链表,比如每个节点只有左孩子,那么递归会一路扎到底,这时候时间和空间都会拉到最坏值。
空间复杂度主要消耗在递归调用栈上,最坏是O(h),h是树的高度。矮胖树的高度接近log2(n),栈空间很安全;瘦高树的高度接近n,递归深度可能达到几万层,这时候就存在栈溢出的隐患。这是很多人在本地跑大数据集时报StackOverflowError的原因之一,不是代码逻辑错了,而是系统栈不够用。
如果你真的担心栈溢出,可以用一个显式栈模拟后序遍历实现迭代版,但代码会复杂不少。我个人在实际刷题中优先写递归版本,把迭代版作为优化选项,因为这道题面试考的就是递归思维,迭代版暴露的细节反而容易让面试官追问到更难的地方。
4.2 三个高频变体:BST、带父指针、N叉树
这道题的变体在面试里比原题还常出现,我整理三个最典型的。
第一个变体是二叉搜索树中的最近公共祖先。BST的优势在于节点值有大小关系,我们可以根据p.val和q.val与当前节点的关系剪枝:如果一个节点比p和q都大,去左子树找;比两者都小,去右子树找;否则当前节点就是答案。这个解法时间复杂度还是O(h),但常数更小,而且不需要处理“两边都非空”的分支。
第二个变体是二叉树节点带parent指针。这种结构把问题变成了两个单链表的交点问题:从p和q分别向root方向走,记录两条路径,然后求第一个公共交点。经典的解法是先分别求出p和q到root的深度,让深的那条链先走差值步,再一起走,相遇点就是答案。时间复杂度O(h),空间O(1),思路转换很巧妙。
第三个变体是N叉树。N叉树没有左右孩子,而是有一个孩子列表children。递归逻辑变成遍历所有孩子,统计非空返回结果的个数。如果非空结果的数量大于等于2,当前节点就是答案;如果只有一个,就把那个结果继续上抛;如果没有,返回null。代码改动很小,但能返程看出你是不是真的理解了后序遍历返回值设计。
5. 二叉树程序的常见运行时错误排查
我一直觉得,二叉树题目的调试过程比题目本身更能涨经验。这一节我把自己在Java实现上踩过的坑和常见的报错场景整理出来。
5.1 空指针:十次报错九次在判空
опрос报NullPointerException,第一反应应该去看你访问节点属性前有没有判空。二叉树递归里最常见的错误是,拿到左子树递归结果后直接访问left.val,比如:
// 错误示范:没有判空就访问left的val if (left != null && right != null) { return root; } if (left.val == 某个值) { ... }这行代码问题在于,left完全可能为null,说明左子树里没有p也没有q,此时访问left.val必然抛异常。我刚开始刷题时就在这里栽过,后来养成一个习惯:凡是递归返回值,第一件事先判断是否为null,再做下一步逻辑。
另外,p和q节点相等要用==而不是equals或value相等。因为TreeNode是引用类型,即使两个节点数值相同,它们也可能是两个不同对象。力扣题目给的是节点引用,直接用root == p判断命中,这是最快也是最稳妥的写法。
5.2 返回值语义混乱与栈溢出
第二种典型问题是递归函数返回值的语义混乱。比如有人把递归函数设计成“返回boolean表示是否找到p或q”,然后在外部维护一个答案变量。这种设计在思路上能走通,但状态变量一多,代码就很容易在某个分支上忘更新答案,调试时看着对但跑起来错。我建议按照本文第2节的做法,让返回值自己携带答案,不要用额外的答案变量。
第三种典型问题就是前面提到的栈溢出。尤其是在本地IDE里构造了一个一万层深的链表树,再跑递归,基本必炸。排查思路是先确认树的形态,如果确实是极端链状结构,再考虑把递归改成显式栈。不过面试手写代码场景下,直接用递归并把复杂度说清楚,通常已经满足要求了。
5.3 常见问题速查表
我把这段内容整理成一张速查表,方便之后复习翻看。
| 报错或异常现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| NullPointerException | 递归返回值没有判空就访问属性 | 先判断left/right是否为null,再分情况处理 |
| 答案结果不对(返回根节点) | 没有理解“从下往上第一个命中点” | 重跑一遍递归调用栈,确认左右非空节点的位置 |
| 节点相等但结果异常 | 用了equals或val比较TreeNode | 对象引用比较直接用== |
| StackOverflowError | 树退化成链表,递归深度过大 | 确认树形态,必要时改为迭代栈版 |
| 结果和预期差一个层级 | 终止条件判断顺序有误 | 先处理root为null或root命中目标,再进行左右递归 |
这张表基本覆盖了这道题在Java环境里能遇到的绝大多数问题。刷题的时候不要只盯着Accepted,真的去跑一遍异常输入,把错误看一遍,收获比想象中多。
我个人在实际操作中的体会是:遇到树相关的高频题,先别急着写代码,在纸上把树画出来,把递归返回路径用箭头标一遍。尤其是“最近公共祖先”这种题目,纸上走完一遍调用栈,返回值语义基本就通了。等你掌握之后再做二叉搜索树版本或者N叉树版本,会发现它们都是同一棵递归树上的分支而已。一个小技巧送给大家:源码的注释区先写清楚“我这个递归函数返回值的三种含义”,再写if逻辑,这样不管是自己后续回顾还是面试讲题,思路都会非常清晰。