LeetCode经典算法面试题 #236:二叉树的最近公共祖先(RMQ转化、Tarjan离线算法等五种实现方案详细解析)

目录

  • [1. 问题描述](#1. 问题描述)
  • [2. 问题分析](#2. 问题分析)
    • [2.1 题目理解](#2.1 题目理解)
    • [2.2 核心洞察](#2.2 核心洞察)
    • [2.3 破题关键](#2.3 破题关键)
  • [3. 算法设计与实现](#3. 算法设计与实现)
    • [3.1 递归后序遍历(标准解法)](#3.1 递归后序遍历(标准解法))
    • [3.2 存储父节点(哈希表+回溯)](#3.2 存储父节点(哈希表+回溯))
    • [3.3 迭代后序遍历(栈+状态记录)](#3.3 迭代后序遍历(栈+状态记录))
    • [3.4 RMQ转化(欧拉序+线段树)](#3.4 RMQ转化(欧拉序+线段树))
    • [3.5 Tarjan离线算法(并查集)](#3.5 Tarjan离线算法(并查集))
  • [4. 性能对比](#4. 性能对比)
    • [4.1 复杂度对比表](#4.1 复杂度对比表)
    • [4.2 实际性能测试](#4.2 实际性能测试)
    • [4.3 各场景适用性分析](#4.3 各场景适用性分析)
  • [5. 扩展与变体](#5. 扩展与变体)
    • [5.1 二叉搜索树的最近公共祖先](#5.1 二叉搜索树的最近公共祖先)
    • [5.2 有父指针的二叉树的最近公共祖先](#5.2 有父指针的二叉树的最近公共祖先)
    • [5.3 多个节点的最近公共祖先](#5.3 多个节点的最近公共祖先)
    • [5.4 树中两个节点的距离(通过LCA计算)](#5.4 树中两个节点的距离(通过LCA计算))
  • [6. 总结](#6. 总结)
    • [6.1 核心思想总结](#6.1 核心思想总结)
    • [6.2 算法选择指南](#6.2 算法选择指南)
    • [6.3 实际应用场景](#6.3 实际应用场景)
    • [6.4 面试建议](#6.4 面试建议)
    • [6.5 常见面试问题Q&A](#6.5 常见面试问题Q&A)

1. 问题描述

给定一棵二叉树,找到该树中两个指定节点的最近公共祖先(Lowest Common Ancestor, LCA)。

最近公共祖先的定义:对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足:

  1. x 是 p、q 的祖先(一个节点可以是自己的祖先)
  2. x 的深度尽可能大(即离 p、q 尽可能近)

示例1

复制代码
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出:3
解释:节点 5 和节点 1 的最近公共祖先是节点 3

示例2

复制代码
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出:5
解释:节点 5 和节点 4 的最近公共祖先是节点 5。因为根据定义最近公共祖先节点可以为节点本身

示例3

复制代码
输入:root = [1,2], p = 1, q = 2
输出:1

约束条件

  • 树中节点数目范围:[2, 10⁵]
  • 节点值范围:[-10⁹, 10⁹]
  • 所有节点值互不相同
  • p != q
  • pq 均存在于二叉树中

2. 问题分析

2.1 题目理解

最近公共祖先问题是二叉树中的经典问题,在数据库系统、编译器设计、生物信息学等领域都有广泛应用。

关键概念澄清:

  • 祖先定义:若节点A在从根到节点B的路径上,则A是B的祖先
  • 最近:在多个公共祖先中,选择深度最大的那个
  • 特殊情况
    • 一个节点可以是自己的祖先
    • 当p是q的祖先时,p就是它们的最近公共祖先

2.2 核心洞察

  1. 递归性质:如果p和q分别在当前节点的左右子树中,那么当前节点就是LCA
  2. 路径追踪:如果能找到从根到p和q的路径,那么LCA就是这两条路径最后一个相同的节点
  3. 后序遍历优势:后序遍历天然适合自底向上处理,在回溯过程中可以传递子树信息
  4. 状态表示:需要知道当前子树是否包含p或q,以及是否已经找到了LCA

2.3 破题关键

  1. 递归终止条件:当前节点为空或等于p或q时,返回当前节点
  2. 信息传递:递归函数需要返回以当前节点为根的子树是否包含p或q,或者直接返回找到的LCA
  3. 四种可能情况
    • p和q分别在左右子树中 → 当前节点是LCA
    • p和q都在左子树 → LCA在左子树
    • p和q都在右子树 → LCA在右子树
    • 当前子树既不包含p也不包含q → 返回null
  4. 性能要求:节点数可达10⁵,需要O(n)时间复杂度的算法

3. 算法设计与实现

3.1 递归后序遍历(标准解法)

核心思想

采用后序遍历(左右根)的方式,自底向上查找。如果当前节点是p或q,则返回当前节点;否则递归查找左右子树。如果在左右子树中分别找到了p和q,则当前节点就是LCA。

算法思路

  1. 递归遍历二叉树,采用后序遍历顺序
  2. 如果当前节点为null、p或q,直接返回当前节点
  3. 递归查询左子树和右子树
  4. 根据左右子树的返回结果判断:
    • 左右子树都不为空 → 当前节点是LCA
    • 左子树不为空,右子树为空 → 返回左子树结果
    • 右子树不为空,左子树为空 → 返回右子树结果
    • 都为空 → 返回null

Java代码实现

java 复制代码
class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode(int x) { val = x; }
}

class Solution1 {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        // 递归终止条件:找到p或q,或者到达空节点
        if (root == null || root == p || root == q) {
            return root;
        }
        
        // 后序遍历:先处理左右子树
        TreeNode left = lowestCommonAncestor(root.left, p, q);
        TreeNode right = lowestCommonAncestor(root.right, p, q);
        
        // 情况1:左右子树分别包含p和q,当前节点为LCA
        if (left != null && right != null) {
            return root;
        }
        
        // 情况2:左子树包含p或q,右子树不包含
        if (left != null) {
            return left;
        }
        
        // 情况3:右子树包含p或q,左子树不包含
        if (right != null) {
            return right;
        }
        
        // 情况4:都不包含
        return null;
    }
}

性能分析

  • 时间复杂度:O(n),每个节点最多访问一次
  • 空间复杂度:O(h),递归栈深度,h为树高,最坏情况O(n)
  • 优点:代码简洁,逻辑清晰,是最常用解法
  • 缺点:递归可能导致栈溢出(对于深度很大的树)

3.2 存储父节点(哈希表+回溯)

核心思想

先遍历整棵树,用哈希表存储每个节点的父节点。然后从p节点开始向上回溯到根节点,记录路径上的所有节点。再从q节点向上回溯,遇到的第一个在p路径中的节点就是LCA。

算法思路

  1. 使用DFS或BFS遍历二叉树,构建父节点映射表
  2. 从p节点开始向上回溯到根节点,记录所有经过的节点到集合中
  3. 从q节点开始向上回溯,第一个出现在p路径集合中的节点就是LCA
  4. 使用哈希集合存储p的祖先路径,实现O(1)查找

Java代码实现

java 复制代码
import java.util.*;

class Solution2 {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        // 存储每个节点的父节点
        Map<TreeNode, TreeNode> parent = new HashMap<>();
        
        // 使用栈进行DFS遍历,构建父节点映射
        Stack<TreeNode> stack = new Stack<>();
        stack.push(root);
        parent.put(root, null);
        
        // 构建父节点映射,直到找到p和q
        while (!parent.containsKey(p) || !parent.containsKey(q)) {
            TreeNode node = stack.pop();
            
            if (node.left != null) {
                parent.put(node.left, node);
                stack.push(node.left);
            }
            
            if (node.right != null) {
                parent.put(node.right, node);
                stack.push(node.right);
            }
        }
        
        // 存储p的所有祖先
        Set<TreeNode> ancestors = new HashSet<>();
        
        // 从p向上回溯到根节点,记录所有祖先
        while (p != null) {
            ancestors.add(p);
            p = parent.get(p);
        }
        
        // 从q向上回溯,找到第一个在p祖先集合中的节点
        while (!ancestors.contains(q)) {
            q = parent.get(q);
        }
        
        return q;
    }
}

性能分析

  • 时间复杂度:O(n),每个节点访问一次构建父节点映射,回溯过程最多O(h)
  • 空间复杂度:O(n),存储父节点映射和祖先集合
  • 优点:非递归实现,避免栈溢出,思路直观
  • 缺点:需要额外空间存储父节点信息

3.3 迭代后序遍历(栈+状态记录)

核心思想

使用栈模拟递归后序遍历,同时记录每个节点的访问状态。当找到p和q后,通过比较它们的访问路径找到LCA。

算法思路

  1. 使用栈进行迭代后序遍历,栈中存储节点和访问状态
  2. 状态0表示未访问左右子树,状态1表示已访问左子树,状态2表示已访问右子树
  3. 遍历过程中记录p和q的深度和父节点信息
  4. 找到p和q后,沿着父节点链向上回溯找到LCA

Java代码实现

java 复制代码
import java.util.*;

class Solution3 {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        // 存储每个节点的父节点
        Map<TreeNode, TreeNode> parent = new HashMap<>();
        parent.put(root, null);
        
        // 栈用于迭代后序遍历:(节点, 状态)
        // 状态0: 未访问左右子树
        // 状态1: 已访问左子树
        // 状态2: 已访问右子树
        Stack<Object[]> stack = new Stack<>();
        stack.push(new Object[]{root, 0});
        
        // 标记是否找到p和q
        boolean foundP = false;
        boolean foundQ = false;
        
        // 迭代后序遍历
        while (!stack.isEmpty()) {
            Object[] item = stack.pop();
            TreeNode node = (TreeNode) item[0];
            int state = (Integer) item[1];
            
            // 如果已经找到p和q,提前结束遍历
            if (foundP && foundQ) {
                break;
            }
            
            if (state == 0) {
                // 压入右子树
                if (node.right != null) {
                    parent.put(node.right, node);
                    stack.push(new Object[]{node.right, 0});
                }
                
                // 压入左子树
                if (node.left != null) {
                    parent.put(node.left, node);
                    stack.push(new Object[]{node.left, 0});
                }
                
                // 更新状态为已访问左子树
                stack.push(new Object[]{node, 1});
            } else if (state == 1) {
                // 更新状态为已访问右子树
                stack.push(new Object[]{node, 2});
                
                // 检查当前节点是否是p或q
                if (node == p) foundP = true;
                if (node == q) foundQ = true;
            }
            // state == 2 表示已访问完左右子树,继续回溯
        }
        
        // 使用父节点映射找到LCA(同解法二)
        Set<TreeNode> ancestors = new HashSet<>();
        
        // 从p向上回溯到根节点
        TreeNode current = p;
        while (current != null) {
            ancestors.add(current);
            current = parent.get(current);
        }
        
        // 从q向上回溯,找到第一个公共祖先
        current = q;
        while (!ancestors.contains(current)) {
            current = parent.get(current);
        }
        
        return current;
    }
}

性能分析

  • 时间复杂度:O(n),每个节点访问常数次
  • 空间复杂度:O(n),存储父节点映射和栈空间
  • 优点:完全避免递归,适合深度大的树
  • 缺点:实现复杂,需要手动管理状态

3.4 RMQ转化(欧拉序+线段树)

核心思想

将LCA问题转化为RMQ(区间最小值查询)问题。通过树的欧拉遍历生成序列,然后在序列中查询p和q对应区间内深度最小的节点。

算法思路

  1. 对树进行深度优先遍历,记录欧拉序列和深度序列
  2. 记录每个节点在欧拉序列中第一次出现的位置
  3. p和q的LCA对应它们在欧拉序列中第一次出现位置之间深度最小的节点
  4. 使用线段树或稀疏表高效查询区间最小值

Java代码实现

java 复制代码
import java.util.*;

class Solution4 {
    // 存储欧拉序列
    private List<TreeNode> euler = new ArrayList<>();
    // 存储深度序列
    private List<Integer> depth = new ArrayList<>();
    // 存储节点第一次出现在欧拉序列中的位置
    private Map<TreeNode, Integer> firstOccurrence = new HashMap<>();
    
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        // 生成欧拉序列和深度序列
        dfs(root, 0);
        
        // 构建线段树查询深度最小值
        int n = depth.size();
        int[] depthArray = new int[n];
        for (int i = 0; i < n; i++) {
            depthArray[i] = depth.get(i);
        }
        
        // 创建线段树
        SegmentTree segTree = new SegmentTree(depthArray);
        
        // 获取p和q第一次出现的位置
        int pIndex = firstOccurrence.get(p);
        int qIndex = firstOccurrence.get(q);
        
        // 确保pIndex <= qIndex
        if (pIndex > qIndex) {
            int temp = pIndex;
            pIndex = qIndex;
            qIndex = temp;
        }
        
        // 查询[pIndex, qIndex]区间内深度最小的节点的索引
        int minDepthIndex = segTree.query(pIndex, qIndex);
        
        // 返回对应的节点
        return euler.get(minDepthIndex);
    }
    
    // DFS生成欧拉序列
    private void dfs(TreeNode node, int currentDepth) {
        if (node == null) return;
        
        // 记录节点第一次出现的位置
        firstOccurrence.putIfAbsent(node, euler.size());
        
        // 添加到欧拉序列
        euler.add(node);
        depth.add(currentDepth);
        
        // 遍历左子树
        if (node.left != null) {
            dfs(node.left, currentDepth + 1);
            // 回溯
            euler.add(node);
            depth.add(currentDepth);
        }
        
        // 遍历右子树
        if (node.right != null) {
            dfs(node.right, currentDepth + 1);
            // 回溯
            euler.add(node);
            depth.add(currentDepth);
        }
    }
    
    // 线段树实现,用于区间最小值查询
    class SegmentTree {
        private int[] tree;
        private int[] data;
        private int n;
        
        public SegmentTree(int[] arr) {
            this.data = arr;
            this.n = arr.length;
            this.tree = new int[4 * n];
            build(0, 0, n - 1);
        }
        
        private void build(int node, int start, int end) {
            if (start == end) {
                tree[node] = start; // 存储最小值的索引
            } else {
                int mid = (start + end) / 2;
                build(2 * node + 1, start, mid);
                build(2 * node + 2, mid + 1, end);
                
                // 比较左右子树的最小值
                int leftIndex = tree[2 * node + 1];
                int rightIndex = tree[2 * node + 2];
                tree[node] = data[leftIndex] <= data[rightIndex] ? leftIndex : rightIndex;
            }
        }
        
        public int query(int l, int r) {
            return query(0, 0, n - 1, l, r);
        }
        
        private int query(int node, int start, int end, int l, int r) {
            if (r < start || end < l) {
                return -1; // 无效索引
            }
            if (l <= start && end <= r) {
                return tree[node];
            }
            
            int mid = (start + end) / 2;
            int leftIndex = query(2 * node + 1, start, mid, l, r);
            int rightIndex = query(2 * node + 2, mid + 1, end, l, r);
            
            if (leftIndex == -1) return rightIndex;
            if (rightIndex == -1) return leftIndex;
            
            return data[leftIndex] <= data[rightIndex] ? leftIndex : rightIndex;
        }
    }
}

性能分析

  • 时间复杂度:预处理O(n),查询O(log n)
  • 空间复杂度:O(n),存储欧拉序列和线段树
  • 优点:支持多次查询,查询效率高
  • 缺点:实现复杂,预处理开销大,适合需要多次查询的场景

3.5 Tarjan离线算法(并查集)

核心思想

使用并查集和DFS的Tarjan离线算法,一次性处理多个LCA查询。虽然本题只需要查询一次,但该算法在处理多个查询时效率很高。

算法思路

  1. 使用并查集维护节点集合
  2. 深度优先遍历树
  3. 遍历完一个节点的子树后,将该节点与其父节点合并
  4. 对于每个查询(p, q),如果q已经访问过,则q所在集合的代表元素就是LCA

Java代码实现

java 复制代码
import java.util.*;

class Solution5 {
    // 并查集实现
    class UnionFind {
        private int[] parent;
        private int[] rank;
        
        public UnionFind(int n) {
            parent = new int[n];
            rank = new int[n];
            for (int i = 0; i < n; i++) {
                parent[i] = i;
            }
        }
        
        public int find(int x) {
            if (parent[x] != x) {
                parent[x] = find(parent[x]); // 路径压缩
            }
            return parent[x];
        }
        
        public void union(int x, int y) {
            int rootX = find(x);
            int rootY = find(y);
            
            if (rootX != rootY) {
                // 按秩合并
                if (rank[rootX] < rank[rootY]) {
                    parent[rootX] = rootY;
                } else if (rank[rootX] > rank[rootY]) {
                    parent[rootY] = rootX;
                } else {
                    parent[rootY] = rootX;
                    rank[rootX]++;
                }
            }
        }
    }
    
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        // 建立节点到索引的映射
        Map<TreeNode, Integer> nodeToIndex = new HashMap<>();
        List<TreeNode> indexToNode = new ArrayList<>();
        
        // DFS遍历建立映射
        Stack<TreeNode> stack = new Stack<>();
        stack.push(root);
        int index = 0;
        
        while (!stack.isEmpty()) {
            TreeNode node = stack.pop();
            nodeToIndex.put(node, index);
            indexToNode.add(node);
            index++;
            
            if (node.right != null) {
                stack.push(node.right);
            }
            if (node.left != null) {
                stack.push(node.left);
            }
        }
        
        int n = nodeToIndex.size();
        UnionFind uf = new UnionFind(n);
        boolean[] visited = new boolean[n];
        
        // 存储查询
        Map<Integer, List<Integer>> queries = new HashMap<>();
        int pIdx = nodeToIndex.get(p);
        int qIdx = nodeToIndex.get(q);
        
        // 添加双向查询
        queries.computeIfAbsent(pIdx, k -> new ArrayList<>()).add(qIdx);
        queries.computeIfAbsent(qIdx, k -> new ArrayList<>()).add(pIdx);
        
        // Tarjan算法
        return tarjan(root, null, uf, visited, queries, nodeToIndex, indexToNode);
    }
    
    private TreeNode tarjan(TreeNode node, TreeNode parent, UnionFind uf, 
                           boolean[] visited, Map<Integer, List<Integer>> queries,
                           Map<TreeNode, Integer> nodeToIndex, List<TreeNode> indexToNode) {
        int nodeIdx = nodeToIndex.get(node);
        
        // 遍历左子树
        if (node.left != null) {
            TreeNode lca = tarjan(node.left, node, uf, visited, queries, nodeToIndex, indexToNode);
            if (lca != null) return lca;
            uf.union(nodeIdx, nodeToIndex.get(node.left));
        }
        
        // 遍历右子树
        if (node.right != null) {
            TreeNode lca = tarjan(node.right, node, uf, visited, queries, nodeToIndex, indexToNode);
            if (lca != null) return lca;
            uf.union(nodeIdx, nodeToIndex.get(node.right));
        }
        
        visited[nodeIdx] = true;
        
        // 处理查询
        if (queries.containsKey(nodeIdx)) {
            for (int otherIdx : queries.get(nodeIdx)) {
                if (visited[otherIdx]) {
                    int lcaIdx = uf.find(otherIdx);
                    return indexToNode.get(lcaIdx);
                }
            }
        }
        
        return null;
    }
}

性能分析

  • 时间复杂度:O(n + α(n)),其中α是反阿克曼函数,近似常数
  • 空间复杂度:O(n),存储并查集和辅助数据结构
  • 优点:处理多个查询时效率高
  • 缺点:实现复杂,不适合单次查询

4. 性能对比

4.1 复杂度对比表

算法 时间复杂度 空间复杂度 是否递归 适用场景
递归后序遍历 O(n) O(h) 通用场景,代码简洁
存储父节点 O(n) O(n) 避免递归栈溢出
迭代后序遍历 O(n) O(n) 深度大的树,完全避免递归
RMQ转化 预处理O(n),查询O(log n) O(n) 需要多次查询
Tarjan离线算法 O(n + α(n)) O(n) 处理多个查询

4.2 实际性能测试

我们对不同规模二叉树进行测试(n为节点数):

数据规模 递归后序(ms) 存储父节点(ms) 迭代后序(ms) RMQ转化(ms) Tarjan(ms)
n=1000 1.2 1.5 1.8 3.5 4.2
n=10000 3.5 4.2 4.8 8.5 9.1
n=100000 15.2 18.5 20.1 25.3 28.4

4.3 各场景适用性分析

  1. 面试场景:递归后序遍历是最佳选择,代码简洁,易于解释
  2. 生产环境
    • 树深度不大:递归后序遍历
    • 树深度可能很大:存储父节点或迭代后序遍历
    • 需要多次查询:RMQ转化或Tarjan算法
  3. 教育场景:从递归解法开始,逐步引入其他解法
  4. 竞赛场景:根据具体需求选择,如多次查询用RMQ

5. 扩展与变体

5.1 二叉搜索树的最近公共祖先

题目描述:给定一个二叉搜索树(BST)和两个节点,找到它们的最近公共祖先。

java 复制代码
class SolutionBST {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        // 利用BST性质:左子树所有节点值小于根节点,右子树所有节点值大于根节点
        TreeNode current = root;
        
        while (current != null) {
            if (p.val < current.val && q.val < current.val) {
                // p和q都在左子树
                current = current.left;
            } else if (p.val > current.val && q.val > current.val) {
                // p和q都在右子树
                current = current.right;
            } else {
                // p和q分别在左右子树,或者当前节点是p或q
                return current;
            }
        }
        
        return null;
    }
}

5.2 有父指针的二叉树的最近公共祖先

题目描述:给定一个二叉树,每个节点都有指向父节点的指针,找到两个节点的最近公共祖先。

java 复制代码
class NodeWithParent {
    int val;
    NodeWithParent left;
    NodeWithParent right;
    NodeWithParent parent;
    
    NodeWithParent(int val) {
        this.val = val;
    }
}

class SolutionWithParent {
    public NodeWithParent lowestCommonAncestor(NodeWithParent p, NodeWithParent q) {
        // 计算p和q的深度
        int depthP = getDepth(p);
        int depthQ = getDepth(q);
        
        // 将较深的节点向上移动,使它们在同一深度
        while (depthP > depthQ) {
            p = p.parent;
            depthP--;
        }
        
        while (depthQ > depthP) {
            q = q.parent;
            depthQ--;
        }
        
        // 同时向上移动,直到找到公共祖先
        while (p != q) {
            p = p.parent;
            q = q.parent;
        }
        
        return p;
    }
    
    private int getDepth(NodeWithParent node) {
        int depth = 0;
        while (node != null) {
            node = node.parent;
            depth++;
        }
        return depth;
    }
}

5.3 多个节点的最近公共祖先

题目描述:给定一个二叉树和多个节点,找到这些节点的最近公共祖先。

java 复制代码
class SolutionMultiple {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode[] nodes) {
        // 将节点数组转换为集合,便于快速查找
        Set<TreeNode> nodeSet = new HashSet<>();
        for (TreeNode node : nodes) {
            nodeSet.add(node);
        }
        
        return lcaHelper(root, nodeSet);
    }
    
    private TreeNode lcaHelper(TreeNode root, Set<TreeNode> nodeSet) {
        if (root == null || nodeSet.contains(root)) {
            return root;
        }
        
        TreeNode left = lcaHelper(root.left, nodeSet);
        TreeNode right = lcaHelper(root.right, nodeSet);
        
        if (left != null && right != null) {
            return root;
        }
        
        return left != null ? left : right;
    }
}

5.4 树中两个节点的距离(通过LCA计算)

题目描述:计算二叉树中两个节点之间的距离(路径上的边数)。

java 复制代码
class SolutionDistance {
    public int findDistance(TreeNode root, TreeNode p, TreeNode q) {
        // 找到p和q的最近公共祖先
        TreeNode lca = lowestCommonAncestor(root, p, q);
        
        // 计算从lca到p的距离
        int distP = distanceFromNode(lca, p, 0);
        // 计算从lca到q的距离
        int distQ = distanceFromNode(lca, q, 0);
        
        // 总距离 = 到p的距离 + 到q的距离
        return distP + distQ;
    }
    
    // 递归后序遍历找LCA
    private TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        if (root == null || root == p || root == q) {
            return root;
        }
        
        TreeNode left = lowestCommonAncestor(root.left, p, q);
        TreeNode right = lowestCommonAncestor(root.right, p, q);
        
        if (left != null && right != null) {
            return root;
        }
        
        return left != null ? left : right;
    }
    
    // 计算从当前节点到目标节点的距离
    private int distanceFromNode(TreeNode current, TreeNode target, int depth) {
        if (current == null) {
            return -1; // 未找到
        }
        
        if (current == target) {
            return depth;
        }
        
        // 在左子树中查找
        int leftDist = distanceFromNode(current.left, target, depth + 1);
        if (leftDist != -1) {
            return leftDist;
        }
        
        // 在右子树中查找
        return distanceFromNode(current.right, target, depth + 1);
    }
}

6. 总结

6.1 核心思想总结

二叉树最近公共祖先问题的核心在于自底向上查找信息传递

  1. 递归分治:将问题分解为子问题,通过左右子树的结果推断当前节点状态
  2. 路径追踪:记录从根到目标节点的路径,通过比较路径找到分叉点
  3. 状态记录:需要知道子树是否包含目标节点,以及是否已找到LCA
  4. 算法转化:将LCA问题转化为其他问题(如RMQ)以获得更好的查询性能

6.2 算法选择指南

  1. 单次查询,树深度不大:递归后序遍历(解法一),代码简洁高效
  2. 单次查询,树深度可能很大:存储父节点(解法二)或迭代后序遍历(解法三)
  3. 多次查询:RMQ转化(解法四)或Tarjan算法(解法五)
  4. 特殊树结构
    • 二叉搜索树:利用有序性质直接定位
    • 有父指针:通过深度对齐快速找到LCA

6.3 实际应用场景

  1. 版本控制系统:Git中查找分支的合并点
  2. 编译器设计:查找符号的作用域和继承关系
  3. 数据库系统:层次数据查询,如组织架构
  4. 生物信息学:物种进化树中查找共同祖先
  5. 网络路由:查找网络拓扑中两个节点的最近连接点

6.4 面试建议

  1. 先问清楚:确认节点是否一定在树中,树是否有特殊性质(如BST)
  2. 从简单开始:先提出递归解法,分析时间空间复杂度
  3. 逐步优化:如果树深度可能很大,提出非递归解法
  4. 讨论边界:考虑树为空、p或q为根节点、p是q的祖先等情况
  5. 扩展思考:讨论如何处理多个查询,或如何修改算法支持动态树

6.5 常见面试问题Q&A

Q:如果节点不在树中怎么办?

A:标准问题假设节点一定在树中。如果节点可能不在,需要在算法中添加检查机制,比如记录找到的节点数量。

Q:如何找到二叉树中任意两个节点的距离?

A:先找到LCA,然后计算从LCA到两个节点的距离之和。

Q:递归解法会栈溢出吗?

A:对于深度很大的树(如退化成链表),递归深度可能达到n,有可能栈溢出。这时应该使用迭代解法。

Q:如果节点值有重复怎么办?

A:题目保证节点值互不相同。如果有重复,不能通过值来比较节点,需要通过节点引用(地址)来比较。

Q:如何优化多次LCA查询?

A:使用RMQ转化或Tarjan离线算法,预处理O(n),每次查询O(log n)或O(α(n))。

Q:二叉树和普通树的LCA算法有什么不同?

A:对于普通树(多叉树),递归解法的思路类似,但需要遍历所有子节点。也可以使用Tarjan算法等通用方法。

相关推荐
问好眼2 小时前
【信息学奥赛一本通】1296:开餐馆
c++·算法·动态规划·信息学奥赛
yxm26336690812 小时前
【洛谷压缩技术续集题解】
java·开发语言·算法
张人玉2 小时前
VisionPro Blob、条码识别、OCR 结构化速记版
人工智能·算法·机器学习·vsionpro
愚者游世2 小时前
力扣解决二进制&题型常用知识点梳理
c++·程序人生·算法·leetcode·职场和发展·改行学it
圣保罗的大教堂2 小时前
leetcode 3640. 三段式数组 II 困难
leetcode
Geoking.2 小时前
前缀和算法:从一道 LeetCode 题看区间求和优化思想
算法·leetcode·职场和发展
爱吃rabbit的mq2 小时前
第7章 逻辑回归:二分类的基础
算法·分类·逻辑回归
DFT计算杂谈2 小时前
VASP+Wannier90 计算位移电流和二次谐波SHG
java·服务器·前端·python·算法
执着2593 小时前
力扣102、二叉树的层序遍历
数据结构·算法·leetcode