跳转至

EC09-在二叉树中找到节点p和q的最近公共祖先

代码实现

链式存储

C
// 函数 find_LCA:在二叉树中找到节点 p 和 q 的最近公共祖先
// 参数:root - 二叉树根节点,p - 目标节点1,q - 目标节点2
BiTree find_LCA(BiTree root, BiTree p, BiTree q) {
    // 递归终止条件
    if (root == NULL || root == p || root == q) {
        return root; // 如果根节点为空或等于其中一个目标节点,则返回根节点
    }

    // 递归在左右子树中查找
    BiTree left = find_LCA(root->lchild, p, q);   // 在左子树中查找
    BiTree right = find_LCA(root->rchild, p, q);  // 在右子树中查找

    // 根据左右子树的查找结果判断最近公共祖先
    if (left == NULL && right == NULL) {
        // 左右子树都没有找到 p 或 q
        return NULL;
    }

    if (left != NULL && right != NULL) {
        // p 和 q 分别在左右子树中,当前根节点就是最近公共祖先
        return root;
    }

    // p 和 q 都在左子树或右子树中,返回非空的那一侧结果
    return left != NULL ? left : right;
}

顺序存储

C
// 函数 find_LCA:在顺序存储的完全二叉树中找到节点的最近公共祖先,数组下标从0开始
// 参数:aa - 顺序存储的数组,i - 节点 p 的数组下标,j - 节点 q 的数组下标
BiTree find_LCA(BiTree aa[], int i, int j) {
    // 通过不断向上移动到父节点,直到两个节点相遇
    while (i != j) {
        if (j > i) {
            // j 节点更深,向上移动到父节点
            j = (j - 1) / 2;
        } else {
            // i 节点更深,向上移动到父节点
            i = (i - 1) / 2;
        }
    }

    // 当 i == j 时,找到了最近公共祖先
    return aa[i];
}

代码逻辑解析

1. 链式存储的 LCA 算法

递归思路:

使用后序遍历的思想: - 先在左右子树中查找目标节点 - 根据查找结果判断最近公共祖先的位置

四种情况分析:

C
// 情况1:当前节点为空或就是目标节点
if (root == NULL || root == p || root == q)
    return root;

// 情况2:左右子树都没找到目标节点
if (left == NULL && right == NULL)
    return NULL;

// 情况3:目标节点分别在左右子树中
if (left != NULL && right != NULL)
    return root;  // 当前节点就是最近公共祖先

// 情况4:目标节点都在同一侧子树中
return left != NULL ? left : right;

2. 顺序存储的 LCA 算法

核心思想:

利用完全二叉树的性质: - 对于下标为 i 的节点,其父节点下标为 (i-1)/2 - 通过不断向上移动,两个节点最终会在最近公共祖先处相遇

移动策略:

C
1
2
3
4
5
6
while (i != j) {
    if (j > i) 
        j = (j - 1) / 2;  // j 节点更深,向上移动
    else 
        i = (i - 1) / 2;  // i 节点更深,向上移动
}

时间复杂度分析

1. 链式存储版本

最坏情况分析:

  • 需要遍历整棵树的所有节点
  • 每个节点最多被访问一次
  • 时间复杂度:O(n)

最好情况分析:

  • 如果两个目标节点都在靠近根部的位置
  • 可能提前返回,但最坏情况仍需遍历

2. 顺序存储版本

分析过程:

  • 每次迭代都将较深的节点向上移动一层
  • 树的高度为 log n(完全二叉树)
  • 最多需要 log n 次迭代
  • 时间复杂度:O(log n)

空间复杂度分析

1. 链式存储版本

递归调用栈:

  • 递归深度取决于树的高度
  • 最好情况(平衡树):O(log n)
  • 最坏情况(退化为链表):O(n)
  • 空间复杂度:O(h),其中 h 是树的高度

2. 顺序存储版本

额外空间使用:

  • 只使用了常量级别的局部变量(i, j)
  • 没有递归调用
  • 空间复杂度:O(1)

较难理解部分的说明

1. 链式存储算法的核心思想

C
// 递归返回值的含义:
// NULL:在以当前节点为根的子树中没有找到 p 或 q
// p/q:在以当前节点为根的子树中找到了 p 或 q
// root:在以当前节点为根的子树中,root 是 p 和 q 的最近公共祖先

// 示例分析:
//        1
//       / \
//      2   3
//     /   / \
//    4   5   6
//   /
//  7

// 查找节点 4 和 6 的 LCA:
// find_LCA(1, 4, 6):
//   left = find_LCA(2, 4, 6) = 2  // 4 在左子树
//   right = find_LCA(3, 4, 6) = 3 // 6 在右子树
//   left != NULL && right != NULL → return 1

