二叉树的最近公共祖先
普通二叉树没有大小路牌。每棵子树只能向父节点回传一个节点信号;左右两边都回传有效信号时,当前节点就是它们第一次合并的位置。
在普通二叉树中找到 p、q 往上追溯时第一个共同节点。
用递归返回值表达子树找到了谁,让局部结果自底向上合并。
nil 表示没找到;命中 p/q 直接返回;左右都非空返回当前节点,否则继续上传非空一侧。
先说结论:这道题到底解决什么
父节点看不到 p、q 藏在哪个方向时,左右子树应该返回什么信息,才能让答案在第一次汇合处被确定?
中心结论:nil 表示没找到;命中 p/q 直接返回;左右都非空返回当前节点,否则继续上传非空一侧。
- 1.递归函数返回的节点究竟代表目标、LCA,还是“什么也没找到”?
- 2.为什么 left、right 都非空时,当前 root 一定是最近公共祖先?
- 3.为什么 root 命中 p 或 q 时可以立刻返回,不必继续向下寻找另一个目标?
完整题目与题意拆解
给定一棵普通二叉树和树中的两个节点 p、q,返回它们的最近公共祖先。
最近公共祖先同时是 p、q 的祖先,并且深度尽可能大。题目定义允许节点是自己的祖先,所以答案可能等于 p 或 q。
- • 这是一棵普通二叉树,不保证左小右大。
- • p、q 不同且都存在于树中。
- • 答案是节点引用,不只是一个布尔值或节点值。
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出:3
边界示例:p = 5, q = 4 时输出 5。把函数理解成对子树的询问:“你这里有没有 p、q,或者是否已经找出了 LCA?”子树只需要返回一个 TreeNode 指针。
返回 nil 表示没有目标;返回 p 或 q 表示找到一个目标;返回其他节点则表示这棵子树内部已经合并出了最终 LCA。
普通树没有方向路牌
观察根 3 无法只凭数值判断 p=5、q=1 的位置,问题因此变成“左右会汇报什么”。
第一层方案:暴力做法
路径法对 p、q 各做一次 DFS,保存从根到目标的节点数组,再线性比较公共前缀。时间仍是 O(n),但需要 O(h) 路径并维护 push/pop。
更不合适的误区是根据 p.Val、q.Val 与 root.Val 选择方向。这只有 LC235 的 BST 才成立,普通二叉树的值与位置没有关系。
pathP = [3,5]
pathQ = [3,1]
最后一个相同节点 = 3为每个候选节点重复 contains 为什么浪费
如果逐个节点询问“子树是否同时包含 p、q”,相同子树会被重复扫描;返回信号让一次遍历携带全部信息。
优化方向:后序递归让每个节点只处理左右两个返回值。父节点不关心目标在子树中的具体路线,只关心左右各自回来了什么信号。
整体地图:先做什么,再做什么
普通二叉树没有 BST 的值域路牌,因此根节点无法提前选择方向。解决办法是把问题改写成通信协议:每棵子树搜索完以后,向父节点返回一个节点信号。
递归先处理基础条件,再分别获得 left、right。两侧都返回非空信号时在当前节点合并;只有一侧非空时原样上传;两侧都空时自然返回 nil。
- • 向下阶段:把同一个问题交给左右子树。
- • 向上阶段:解释每个子树找到的有效信号。
- • 合并阶段:第一处左右都非空的位置就是答案。
返回信号到底表示什么
`nil` 表示当前子树没有发现目标;返回 p 或 q 表示发现了其中一个目标;返回其他节点表示更深处已经形成 LCA,当前层只负责继续上报。
这个协议把“是否找到”和“找到的是谁”合在一个节点指针中。若只返回 bool,父节点无法区分找到 p、找到 q,还是已经找到最终答案。
left=nil, right=nil → return nil
left=5, right=nil → return 5
left=nil, right=1 → return 1
left=5, right=1 → return current root逐个识别 nil、p、q、LCA 信号
观察左侧找到 5、右侧找到 1,以及两条信号在父节点汇合前分别代表什么。
如何构建自底向上的合并规则
基础条件先截住空节点和目标节点。只有普通节点才继续递归左右子树,因此决策发生在两个孩子都完成之后,这正是后序思路。
left 与 right 同时非空说明两条目标路径第一次在当前 root 汇合;只收到一条信号时,当前节点还不能证明自己是答案,只能把已有信号继续交给父节点。
- • 基础条件:`root == nil || root == p || root == q`。
- • 双非空:`return root`。
- • 单非空:返回非空一侧;双空也会返回 nil。
先处理 nil 与命中目标的基础情况;再递归计算 left 和 right;最后根据两个返回值做合并。
若 left、right 都非空返回 root,否则 left 非空就返回 left,剩下情况返回 right。right 也可能是 nil,正好覆盖双空。
构建双非空合并与单非空上报规则
把父节点等待、双信号合并、单信号继续上传和自身为祖先串起来。
核心难点:为什么两条非空信号在这里合并
left 非空说明左子树包含 p、q 中至少一个目标或已经形成的 LCA;right 非空同理。题目只有两个目标,因此两侧同时非空时,两个目标分别落在当前节点两侧。
当前 root 能同时覆盖左右两侧目标。任何比 root 更低的节点都只能属于左子树或右子树之一,不可能跨过 root 同时覆盖另一侧,所以 root 就是最低公共祖先。
若只有一侧非空,两个目标可能都在那一侧,也可能目前只找到一个;无论哪种情况,当前 root 都没有足够证据成为答案,原样上传是唯一安全动作。
- • 基础条件准确产生 nil 或目标节点信号。
- • 左右都非空时两个目标分居两侧,当前节点是最低合并点。
- • 单边非空时答案仍在该边,把信号原样上传不会丢失正确答案。
完整执行过程
把递归看成信号回传:重点不是节点访问顺序,而是每个调用返回 nil、目标节点还是已经合并出的 LCA。
- 1从 root=3 开始,普通树无法判断方向,于是递归询问左右子树。
- 2左子树在节点 5 命中 p,返回节点 5;这是一条有效信号,不代表根 3 已经决定。
- 3右子树在节点 1 命中 q,返回节点 1。
- 4根 3 收到 left=5、right=1,两侧同时非空,返回自己作为 LCA。
- 5对照 p=5、q=4:节点 5 命中 p 后返回 5,最终信号仍会正确上报,因此答案可等于目标。
完整观察两条信号从目标回到根
拖动时间轴查看 p、q 信号何时产生、沿哪条边上传、在哪个最低节点第一次相遇。
把动画和 Go 代码逐行对应
每一帧使用稳定的语义行 ID,不依赖容易漂移的数字行号。先观察分支为什么成立, 再看对应代码,而不是先背模板。
返回信号与 Go 分支逐行同步
重点观察基础条件、左右递归、双非空返回 root、单非空返回对应节点。
普通二叉树不满足左小右大,按值剪枝可能直接丢掉目标。
父节点需要知道“哪一个节点”被找到,单纯 true/false 无法完成合并。
当前节点是否为分岔点,取决于两侧是否都找到有效信号。
这表示“我的子树里找到一个目标”,并不要求此刻继续搜索另一目标。
左右子树使用完全相同的返回协议,父节点可以统一合并。
两个目标分居当前节点两侧,3 是两条向上路径首次相遇的最低节点。
这说明当前节点还不是两条路径的合并处,真正答案可能在更高层。
祖先关系允许节点包含自己,题目又保证 q 存在,所以无需在 5 以下再次证明。
没有 BST 方向可剪枝;同时暂停的递归调用数量等于当前根到叶的高度 h。
完整 Go 提交代码与最小测试
1func lowestCommonAncestor(root, p, q *TreeNode) *TreeNode {2 if root == nil || root == p || root == q {3 return root4 }5 6 left := lowestCommonAncestor(root.Left, p, q)7 right := lowestCommonAncestor(root.Right, p, q)8 9 if left != nil && right != nil {10 return root11 }12 if left != nil {13 return left14 }15 return right16}// 两个目标分居根的两侧
lowestCommonAncestor(root, node5, node1) // 3
// 一个目标是另一个的祖先
lowestCommonAncestor(root, node5, node4) // 5
// 两个目标同在左子树
lowestCommonAncestor(root, node6, node4) // 5
// 最小树:根的两个孩子正好是目标
lowestCommonAncestor(root3, root3.Left, root3.Right) // 3正确性与复杂度
普通二叉树没有方向信息,最坏需要访问每个节点一次。
递归栈深度等于树高 h;平衡树约 O(log n),退化成链时最坏 O(n)。
最容易写错的地方
像 LC235 一样按节点值大小选择方向。
把返回值压成 bool,失去具体目标或 LCA 节点。
忘记答案可以等于 p 或 q。
忽略递归调用栈,错误写成空间 O(1)。
最后复盘:带走逻辑链
- 1.普通二叉树没有路牌,需要左右都询问。
- 2.基础信号是 nil、p、q,双非空时合并为 LCA。
- 3.单边非空不代表当前节点是答案,只负责继续上报。
- 4.迁移题:LC235 BST LCA、LC1676 多节点 LCA。
- 1.我把递归函数定义为:返回当前子树中找到的 p、q 或已经形成的 LCA。
- 2.root 为空或命中 p/q 时直接返回 root,然后递归获得 left、right。
- 3.左右都非空返回当前 root;否则返回非空一侧,双空自然返回 nil。
- 4.每个节点访问一次,时间 O(n);递归栈由树高决定,空间 O(h)。