目录

题目描述

面试题 04.08. 首个共同祖先

题意分析

给定一棵二叉树和树中的两个节点 pq,返回它们最靠近的那个共同祖先。所谓共同祖先,是指以它为根的子树同时包含 pq;「最靠近」即在所有这样的节点中取深度最大的一个。

两条前提必须先读准:节点不含父指针,所以无法从 p 往上走;pq 一定存在于树中,所以不需要考虑「找不到」的返回值语义。这两条合起来把解法限定在「自顶向下遍历、自底向上汇报」的形态上。

题面还特意声明一个节点可以是自己的祖先。这句话决定了当 q 落在 p 的子树里时答案就是 p 本身,而不是 p 的父节点——这是本题最容易被写漏的一种情况。

树是普通二叉树而非二叉搜索树,节点值也不保证有序甚至不保证互异,所以不能靠比较值来决定往哪边走,只能两侧都搜。边界:pq 可能相邻、可能一个是另一个的祖先、也可能分别在根的两侧。

解法:深度优先搜索

核心思路

朴素做法是先各求一条从根到 p、到 q 的路径,再比较两条路径的最后一个公共节点。它正确且易懂,代价是要额外存两条路径、还要走两趟搜索。瓶颈在于路径信息其实只在「分叉点」那一处才被真正用到,其余全是冗余。

换个提问方式:与其问「路径长什么样」,不如让每个节点回答一个更小的问题——在以我为根的子树里,能找到 pq 吗?找到了就把找到的那个报上去。这个问题的答案可以从孩子的答案合成,于是一趟后序遍历就够了。

把返回值的语义严格定义清楚,是这段短代码能成立的全部关键:dfs(x) 返回「以 x 为根的子树中,pq 的最近公共祖先;若子树中只出现了其中一个,就返回那一个;两个都没出现则返回空」。这三种情况被压进同一个返回值里,才让上层能用统一的方式合并。

合并规则由此自然推出:设 left = dfs(x.left)right = dfs(x.right)。两者都非空,说明 pq 分居 x 的两侧,x 就是最近公共祖先,返回 x;只有一侧非空,说明两个目标(或唯一找到的那个)都在该侧,把该侧结果原样上传即可;两侧都空则返回空。

递归基 x == null || x == p || x == q 里的后两项正是「一个节点可以是自己的祖先」的落地:一旦撞到目标就立即返回它、不再往下找。这样做不会漏解——若另一个目标就藏在下面,上层会看到「这一侧非空、另一侧为空」,从而把当前这个目标继续上传,最终答案正是它本身。

解题步骤

  • 递归基三合一root == null 返回空表示这一侧什么都没找到;root == proot == q 直接返回 root,把「撞到目标」当作一次成功上报。用引用比较而不是值比较,因为题目给的是节点对象且值可能重复。
  • 先左后右各搜一次left = dfs(root.left)right = dfs(root.right)。两侧都必须搜,不能因为左侧已有结果就跳过右侧——只搜一侧就无法判断两个目标是否分居两侧。
  • 合并三分支left 为空返回 right(要么右侧有结果,要么两侧皆空返回空,一行覆盖两种情况);right 为空返回 left;两者皆非空返回 root。这三行没有任何一处可以省略。
  • 入口无需特判:题目保证 pq 在树中,所以根调用一定会返回非空节点,不必在外层再包一层判断。

以经典树 [3, 5, 1, 6, 2, 0, 8, null, null, 7, 4] 走一遍,即根 3 的左孩子是 5、右孩子是 1;5 的孩子是 6 和 2,2 的孩子是 7 和 4;1 的孩子是 0 和 8。

先看 p = 5q = 1:从根 3 出发,左侧递归立刻撞到 5,返回节点 5,不再深入;右侧递归撞到 1,返回节点 1。回到根,两侧皆非空,返回根 3。答案是 3,符合直觉——5 和 1 分居根的两侧。

再看 p = 5q = 4:根 3 的右子树里递归到 1、0、8 都不是目标,全部返回空,右侧结果为空;左侧递归到 5 时命中递归基,直接返回 5,并不会继续深入去找子树里的 4。回到根,左非空右为空,返回左侧结果 5。答案是 5,正是「节点可以是自己的祖先」这条规则的体现;即便递归提前在 5 处停下没找到 4,结论依然正确,因为 4 一定在 5 的子树里,5 就是最近公共祖先。

