一、题目描述
给定一棵二叉树和其中两个节点 p、q,找出它们的最近公共祖先。
最近公共祖先是同时作为 p、q 祖先且深度最大的节点。一个节点也可以是它自己的祖先,因此当 p 位于 q 的祖先链上时,p 本身就可能是答案。
例如:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
-
当
p = 5、q = 1时,最近公共祖先是3; -
当
p = 5、q = 4时,最近公共祖先是5。
本文严格采用一种直观思路:先通过 DFS 找出根节点到 p、q 的两条路径,再比较路径中的节点。
二、核心思路:记录两条根节点路径
以查找节点 6 和节点 4 的最近公共祖先为例。
从根节点出发进行深度优先搜索,可以得到:
根节点到 6 的路径:3 -> 5 -> 6
根节点到 4 的路径:3 -> 5 -> 2 -> 4
两条路径从根节点开始比较:
3 == 3
5 == 5
6 != 2
最后一个相同节点是 5,所以 5 就是节点 6 和节点 4 的最近公共祖先。
整个算法可以分为两步:
-
使用 DFS 分别保存根节点到
p、q的路径; -
同时遍历两条路径,记录最后一个相同节点。
三、DFS 如何记录路径
搜索过程中使用三个集合:
-
tempPath:保存 DFS 当前走过的临时路径; -
pathToP:保存根节点到p的完整路径; -
pathToQ:保存根节点到q的完整路径。
访问一个节点时,先把它加入 tempPath。如果当前节点就是 p 或 q,便使用 addAll 将当前路径复制到对应集合。
为什么必须复制,而不能直接令 pathToP = tempPath?因为 tempPath 会在后续回溯中不断删除节点。如果两个变量指向同一个集合,已经找到的路径也会随之改变。
处理完当前节点的左右子树后,需要删除 tempPath 的最后一个节点:
tempPath.remove(tempPath.size() - 1);
这就是回溯。例如从节点 6 返回节点 5 时,要把 6 移出当前路径,之后才能继续搜索节点 2。可以记成一句话:
进入节点时加入路径,离开节点时撤销选择。
当 pathToP 和 pathToQ 都不为空时,说明两条路径已经找到,不必再继续搜索其他分支。
四、完整 Java 代码
import java.util.ArrayList;
import java.util.List;
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root,
TreeNode p,
TreeNode q) {
// DFS 搜索过程中的当前路径
List<TreeNode> tempPath = new ArrayList<>();
// 根节点到 p、q 的路径
List<TreeNode> pathToP = new ArrayList<>();
List<TreeNode> pathToQ = new ArrayList<>();
dfs(root, p, q, tempPath, pathToP, pathToQ);
TreeNode result = null;
// 从根节点开始比较,最后一个相同节点就是最近公共祖先
for (int i = 0; i < pathToP.size() && i < pathToQ.size(); i++) {
if (pathToP.get(i) == pathToQ.get(i)) {
result = pathToP.get(i);
} else {
break;
}
}
return result;
}
private void dfs(TreeNode node,
TreeNode p,
TreeNode q,
List<TreeNode> tempPath,
List<TreeNode> pathToP,
List<TreeNode> pathToQ) {
if (node == null) {
return;
}
// 进入当前节点
tempPath.add(node);
if (node == p) {
pathToP.addAll(tempPath);
}
if (node == q) {
pathToQ.addAll(tempPath);
}
// 只要还有一条路径未找到,就继续搜索
if (pathToP.isEmpty() || pathToQ.isEmpty()) {
dfs(node.left, p, q, tempPath, pathToP, pathToQ);
dfs(node.right, p, q, tempPath, pathToP, pathToQ);
}
// 离开当前节点,恢复进入前的路径状态
tempPath.remove(tempPath.size() - 1);
}
}
题目已经保证 p、q 都存在于二叉树中,因此正常情况下两条路径一定能够找到。
五、关键代码解析
1. 为什么比较节点要使用 ==
pathToP.get(i) == pathToQ.get(i)
路径中保存的是 TreeNode 对象。题目要求判断是否为树中的同一个节点,因此直接比较对象引用,而不是只比较 val。即使题目通常保证节点值唯一,比较节点本身也更准确。
2. 为什么能处理"节点是自己的祖先"
假设 p = 5、q = 4:
pathToP:3 -> 5
pathToQ:3 -> 5 -> 2 -> 4
遍历到较短路径末尾时,最后一个相同节点是 5,因此算法自然返回 5,符合题目定义。
3. 提前停止条件为什么使用 ||
if (pathToP.isEmpty() || pathToQ.isEmpty())
只要其中一条路径还没有找到,就仍需继续 DFS。只有两条路径都已经找到时,条件才为 false,搜索才会停止。如果误写成 &&,找到其中一个节点后就可能过早停止。
六、复杂度分析
设二叉树共有 n 个节点,高度为 h。
-
时间复杂度:
O(n)。最坏情况下需要遍历整棵树,之后比较两条路径最多再花费O(h); -
空间复杂度:
O(h)。递归调用栈、临时路径及两条结果路径都与树高有关;最坏的链状树中为O(n)。
这里的复杂度分析不把保存答案路径所需空间忽略,因为它正是该解法的重要组成部分。
七、常见错误
-
找到目标后直接保存
tempPath的引用,导致回溯时结果路径被修改; -
递归返回前忘记删除当前节点,使不同分支的节点混入同一条路径;
-
使用节点值比较路径,而不是判断是否为同一个节点;
-
提前停止条件误写成两条路径都为空,导致只找到一个目标就停止;
-
从路径尾部反向查找公共节点。两条路径长度不同,直接从尾部比较并不能保证对齐,应该从共同的根节点向下比较。
八、总结
这道题的路径记录法非常直观:
DFS 记录 root 到 p 的路径
+
DFS 记录 root 到 q 的路径
↓
从头比较两条路径
↓
最后一个相同节点就是最近公共祖先
实现时最关键的是理解 DFS 回溯:tempPath 表示当前递归路径,进入节点时加入,退出节点时删除;找到目标后必须复制路径。掌握这个过程后,这种方法还可以迁移到"输出根到目标节点路径""判断两节点关系"等二叉树问题中。