当前:LC236 · 二叉树的最近公共祖先 · 首次出现于 Day 23 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC236Medium二叉树DFS后序递归返回信号

二叉树的最近公共祖先

普通二叉树没有大小路牌。每棵子树只能向父节点回传一个节点信号;左右两边都回传有效信号时,当前节点就是它们第一次合并的位置。

题目是什么

在普通二叉树中找到 p、q 往上追溯时第一个共同节点。

解决什么问题

用递归返回值表达子树找到了谁,让局部结果自底向上合并。

核心结论

nil 表示没找到;命中 p/q 直接返回;左右都非空返回当前节点,否则继续上传非空一侧。

01交互算法精讲

先说结论:这道题到底解决什么

父节点看不到 p、q 藏在哪个方向时,左右子树应该返回什么信息,才能让答案在第一次汇合处被确定?

中心结论:nil 表示没找到;命中 p/q 直接返回;左右都非空返回当前节点,否则继续上传非空一侧。

读完必须能回答
  1. 1.递归函数返回的节点究竟代表目标、LCA,还是“什么也没找到”?
  2. 2.为什么 left、right 都非空时,当前 root 一定是最近公共祖先?
  3. 3.为什么 root 命中 p 或 q 时可以立刻返回,不必继续向下寻找另一个目标?
02交互算法精讲

完整题目与题意拆解

给定一棵普通二叉树和树中的两个节点 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。

主例中左子树返回 5,右子树返回 1,根 3 同时收到两条非空信号,因此 3 是两条回传路径第一次相遇的位置。
动画 1 · 题意扫描

普通树没有方向路牌

观察根 3 无法只凭数值判断 p=5、q=1 的位置,问题因此变成“左右会汇报什么”。

Step 1/20%
LC236 · 普通二叉树
3
root
5
p
1
q
6
2
0
8
7
4
不能使用 p.Val < root.Val 剪枝
看完带走:普通二叉树 LCA 的核心不是向下选路,而是让搜索结果自底向上通信。
03交互算法精讲

第一层方案:暴力做法

路径法对 p、q 各做一次 DFS,保存从根到目标的节点数组,再线性比较公共前缀。时间仍是 O(n),但需要 O(h) 路径并维护 push/pop。

更不合适的误区是根据 p.Val、q.Val 与 root.Val 选择方向。这只有 LC235 的 BST 才成立,普通二叉树的值与位置没有关系。

pathP = [3,5]
pathQ = [3,1]
最后一个相同节点 = 3
优化后的递归不是为了改变 O(n) 数量级,而是把“找路径再比较”收敛为一个稳定的返回值协议。
动画 2 · 暴力重复

为每个候选节点重复 contains 为什么浪费

如果逐个节点询问“子树是否同时包含 p、q”,相同子树会被重复扫描;返回信号让一次遍历携带全部信息。

Step 1/30%
LC236 · 普通二叉树
3
root
5
p
1
q
6
2
0
8
7
4
不能使用 p.Val < root.Val 剪枝
看完带走:一次后序遍历能在返回途中完成判断,无需为每个候选祖先重新搜索子树。

优化方向:后序递归让每个节点只处理左右两个返回值。父节点不关心目标在子树中的具体路线,只关心左右各自回来了什么信号。

04交互算法精讲

整体地图:先做什么,再做什么

普通二叉树没有 BST 的值域路牌,因此根节点无法提前选择方向。解决办法是把问题改写成通信协议:每棵子树搜索完以后,向父节点返回一个节点信号。

递归先处理基础条件,再分别获得 left、right。两侧都返回非空信号时在当前节点合并;只有一侧非空时原样上传;两侧都空时自然返回 nil。

  • 向下阶段:把同一个问题交给左右子树。
  • 向上阶段:解释每个子树找到的有效信号。
  • 合并阶段:第一处左右都非空的位置就是答案。
