1. 问题背景与核心概念解析
最近在力扣(LeetCode)刷二叉树相关题目时,遇到了一个很有意思的问题——如何找到给定两个节点的最近公共祖先(Lowest Common Ancestor, LCA)。这个问题看似简单,但在实际编码和面试中却经常让不少同学栽跟头。今天我就结合自己刷题的经验,详细剖析这个经典问题的解法思路。
首先明确几个关键概念:
- 二叉树节点:每个节点包含val(值)、left(左子节点指针)、right(右子节点指针)
- 祖先节点:若节点p在从根节点到q的路径上,则p是q的祖先
- 最近公共祖先:两个节点p和q的最近公共祖先是在树中同时拥有p和q作为后代的最低节点(允许一个节点是其自身的后代)
举个例子,在下图二叉树中:
code复制 3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
- 节点5和1的LCA是3
- 节点5和4的LCA是5
- 节点6和8的LCA是3
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与时间复杂度分析
2.1 朴素思路:存储路径法
最直观的解法是分别记录从根节点到p和q的路径,然后比较两条路径,最后一个相同的节点就是LCA。具体步骤:
- 从根节点开始遍历树,存储根节点到p的路径到列表path_p
- 同样方法存储根节点到q的路径到path_q
- 遍历两个路径,找到最后一个相同的节点
python复制def lowestCommonAncestor(root, p, q):
def getPath(node, target):
path = []
while node != target:
path.append(node)
if target.val < node.val:
node = node.left
else:
node = node.right
path.append(node)
return path
path_p = getPath(root, p)
path_q = getPath(root, q)
ancestor = None
for u, v in zip(path_p, path_q):
if u == v:
ancestor = u
else:
break
return ancestor
注意:这个方法在二叉搜索树(BST)中有效,因为BST的有序性让我们可以确定搜索方向。对于普通二叉树需要DFS遍历。
2.2 时间复杂度分析
- 存储路径:O(h),h是树高
- 比较路径:O(h)
- 总时间复杂度:O(h)
- 空间复杂度:O(h)(存储路径)
对于平衡二叉树,h=logN;最坏情况(链表状)h=N
3. 优化解法:递归DFS
3.1 递归思路的精髓
更优雅的解法是利用递归深度优先搜索(DFS)。核心思想是:
- 如果当前节点是p或q,返回当前节点
- 递归搜索左右子树
- 如果左右子树都返回非空节点,说明当前节点就是LCA
- 如果只有一边返回非空,返回那边的结果
python复制def lowestCommonAncestor(root, p, q):
if not root or root == p or root == q:
return root
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
if left and right:
return root
return left if left else right
3.2 递归过程图解
以之前的二叉树为例,找节点7和4的LCA:
- 从根节点3开始,递归左子树5
- 节点5递归左子树6 → 返回None
- 节点5递归右子树2
- 节点2递归左子树7 → 返回7
- 节点2递归右子树4 → 返回4
- 节点2得到left=7和right=4 → 返回自身2
- 节点5得到left=None和right=2 → 返回2
- 根节点3得到left=2和right=None → 返回2
- 最终LCA是5(因为2的父节点是5)
实际代码中会直接返回2,因为题目允许一个节点是其自身的祖先。如果严格要求LCA必须是父节点,需要额外处理。
4. 迭代解法与空间优化
4.1 使用父指针的迭代法
对于特别大的树,递归可能导致栈溢出。这时可以用迭代法+哈希表存储父指针:
- 使用栈进行迭代遍历,记录每个节点的父节点
- 找到p和q后,回溯p的所有祖先并记录
- 然后回溯q的祖先,第一个在p祖先集合中的节点就是LCA
python复制def lowestCommonAncestor(root, p, q):
stack = [root]
parent = {root: None}
while p not in parent or q not in parent:
node = stack.pop()
if node.left:
parent[node.left] = node
stack.append(node.left)
if node.right:
parent[node.right] = node
stack.append(node.right)
ancestors = set()
while p:
ancestors.add(p)
p = parent[p]
while q not in ancestors:
q = parent[q]
return q
4.2 时间复杂度比较
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 路径存储法 | O(h) | O(h) | BST或已知路径 |
| 递归DFS | O(N) | O(h) | 通用二叉树 |
| 迭代父指针 | O(N) | O(N) | 避免递归栈溢出 |
5. 边界条件与常见错误
5.1 特殊case处理
- p或q不存在于树中:题目通常假设存在,但面试时可能需要处理
- p就是q:直接返回p
- p是q的祖先:返回p
- 空树:返回None
- 根节点就是p或q:返回根节点
5.2 常见编码错误
- 忘记处理root为None的情况
- 递归时混淆返回条件顺序
- 在迭代法中错误更新父指针
- 没有考虑节点等于自身的情况
- 在BST解法中使用错误的比较逻辑
5.3 调试技巧
- 先画一个小型二叉树的例子
- 手动模拟递归/迭代过程
- 打印关键节点的值检查逻辑
- 使用力扣的测试用例功能验证边界条件
6. 变种问题与扩展思考
6.1 二叉搜索树的LCA
对于BST,可以利用其有序性优化搜索:
python复制def lowestCommonAncestor(root, p, q):
while root:
if p.val < root.val and q.val < root.val:
root = root.left
elif p.val > root.val and q.val > root.val:
root = root.right
else:
return root
6.2 多叉树的LCA
算法思路类似,只是需要遍历所有子节点而非仅左右子树:
python复制def lowestCommonAncestor(root, p, q):
if not root or root == p or root == q:
return root
count = 0
res = None
for child in root.children:
node = lowestCommonAncestor(child, p, q)
if node:
count += 1
res = node
return root if count == 2 else res
6.3 带父指针的树的LCA
如果有父指针,可以转化为两个链表的交点问题:
- 分别获取p和q到根节点的路径长度
- 将较长的路径先前进差值步
- 然后同时前进直到相遇
7. 实际应用场景
LCA算法不仅在面试中常见,在实际工程中也有广泛应用:
- Git版本控制:寻找两个分支的最近共同提交
- DOM树操作:寻找两个HTML元素的最近共同父容器
- 文件系统:确定两个文件的最近共同目录
- 网络路由:寻找两个IP地址的共同网关
- 生物信息学:在系统发育树中寻找物种的共同祖先
8. 力扣相关题目推荐
为了巩固LCA相关的算法技能,建议练习以下力扣题目:
- #236 二叉树的最近公共祖先(标准版)
- #235 二叉搜索树的最近公共祖先(简单版)
- #1644 二叉树的最近公共祖先 II(节点可能不存在)
- #1650 二叉树的最近公共祖先 III(带父指针)
- #1676 二叉树的最近公共祖先 IV(多个节点)
我在实际刷题中发现,先掌握#236的标准解法,再逐步解决其变种,是最有效的学习路径。对于每个变种问题,先思考如何修改基础解法来适应新的约束条件,这能很好地训练算法思维。
