LeetCode 236 二叉树的最近公共祖先:python3 题解
题目链接:236. 二叉树的最近公共祖先
1. 题目理解
什么是“最近公共祖先” (Lowest Common Ancestor, LCA)?
想象一棵家谱树。对于两个人 p 和 q,他们的“公共祖先”是指一个人,这个人既是 p 的长辈(或自己),也是 q 的长辈(或自己),注意在这个定义里,自己可以是自己的祖先。而“最近”公共祖先,是指在所有公共祖先中,辈分最低(深度最深、离 p 和 q 最近)的那一位。
题目核心要求:
给定一个普通的二叉树(注意:不是二叉搜索树,节点值没有大小顺序),以及树中的两个节点 p 和 q,找到它们的最近公共祖先。
关键定义:
- 一个节点可以是它自己的祖先。
p和q一定存在于树中。p和q不相同。
2. 解题思路分析
这道题主要有两种经典的解法:
- 递归法(后序遍历):最简洁、最常用,利用函数返回值传递信息。
- 存储父节点法(迭代):直观易懂,将树转换为“向上查找”的结构。
我们将重点讲解这两种方法。
3. 解法一:递归法 (推荐)
这是最优雅的解法。核心思想是自底向上的信息传递。
算法逻辑
我们定义一个递归函数 dfs(node),它的任务是:在以 node 为根的子树中,寻找 p 或 q。
这个函数会有三种返回情况:
- 返回
p或q:表示在子树中找到了p或q中的一个。 - 返回
None:表示在子树中既没找到p也没找到q。 - 返回 最近公共祖先:表示
p和q分别位于当前节点的左右两侧,当前节点就是 LCA。
递归步骤:
- 终止条件:如果当前节点
root为空,或者root就是p或q,直接返回root。- 为什么? 如果找到了目标节点,就把它汇报上去。
- 递归左右:分别在左子树和右子树中调用递归函数。
left = dfs(root.left)right = dfs(root.right)
- 判断结果:
- 情况 A:如果
left和right都不为空。说明p和q一个在左边,一个在右边。那么当前root就是最近公共祖先,返回root。 - 情况 B:如果
left不为空,right为空。说明p和q都在左子树里(或者左边找到了一个,右边没找到)。LCA 一定在左边,返回left。 - 情况 C:如果
right不为空,left为空。同理,返回right。 - 情况 D:如果都为空。说明这棵子树里没有
p或q,返回None。
- 情况 A:如果
代码实现 (Python 3)
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
"""
使用递归(后序遍历)寻找最近公共祖先
时间复杂度:O(N),需要遍历所有节点
空间复杂度:O(H),递归栈的深度,H 为树的高度
"""
# 定义递归辅助函数
def dfs(node):
# 1. 终止条件:
# 如果节点为空,返回 None
# 如果当前节点就是 p 或 q,返回当前节点(向上传递信号:我找到目标了)
if not node or node == p or node == q:
return node
# 2. 递归处理左右子树
left = dfs(node.left)
right = dfs(node.right)
# 3. 处理返回值
# 如果左右子树都找到了目标(一个返回 p,一个返回 q)
# 说明当前节点 node 就是分叉点,即最近公共祖先
if left and right:
return node
# 如果只有一边找到了目标,那就把找到的那个结果继续向上传
# 如果左边有结果,返回左边;否则返回右边(右边可能有结果,也可能都是 None)
return left if left else right
# 从根节点开始递归
return dfs(root)
图解递归过程
假设树结构如下,找 p=5, q=4:
3
/ \
5 1
/ \
6 2
/ \
7 4 (q)
(p)
- 递归到节点
5时,发现node == p,返回节点5。 - 递归到节点
4时,发现node == q,返回节点4。 - 回溯到节点
2:- 左子树 (
7) 返回None。 - 右子树 (
4) 返回4。 - 节点
2返回4(把找到的信息向上传)。
- 左子树 (
- 回溯到节点
5:- 左子树 (
6) 返回None。 - 右子树 (
2) 返回4。 - 关键点:此时节点
5本身就是p。根据代码逻辑if not node or node == p or node == q: return node,它在第一步就直接返回了自己5。 - 修正理解:实际上,当递归进入
5时,因为它匹配p,直接返回5。它的子树不会再被深入遍历(这是短路优化,但不影响结果正确性,因为如果q在5的子树里,5本身就是 LCA)。 - 最终,节点
3的左边收到5,右边收到None(因为1那边没有p或q)。3返回5。 - 最终结果
5。
- 左子树 (
4. 解法二:存储父节点法 (迭代)
这种方法更符合直觉:先找到从根到 p 的路径,和从根到 q 的路径,然后找两条路径的最后一个公共节点。
由于题目给的节点没有指向父节点的指针,我们需要自己构建一个“子节点 -> 父节点”的映射表。
算法逻辑
- 构建父节点映射:使用 BFS 或 DFS 遍历整棵树,用一个哈希表
parent_map记录每个节点的父节点。parent_map[child] = parent。 - 记录
p的祖先链:从p开始,通过parent_map不断向上找父节点,直到根节点。将经过的所有节点存入一个集合p_ancestors。 - 查找
q的祖先:从q开始,不断向上找父节点。遇到的第一个存在于p_ancestors集合中的节点,就是最近公共祖先。
代码实现 (Python 3)
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
"""
使用父节点映射表 + 迭代查找
时间复杂度:O(N),遍历树构建映射 + 向上查找路径
空间复杂度:O(N),存储父节点映射和祖先集合
"""
# 1. 构建父节点映射表 {子节点:父节点}
# 使用栈进行 DFS 遍历,也可以用队列 BFS
parent_map = {root: None}
stack = [root]
# 遍历直到找到 p 和 q 的父节点信息即可,但为了简单通常遍历完或找到两者为止
# 这里为了代码简洁,我们遍历整棵树建立完整映射
while stack:
node = stack.pop()
if node.left:
parent_map[node.left] = node
stack.append(node.left)
if node.right:
parent_map[node.right] = node
stack.append(node.right)
# 2. 记录 p 的所有祖先节点(包括 p 自己)
p_ancestors = set()
curr = p
while curr:
p_ancestors.add(curr)
curr = parent_map[curr] # 向上移动
# 3. 从 q 开始向上找,第一个在 p_ancestors 中的节点即为 LCA
curr = q
while curr:
if curr in p_ancestors:
return curr
curr = parent_map[curr]
return None # 理论上不会执行到这里,因为题目保证 p, q 存在
5. 两种解法对比
| 特性 | 解法一:递归法 | 解法二:父节点映射法 |
|---|---|---|
| 代码复杂度 | 低,代码非常短 | 中,需要额外构建哈希表 |
| 空间复杂度 | O(H),H 为树高 (递归栈) | O(N),需要存储所有节点的父指针 |
| 时间复杂度 | O(N) | O(N) |
| 理解难度 | 较高 (需要理解递归返回值含义) | 较低 (符合直觉的路径比较) |
| 适用场景 | 面试首选,空间更优 | 树极深防止递归溢出,或需要多次查询 LCA 时 |
注意:对于 Python 而言,如果树退化成链表(深度 \(10^5\)),递归法可能会触发 RecursionError(最大递归深度限制)。虽然 LeetCode 通常调整了限制允许通过,但在实际工程中,解法二(迭代)更安全。但在算法面试中,解法一(递归)是标准答案。
6. 常见疑问解答 (FAQ)【⭐】
Q1: 为什么递归法中,如果 left 和 right 都有值,当前节点就是 LCA?
A: 因为 left 有值意味着左子树里包含了 p 或 q 中的一个,right 有值意味着右子树里包含了另一个。既然分居两侧,那么当前节点 root 必然是它们汇合的最低点。
Q2: 如果 p 是 q 的祖先(例如示例 2),递归法怎么处理?
A: 当递归遍历到 p 时,满足 node == p,直接返回 p。此时 p 的子树(包含 q)不会被继续深入递归。这个 p 会一路向上传递,直到根节点或其他分叉点。最终返回的就是 p。这符合定义(节点可以是自己的祖先)。
Q3: 为什么不用先判断 p 和 q 是否在树中?
A: 题目提示中明确说明了:"p 和 q 均存在于给定的二叉树中”。所以不需要额外的检查步骤,简化了代码。
7. 总结
- 首选解法:递归后序遍历。它利用了二叉树的结构特性,代码最精简,空间效率最高。
- 核心技巧:理解递归函数的返回值不仅仅是“找到了”,而是“找到的那个节点(可能是目标,也可能是 LCA)”。
- 备选解法:如果担心递归深度溢出,或者需要多次查询不同节点的 LCA,可以使用父节点映射法。

浙公网安备 33010602011771号