代码实现

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);
        // 两侧都有:当前节点即分叉点;只有一侧:把该侧结果上传。
        return left == null ? right : (right == null ? left : root);
    }
}
func lowestCommonAncestor(root *TreeNode, p *TreeNode, q *TreeNode) *TreeNode {
    // 撞到目标即上报,不再深入。
    if root == nil || root == p || root == q {
        return root
    }

    left := lowestCommonAncestor(root.Left, p, q)
    right := lowestCommonAncestor(root.Right, p, q)
    // 两侧都有:当前节点即分叉点;只有一侧:把该侧结果上传。
    if left == nil {
        return right
    }
    if right == nil {
        return left
    }
    return root
}

复杂度分析

  • 时间复杂度:$O(n)$,n 为节点数。每个节点最多被访问一次,节点内部只做常数次引用比较;命中目标时提前返回只会让访问量更少。
  • 空间复杂度:$O(h)$,h 为树高,全部来自递归调用栈,没有额外的路径数组或哈希表。树退化成链时为 $O(n)$。

关键点总结

  • 这段代码之所以短,是因为返回值同时表达了三种含义:分叉点、唯一找到的目标、什么都没找到;写树形递归时先把返回值语义用一句话钉死,代码几乎就自己写出来了。
  • 「撞到目标立即返回、不再深入」之所以不漏解,靠的是「另一个目标若在其下方,当前节点必然就是答案」这条推理;面试时必须能主动解释这一步,否则会被追问到卡壳。
  • 一侧为空就上传另一侧,这一行同时覆盖了「结果在该侧」和「两侧皆空」两种情况,是把分支数从四降到三的技巧。
  • 用引用比较而不是值比较:普通二叉树的节点值可能重复,按值判断会在重复值的树上返回错误节点。
  • 面试常见的两个追问要预备好答案:若节点带父指针,可以像求两条链表交点那样从两侧同时上溯;若 pq 不保证存在(见 1644 题),必须改成完整搜索并额外记录两个目标各自是否被找到,不能再提前返回。

易错点总结

  • 用值比较代替引用比较:树中存在两个值同为 5 的节点,p 指向深处那个 → 递归在浅处的同值节点就提前返回,得到的祖先偏高。
  • 左侧找到就不搜右侧p = 5q = 1 的经典树 → 根的左侧返回 5 后直接上传,答案变成 5 而不是 3。
  • 递归基漏掉 root == proot == qp = 5q = 4 → 5 这一侧继续深入只找到 4,返回 4,最终答案变成 4,丢掉了「自己可以是自己祖先」的情况。
  • 两侧皆非空时返回 leftright 而不是 rootp = 6q = 2 → 应返回它们的父节点 5,却返回了 6,答案偏低。
  • 合并顺序写反成「left 非空就直接返回 leftp = 6q = 2 → 左侧返回 6 即被上传,右侧的 2 根本没机会参与合并,同样漏掉分叉点。
  • 把递归基里的空判去掉:任意叶子节点 → 递归到空孩子时访问 root.left 直接空指针异常。
  • 误以为可以按二叉搜索树的方式剪枝[3, 5, 1] 这类无序树里按值大小只往一侧走 → 目标在另一侧时直接搜不到,返回空。
  • 想当然地对不存在的节点也返回结果:若把本题解法照搬到「p 不在树中」的变体,p 缺席时会把 q 当成答案返回,而正确答案应是空。
  • 改成迭代写法时忘了记录父节点映射:只用栈遍历而不存 child → parent 的映射,回溯阶段无法上溯,最终仍要退回递归。

相似题目

题目 难度 考察点
236. 二叉树的最近公共祖先 中等 与本题同题,是这套后序合并模板的原型
235. 二叉搜索树的最近公共祖先 中等 有序性让分叉点可由值域判断,一路单向下探即可,无需两侧都搜
1644. 二叉树的最近公共祖先 II 中等 节点不保证存在,必须走满全树并额外标记两个目标是否真的找到
剑指 Offer 68 - I. 二叉搜索树的最近公共祖先 简单 与 235 同题,迭代写法只需一个 while 循环
剑指 Offer 68 - II. 二叉树的最近公共祖先 简单 与本题同题,可直接套用
面试题 04.10. 检查子树 中等 同为「主树遍历 + 子问题判定」的双层递归,判定的是结构相等而非归属
543. 二叉树的直径 简单 同样在后序里合并左右结果,但合并出的是长度而非节点