NeFut Logo NeFut
EN 管理员登录

高效求解树上最近公共祖先的三种算法解析

发布于:2026-05-27 09:17 最后更新:2026-06-10 09:29
#algorithm #C++ #LCA

核心逻辑与数学原理

最近公共祖先(Lowest Common Ancestor, LCA)定义为:在有根树 $T$ 中,对于任意两点 $u, v$,满足既是 $u$ 的祖先也是 $v$ 的祖先,且深度最大的节点,记为 $\text{LCA}(u, v)$。

NOIP 核心的三种求解范式在时间与空间权衡上各有其数学本质:

1. 倍增法(树上二进制拆分)

基于二进制状态压缩的思想。通过预处理出每个节点的 $2^k$ 级祖先,将线性跳转 $O(N)$ 优化为对数级跳转 $O(\text{log} N)$。

2. Tarjan 算法(离线并查集)

基于深度优先遍历与并查集(Disjoint Set Union, DSU)融合的离线算法。

3. RMQ 转化法(欧拉序 + ST 表)

将树形拓扑结构向线性代数最值问题的无损映射。


状态设计与算法推导

1. 倍增法

定义 $fa[u][k]$ 为节点 $u$ 向上跳 $2^k$ 步所到达的祖先节点;$\text{dep}[u]$ 为节点 $u$ 的深度。

$$fa[u][k] = fa[fa[u][k-1]][k-1]$$

物理含义:向上跳 $2^k$ 步相当于先向上跳 $2^{k-1}$ 步,再从该位置向上跳 $2^{k-1}$ 步。

  1. 设 $\text{dep}[u] \text{≥} \text{dep}[v]$。利用二进制拆分,将 $u$ 向上提升到与 $v$ 同一深度。
  2. 若此时 $u == v$,则 $v$ 即为 LCA。
  3. 否则,$u$ 和 $v$ 同时向上尝试跳跃 $2^k$ 步。若 $fa[u][k] \neq fa[v][k]$,则同步更新 $u = fa[u][k], v = fa[v][k]$。
  4. 循环结束后,$\text{LCA}(u, v) = fa[u][0]$。

2. RMQ 转化法

定义 $\text{euler}[i]$ 为欧拉序列,$\text{dep\_seq}[i] = \text{dep}[\text{euler}[i]]$ 为该位置对应的深度序列。利用 ST 表维护区间最值:定义 $f[i][k]$ 为深度序列中从位置 $i$ 开始,长度为 $2^k$ 的区间内,深度最小的值所对应的欧拉序索引

$$pos\text{_}l = f[i][k-1], \quad pos\text{_}r = f[i + 2^{k-1}][k-1]$$

$$f[i][k] = \text{dep\_seq}[pos\text{_}l] < \text{dep\_seq}[pos\text{_}r] ? pos\text{_}l : pos\text{_}r$$


C++ 标准源码(倍增法工业级模板)

#include <iostream>
#include <vector>
#include <algorithm>

const int MAXN = 500005;
const int MAXLOG = 21; // 2^20 > 500000,确保覆盖深度

std::vector<int> adj[MAXN];
int dep[MAXN];
int fa[MAXN][MAXLOG];

// 预处理深度与 2^0 级祖先
void dfs_lca(int u, int p) {
    dep[u] = dep[p] + 1;
    fa[u][0] = p;

    // 动态规划状态转移,预处理倍增数组
    for (int k = 1; k < MAXLOG; ++k) {
        if (fa[u][k - 1] != 0) {
            fa[u][k] = fa[fa[u][k - 1]][k - 1];
        } else {
            fa[u][k] = 0; // 致命低级错误防范:跳出根节点必须置 0
        }
    }

    for (size_t i = 0; i < adj[u].size(); ++i) {
        int v = adj[u][i];
        if (v == p) continue;
        dfs_lca(v, u);
    }
}

// 在线查询 LCA
int query_lca(int u, int v) {
    if (dep[u] < dep[v]) std::swap(u, v); // 始终保证 u 比 v 深

    // 第一阶段:将 u 提升到与 v 同等深度
    for (int k = MAXLOG - 1; k >= 0; --k) {
        if (dep[fa[u][k]] >= dep[v]) {
            u = fa[u][k];
        }
    }

    if (u == v) return u;

    // 第二阶段:同步向上逼近
    for (int k = MAXLOG - 1; k >= 0; --k) {
        if (fa[u][k] != fa[v][k]) { // 关键:只有两者的祖先不同时才跳转
            u = fa[u][k];
            v = fa[v][k];
        }
    }

    // 最终停在 LCA 的下一层,返回父节点即可
    return fa[u][0];
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m, root;
    if (!(std::cin >> n >> m >> root)) return 0;

    for (int i = 1; i < n; ++i) {
        int u, v;
        std::cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    // 初始化根节点深度的物理边界,传 0 作为根的父节点
    dep[0] = 0;
    dfs_lca(root, 0);

    for (int i = 0; i < m; ++i) {
        int u, v;
        std::cin >> u >> v;
        std::cout << query_lca(u, v) << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

  1. 倍增法提升深度时循环顺序逆反: 在对齐深度或双指针同步向上逼近时,二进制循环必须从大到小(即 for (int k = MAXLOG - 1; k >= 0; --k))。如果写成从小到大循环,贪心策略失效,无法正确组装出目标深度差值,导致查询结果退化或指针死锁。
  2. 根节点父节点未做越界保护: 若没有在 dfs_lca 中正确处理 fa[u][k - 1] == 0 的边界,导致 fa[u][k] 访问了 fa[0][k - 1]。如果 dep[0]fa[0] 数组未清零或包含了脏数据,整个倍增链条将发生拓扑崩塌,引发死循环或错误的祖先定位。

经典 NOIP/洛谷 真题

1. 洛谷 P1967 [NOIP2013 提高组] 货车运输

2. 洛谷 P3128 [USACO15JAN] Max Flow P

$$\text{diff}[u] \leftarrow \text{diff}[u] + 1, \quad \text{diff}[v] \leftarrow \text{diff}[v] + 1$$

$$\text{diff}[a] \leftarrow \text{diff}[a] - 1, \quad \text{diff}[fa[a][0]] \leftarrow \text{diff}[fa[a][0]] - 1$$

修改完成后,运行一次自底向上的 DFS 回溯,各节点的真实压力值即为其子树的差分值之和。总时间复杂度 $O((N + K) \text{log} N)$。

原文链接: local://9.3

[h] 返回首页