🌳 问题回顾:什么是最近公共祖先(LCA)?
给定一棵二叉树和两个节点 p、q,找到它们的最近公共祖先(Lowest Common Ancestor, LCA) 。
定义:LCA 是同时为
p和q祖先的深度最大的节点。一个节点可以是自身的祖先。
例如:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
LCA(5, 1) = 3(分属左右子树)LCA(5, 4) = 5(4 是 5 的后代)
🔍 初步思考:能用前序或中序吗?
很多初学者会问:为什么不能用前序遍历?
假设我们从前序开始(根 → 左 → 右):
- 在根节点
3,我们还不知道p和q到底在左子树还是右子树; - 即使我们记录路径,也需要额外空间存储从根到每个节点的路径,再比较;
- 更麻烦的是,我们无法在访问当前节点时,就确定它是不是“最近”的公共祖先。
✅ 关键洞察:LCA 的判定依赖于子树的信息——只有知道左右子树是否包含
p或q,才能判断当前节点是否为 LCA。
这就引出了核心原则:
必须先知道子树的结果,再决定当前节点的行为。
而这,正是 后序遍历(左 → 右 → 根) 的本质!
🔄 为什么后序遍历 + 回溯是天然选择?
Step 1:我们需要“自底向上”的信息
想象你在树的最底层(比如叶子节点):
- 如果你找到了
p,你会告诉父节点:“我这边有p!” - 如果你没找到,就告诉父节点:“我这边啥也没有。”
父节点收集左右孩子的反馈后,就能判断:
- 如果左边说有,右边也说有 → 那我就是 LCA!
- 如果只有一边有 → 我把那个结果继续往上传。
这个过程,就是典型的 回溯(Backtracking) :先深入到底,再带着结果一层层返回。
Step 2:后序遍历天然支持“先处理子树,再处理根”
后序遍历的执行顺序保证了:
- 在处理当前节点
root之前,root.left和root.right的递归已经完成; - 我们可以直接拿到左右子树的“搜索结果”。
这正是 LCA 问题所需要的 信息聚合时机。
💡 所以,并不是“我们选择了后序遍历”,而是 问题本身的性质决定了必须用后序遍历。
🧠 递归函数的设计哲学
我们定义递归函数:
function dfs(root, p, q)
语义:在以 root 为根的子树中,如果包含 p 或 q,则返回其中“有意义”的节点(可能是 LCA,也可能是 p/q 本身);否则返回 null。
✅ 终止条件:何时停止递归?
if (root === null || root === p || root === q) {
return root;
}
root === null:空节点,无意义,返回null;root === p或root === q:找到了目标,立即返回该节点。
这不是为了“判断叶子”,而是为了 提前终止无效搜索——一旦命中目标,无需再看其子树(因为题目保证节点存在,且 LCA 要么是它自己,要么在其祖先中)。
🔁 递归与回溯:收集子树信息
const left = dfs(root.left, p, q);
const right = dfs(root.right, p, q);
现在,我们有了左右子树的“报告”:
| 情况 | 含义 | 当前节点应返回 |
|---|---|---|
left 和 right 都非空 | p 和 q 分居两侧 | root(这就是 LCA!) |
仅 left 非空 | p 和 q 都在左子树 | left(LCA 在左边) |
仅 right 非空 | p 和 q 都在右子树 | right(LCA 在右边) |
| 都为空 | 当前子树不含 p 或 q | null |
这个逻辑,完美体现了 “子树结果决定当前行为” 的后序思想。
✅ 完整代码(JavaScript)
var lowestCommonAncestor = function(root, p, q) {
const dfs = (root, p, q) => {
// 基线条件:空节点或命中目标
if (root === null || root === p || root === q) {
return root;
}
// 先递归左右子树(后序的第一步)
const left = dfs(root.left, p, q);
const right = dfs(root.right, p, q);
// 根据子树结果决定当前返回值(后序的第二步)
if (left && right) {
return root; // 分居两侧,当前是 LCA
}
return left || right; // 哪边有结果就传哪边
};
return dfs(root, p, q);
};
💡 小技巧:最后可以用
return left || right简化逻辑,因为null是 falsy 值。
🌟 总结:LCA 与后序遍历的必然联系
| 关键点 | 说明 |
|---|---|
| 为什么必须后序? | LCA 的判定依赖子树信息,只有后序能先获得左右结果 |
| 回溯体现在哪? | 从叶子带回“是否找到目标”的信息,逐层向上传递 |
| 终止条件的意义 | 提前返回目标节点,避免无效遍历,提升效率 |
| 返回值的含义 | 不一定是 LCA,也可能是 p/q,由上层判断 |
📚 延伸思考
- 如果是 二叉搜索树(BST) ,能否利用有序性改用前序?(答案:可以!)
- 如果要找 k 个节点的 LCA,逻辑如何扩展?
- 如果树中 可能不存在
p或q,如何验证结果有效性?
这些问题,都建立在对“信息回传”和“遍历顺序”深刻理解的基础上。
✅ 结语
LCA 问题看似简单,却蕴含着递归设计的精髓:不是我们选择了某种遍历方式,而是问题本身的结构决定了最优解法。
当你下次遇到“需要子树信息才能决策”的树形问题时,请记住:后序遍历,往往是答案所在。