2. 顺序存储算法的数学原理

C
// 完全二叉树的性质:
// 节点编号从 0 开始,对于节点 i:
// 父节点:(i-1)/2
// 左子节点:2*i+1
// 右子节点:2*i+2

// 路径追溯示例:
// 假设节点下标:i=7, j=5
// 路径:
// 7 → 3 → 1 → 0
// 5 → 2 → 1 → 0
// 在节点 1 处相遇,所以 LCA 是节点 1

3. 图解说明

链式存储算法示例:

Text Only
        3
       / \
      5   1
     / \ / \
    6  2 0  8
      / \
     7   4

查找节点 5 和 1 的 LCA:
1. find_LCA(3, 5, 1)
2. left = find_LCA(5, 5, 1) = 5  // 找到 5
3. right = find_LCA(1, 5, 1) = 1 // 找到 1
4. left != NULL && right != NULL → return 3

顺序存储算法示例:

Text Only
数组表示的完全二叉树:
索引: 0  1  2  3  4  5  6
节点: 3  5  1  6  2  0  8

查找节点 6(索引3) 和 0(索引5) 的 LCA:
初始:i=3, j=5
第1轮:j > i → j = (5-1)/2 = 2
第2轮:i < j → i = (3-1)/2 = 1
第3轮:i > j → j = (2-1)/2 = 0
第4轮:i > j → j = (1-1)/2 = 0
第5轮:i = j = 0 → 返回 aa[0] = 3

延伸知识点

1. 链式存储的优化版本

C
// 带路径记录的 LCA 算法
bool findPath(BiTree root, BiTree target, vector<BiTree>& path) {
    if (root == NULL) return false;

    path.push_back(root);

    if (root == target) return true;

    if (findPath(root->lchild, target, path) || 
        findPath(root->rchild, target, path)) {
        return true;
    }

    path.pop_back();
    return false;
}

BiTree find_LCA_path(BiTree root, BiTree p, BiTree q) {
    vector<BiTree> path1, path2;

    if (!findPath(root, p, path1) || !findPath(root, q, path2)) {
        return NULL;
    }

    // 找到两条路径的第一个分叉点
    int i = 0;
    while (i < path1.size() && i < path2.size() && path1[i] == path2[i]) {
        i++;
    }

    return path1[i-1];
}

2. Tarjan 离线 LCA 算法

C
// 使用并查集的 Tarjan 算法(处理多个查询)
class UnionFind {
private:
    vector<int> parent, rank;
public:
    UnionFind(int n) : parent(n), rank(n, 0) {
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }

    int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // 路径压缩
        }
        return parent[x];
    }

    void unite(int x, int y) {
        int rootX = find(x), rootY = find(y);
        if (rootX != rootY) {
            if (rank[rootX] < rank[rootY]) {
                swap(rootX, rootY);
            }
            parent[rootY] = rootX;
            if (rank[rootX] == rank[rootY]) {
                rank[rootX]++;
            }
        }
    }
};

3. RMQ 转化为 LCA

C
// 通过欧拉序列将 LCA 问题转化为 RMQ 问题
class LCA_RMQ {
private:
    vector<int> euler, depth, first;
    SparseTable st; // 稀疏表用于 RMQ

public:
    void dfs(BiTree root, int d) {
        first[root->val] = euler.size();
        euler.push_back(root->val);
        depth.push_back(d);

        if (root->lchild) {
            dfs(root->lchild, d + 1);
            euler.push_back(root->val);
            depth.push_back(d);
        }

        if (root->rchild) {
            dfs(root->rchild, d + 1);
            euler.push_back(root->val);
            depth.push_back(d);
        }
    }

    int query(int u, int v) {
        int left = first[u], right = first[v];
        if (left > right) swap(left, right);
        return st.query(left, right); // 返回深度最小的节点
    }
};

4. 实际应用场景

  • 文件系统:查找两个文件的最近公共目录
  • 社交网络:查找两个人的最近公共好友
  • 版本控制:Git 中查找两个提交的最近公共祖先
  • 网络路由:在网络拓扑中查找两个节点的最近公共路由器
  • 编译器优化:在抽象语法树中查找表达式的公共子表达式

5. 算法对比总结

算法类型 时间复杂度 空间复杂度 适用场景
递归遍历 O(n) O(h) 通用二叉树
顺序存储 O(log n) O(1) 完全二叉树
路径记录 O(n) O(h) 需要路径信息
Tarjan O(n+q) O(n) 多次查询
RMQ转化 O(nlogn)预处理,O(1)查询 O(nlogn) 大量查询

这两种 LCA 算法展示了根据数据结构特性选择合适算法的重要思想,顺序存储的优化体现了利用数据特性提升算法效率的设计理念。