三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

二叉树最近公共祖先(LCA)的递归与迭代解法详解

二叉树最近公共祖先(LCA)的递归与迭代解法详解

1. 问题背景与核心概念

最近在刷LeetCode时遇到了236题"二叉树的最近公共祖先",这道题在面试中出现频率相当高。作为二叉树类问题的经典代表,它完美展现了递归思想的精妙之处。我们先明确几个关键概念:

最近公共祖先(Lowest Common Ancestor, LCA)指的是二叉树中两个节点p和q在树结构中最深的共同祖先节点。举个例子,假设我们有以下二叉树:

3 / \ 5 1 / \ / \ 6 2 0 8 / \ 7 4
  • 节点5和1的LCA是3
  • 节点5和4的LCA是5
  • 节点7和8的LCA是3

理解这个概念后,我们来看递归解法。递归之所以适合解决这类问题,是因为二叉树本身就是一个递归定义的数据结构——每个节点的左右子树也都是二叉树。

2. 递归解法思路拆解

2.1 基础递归框架

解决二叉树问题的递归模板通常包含三个要素:

  1. 递归终止条件
  2. 递归处理左子树
  3. 递归处理右子树

对于LCA问题,我们可以这样设计递归逻辑:

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

2.2 递归过程详解

让我们一步步分析这个递归函数的执行过程:

  1. 终止条件:当遇到空节点或找到p/q节点时直接返回当前节点。这是递归的基准情况。

  2. 左右子树递归:分别在左右子树中搜索p和q节点。这一步体现了"分而治之"的思想。

  3. 结果合并

    • 如果左右子树都返回非空,说明当前节点就是LCA
    • 如果只有一边非空,说明LCA在非空的那一侧
    • 如果都为空,说明当前子树不包含目标节点

关键理解点:递归函数返回值的含义是"当前子树中是否包含p或q节点"。当某个节点的左右子树分别包含p和q时,它就是我们要找的LCA。

2.3 时间复杂度分析

这个解法的时间复杂度是O(n),其中n是树中的节点数。因为我们需要访问每个节点一次。空间复杂度取决于递归栈的深度,最坏情况下(树退化为链表)是O(n),平均情况下是O(logn)。

3. 迭代解法与优化思路

虽然递归解法简洁优雅,但在实际工程中,我们有时也需要考虑迭代解法,特别是当树很深可能导致栈溢出时。

3.1 使用父指针的迭代方法

def lowestCommonAncestor(root, p, q): # 建立父指针字典 parent = {root: None} stack = [root] # 迭代直到找到p和q的父指针链 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) # 收集p的祖先链 ancestors = set() while p: ancestors.add(p) p = parent[p] # 在q的祖先链中找第一个公共节点 while q not in ancestors: q = parent[q] return q

这种方法通过两次遍历:

  1. 第一次遍历建立所有节点的父指针映射
  2. 第二次遍历通过比较祖先集合找到LCA

3.2 路径比较法

另一种思路是分别记录从根到p和q的路径,然后比较这两条路径,最后一个相同的节点就是LCA。实现上可以使用DFS或BFS来记录路径。

4. 常见问题与调试技巧

4.1 边界情况处理

在实际编码时,有几个边界情况需要特别注意:

  1. p或q就是根节点
  2. p是q的祖先或反之
  3. 树为空或p/q不在树中
  4. p和q是同一个节点

4.2 递归调试技巧

调试递归函数时,可以:

  1. 添加打印语句显示当前递归层级和参数
  2. 使用小规模的测试用例手动模拟递归过程
  3. 绘制递归调用树帮助理解

例如,可以这样修改递归函数添加调试信息:

def lowestCommonAncestor(root, p, q, depth=0): indent = " " * depth print(f"{indent}Entering with root={root.val if root else None}") if not root or root == p or root == q: print(f"{indent}Base case returning {root.val if root else None}") return root print(f"{indent}Checking left subtree") left = lowestCommonAncestor(root.left, p, q, depth+1) print(f"{indent}Checking right subtree") right = lowestCommonAncestor(root.right, p, q, depth+1) if left and right: print(f"{indent}Found LCA: {root.val}") return root result = left if left else right print(f"{indent}Returning {result.val if result else None}") return result

4.3 性能优化考虑

对于需要频繁查询LCA的场景,可以考虑以下优化:

  1. 预处理建立每个节点的深度和父指针信息
  2. 使用Tarjan的离线算法批量处理查询
  3. 使用二进制提升技术优化查询速度

