最近公共祖先(LCA)

最近公共祖先 (LCA)

在一个有根树中,两个结点 \(a、b\)​ 都有若干个祖先(本身也是自己的祖先),他们也会有公共的祖先,距离他们最近,也就是在有根树中深度最大的祖先,被称做 \(a、b\) 的最近公共祖先。

求最近公共祖先

向上标记法

先从一个点开始向上走到根节点,再由另一个结点开始向上走,走到一个已经被标记过的结点,那么这个结点就是这两个点的最近公共祖先。

最坏情况下树为一条链,时间复杂度为 \(O(n)\)​ 。

由于复杂度比较大,因此这个算法不常用。

倍增法

倍增法基于二进制拆分,每次都尽可能向上多跳几步。

\(\displaystyle {fa(i, j})\)​ 表示结点 \(i\) 向上走 \(2^j\) 次后到达的结点。

\(depth(i)\) 表示结点 \(i\) 在有根树中的深度。

对于结点 \(a 、 b\) ,假设 \(depth(a) < depth(b)\) ,我们先把 \(a\) 跳到与 \(b\) 相同的深度;如果此时 \(a == b\) ,那么LCA就是 \(y\) ;否则,我们同时让 \(a、b\) 向上跳到不是 LCA 的结点,最后跳到的结点的父节点就是 LCA 。​

由于每次跳的步数都是 \(2\) 的整数次幂,所以时间复杂度为 \(O (log n)\)

预处理出所有 \(fa(i, j)\) 的时间复杂度为 \(O (n log n)\) ,所以倍增法适用于多次查询LCA。

例题:洛谷 P3379

#include <bits/stdc++.h>

using namespace std;

const int N = 500010, M = N << 1;

int n, qus, root;
int fa[N][20]; // fa(i, j) 表示i结点向上走 2^j 步到达的结点
int depth[N];
int q[N]; bool st[N]; // BFS
int h[N], e[M], ne[M], idx;

void add (int a, int b)
{
    e[idx] = b; ne[idx] = h[a]; h[a] = idx ++ ;
}

void bfs ()
{
    int hh = 0, tt = 0;
    q[0] = root;
    st[root] = true; depth[root] = 1;
    while (hh <= tt)
    {
        int t = q[hh ++ ];
        for (int i = h[t]; ~i; i = ne[i] )
        {
            int j = e[i];
            if (!st[j])
            {
                st[j] = true;
                depth[j] = depth[t] + 1;
                q[++ tt] = j;
                fa[j][0] = t; // 初始化 fa(i, 0) 为父节点
            }
        }
    }
    // 预处理 fa 数组
    for (int j = 1; j < 20; ++ j )
        for (int i = 1; i <= n; ++ i )
            fa[i][j] = fa[fa[i][j-1]][j-1];
}

// 返回 (a, b) 的lca
int lca (int a, int b)
{
    if (depth[a] < depth[b]) swap(a, b); // 保证 a 的深度大于 b
    // a 跳到与 b 相同深度的结点
    for (int i = 19; i >= 0; -- i )
        if (depth[fa[a][i]] >= depth[b])
            a = fa[a][i];
    if (a == b) return b; // a == b ,那么 b 就是 lca
    // 同时跳上面
    for (int i = 19; i >= 0; -- i )
        if (fa[a][i] != fa[b][i])
        {
            a = fa[a][i];
            b = fa[b][i];
        }
    return fa[a][0]; // 此时 a 父节点就算 lca
}

int main ()
{
    cin >> n >> qus >> root;
    memset(h, -1, sizeof h);
    for (int i = 1; i < n; ++ i )
    {
        int x, y; cin >> x >> y;
        add (x, y); add (y, x);
    }
    bfs (); // 求出每个结点的深度及fa数组
    while (qus -- )
    {
        int x, y; cin >> x >> y;
        cout << lca (x, y) << endl;
    }
    return 0;
}

Tarjan离线算法

Tarjan算法基于深度优先遍历,本质上是对向上标记法的优化。

遍历有根树,给每个结点分为三类。

  • 已经遍历过并且已经回溯的点
  • 正在被遍历的点
  • 还没有被搜索过的点

当一个结点回溯,我们将它合并到它的父节点上,我们可以发现,如果一次查询包括当前正在被遍历的结点和已经回溯的结点,那么他们的 LCA 就是已经回溯的点所在的集合的祖先结点。而合并及查询集合祖先的操作可以使用并查集。

如图,粉色是未被搜索到的点,蓝色是正在被遍历的点,其他都是已经遍历的点(有些未回溯,所以他们处于不同的集合)。

绿色和黄色任意点的LCA都是黄色点集合的祖先结点,其他也是如此。

例题:洛谷 P3379

#include <bits/stdc++.h>

using namespace std;

const int N = 500010, M = N << 1;

typedef pair<int, int> PII ;

int n, qus, root;
int p[N];
int st[N]; // 记录每个点的状态:1表示正在搜索,2表示已经回溯,0表示还没有搜索到
vector<PII> query[N]; // query[i] -> (j, id) 表示在第id个查询中,查询的结点是(i, j)
int ans[N]; // 存储每个查询的结果
int h[N], e[M], ne[M], idx;

void add (int a, int b)
{
    e[idx] = b; ne[idx] = h[a]; h[a] = idx ++ ;
}

int find (int x)
{
    return p[x] == x ? x : p[x] = find(p[x]);
}

void tarjan (int u)
{
    st[u] = 1; // 正在搜索 u 结点
    for (int i = h[u]; ~i; i = ne[i])
    {
        int j = e[i];
        // 如果还没有遍历,则遍历子结点
        if (!st[j]) { tarjan(j); p[j] = u; } // 回溯后记得及时合并到父节点上
    }
    for (auto t : query[u])
    {
        int y = t.first, id = t.second;
        if (st[y] == 2) ans[id] = find(y); // 如果j已经回溯
    }
    st[u] = 2; // u 结点回溯
}

int main ()
{
    cin >> n >> qus >> root;
    memset(h, -1, sizeof h);
    for (int i = 1; i < n; ++ i )
    {
        int x, y; cin >> x >> y;
        add (x, y); add (y, x);
    }
    for (int i = 1; i <= qus; ++ i )
    {
        int x, y; cin >> x >> y;
        if (x != y)
        {
            query[x].push_back({y, i});
            query[y].push_back({x, i});
        }
    }
    for (int i = 1; i <= n; ++ i ) p[i] = i;
    tarjan(root);
    for (int i = 1; i <= n; ++ i ) cout << ans[i] << endl;
    return 0;
}
posted @ 2021-11-20 14:21  Horb7  阅读(54)  评论(0编辑  收藏  举报