题目描述
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:"对于有根树 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 4
节点 5 和节点 1 的最近公共祖先是节点 3
示例 2:
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:5
节点 5 和节点 4 的最近公共祖先是节点 5
因为节点 5 是节点 4 的祖先,且深度最大
示例 3:
输入:root = [1,2], p = 1, q = 2
输出:1
节点 1 和节点 2 的最近公共祖先是节点 1
一个节点也可以是它自己的祖先
提示:
- 树中节点数目在范围 [2, 10^5] 内
- -10^9 <= Node.val <= 10^9
- 所有 Node.val 互不相同
- p != q
- p 和 q 均存在于给定的二叉树中
解题思路总览
| 方法 | 思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 方法一:后序遍历 | 递归后序遍历,左右子树查找,根据返回判断 | O(n) | O(h) | 面试首选 |
| 方法二:存储父节点 | 用 HashMap 存储每个节点的父节点,然后上浮 | O(n) | O(n) | 容易理解 |
核心原理: 如果一个节点既能在左子树找到 p/q,又能在右子树找到 p/q,那它就是最近公共祖先
方法一:后序遍历(推荐)
思路
利用递归后序遍历的思想,从底向上查找:
- 如果当前节点为空,或者等于 p 或 q,直接返回当前节点
- 递归在左子树和右子树中查找 p 和 q
- 根据左右子树的返回结果判断:
- 左子树为空:说明 p/q 都在右子树,返回右子树结果
- 右子树为空:说明 p/q 都在左子树,返回左子树结果
- 都不为空:说明 p/q 分别在左右子树,当前节点就是最近公共祖先
- 都为空:说明当前子树不包含 p/q,返回空
完整代码
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
// 递归终止条件:节点为空或找到 p 或 q
if (root == NULL) return root;
if (root == p || root == q) return root;
// 在左右子树中查找
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
// 根据左右子树的返回结果判断
if (left == NULL) return right; // 左子树为空,p/q 都在右子树
if (right == NULL) return left; // 右子树为空,p/q 都在左子树
return root; // 左右都不为空,root 是最近公共祖先
}
};
算法流程图
以示例 1 为例,root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1:
树结构:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
递归返回过程(后序遍历):
LCA(node=3, p=5, q=1):
node==p? 否, node==q? 否
left = LCA(node=5, p=5, q=1)
right = LCA(node=1, p=5, q=1)
LCA(node=5, p=5, q=1):
node==p? 是 → return 5
left = 5
LCA(node=1, p=5, q=1):
node==q? 是 → return 1
right = 1
left!=NULL && right!=NULL → return node(3)
返回 3
逐行解析
cpp
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
// 递归终止条件:节点为空或找到 p 或 q
if (root == NULL) return root;
if (root == p || root == q) return root;
- 递归终止条件一 :
root == NULL,空树直接返回空。 - 递归终止条件二 :
root == p || root == q,找到了 p 或 q,返回当前节点。
cpp
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
- 递归在左子树和右子树中查找 p 和 q。
- 使用后序遍历(左右根),确保从底向上返回结果。
cpp
if (left == NULL) return right;
- 左子树为空,说明左子树中不存在 p 也不存在 q(否则已经返回了)。
- p 和 q 一定都在右子树中,返回右子树的结果。
cpp
if (right == NULL) return left;
- 右子树为空,说明 p 和 q 都在左子树中。
- 返回左子树的结果。
cpp
return root;
- 左右子树都不为空,说明 p 和 q 分别在左右子树中。
- 当前节点就是它们的最近公共祖先。
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 最坏情况遍历所有节点(当 p 和 q 在树的底部时) |
| 空间 | O(h) | 递归栈深度,h 为树高 |
优点: 一次遍历搞定,时间效率高,代码简洁
缺点: 需要理解后序遍历和递归返回值的设计
方法二:存储父节点
思路
- 第一次 DFS,存储每个节点的父节点
- 将 p 的所有祖先节点(包括 p 自己)加入集合
- 从 q 开始向上浮,一直找到第一个出现在集合中的节点
完整代码
cpp
class Solution {
public:
unordered_map<TreeNode*, TreeNode*> parent;
void dfs(TreeNode* node, TreeNode* par) {
if (!node) return;
parent[node] = par;
dfs(node->left, node);
dfs(node->right, node);
}
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
dfs(root, NULL); // 记录每个节点的父节点
unordered_set<TreeNode*> ancestors;
// 将 p 及其所有祖先加入集合
while (p) {
ancestors.insert(p);
p = parent[p];
}
// 从 q 开始向上找,第一个在集合中的就是 LCA
while (q) {
if (ancestors.count(q)) {
return q;
}
q = parent[q];
}
return NULL; // 不会走到这里
}
};
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 两次遍历 |
| 空间 | O(n) | HashMap 和 HashSet 存储节点 |
两种方法对比
| 维度 | 方法一 后序遍历 | 方法二 存储父节点 |
|---|---|---|
| 代码复杂度 | 简洁 | 中等 |
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(h) | O(n) |
| 面试推荐度 | 首选 | 容易想到 |
| 特点 | 一次遍历搞定 | 需要额外的 parent HashMap |
面试追问 FAQ
| 问题 | 解答 |
|---|---|
| Q1:为什么后序遍历能找到最近公共祖先? | 后序遍历的特点是从叶子节点向上返回。递归返回时,如果一个节点的左右子树分别包含 p 和 q,那该节点就是最近公共祖先(因为它既是 p 的祖先也是 q 的祖先,且深度最大)。 |
| Q2:为什么 `root == p | |
Q3:如何理解 if (left == NULL) return right? |
左子树为空有两种情况:1)左子树不包含 p 也不包含 q;2)左子树包含了 p 或 q 但没有返回(这种情况不存在,因为找到就会返回)。所以如果左子树为空,p 和 q 一定都在右子树中。 |
| Q4:如果 p 是 q 的祖先,结果是什么? | 结果是 p。当递归到节点 p 时,root == p 直接返回 p。然后 p 作为返回值一路向上传递,最终成为最终结果。 |
| Q5:方法二为什么需要 parent HashMap? | 因为方法二是用"上浮"的方式查找。从 q 开始,依次访问父节点,直到找到第一个也在 p 的祖先链上的节点。这个"父节点"关系需要用 HashMap 存储。 |
| Q6:两种方法如何选择? | 方法一更优,因为只需要 O(h) 空间且一次遍历搞定。方法二适合需要频繁查询两个节点的 LCA 的场景(可以预处理 parent 信息)。 |
相关题目
| 题目 | 难度 | 关键点 |
|---|---|---|
| 236. 二叉树的最近公共祖先 | 中等 | 后序遍历,LCA |
| 235. 二叉搜索树的最近公共祖先 | 简单 | 利用 BST 性质 |
| 剑指 Offer 68-II. 二叉树的最近公共祖先 | 简单 | 同 236 |
| 1644. 二叉树中第 K 小的元素 | 中等 | BST + LCA 变形 |
总结
| 要点 | 说明 |
|---|---|
| 核心原理 | 后序遍历从底向上,如果左右子树分别包含 p 和 q,当前节点就是 LCA |
| 递归终止条件 | root == NULL 或 root == p 或 root==q |
| 返回值判断 | left == NULL 返回 right,right==NULL 返回 left,都不空返回 root |
| 时间复杂度 | O(n),每个节点最多访问一次 |
| 空间复杂度 | O(h),递归栈深度 |