5. 实际应用场景

理解LCA算法不仅对面试有帮助,在实际开发中也有很多应用:

  1. DOM树操作:在网页DOM树中查找两个元素的最近共同容器
  2. 版本控制系统:Git中查找两个提交的共同祖先
  3. 计算生物学:在系统发育树中查找物种的共同祖先
  4. 网络路由:在网络拓扑中查找两个节点的最近连接点

6. 扩展思考

6.1 二叉搜索树的LCA

对于BST,由于节点有序性,可以更高效地找到LCA:

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

对于多叉树,递归思路类似,只是需要遍历所有子节点而非仅左右子树:

def lowestCommonAncestor(root, p, q): if not root or root == p or root == q: return root found = [] for child in root.children: res = lowestCommonAncestor(child, p, q) if res: found.append(res) if len(found) == 2: return root return found[0] if found else None

6.3 带父指针的树

如果树节点包含指向父节点的指针,可以不用递归,通过比较祖先链来找到LCA,类似于求两个链表交点的问题。

7. 代码实现细节

让我们看一个完整的Python实现,包含详细的注释和类型提示:

class TreeNode: def __init__(self, x): self.val = x self.left = None self.right = None def lowestCommonAncestor(root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode: """ 寻找二叉树的最近公共祖先 参数: root: 二叉树根节点 p: 要查找的第一个节点 q: 要查找的第二个节点 返回: 找到的最近公共祖先节点 时间复杂度: O(n) 空间复杂度: O(h), h是树的高度 """ # 基准情况:当前节点为空或是p/q本身 if not root or root == p or root == q: return root # 递归查询左右子树 left_lca = lowestCommonAncestor(root.left, p, q) right_lca = lowestCommonAncestor(root.right, p, q) # 如果左右子树分别包含p和q,当前节点就是LCA if left_lca and right_lca: return root # 否则,返回非空的那一侧的结果 return left_lca if left_lca else right_lca

8. 测试用例设计

为了验证我们的解法,应该设计全面的测试用例:

import unittest class TestLCA(unittest.TestCase): def setUp(self): # 构建测试用二叉树 # 3 # / \ # 5 1 # / \ / \ # 6 2 0 8 # / \ # 7 4 self.root = TreeNode(3) self.root.left = TreeNode(5) self.root.right = TreeNode(1) self.root.left.left = TreeNode(6) self.root.left.right = TreeNode(2) self.root.right.left = TreeNode(0) self.root.right.right = TreeNode(8) self.root.left.right.left = TreeNode(7) self.root.left.right.right = TreeNode(4) self.p = self.root.left # 5 self.q = self.root.left.right.right # 4 def test_normal_case(self): lca = lowestCommonAncestor(self.root, self.p, self.q) self.assertEqual(lca.val, 5) def test_lca_is_root(self): p = self.root.left.left # 6 q = self.root.right.right # 8 lca = lowestCommonAncestor(self.root, p, q) self.assertEqual(lca.val, 3) def test_p_is_lca(self): p = self.root.left # 5 q = self.root.left.right.right # 4 lca = lowestCommonAncestor(self.root, p, q) self.assertEqual(lca.val, 5) def test_same_node(self): p = q = self.root.left.right # 2 lca = lowestCommonAncestor(self.root, p, q) self.assertEqual(lca.val, 2) def test_null_case(self): self.assertIsNone(lowestCommonAncestor(None, self.p, self.q)) if __name__ == '__main__': unittest.main()

9. 算法可视化理解

为了更直观地理解算法,我们可以想象递归过程像是在树上进行"染色":

  1. 从叶子节点开始向上"传递颜色"(p或q的存在信息)
  2. 当一个节点收到来自左右子树的不同"颜色"时,它就成为LCA
  3. 如果只收到一种"颜色",就继续向上传递这种颜色
  4. 如果什么颜色都没收到,就不传递任何信息

这种可视化方法可以帮助理解递归是如何自底向上解决问题的。

10. 与其他二叉树问题的联系

LCA问题与许多其他二叉树问题有密切联系:

  1. 二叉树的最大深度:递归过程中可以同时计算深度
  2. 二叉树的直径:可以通过修改LCA算法来计算
  3. 节点间距离:两个节点之间的距离等于它们到LCA的距离之和
  4. 子树判断:判断一个节点是否在另一个节点的子树中

理解这些联系可以帮助我们举一反三,解决更多二叉树相关问题。

← 返回列表