题解:洛谷 P3379 最近公共祖先(LCA)

【题目来源】

洛谷:P3379 【模板】最近公共祖先(LCA) - 洛谷

【题目描述】

如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先。

【输入】

第一行包含三个正整数 \(N,M,S\),分别表示树的结点个数、询问的个数和树根结点的序号。

接下来 \(N-1\) 行每行包含两个正整数 \(x, y\),表示 \(x\) 结点和 \(y\) 结点之间有一条直接连接的边(数据保证可以构成树)。

接下来 \(M\) 行每行包含两个正整数 \(a, b\),表示询问 \(a\) 结点和 \(b\) 结点的最近公共祖先。

【输出】

输出包含 \(M\) 行,每行包含一个正整数,依次为每一个询问的结果。

【输入样例】

5 5 4
3 1
2 4
5 1
1 4
2 4
3 2
3 5
1 2
4 5

【输出样例】

4
4
1
4
4

【核心思想】

  1. 问题分析:给定一棵有根多叉树,节点数为 \(N\),根节点为 \(S\),需要回答 \(M\) 次查询,每次查询给出两个节点 \(u, v\),求它们的最近公共祖先(LCA),即深度最大且同时是 \(u\)\(v\) 的祖先的节点。这是树结构中最基础也是最重要的查询问题,广泛应用于路径统计、树上差分等场景。

  2. 算法选择

    • 倍增法(在线):预处理每个节点的 \(2^k\) 级祖先和深度,每次查询通过“将较深节点上提至同深度”和“两节点同步上跳”两步,在 \(O(\log N)\) 时间内得到 LCA。适合在线查询,实现简单,时空复杂度均衡。
    • Tarjan 离线算法:利用 DFS 遍历树,并用并查集维护已访问节点的祖先关系,在回溯时回答所有查询,总复杂度 \(O(N + M)\),适合所有查询已知的离线场景。
    • 树链剖分:通过两次 DFS 将树剖分成若干条链,查询时通过链顶跳跃求 LCA,单次查询 \(O(\log N)\),也是一种常用方法。
    • 本模板题提供三种实现,通常以倍增法为首选,因其在线特性且代码易于理解。
  3. 关键步骤(以倍增法为例):

    • 预处理:从根节点 \(S\) 开始 DFS,计算每个节点的深度 \(dep[u]\),并记录直接父节点 \(fa[u][0]\)。然后递推 \(fa[u][i] = fa[fa[u][i-1]][i-1]\)\(i=1..\lfloor \log N \rfloor\)),得到所有 \(2^i\) 级祖先。
    • 查询 LCA(u, v)
      • \(dep[u] < dep[v]\),交换 \(u, v\),保证 \(u\) 更深。
      • \(u\) 向上跳至与 \(v\) 同深度:从高位向低位尝试,若 \(dep[fa[u][i]] \ge dep[v]\),则令 \(u = fa[u][i]\)
      • \(u == v\),则 \(v\) 即为 LCA。
      • 否则,\(u\)\(v\) 同时向上跳:从高位向低位尝试,若 \(fa[u][i] \neq fa[v][i]\),则令 \(u = fa[u][i], v = fa[v][i]\)
      • 最终 \(fa[u][0]\) 即为 LCA。
    • Tarjan 离线法:DFS 遍历树,访问节点 \(u\) 后标记为已访问,并将其子节点合并到 \(u\) 的并查集中;处理所有与 \(u\) 相关的查询 \((u, v)\),若 \(v\) 已访问,则答案为 \(find(v)\)(即 \(v\) 所在集合的根,代表当前已访问的最深祖先)。
    • 树链剖分法:第一次 DFS 计算父节点、深度、子树大小和重儿子;第二次 DFS 确定每个节点所在链的顶端。查询时不断将链顶深度较大的节点上跳,直到两节点在同一条链上,深度较小的即为 LCA。
  4. 时间/空间复杂度

    • 倍增法:预处理 \(O(N \log N)\),每次查询 \(O(\log N)\),空间 \(O(N \log N)\)
    • Tarjan 离线法:总时间复杂度 \(O((N+M) \cdot \alpha(N))\),空间 \(O(N+M)\)
    • 树链剖分法:预处理 \(O(N)\),每次查询 \(O(\log N)\),空间 \(O(N)\)
  5. 倍增法(LCA)的核心思想

    • 二进制拆分:将“向上跳 \(d\) 步”分解为若干个 \(2^i\) 步的组合,利用预处理好的 \(2^i\) 级祖先快速跳转,避免逐级上爬。
    • 深度对齐:先将较深节点提升到与另一节点相同深度,保证两节点在“同一水平线”上。
    • 同步跳跃:从最大步长向下尝试,若跳 \(2^i\) 步后两节点祖先不同,则同时上跳,最终两节点的父节点即为 LCA。该过程保证了不跳过 LCA,且能精确到达。
    • 适用场景:树上的祖先查询、路径长度计算、树上差分等需要频繁查询 LCA 的问题。