页面的主角不是 DFS 访问顺序,而是返回值。每一帧都必须能回答:当前调用将返回哪个节点,为什么。
05交互算法精讲

返回信号到底表示什么

`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
同一个非空返回值在不同深度可能代表目标或已经形成的 LCA,但父节点不必区分:它都应该被当成一条有效信号继续合并或上报。
动画 3 · 核心概念

逐个识别 nil、p、q、LCA 信号

观察左侧找到 5、右侧找到 1,以及两条信号在父节点汇合前分别代表什么。

Step 1/30%
返回协议
3
等待
5
p 信号
1
q 信号
6
2
0
8
7
4
nil / p / q / LCA
看完带走:非空节点指针是一条可继续复用的发现信号,nil 才表示这条路没有目标。
06交互算法精讲

如何构建自底向上的合并规则

基础条件先截住空节点和目标节点。只有普通节点才继续递归左右子树,因此决策发生在两个孩子都完成之后,这正是后序思路。

left 与 right 同时非空说明两条目标路径第一次在当前 root 汇合;只收到一条信号时,当前节点还不能证明自己是答案,只能把已有信号继续交给父节点。

  • 基础条件:`root == nil || root == p || root == q`。
  • 双非空:`return root`。
  • 单非空:返回非空一侧;双空也会返回 nil。

先处理 nil 与命中目标的基础情况;再递归计算 left 和 right;最后根据两个返回值做合并。

若 left、right 都非空返回 root,否则 left 非空就返回 left,剩下情况返回 right。right 也可能是 nil,正好覆盖双空。

p=5、q=4 时在节点 5 直接返回 5 是正确的:题目保证 q 存在,而 q 位于 5 的子树中,5 本身就是最低共同祖先。
动画 4 · 机制构建

构建双非空合并与单非空上报规则

把父节点等待、双信号合并、单信号继续上传和自身为祖先串起来。

Step 1/40%
后序等待
3
root
5
p
1
q
6
2
0
8
7
4
递归栈
dfs(3)dfs(5)
left = ? · right = ?
看完带走:只有左右同时非空时当前节点才宣布答案,其余情况都在传递信息。
07交互算法精讲

核心难点:为什么两条非空信号在这里合并

left 非空说明左子树包含 p、q 中至少一个目标或已经形成的 LCA;right 非空同理。题目只有两个目标,因此两侧同时非空时,两个目标分别落在当前节点两侧。

当前 root 能同时覆盖左右两侧目标。任何比 root 更低的节点都只能属于左子树或右子树之一,不可能跨过 root 同时覆盖另一侧,所以 root 就是最低公共祖先。

若只有一侧非空,两个目标可能都在那一侧,也可能目前只找到一个;无论哪种情况,当前 root 都没有足够证据成为答案,原样上传是唯一安全动作。

`root==p` 直接返回依赖题目保证 p、q 都存在:若 q 在 p 的子树内,p 是答案;若 q 在别处,p 信号会在更高处与 q 汇合。
正确性抓手
  • 基础条件准确产生 nil 或目标节点信号。
  • 左右都非空时两个目标分居两侧,当前节点是最低合并点。
  • 单边非空时答案仍在该边,把信号原样上传不会丢失正确答案。
08交互算法精讲

完整执行过程

把递归看成信号回传:重点不是节点访问顺序,而是每个调用返回 nil、目标节点还是已经合并出的 LCA。

  1. 1从 root=3 开始,普通树无法判断方向,于是递归询问左右子树。
  2. 2左子树在节点 5 命中 p,返回节点 5;这是一条有效信号,不代表根 3 已经决定。
  3. 3右子树在节点 1 命中 q,返回节点 1。
  4. 4根 3 收到 left=5、right=1,两侧同时非空,返回自己作为 LCA。
  5. 5对照 p=5、q=4:节点 5 命中 p 后返回 5,最终信号仍会正确上报,因此答案可等于目标。
动画 5 · 完整执行

完整观察两条信号从目标回到根

拖动时间轴查看 p、q 信号何时产生、沿哪条边上传、在哪个最低节点第一次相遇。

Step 1/90%
LC236 · 普通二叉树
3
root
5
p
1
q
6
2
0
8
7
4
不能使用 p.Val < root.Val 剪枝
看完带走:LCA 是两条有效返回路径第一次汇合的位置,而不是 DFS 最先访问的公共节点。
09交互算法精讲

把动画和 Go 代码逐行对应

每一帧使用稳定的语义行 ID,不依赖容易漂移的数字行号。先观察分支为什么成立, 再看对应代码,而不是先背模板。

动画 6 · 代码映射

返回信号与 Go 分支逐行同步

重点观察基础条件、左右递归、双非空返回 root、单非空返回对应节点。

Step 1/50%
左信号返回
3
root
5
return 5
1
q
6
2
0
8
7
4
递归栈
dfs(3)
left = 5
看完带走:读这段代码时,每个 return 都要翻译成一条明确的信号语义。
Step 1
普通二叉树没有大小路牌

普通二叉树不满足左小右大,按值剪枝可能直接丢掉目标。

func · base-check
Step 2
返回值是一只节点信号包

父节点需要知道“哪一个节点”被找到,单纯 true/false 无法完成合并。

base-check · base-return · return-right
Step 3
根节点必须等左右子树汇报

当前节点是否为分岔点,取决于两侧是否都找到有效信号。

call-left · call-right
Step 4
左边找到 5,向上返回 p 信号

这表示“我的子树里找到一个目标”,并不要求此刻继续搜索另一目标。

base-check · base-return
Step 5
右边找到 1,向上返回 q 信号

左右子树使用完全相同的返回协议,父节点可以统一合并。

call-right · base-check · base-return
Step 6
两条非空信号在 3 第一次合并

两个目标分居当前节点两侧,3 是两条向上路径首次相遇的最低节点。

merge-check · merge-return
Step 7
只有一侧非空,就继续上传那条信号

这说明当前节点还不是两条路径的合并处,真正答案可能在更高层。

merge-check · left-check · return-left · return-right
Step 8
p=5、q=4 时,5 自己就是答案

祖先关系允许节点包含自己,题目又保证 q 存在,所以无需在 5 以下再次证明。

base-check · base-return
Step 9
每个节点最多进入一次,栈由树高决定

没有 BST 方向可剪枝;同时暂停的递归调用数量等于当前根到叶的高度 h。

func · call-left · call-right · return-right
10交互算法精讲

完整 Go 提交代码与最小测试

完整 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
11交互算法精讲

正确性与复杂度

时间复杂度 O(n)

普通二叉树没有方向信息,最坏需要访问每个节点一次。

空间复杂度 O(h)

递归栈深度等于树高 h;平衡树约 O(log n),退化成链时最坏 O(n)。

12交互算法精讲

最容易写错的地方

错误 1

像 LC235 一样按节点值大小选择方向。

错误 2

把返回值压成 bool,失去具体目标或 LCA 节点。

错误 3

忘记答案可以等于 p 或 q。

错误 4

忽略递归调用栈,错误写成空间 O(1)。

13交互算法精讲

最后复盘:带走逻辑链

  1. 1.普通二叉树没有路牌,需要左右都询问。
  2. 2.基础信号是 nil、p、q,双非空时合并为 LCA。
  3. 3.单边非空不代表当前节点是答案,只负责继续上报。
  4. 4.迁移题:LC235 BST LCA、LC1676 多节点 LCA。
面试表达
  1. 1.我把递归函数定义为:返回当前子树中找到的 p、q 或已经形成的 LCA。
  2. 2.root 为空或命中 p/q 时直接返回 root,然后递归获得 left、right。
  3. 3.左右都非空返回当前 root;否则返回非空一侧,双空自然返回 nil。
  4. 4.每个节点访问一次,时间 O(n);递归栈由树高决定,空间 O(h)。