【LeetCode热题100】Java详解:二叉树的最近公共祖先(含递归/父指针双解法与工程实践)
面向人群
- 正在准备技术面试(尤其是大厂后端、算法岗)的开发者
- 已掌握二叉树基本遍历,希望深入理解LCA(最近公共祖先)算法的学习者
- 刷 LeetCode「热题100」或「二叉树专题」的中级程序员
- 对递归思想、树形DP感兴趣的工程人员
📌 本文适合已能手写二叉树DFS遍历的读者。若对递归、哈希表不熟悉,建议先完成 LeetCode 第235题(二叉搜索树的最近公共祖先)和第104题(二叉树的最大深度)。
关键词
LeetCode 236、二叉树的最近公共祖先、LCA、递归、父指针、深度优先搜索、树形DP、面试高频题、时间复杂度分析
阅读前必须掌握的基础
在深入阅读本文前,请确保你已熟练掌握以下知识点:
二叉树的基本操作
- DFS遍历(前序、中序、后序)
- 递归实现树遍历
- 树节点的定义和基本操作
递归思想
- 递归终止条件
- 递归返回值的含义
- 自底向上的递归处理
Java 基础数据结构
HashMap的基本操作(put,get)HashSet的基本操作(add,contains)- 递归栈的工作原理
复杂度分析
- 能分析 O(n) 时间复杂度
- 理解递归栈空间复杂度
✅ 推荐前置练习:
- LeetCode 235:二叉搜索树的最近公共祖先
- LeetCode 104:二叉树的最大深度
- LeetCode 112:路径总和
一、原题回顾
题目链接:236. 二叉树的最近公共祖先
题目描述
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科定义:“对于有根树 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 解释:节点 5 和节点 1 的最近公共祖先是节点 3 。可视化树结构:
3 / \ 5 1 / \ / \ 6 2 0 8 / \ 7 4示例 2:
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4 输出:5 解释:节点 5 和节点 4 的最近公共祖先是节点 5 。因为根据定义最近公共祖先节点可以为节点本身。示例 3:
输入:root = [1,2], p = 1, q = 2 输出:1提示:
- 树中节点数目在范围
[2, 10⁵]内 -10⁹ <= Node.val <= 10⁹- 所有
Node.val互不相同 p != qp和q均存在于给定的二叉树中
二、原题分析
2.1 核心概念理解
最近公共祖先(LCA)的关键特性:
- 祖先关系:LCA必须是p和q的共同祖先
- 深度最大:在所有公共祖先中,LCA的深度最大(离叶子最近)
- 自包含性:如果p是q的祖先,那么p就是LCA
2.2 问题的难点
- 普通二叉树:没有BST那样的有序性质,无法通过值的大小判断方向
- 任意位置:p和q可以在树的任意位置,包括同一子树或不同子树
- 自包含情况:需要正确处理一个节点是另一个节点祖先的情况
2.3 解题思路的核心洞察
对于任意节点x,要成为p和q的LCA,必须满足以下两种情况之一:
情况1:p和q分别在x的左右子树中
- 左子树包含p,右子树包含q(或反之)
- 此时x就是LCA
情况2:x本身就是p或q,且另一个节点在其子树中
- x = p,且q在x的子树中
- x = q,且p在x的子树中
- 此时x就是LCA
✅ 这个洞察是递归解法的基础!
三、答案构思:两种主流解法
本题有两种经典解法,体现了递归思维 vs 数据结构思维的不同角度:
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 | 实现难度 |
|---|---|---|---|---|
| 方法一:递归(推荐) | 自底向上,后序遍历,树形DP思想 | O(N) | O(N) | 中等 |
| 方法二:父指针 + 哈希表 | 存储父节点信息,模拟向上查找 | O(N) | O(N) | 简单 |
💡 两种方法时间复杂度相同,但递归法更优雅,父指针法更直观。
四、完整答案与代码实现(Java)
方法一:递归(后序遍历 + 树形DP)
思路详解
这是最经典的解法,采用后序遍历的思想:
- 递归函数定义:
dfs(root, p, q)返回布尔值,表示以root为根的子树是否包含p或q - 递归过程:
- 先递归处理左子树,得到
lson - 再递归处理右子树,得到
rson - 检查当前节点是否满足LCA条件
- 先递归处理左子树,得到
- LCA判断条件:
(lson && rson):p和q分别在左右子树((root == p || root == q) && (lson || rson)):当前节点是p或q,且另一个在子树中
🔑关键技巧:使用全局变量
ans记录找到的LCA,一旦找到就不再更新(因为后序遍历保证第一次找到的就是深度最大的)
Java 代码(标准版)
classSolution{privateTreeNodeans;publicTreeNodelowestCommonAncestor(TreeNoderoot,TreeNodep,TreeNodeq){dfs(root,p,q);returnans;}/** * DFS遍历,返回以root为根的子树是否包含p或q */privatebooleandfs(TreeNoderoot,TreeNodep,TreeNodeq){if(root==null){returnfalse;}// 递归处理左右子树booleanlson=dfs(root.left,p,q);booleanrson=dfs(root.right,p,q);// 检查当前节点是否为LCAif((lson&&rson)||((root==p||root==q)&&(lson||rson))){ans=root;}// 返回当前子树是否包含p或qreturnlson||rson||(root==p||root==q);}}Java 代码(优化版 - 直接返回LCA)
有些面试官可能不喜欢使用全局变量,我们可以直接让递归函数返回LCA:
classSolution{publicTreeNodelowestCommonAncestor(TreeNoderoot,TreeNodep,TreeNodeq){// 终止条件if(root==null||root==p||root==q){returnroot;}// 递归处理左右子树TreeNodeleft=lowestCommonAncestor(root.left,p,q);TreeNoderight=lowestCommonAncestor(root.right,p,q);// 情况1:p和q分别在左右子树if(left!=null&&right!=null){returnroot;}// 情况2:p和q在同一子树,返回非空的那个returnleft!=null?left:right;}}✅优化版的优势:
- 无需全局变量,更符合函数式编程思想
- 代码更简洁,逻辑更清晰
- 同样保证O(N)时间复杂度
执行过程图解(示例1)
以p=5, q=1为例:
后序遍历顺序:6→7→4→2→5→0→8→1→3 访问6: 不是5或1,返回false 访问7: 不是5或1,返回false 访问4: 不是5或1,返回false 访问2: 左=false, 右=false, 自己不是5/1 → 返回false 访问5: 自己是5 → 返回true (此时lson=false, rson=false) 访问0: 不是5或1,返回false 访问8: 不是5或1,返回false 访问1: 自己是1 → 返回true 访问3: lson=true(来自5), rson=true(来自1) → 满足lson&&rson → ans=3方法二:父指针 + 哈希表
思路详解
这个方法更直观,模拟了"从下往上找"的过程:
- 预处理:遍历整棵树,用哈希表记录每个节点的父节点
- 标记路径:从p开始,沿着父指针向上,标记所有访问过的节点
- 查找交点:从q开始,沿着父指针向上,第一个被标记的节点就是LCA
🔍为什么这样工作?
- 从p到根的路径和从q到根的路径,它们的第一个交点就是LCA
- 这类似于"链表相交"问题的解法
Java 代码
classSolution{// 存储每个节点的父节点privateMap<TreeNode,TreeNode>parentMap=newHashMap<>();// 存储从p到根路径上访问过的节点privateSet<TreeNode>visited=newHashSet<>();publicTreeNodelowestCommonAncestor(TreeNoderoot,TreeNodep,TreeNodeq){// 构建父节点映射buildParentMap(root);// 从p向上遍历,标记所有祖先TreeNodecurrent=p;while(current!=null){visited.add(current);current=parentMap.get(current);}// 从q向上遍历,找到第一个已访问的节点current=q;while(current!=null){if(visited.contains(current)){returncurrent;}current=parentMap.get(current);}returnnull;// 理论上不会执行到这里}/** * DFS遍历构建父节点映射 */privatevoidbuildParentMap(TreeNodenode){if(node==null){return;}if(node.left!=null){parentMap.put(node.left,node);buildParentMap(node.left);}if(node.right!=null){parentMap.put(node.right,node);buildParentMap(node.right);}}}执行过程图解(示例2)
以p=5, q=4为例:
构建父节点映射: 5.parent = 3 6.parent = 5, 2.parent = 5 7.parent = 2, 4.parent = 2 1.parent = 3 0.parent = 1, 8.parent = 1 从p=5向上标记: visited = {5, 3} 从q=4向上查找: 4 → 2 → 5(5在visited中)→ 返回5五、代码分析与对比
| 维度 | 递归法 | 父指针法 |
|---|---|---|
| 时间复杂度 | O(N) | O(N) |
| 空间复杂度 | O(N) | O(N) |
| 代码简洁性 | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 实现难度 | 中等 | 简单 |
| 函数调用开销 | 有(递归栈) | 无 |
| 内存使用 | 仅递归栈 | 哈希表+递归栈 |
| 面试推荐度 | 首选 | 备选方案 |
💡面试策略:
- 优先实现递归法(展示算法思维)
- 如果面试官要求不用递归,再提供父指针法
- 重点解释递归法中的LCA判断条件
六、时间复杂度与空间复杂度深度分析
时间复杂度:O(N)
递归法:
- 每个节点被访问一次
- 每次访问的操作是O(1)
- 总计:O(N)
父指针法:
- 构建父节点映射:O(N)
- 从p向上遍历:O(h),h为树高
- 从q向上遍历:O(h)
- 总计:O(N)
空间复杂度:O(N)
递归法:
- 递归栈深度:O(h),最坏O(N)(链状树)
- 全局变量:O(1)
- 总计:O(N)
父指针法:
- 父节点哈希表:O(N)
- visited集合:O(h)
- 递归栈(构建父映射):O(h)
- 总计:O(N)
📌实际性能差异:
- 递归法常数因子更小,实际运行更快
- 父指针法在极端情况下(超深树)可能栈溢出风险更低
七、常见问题解答(FAQ)
Q1:为什么递归法使用后序遍历而不是前序遍历?
A:因为我们需要先知道左右子树的信息,才能判断当前节点是否为LCA。后序遍历(左→右→根)保证了在处理根节点时,左右子树的信息已经计算完成。
Q2:递归法中的全局变量ans会不会被多次赋值?
A:理论上会,但实际上只有一次有效赋值。因为后序遍历是从底向上的,第一个满足条件的节点就是深度最大的LCA。即使后续更高层的节点也满足条件,但由于题目保证p和q都存在,所以不会出现多个有效的LCA。
Q3:如果树中有重复值怎么办?
A:题目保证所有Node.val互不相同,所以不用担心。但在实际工程中,我们应该比较节点引用(==)而不是节点值(.val),这在我们的代码中已经正确处理了。
Q4:能否用BFS替代DFS?
A:可以,但实现更复杂。BFS无法自然地进行自底向上的处理,需要额外的数据结构来存储层次信息。
Q5:父指针法的空间复杂度真的是O(N)吗?
A:是的。虽然visited集合最多只存储O(h)个元素,但parentMap需要存储N-1个父节点映射,所以总体空间复杂度是O(N)。
八、优化思路总结
8.1 代码优化
- 避免全局变量:使用直接返回LCA的递归版本
- 早期终止:在找到LCA后可以考虑提前终止(但实现复杂,收益不大)
- 类型安全:始终比较节点引用而非节点值
8.2 性能优化
- 迭代替代递归:对于超深树,可以用显式栈避免栈溢出
- 内存优化:父指针法中,visited可以用位图优化(但节点值范围太大,不实用)
8.3 健壮性优化
- 输入验证:检查p和q是否真的存在于树中(题目已保证)
- 异常处理:处理空树、空节点等边界情况
九、数据结构与算法基础知识点回顾
9.1 树的遍历方式对比
| 遍历方式 | 访问顺序 | 适用场景 |
|---|---|---|
| 前序遍历 | 根→左→右 | 复制树、序列化 |
| 后序遍历 | 左→右→根 | 删除树、计算子树信息 |
| 中序遍历 | 左→根→右 | BST排序输出 |
✅ LCA问题天然适合后序遍历,因为需要子树信息来决定根节点的行为。
9.2 树形动态规划(Tree DP)
LCA的递归解法实际上是树形DP的一个简单例子:
- 状态定义:
f(root)= 以root为根的子树是否包含p或q - 状态转移:
f(root) = f(left) || f(right) || (root == p || root == q) - 答案提取:在状态转移过程中,根据特定条件提取最终答案
9.3 路径相交问题
父指针法将LCA问题转化为两条路径的相交问题:
- 路径1:p → root
- 路径2:q → root
- 求两条路径的第一个交点
这与LeetCode 160(相交链表)的思想完全一致。
十、面试官提问环节(模拟)
Q:你的递归解法中,为什么不需要检查p和q是否存在于树中?
A:题目明确说明"p和q均存在于给定的二叉树中",所以我们不需要做额外的存在性检查。但在实际工程中,这是一个重要的边界条件,应该添加相应的验证逻辑。
Q:如果要求找到k个节点的LCA,你的解法还能工作吗?
A:递归法可以扩展。对于k个节点,我们需要统计当前子树中包含了多少个目标节点。当某个节点的子树恰好包含所有k个节点,且它的子节点都不满足这个条件时,该节点就是LCA。
Q:能否将空间复杂度优化到O(1)?
A:在一般二叉树中很难做到O(1)空间复杂度。但如果树节点包含父指针,那么父指针法的空间复杂度可以降到O(h)(只需要visited集合)。不过题目给出的TreeNode定义没有父指针。
Q:你的优化版递归代码中,为什么if (root == null || root == p || root == q)可以直接返回root?
A:这是一个巧妙的剪枝:
- 如果root为null,说明没找到目标节点
- 如果root等于p或q,说明找到了目标节点,直接返回
- 这样可以避免不必要的递归,并且正确处理了"一个节点是另一个节点祖先"的情况
十一、实际开发中的应用场景
11.1 文件系统路径解析
- 在目录树中,找到两个文件的最近公共目录
- 实现
commonPrefix功能,用于相对路径计算 - 版本控制系统中的分支合并点查找
11.2 DOM树操作
- 在HTML DOM树中,找到两个元素的最近公共祖先
- 实现事件委托时,确定事件处理的最佳位置
- CSS选择器引擎中的祖先匹配
11.3 组织架构管理
- 在公司组织架构树中,找到两个员工的最近共同上级
- 权限系统中的角色继承关系分析
- 审批流程中的共同审批人确定
11.4 编译器AST(抽象语法树)
- 在AST中,找到两个表达式的最近公共作用域
- 变量作用域分析和符号表管理
- 代码优化中的公共子表达式识别
11.5 网络路由协议
- 在网络拓扑树中,找到两个节点的最优汇聚点
- 多播路由中的汇聚路由器选择
- CDN节点选择中的最优边缘节点确定
十二、相关题目推荐
| 题号 | 题目 | 关联点 |
|---|---|---|
| 235 | 二叉搜索树的最近公共祖先 | BST特殊性质 |
| 1650 | 二叉树的最近公共祖先 III | 节点包含父指针 |
| 1676 | 二叉树的最近公共祖先 IV | 多个节点的LCA |
| 1644 | 二叉树的最近公共祖先 II | p或q可能不存在 |
| 236 | 本题 | 普通二叉树LCA |
🔗 学习路径建议:235 → 236 → 1650 → 1676
十三、总结与延伸
13.1 核心思想提炼
- 递归思维:自底向上,后序遍历,利用子树信息决策
- 分类讨论:LCA的两种情况必须都考虑到
- 树形DP:将复杂问题分解为子问题的典型应用
- 路径相交:将树问题转化为路径问题的巧妙转换
13.2 延伸思考
- 动态LCA:支持在线插入/删除节点的LCA查询
- 批量LCA:预处理后支持O(1)查询任意两点LCA(Tarjan算法、倍增算法)
- 带权LCA:路径上有权重,求最小权重的公共祖先
- 分布式LCA:在分布式系统中处理超大树的LCA查询
13.3 面试答题建议
展示完整的思考过程:
- 确认理解:“LCA是指深度最大的公共祖先,一个节点可以是自己的祖先,对吗?”
- 分析性质:解释LCA的两种情况,画图说明
- 提出方案:先说递归思路,强调后序遍历的必要性
- 代码实现:写出清晰的递归代码,解释关键条件
- 优化讨论:提到父指针法作为备选方案
- 复杂度分析:准确分析时间和空间复杂度
- 边界处理:讨论特殊情况的处理
LCA问题是树形数据结构的经典代表,它不仅考察了对树遍历的理解,更体现了分治思想和递归思维的精髓。
掌握LCA的解法,就掌握了处理树形结构中"关系查询"问题的核心能力!