【算法标签】

普及 #最近公共祖先

【代码详解】

// 倍增法
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;
int n, m, s, a, b;  // n: 节点数,m: 查询数,s: 根节点
vector<int> e[N];   // 邻接表存储树
int dep[N];         // 节点的深度
int fa[N][20];      // 倍增祖先表,fa[u][i]表示u的2^i级祖先

// 深度优先搜索,预处理深度和祖先表
void dfs(int u, int father)
{
    // 计算当前节点的深度
    dep[u] = dep[father] + 1;

    // 初始化直接父节点
    fa[u][0] = father;

    // 预处理倍增祖先表
    for (int i = 1; i <= 19; i++)
        fa[u][i] = fa[fa[u][i-1]][i-1];

    // 遍历子节点
    for (int v : e[u])
        if (v != father)  // 避免走回父节点
            dfs(v, u);
}

// 求两个节点的最近公共祖先
int lca(int u, int v)
{
    // 第一步:将u和v调整到同一深度
    if (dep[u] < dep[v]) swap(u, v);

    // 将u向上跳,直到与v同深度
    for (int i = 19; i >= 0; i--)
        if (dep[fa[u][i]] >= dep[v])
            u = fa[u][i];

    // 如果此时u==v,说明v是u的祖先
    if (u == v) return v;

    // 第二步:u和v同时向上跳
    for (int i = 19; i >= 0; i--)
        if (fa[u][i] != fa[v][i])  // 如果祖先不同,就一起向上跳
            u = fa[u][i], v = fa[v][i];

    // 此时u和v的父节点就是LCA
    return fa[u][0];
}

int main()
{
    // 输入树的信息
    cin >> n >> m >> s;

    // 读入n-1条边
    for (int i = 1; i < n; i++)
    {
        int x, y;
        cin >> x >> y;
        e[x].push_back(y);
        e[y].push_back(x);
    }

    // 从根节点s开始DFS,预处理深度和祖先表
    dfs(s, 0);

    // 处理m个查询
    for (int i = 1; i <= m; i++)
    {
        int a, b;
        cin >> a >> b;
        cout << lca(a, b) << endl;  // 输出LCA
    }

    return 0;
}
// tarjan算法
#include <bits/stdc++.h>
using namespace std;
const int N = 500005, M = 500005;   // N: 最大节点数, M: 最大询问数
int n, m, s;                        // n: 节点数, m: 询问数, s: 根节点
vector<int> e[N];                   // 邻接表:存储树的边
vector<pair<int, int> > query[N];   // 存储每个节点相关的询问:first=另一节点, second=询问编号
int p[N];                           // 并查集父节点数组(Tarjan中用于维护已访问节点的祖先关系)
int vis[N];                         // vis[i]: 标记节点i是否已被DFS访问过
int ans[M];                         // ans[i]: 第i个询问的答案(LCA)

