LeetCode 上的经典二叉树问题——236. 二叉树的最近公共祖先,这道题是二叉树遍历的核心应用,也是面试中高频考察的题目,不仅要会做,还要理解两种主流解法(递归、迭代)的逻辑,搞懂“最近”二字的底层判断逻辑。
先明确题目核心,避免踩坑:题目给出一棵二叉树(注意:不是二叉搜索树!这和后续的 BST 版本有本质区别),以及两个指定节点 p 和 q,要求找到它们的最近公共祖先(LCA)。
一、题目核心定义(必看)
百度百科对最近公共祖先的定义:对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足两个条件:
-
x 是 p 和 q 的共同祖先(节点本身也可以是自己的祖先,比如 p 是 q 的祖先时,p 就是 LCA);
-
x 的深度尽可能大(“最近”的核心含义:在所有共同祖先中,离 p、q 最近,也就是最深的那个)。
举个简单例子:如果 p 在 q 的左子树里,那么 q 就是 LCA;如果 p 和 q 分别在某个节点的左右子树里,那么这个节点就是 LCA。
二、前置准备(TreeNode 类定义)
题目给出的二叉树节点类定义(TypeScript 版本),后续解法均基于这个类实现,这里统一贴出,方便大家对照:
class TreeNode {
val: number
left: TreeNode | null
right: TreeNode | null
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
this.val = (val === undefined ? 0 : val)
this.left = (left === undefined ? null : left)
this.right = (right === undefined ? null : right)
}
}
三、解法一:递归(最优解,时间/空间 O(n))
3.1 核心思路
递归的核心是「后序遍历」—— 因为我们需要先遍历当前节点的左、右子树,判断左、右子树中是否包含 p 或 q,再根据判断结果,确定当前节点是否是 LCA。
递归函数的作用:判断当前节点所在的子树中,是否包含 p 或 q(返回布尔值)。
LCA 的判断条件(满足任一即可):
-
当前节点的左子树包含一个节点(p 或 q),右子树包含另一个节点(q 或 p)—— 此时当前节点就是 LCA;
-
当前节点本身就是 p 或 q,且其左子树或右子树中包含另一个节点 —— 此时当前节点就是 LCA(因为节点本身可以是自己的祖先)。
注意:因为递归是后序遍历,会从叶子节点往上回溯,所以第一次满足上述条件的节点,就是「最深」的公共祖先(即最近公共祖先),我们只需记录这个节点即可。
3.2 完整代码
function lowestCommonAncestor_1(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
let result: TreeNode | null = null; // 存储最终的最近公共祖先
// 递归函数:判断当前节点所在子树是否包含p或q
const dfs = (node: TreeNode | null, p: TreeNode | null, q: TreeNode | null): boolean => {
if (!node) { // 空节点,不包含p和q,返回false
return false;
}
// 递归遍历左子树,判断左子树是否包含p/q
const lson = dfs(node.left, p, q);
// 递归遍历右子树,判断右子树是否包含p/q
const rson = dfs(node.right, p, q);
// 判断当前节点是否是LCA
if ((lson && rson) || ((node.val === p?.val || node.val === q?.val) && (lson || rson))) {
result = node; // 满足条件,更新result为当前节点
}
// 返回当前子树是否包含p或q(供父节点判断)
return lson || rson || (node.val === p?.val || node.val === q?.val);
}
dfs(root, p, q); // 从根节点开始递归
return result;
};
3.3 代码解析(关键细节)
-
result 变量:用于存储 LCA,因为递归函数返回的是布尔值,无法直接返回节点,所以用全局变量(相对于递归函数)记录。
-
lson 和 rson:分别表示左、右子树是否包含 p 或 q,由递归调用的返回值获得。
-
判断条件拆解:
-
(lson && rson):左子树有一个,右子树有另一个,当前节点是 LCA;
-
((node.val === p?.val || node.val === q?.val) && (lson || rson)):当前节点是 p 或 q,且子树中包含另一个节点,当前节点是 LCA。
-
-
递归终止条件:节点为 null 时,返回 false(空树不包含任何节点)。
3.4 复杂度分析
-
时间复杂度:O(n) —— 最坏情况下,需要遍历二叉树的所有节点(比如 p 和 q 分别在叶子节点)。
-
空间复杂度:O(n) —— 递归调用栈的深度,最坏情况下(二叉树为链状),栈深为 n。
四、解法二:迭代(基于栈的后序遍历,时间/空间 O(n))
递归的思路很简洁,但如果担心递归栈溢出(比如面对极深的二叉树),可以用迭代的方式实现,核心还是「后序遍历」,同时记录节点的路径,通过路径对比找到 LCA。
4.1 核心思路
迭代法的核心是「记录路径」:
-
用栈模拟后序遍历,遍历过程中,记录从根节点到当前节点的路径(path 数组);
-
用 visited 集合标记已经访问过的节点(避免重复遍历右子树);
-
第一次找到 p 或 q 时,记录此时的路径(tempPath);
-
第二次找到另一个节点时,对比 tempPath(第一个节点的路径)和当前 path(第二个节点的路径),找到两条路径的「最后一个公共节点」,这个节点就是 LCA;
-
如果只找到一个节点(p 或 q 不在树中),则返回 null。
4.2 完整代码
function lowestCommonAncestor_2(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
// 边界判断:根节点或p/q为空,直接返回null
if (!root || !p || !q) {
return null;
}
// 特殊情况:根节点就是p或q,直接返回根节点(另一个节点一定在根节点的子树中)
if (root === p || root === q) {
return root;
}
const path: TreeNode[] = []; // 存储当前遍历的路径(根节点到当前节点)
const visited: Set<TreeNode> = new Set(); // 标记已访问的节点,避免重复处理
let tempPath: TreeNode[] = []; // 存储第一个找到的节点(p或q)的路径
let curr: TreeNode | null | undefined = root;
// 先把根节点的所有左子节点入栈(初始化路径)
while (curr) {
path.push(curr);
curr = curr.left;
}
let foundCount = 0; // 记录找到p/q的次数(0/1/2)
let result: TreeNode | null = null;
while (path.length) {
curr = path[path.length - 1]; // 栈顶元素(当前节点)
if (!curr) { // 空节点,弹出,继续循环
path.pop();
continue;
}
if (!visited.has(curr)) {
// 标记当前节点为已访问(先标记,再处理右子树)
visited.add(curr);
// 判断当前节点是否是p或q
if (curr?.val === p?.val || curr?.val === q?.val) {
foundCount++;
if (foundCount === 1) {
// 第一次找到,记录当前路径
tempPath = [...path];
} else if (foundCount === 2) {
// 第二次找到,对比两条路径,找最后一个公共节点
let i = 0;
while (i < tempPath.length && i < path.length && tempPath[i].val === path[i].val) {
i++;
}
// i-1就是最后一个公共节点的索引(i是第一个不相等的位置)
result = i > 0 ? tempPath[i - 1] : root;
break; // 找到LCA,退出循环
}
}
// 处理右子树:把右子树的所有左节点入栈(模拟后序遍历)
let right: TreeNode | null = curr.right;
while (right && !visited.has(right)) {
path.push(right);
right = right.left;
}
} else {
// 已访问过,弹出栈(后序遍历:左->右->根,处理完右子树再弹出根)
path.pop();
}
}
// 只有找到两个节点,才返回result,否则返回null
return foundCount === 2 ? result : null;
};
4.3 代码解析(关键细节)
-
边界处理:先判断根节点、p、q是否为空,再判断根节点是否是p或q(此时根节点就是LCA)。
-
路径初始化:先把根节点的所有左子节点入栈,这是迭代后序遍历的常用初始化方式,保证先遍历左子树。
-
visited 集合:避免重复处理节点(比如右子树的节点不会被多次入栈),同时控制栈的弹出时机(处理完右子树后,再弹出当前节点)。
-
路径对比:当第二次找到节点时,两条路径的前缀是从根节点到LCA的路径,循环找到第一个不相等的位置,前一个位置就是LCA。
4.4 复杂度分析
-
时间复杂度:O(n) —— 每个节点入栈、出栈各一次,遍历所有节点。
-
空间复杂度:O(n) —— 栈(path)和 visited 集合的最大容量均为 n(链状二叉树时)。
五、两种解法对比 & 解题技巧
| 解法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 递归(解法一) | 代码简洁、逻辑清晰,容易理解和实现 | 递归栈可能溢出(极深二叉树) | 日常刷题、面试优先写(代码短,不易出错) |
| 迭代(解法二) | 无递归栈溢出问题,稳定性更高 | 代码繁琐,逻辑细节多(路径记录、访问标记) | 二叉树极深,或禁止使用递归的场景 |
解题关键技巧
-
区分「二叉树」和「二叉搜索树」的LCA:本题是普通二叉树,无法利用BST的有序性,只能用后序遍历;如果是BST,可通过节点值大小判断p、q在左/右子树,逻辑更简单。
-
节点本身是自己的祖先:这是容易忽略的点,比如p是q的父节点,此时p就是LCA,两种解法都通过判断“当前节点是p/q且子树包含另一个节点”覆盖了这种情况。
-
递归的“回溯性”:递归的后序遍历天然带有回溯特性,从叶子节点往上判断,第一次满足条件的节点就是最近公共祖先,无需额外判断深度。
六、总结
LeetCode 236 是二叉树后序遍历的典型应用,核心是「先判断子树,再判断当前节点」,找到满足条件的最深公共祖先。
面试中,优先写出递归解法(简洁高效),并能解释清楚递归的逻辑(后序遍历、LCA的判断条件);如果面试官追问迭代解法,再写出基于栈的后序遍历+路径对比的实现。
建议大家动手模拟一下递归和迭代的执行过程(比如用简单的二叉树,比如根节点1,左2右3,p=2、q=3),就能更直观地理解两种解法的逻辑,避免死记硬背代码。