LeetCode 236:二叉树的最近公共祖先——DFS 路径记录法详解

一、题目描述

给定一棵二叉树和其中两个节点 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 的最近公共祖先。

整个算法可以分为两步:

  1. 使用 DFS 分别保存根节点到 p、q 的路径;

  2. 同时遍历两条路径,记录最后一个相同节点。

三、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)。

这里的复杂度分析不把保存答案路径所需空间忽略,因为它正是该解法的重要组成部分。

七、常见错误

  1. 找到目标后直接保存 tempPath 的引用,导致回溯时结果路径被修改;

  2. 递归返回前忘记删除当前节点,使不同分支的节点混入同一条路径;

  3. 使用节点值比较路径,而不是判断是否为同一个节点;

  4. 提前停止条件误写成两条路径都为空,导致只找到一个目标就停止;

  5. 从路径尾部反向查找公共节点。两条路径长度不同,直接从尾部比较并不能保证对齐,应该从共同的根节点向下比较。

八、总结

这道题的路径记录法非常直观:

复制代码
DFS 记录 root 到 p 的路径
        +
DFS 记录 root 到 q 的路径
        ↓
从头比较两条路径
        ↓
最后一个相同节点就是最近公共祖先

实现时最关键的是理解 DFS 回溯:tempPath 表示当前递归路径,进入节点时加入,退出节点时删除;找到目标后必须复制路径。掌握这个过程后,这种方法还可以迁移到"输出根到目标节点路径""判断两节点关系"等二叉树问题中。

相关推荐
Fuyo_111939 分钟前
C++ AVL树保姆级详解:从平衡因子到四大旋转机制
java·c++·算法
jktantan1 小时前
每个节点 41 字节:把一棵百万节点树拆进 14 个 typed array
前端·javascript·vue.js·算法·typescript
揽星河..1 小时前
【C语言】猜数字游戏
c语言·算法·游戏
LabVIEW开发2 小时前
CTM-3 红外测温仪 + LabVIEW 温度怎么测?
算法·labview·测试·labview程序
纪念 2292 小时前
C++算法(六)
开发语言·c++·算法
Dumbness3 小时前
UIA 读微信群消息没有 id,怎么判断哪条是新的:后缀对齐在翻历史和撤回时整屏重报
python·算法
taka3 小时前
从 24x 到 141x:我用纯 Python 零依赖项目,扒开了 GPU 加速的性能黑洞
算法
UIU1144 小时前
P16237 [蓝桥杯 2026 省 B] 应急布线
数据结构·c++·算法·蓝桥杯
旖旎夜光4 小时前
LeetCode 315: 算右侧小于当前元素的个数(归并排序) —— 题解
c++·学习·算法·leetcode·排序算法
像风一样自由20204 小时前
2.如何用FastAPI把一个Python算法封装成HTTP服务
python·算法·fastapi