236. 二叉树的最近公共祖先#
从生活场景理解LCA#
想象你在整理家族族谱时,需要找到两位家族成员的最近共同祖先。比如小明和小红,他们的最近共同祖父可能不是年龄最大的祖先,而是离他们最近的共同长辈。在计算机科学中,二叉树中的最近公共祖先(LCA)问题与此如出一辙。
问题描述#
题目目标#
给定一个二叉树,找到该树中两个指定节点 p 和 q 的最近公共祖先。最近公共祖先的定义为:对于有根树 T 的两个节点 p、q,最近公共祖先表示一个节点 x,满足 x 是 p、q 的祖先,且 x 的深度尽可能大。
示例 1#
输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出: 3
二叉树示意:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4text说明: 节点 5 和节点 1 分别位于根节点 3 的左右子树中,所以它们的最近公共祖先是 3。
补充说明#
- 二叉树通常使用层序数组表示,
null表示该位置没有节点。
问题解析#
LeetCode第236题要求:给定一个二叉树和两个节点p、q,找到这两个节点在树中的最近公共祖先。注意:
- 节点可以是自身的祖先
- p和q都存在于二叉树中
- 所有节点值唯一
递归法:优雅的分治策略#
递归思路分解#
我们可以把问题分解为三个层次来思考:
- 当前节点是否是p或q?
- 目标节点是否在左子树?
- 目标节点是否在右子树?
当出现以下情况之一时,当前节点就是LCA:
- p和q分别位于左右子树(即左右递归都返回非空)
- 当前节点是p/q,且另一个节点在其子树中
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 递归终止条件:
// 1) root 为空,说明这条分支没找到目标
// 2) root == p 或 root == q,说明找到其中一个目标节点
if (root == null || root == p || root == q) return root;
// 去左子树寻找 p/q
TreeNode left = lowestCommonAncestor(root.left, p, q);
// 去右子树寻找 p/q
TreeNode right = lowestCommonAncestor(root.right, p, q);
// 若左右子树都非空,说明 p 和 q 分别位于两侧,当前节点即最近公共祖先
if (left != null && right != null) return root;
// 否则只可能在一侧找到目标(或都没找到),把非空结果继续向上返回
return left != null ? left : right;
}
}java递归过程图解#
以二叉树[3,5,1,6,2,0,8,null,null,7,4]为例,寻找节点5和1的LCA:
3
/ \
5 1 ← LCA是3
/ \ / \
6 2 0 8
/ \
7 4plaintext递归执行过程:
- 根节点3不是目标节点,递归左右子树
- 左子树5发现目标节点,右子树1发现另一个目标节点
- 根据判断条件,返回根节点3作为LCA
迭代法:显式记录访问路径#
虽然递归法简洁优雅,但可能有读者更习惯迭代思维。我们可以通过记录访问路径的方式实现:
实现步骤#
- 使用哈希表存储每个节点的父指针
- 记录从p到根节点的访问路径
- 从q出发向上查找,第一个出现在p路径中的节点即为LCA
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// parent 记录每个节点的父节点:child -> parent
Map<TreeNode, TreeNode> parent = new HashMap<>();
// 栈用于遍历整棵树并建立 parent 映射
Deque<TreeNode> stack = new ArrayDeque<>();
// 根节点的父节点记为 null
parent.put(root, null);
// 根节点入栈
stack.push(root);
// 遍历整棵树,构建完整父指针表
while (!stack.isEmpty()) {
// 弹出当前节点
TreeNode node = stack.pop();
// 若左孩子存在,记录其父节点并入栈
if (node.left != null) {
parent.put(node.left, node);
stack.push(node.left);
}
// 若右孩子存在,记录其父节点并入栈
if (node.right != null) {
parent.put(node.right, node);
stack.push(node.right);
}
}
// ancestors 存储从 p 一路向上的所有祖先(含 p 自身)
Set<TreeNode> ancestors = new HashSet<>();
// 从 p 向上走到根,把路径节点全部加入集合
while (p != null) {
ancestors.add(p);
p = parent.get(p);
}
// 从 q 向上走,第一个出现在 ancestors 中的节点就是最近公共祖先
while (!ancestors.contains(q)) {
q = parent.get(q);
}
// 返回最近公共祖先
return q;
}
}java方法对比#
| 方法 | 时间复杂度 | 空间复杂度 | 实现难度 |
|---|---|---|---|
| 递归法 | O(n) | O(h) | ★★☆☆☆ |
| 迭代法 | O(n) | O(n) | ★★★☆☆ |
递归法凭借其简洁性成为首选方案,迭代法则在特定场景下(如需要避免递归栈溢出)更具优势。
关键点解析#
- 后序遍历特性:递归法本质上是后序遍历的应用,先处理子树再处理当前节点
- 短路优化:当找到目标节点时,可以提前终止不必要的搜索
- 边界处理:巧妙利用空值传递信息,空值表示子树中无目标节点
常见误区#
- 误认为LCA必须是父节点(可能是祖父节点)
- 忘记处理节点自身是祖先的情况
- 混淆节点值和节点引用(Java中要比较对象地址)
实际应用#
- Git版本控制中的最近共同提交
- 社交网络中的共同联系人查找
- 生物信息学中的进化树分析
- 编译器中的符号解析
进阶思考#
- 如果树节点包含父指针,如何优化算法?
- 如何处理多个目标节点的LCA问题?
- 当树结构动态变化时,如何高效维护LCA信息?
- 在N叉树中如何寻找LCA?
总结#
LCA问题教会我们:
- 递归思维在树结构问题中的强大威力
- 通过分治策略将复杂问题拆解为子问题
- 不同数据结构组合带来的性能优化空间
- 算法设计中对边界条件的敏感性
无论是递归法还是迭代法,其核心都在于理解树结构的遍历特性。就像在家族中寻找共同祖先一样,算法本质上是在建立节点间的联系网络。
- 高频面试题分类解析
- 递归与非递归模板代码
- 经典题目变形题解
- 大厂真题实战演练
让我们一起在算法的世界里修炼忍者之道!🗡️