// 并查集查找(路径压缩)
// 在Tarjan算法中,find(x)返回的是x所在集合的代表元素
// 该代表元素即为x的当前最近已访问祖先(即LCA)
int find(int x)
{
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

// Tarjan离线LCA算法:DFS遍历树的同时回答询问
void tarjan(int u)
{
    vis[u] = true;                  // 标记u已访问

    // 遍历u的所有子节点
    for (auto v : e[u])
    {
        if (!vis[v])                // v未访问,说明v是u的子节点
        {
            tarjan(v);              // 递归DFS子树
            p[v] = u;               // 子树遍历完毕后,将v的集合合并到u(v的祖先设为u)
        }
    }

    // 处理所有与u相关的询问
    for (auto q : query[u])
    {
        int v = q.first;            // 询问的另一节点
        int i = q.second;           // 询问编号

        // 如果v已被访问,则u和v的LCA就是find(v)
        // 原理:v所在集合的代表元素,就是v的最近已访问祖先
        // 由于u刚刚访问完所有子树,此时u是v的最近公共祖先
        if (vis[v]) ans[i] = find(v);
    }
}

int main()
{
    cin >> n >> m >> s;             // 读入节点数、询问数、根节点

    // 读入N-1条边,建立无向图(树)
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        e[u].push_back(v);
        e[v].push_back(u);
    }

    // 读入M个询问,每个询问存储在两个端点的query列表中
    for (int i = 1; i <= m; i++)
    {
        int u, v;
        cin >> u >> v;
        query[u].push_back({v, i});    // u的询问列表中记录(v, 编号i)
        query[v].push_back({u, i});     // v的询问列表中记录(u, 编号i)
    }

    // 初始化并查集:每个节点自成一个集合
    for (int i = 1; i < N; i++) p[i] = i;

    // 从根节点开始Tarjan DFS
    tarjan(s);

    // 输出所有询问的答案
    for (int i = 1; i <= m; i++)
        cout << ans[i] << endl;

    return 0;
}
// 树链剖分算法
#include <bits/stdc++.h>
using namespace std;

const int N = 500005;

int n, m, s;                // n: 节点数, m: 询问数, s: 根节点编号
vector<int> g[N];           // 邻接表存储树
int fa[N], dep[N], son[N], sz[N];
int top[N];                 // 树链剖分中的链顶

// 第一次 DFS:计算父节点、深度、子树大小、重儿子
void dfs1(int u, int father)
{
    fa[u] = father;
    dep[u] = dep[father] + 1;
    sz[u] = 1;

    for (int v : g[u])
    {
        if (v == father)
            continue;
        dfs1(v, u);
        sz[u] += sz[v];
        if (sz[son[u]] < sz[v])
            son[u] = v;     // 更新重儿子(子树最大的儿子)
    }
}

// 第二次 DFS:进行树链剖分,确定每个节点所在链的顶端
void dfs2(int u, int t)
{
    top[u] = t;             // 当前节点所在链的顶端
    if (!son[u])
        return;             // 叶子节点,无重儿子
    dfs2(son[u], t);        // 优先处理重儿子,延续当前链

    for (int v : g[u])
    {
        if (v == fa[u] || v == son[u])
            continue;
        dfs2(v, v);         // 轻儿子单独开一条新链
    }
}

// 利用树链剖分求最近公共祖先 (LCA)
int lca(int u, int v)
{
    while (top[u] != top[v])            // 不在同一条链上
    {
        if (dep[top[u]] < dep[top[v]])  // 保证 u 所在链的顶端更深
            swap(u, v);
        u = fa[top[u]];                 // 将 u 跳到链顶的父亲
    }
    // 此时在同一链上,深度较小的即为 LCA
    return dep[u] < dep[v] ? u : v;
}

int main()
{
    cin >> n >> m >> s;                // 输入节点数、询问数、根节点
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    dfs1(s, 0);            // 从根开始预处理
    dfs2(s, s);            // 树链剖分

    while (m--)
    {
        int a, b;
        cin >> a >> b;
        cout << lca(a, b) << endl;
    }

    return 0;
}

【运行结果】

5 5 4
3 1
2 4
5 1
1 4
2 4
4
3 2
4
3 5
1
1 2
4
4 5
4
posted @ 2026-09-01 16:52  团爸讲算法  阅读(24)  评论(0)    收藏  举报