【图论】 最近公共祖先LCA

补题遇到的知识点 & 算法提高课笔记

定义

什么是最近公共祖先?

在一棵树中(也就是有向无环图中),从根结点遍历到每个结点,路径上所经过的所有结点都叫做该结点的祖先结点

两个结点的祖先结点中重复的部分叫做公共祖先

公共祖先中层数最大的一个点叫做最近公共祖先(LCA)

求法

向上标记法

要求的两个结点依次向上遍历,找到的第一个公共祖先就是最近公共祖先

但是这个做法比较暴力,时间复杂度O(n)

倍增法

首先需要预处理f[i][j]:从结点 i 向上走 2j 步走到的结点,j 的范围是0 <= j <= logn
比如说,当 j = 0 时,f[i][j]表示的就是 i 向上走一步到达的结点,其实也就是 i 的父结点

(如果从 i 开始跳 2j 跳出了根结点,那么规定f[i][j] = 0dist[0] = 0

怎么实现这个算法呢?

我们发现,从结点 i 向上走 2j 步,相当于结点 i 先向上走 2 j-1 步,再向上走 2 j-1
据此,我们可以利用递归来解决这个问题

用式子可以表示为:f[i][j] = f[f[i][j - 1]][j - 1]

然后,我们需要预处理另一个数组depth[i]:表示 i 结点的深度

上面的两个数组都可以利用 DFS / BFS 求出

当我们知道了f[i][j]depth[i],应该怎么求LCA呢?

步骤:

  • 先将两个结点跳到同一层
    具体怎么做呢?
    首先我们需要了解二进制拼凑:所有整数都可以由2的整次幂的和来表示(因为所有数都可以表示为二进制)
    当我们需要拼出一个 n ,从大到小开始枚举,当找到第一个数 k 满足2^k <= n时,表示我们需要选择 2 k,之后用 n - 2 k 再根据一样的步骤求出下一个满足条件的 k,以此类推
    了解了这个知识之后,回到怎么将两个不同深度的结点跳到同一层这个问题
    我们已经知道了depth[i]depth[j](假设要求的两个结点是 i 和 j,i 的深度比 j 大)
    depth[f[i][k]] >= depth[j]时,说明结点 i 向上跳了 2 k 步后的祖先结点还是比结点 j 深度大,那就说明可以跳,根据这个方法,我们可以将深度较大的结点跳到和深度较小的结点的同一层
  • 让两个结点同时往上跳,直到跳到最近公共祖先的下一层
    为什么要让这两个结点跳到LCA的下一层而不是直接跳到LCA呢?
    因为如果这两个结点跳到的祖先是同一个结点,我们只能说这个相同的结点是公共祖先祖先,而无法保证是最近公共祖先。当我们跳到LCA的下一层,只要他们的父结点是同一个结点,那这个结点就一定是它们的最近公共祖先了
    那该怎么实现这一步呢?
    还是从大到小枚举 k,当f[i][k] != f[j][k]时,说明 i 和 j 还没有跳到公共祖先,当f[i][k] == f[j][k]时,跳到了公共祖先,此时 k - 1 就是不能跳到公共祖先的最大的 k,于是我们将 i 和 j 同时往上跳 2 k-1步,以此类推

预处理的时间复杂度是O(nlogn)
查询的时间复杂度是O(logn)

模板题

题目 祖孙询问

给定一棵包含 n 个节点的有根无向树,节点编号互不相同,但不一定是 1∼n。

有 m 个询问,每个询问给出了一对节点的编号 x 和 y,询问 x 与 y 的祖孙关系。

输入格式

输入第一行包括一个整数 表示节点个数;

接下来 n 行每行一对整数 a 和 b,表示 a 和 b 之间有一条无向边。如果 b 是 −1,那么 a 就是树的根;

第 n+2 行是一个整数 m 表示询问个数;

接下来 m 行,每行两个不同的正整数 x 和 y,表示一个询问。

输出格式

对于每一个询问,若 x 是 y 的祖先则输出 1,若 y 是 x 的祖先则输出 2,否则输出 0。

数据范围

1 ≤ n, m ≤ 4 × 10^4,
1 ≤ 每个节点的编号 ≤ 4 × 10^4

输入样例

10
234 -1
12 234
13 234
14 234
15 234
16 234
17 234
18 234
19 234
233 19
5
234 233
233 12
233 13
233 15
233 19

输出样例

1
0
0
0
2

代码(加了注释^^)

 #include <bits/stdc++.h>

using namespace std;

const int N = 40010, M = N * 2;

int n, m;
int h[N], e[M], ne[M], idx;
int depth[N];
int fa[N][16];
queue<int> q;

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

void bfs(int root) // 预处理depth和fa
{
    memset(depth, 0x3f3f3f, sizeof depth);
    depth[0] = 0, depth[root] = 1; // 设置哨兵
    q.push(root);

    while (q.size())
    {
        int t = q.front();
        q.pop();

        for (int i = h[t]; ~i; i = ne[i])
        {
            int j = e[i];
            if (depth[j] > depth[t] + 1)
            {
                depth[j] = depth[t] + 1; // 更新depth
                q.push(j);
                fa[j][0] = t; // 记录父结点
                for (int k = 1; k <= 15; k ++ )
                    fa[j][k] = fa[fa[j][k - 1]][k - 1]; // 更新fa
            }
        }
    }
}

int lca(int a, int b) // 返回最近公共祖先
{
    if (depth[a] < depth[b]) swap(a, b); // 把深度大的调到a
    for (int k = 15; k >= 0; k -- ) // 把两个结点调到同一层
        if (depth[fa[a][k]] >= depth[b])
            a = fa[a][k];
    if (a == b) return a;

    for (int k = 15; k >= 0; k -- ) // 两个点同时往上跳
        if (fa[a][k] != fa[b][k])
        {
            a = fa[a][k];
            b = fa[b][k];
        }
    return fa[a][0];
}

int main()
{
    cin >> n;
    int root = 0;
    memset(h, -1, sizeof h);

    for (int i = 0; i < n; i ++ ) // 构图
    {
        int a, b;
        cin >> a >> b;
        if (b == -1) root = a;
        else add(a, b), add(b, a);
    }

    bfs(root); // 预处理

    cin >> m;
    while (m -- )
    {
        int a, b;
        cin >> a >> b;
        int p = lca(a, b);
        if (p == a) cout << "1\n";
        else if (p == b) cout << "2\n";
        else cout << "0\n";
    }

    return 0;
}

Tarjan法

本质是对向上标记法的优化,这是一种离线做法(意思是读入所有询问后统一计算统一输出),时间复杂度O(n + m)

在DFS中,把所有的点分成三大类:已经遍历过且回溯过的点(意思是它的所有子树都被遍历了,换句话说就是经过了该点两次)、正在遍历的分支(遍历过一次还没有回溯的点,换句话说就是经过了该点一次)、还没搜索到的点(经过了该点0次)

以下面这张图为例,设红色的路线使我们正在遍历的点,红色左边的所有点都是已经遍历完的,右边的所有点都是还没有被遍历的
可以发现,黄色区域内的所有点与 j 的最近公共祖先就是黄色区域的父结点,蓝色区域和绿色区域也一样,因此我们可以通过并查集的方式求得两个结点的lca
在这里插入图片描述

例题

题目 距离

给出 n 个点的一棵树,多次询问两点之间的最短距离。

注意:

  • 边是无向的。
  • 所有节点的编号是 1,2,…,n。

输入格式

第一行为两个整数 n 和 m。n 表示点数,m 表示询问次数;

下来 n−1 行,每行三个整数 x,y,k,表示点 x 和点 y 之间存在一条边长度为 k;

再接下来 m 行,每行两个整数 x,y,表示询问点 x 到点 y 的最短距离。

树中结点编号从 1 到 n。

输出格式
共 m 行,对于每次询问,输出一行询问结果。

数据范围
2 ≤ n ≤ 104, 1 ≤ m ≤ 2 × 104, 0 < k ≤ 100, 1 ≤ x, y ≤ n

输入样例1:

2 2 
1 2 100 
1 2 
2 1

输出样例1:

100
100

输入样例2:

3 2
1 2 10
3 1 15
1 2
3 2

输出样例2:

10
25

思路

只需要知道,求两个点的路径长度,就是用第一个点到根结点的距离加上第二个点到根结点的距离减去两倍的最近公共祖先到根节点的距离,所以问题还是转化成求lca的问题
在这里插入图片描述

代码(加了注释)

#include <bits/stdc++.h>

using namespace std;

const int N = 20010, M = 2 * N;

typedef pair<int, int> PII; 

int n, m;
int h[N], e[M], ne[M], w[M], idx;  
int dist[N]; // 存储每个点到根结点的距离
int p[N]; // 每个点的祖宗结点
int res[N]; // 存储输出结果
int st[N]; // 表示每个点的遍历状态 1:正在搜索 2:已经搜完 0:还没搜
vector<PII> query[N]; // 存储查询的点,first存另一个点,second存查询编号

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

void dfs(int u, int fa)
{
    for (int i = h[u]; ~i; i = ne[i])
    {
        int j = e[i];
        if (j == fa) continue; // 需要记录父结点防止遍历到上面去(因为这是一个图我们把它看成树而已)
        dist[j] = dist[u] + w[i]; // dist[j]是点 w[i]是边 更新距离dist
        dfs(j, u);
    }
}

int find(int x)
{
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

void tarjan(int u)
{
    st[u] = 1; // 修改当前点状态
    for (int i = h[u]; ~i; i = ne[i])
    {
        int j = e[i];
        if (!st[j]) // 如果当前点未被遍历过
        {
            tarjan(j);
            p[j] = u;
        }
    }
    for (auto item : query[u]) // 处理和当前点相关的所有询问
    {
        int y = item.first, id = item.second; // y是另一个点 id是询问编号
        if (st[y] == 2) // 另一个点已经搜完才处理
        {
            int anc = find(y); // anc为最近公共祖先
            res[id] = dist[u] + dist[y] - 2 * dist[anc];
        }
    }
    st[u] = 2; // 修改当前点状态
}

int main()
{
    cin >> n >> m;
    memset(h, -1, sizeof h);
    for (int i = 0; i < n - 1; i ++ ) // 构图
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c), add(b, a, c);
    }

    for (int i = 0; i < m; i ++ )
    {
        int a, b;
        cin >> a >> b;
        query[a].push_back({b, i});
        query[b].push_back({a, i});
    }

    for (int i = 1; i <= n; i ++ ) p[i] = i; // 并查集初始化

    dfs(1, -1); // -1表示1是根结点
    tarjan(1);

    for (int i = 0; i < m; i ++ ) cout << res[i] << '\n';

    return 0;
}

例题

次小生成树

给定一张 N 个点 M 条边的无向图,求无向图的严格次小生成树。

设最小生成树的边权之和为 sum,严格次小生成树就是指边权之和大于 sum 的生成树中最小的一个。

输入格式

第一行包含两个整数 N 和 M。

接下来 M 行,每行包含三个整数 x,y,z,表示点 x 和点 y 之前存在一条边,边的权值为 z。

输出格式

包含一行,仅一个数,表示严格次小生成树的边权和。(数据保证必定存在严格次小生成树)

数据范围

N ≤ 105, M ≤ 3 × 105,
1 ≤ x, y ≤ N,
0 ≤ z ≤ 106

输入样例

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

输出样例

11

思路

总体思路是:先利用Kruskal算法求出最小生成树,然后看其余不在最小生成树中的每条边能不能替换最小生成树中的一条边使得其变成次小生成树
不在最小生成树的边加到最小生成树里,(比如说加了a-b这条边),那ab在最小生成树中的路径一定会和加的这条边构成环(这点很重要,理解了就很清楚),那我们就需要在这个环里去掉一条边让a-b这条边来代替它,设新加的边权重w,去掉的边权重wi,最小生成树边权和sum,那么新生成的树边权和sum - wi + w,要让新生成的树权重尽可能小,我们就要让减掉的边权重尽可能大,因此减掉的边只可能是最小生成树中的最大边或者次大边(因为要求生成严格的次小生成树,那么次小生成树的边权和就不能和最小生成树一样大,如果新加入的边和最小生成树中的最大边权重一样,就不能用这条边替换最大边(否则最终权重还是一样),就应该用这条边替换次大边)

代码中的 lca 函数利用最近公共祖先求出环中除了新加的边之外的最大边和次大边,bfs 函数更新 d1 和 d2 的值
具体看代码注释( 受不了了好难qaq

代码(写了注释)

#include <bits/stdc++.h>

using namespace std;

using i64 = long long;

const int inf = 0x3f3f3f3f;
const int N = 100010, M = 300010;

int n, m;
struct Edge // 存边信息
{
    int a, b, w;
    bool used;
    bool operator< (const Edge &t) const
    {
        return w < t.w;
    }
}edge[M];
int p[N]; // 并查集
int h[N], e[M], ne[M], w[M], idx;
int depth[N]; // 每个点的层数
int fa[N][17]; // 每个点向上走2^j到达的点
int d1[N][17]; // d1[i][j] 表示从i点向上走2^j步这段路径中最大边
int d2[N][17]; // d2[i][j] 表示从i点向上走2^j步这段路径中(严格)次大边
queue<int> q;

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

int find(int x)
{
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

i64 kruskal() // 构建最小生成树
{
    for (int i = 1; i <= n; i ++ ) p[i] = i;
    sort(edge, edge + m); // 按边权从小到大排序
    i64 res = 0;
    for (int i = 0; i < m; i ++ )
    {
        int a = find(edge[i].a), b = find(edge[i].b), w = edge[i].w;
        if (a != b) // ab不属于同一集合
        {
            p[a] = b; // 将a加到b所在集合
            res += w; // 更新最小生成树边权
            edge[i].used = true; // 标记i这条边已被使用
        }
    }
    return res;
}

void build() // 构图
{
    memset(h, -1, sizeof h);
    for (int i = 0; i < m; i ++ )
        if (edge[i].used) // 这条边在最小生成树里才加到图里
        {
            int a = edge[i].a, b = edge[i].b, w = edge[i].w;
            add(a, b, w), add(b, a, w);
        }
}

void bfs() // 更新depth和fa和d1和d2
{
    memset(depth, 0x3f3f3f, sizeof depth);
    depth[0] = 0, depth[1] = 1; // 设置哨兵和初始化
    q.push(1);
    while (q.size())
    {
        int t = q.front();
        q.pop();

        for (int i = h[t]; ~i; i = ne[i])
        {
            int j = e[i];
            if (depth[j] > depth[t] + 1) // 说明j没被遍历过,更新j相关信息
            {
                depth[j] = depth[t] + 1;
                q.push(j);
                fa[j][0] = t; // 更新j父结点
                d1[j][0] = w[i], d2[j][0] = -inf; // 初始化
                for (int k = 1; k < 16; k ++ )
                {
                    int anc = fa[j][k - 1]; // j向上走2^(k-1)所到达点
                    fa[j][k] = fa[anc][k - 1]; // 递归思想
                    // 最大边和次大边一定是下方四个值中的一个
                    int distance[4] = {d1[j][k - 1], d2[j][k - 1], d1[anc][k - 1], d2[anc][k - 1]};
                    d1[j][k] = d2[j][k] = -inf; // 初始化最大边和次大边的值
                    for (int u = 0; u < 4; u ++ ) // 更新最大边和次大边
                    {
                        int d = distance[u];
                        if (d > d1[j][k]) d2[j][k] = d1[j][k], d1[j][k] = d;
                        else if (d != d1[j][k] && d > d2[j][k]) d2[j][k] = d;
                    }
                }
            }
        }
    }
}

int lca(int a, int b, int w)
{
    static int distance[N * 2]; // 存储所有跳的路径中的最大边和次大边
    int cnt = 0; // distance大小
    if (depth[a] < depth[b]) swap(a, b); // 把深度大的调给a
    for (int k = 16; k >= 0; k -- )
        if (depth[fa[a][k]] >= depth[b]) // 把ab调到同一层,a往上跳2^k还是比b深度大,说明可以跳
        {
            distance[cnt ++ ] = d1[a][k];
            distance[cnt ++ ] = d2[a][k];
            a = fa[a][k];
        }
    if (a != b) // 把ab同时跳到最近公共祖先的下一层
    {
        for (int k = 16; k >= 0; k -- )
            if (fa[a][k] != fa[b][k])
            {
                distance[cnt ++ ] = d1[a][k];
                distance[cnt ++ ] = d2[a][k];
                distance[cnt ++ ] = d1[b][k];
                distance[cnt ++ ] = d2[b][k];
                a = fa[a][k], b = fa[b][k];
            }
        distance[cnt ++ ] = d1[a][0];
        distance[cnt ++ ] = d1[b][0];
    }

    // 在distance中找到最大边和次大边赋给dist1 dist2
    int dist1 = -inf, dist2 = -inf;
    for (int i = 0; i < cnt; i ++ )
    {
        int d = distance[i];
        if (d > dist1) dist2 = dist1, dist1 = d;
        else if (d != dist1 && d > dist2) dist2 = d;
    }

    if (w > dist1) return w - dist1; // w替换最大边
    if (w > dist2) return w - dist2; // w替换次大边
    return inf;
}

int main()
{
    cin >> n >> m;
    for (int i = 0; i < m; i ++ )
    {
        int a, b, c;
        cin >> a >> b >> c;
        edge[i] = {a, b, c};
    }

    i64 sum = kruskal();
    build();
    bfs();

    i64 res = 1e18;
    for (int i = 0; i < m; i ++ )
        if (!edge[i].used) // 在没选的所有边中找到要替换的边
        {
            int a = edge[i].a, b = edge[i].b, w = edge[i].w;
            res = min(res, sum + lca(a, b, w));
        }
    cout << res << '\n';
    return 0;
}

闇の連鎖

传说中的暗之连锁被人们称为 Dark。

Dark 是人类内心的黑暗的产物,古今中外的勇者们都试图打倒它。

经过研究,你发现 Dark 呈现无向图的结构,图中有 N 个节点和两类边,一类边被称为主要边,而另一类被称为附加边。

Dark 有 N–1 条主要边,并且 Dark 的任意两个节点之间都存在一条只由主要边构成的路径。

另外,Dark 还有 M 条附加边。

你的任务是把 Dark 斩为不连通的两部分。

一开始 Dark 的附加边都处于无敌状态,你只能选择一条主要边切断。

一旦你切断了一条主要边,Dark 就会进入防御模式,主要边会变为无敌的而附加边可以被切断。

但是你的能力只能再切断 Dark 的一条附加边。

现在你想要知道,一共有多少种方案可以击败 Dark。

注意,就算你第一步切断主要边之后就已经把 Dark 斩为两截,你也需要切断一条附加边才算击败了 Dark。

输入格式

第一行包含两个整数 N 和 M。

之后 N–1 行,每行包括两个整数 A 和 B,表示 A 和 B 之间有一条主要边。

之后 M 行以同样的格式给出附加边。

输出格式

输出一个整数表示答案。

数据范围

N ≤ 100000, M ≤ 200000,数据保证答案不超过 231−1

输入样例

4 1
1 2
2 3
1 4
3 4

输出样例

3

思路

这一题转换一下语言,意思就是有一棵树上还有一些边,属于树的边叫做树边,另外的边叫做非树边,现在要求切断一条树边一条非树边,把这个图分成两个部分

首先明确一点,每个非树边连接的两个点都可由唯一的一条由树边组成的路径连接,这条路径和该非树边组成一个环

我们想要断开这个环,把这个环分成两部分,首先需要切断那一条非树边,然后需要切断任意一条环上的树边,所以我们遍历每一条非树边,然后把这个环上的所有树边标记一次

  • 对于没有被标记过的树边,说明只要切断这条树边就可以将图分成两部分,此时再任意切一条非树边就可以了,答案加上非树边的条数
  • 对于只标记过一次的树边,说明需要切断这条树边和另一条对应的非树边,答案加上1
  • 对于标记过两次及以上的树边,此时切断这条树边,会导致原来的两个环合并成一个环,但此时只能再切一条边了,不可能断开形成的新环,所以答案不变

于是这一题的问题就转换成了在每一条树边上标记的问题

怎样快速的对图中的边进行标记呢?

这里我们利用树上差分 的思路

我们学习过一维数组的差分,将[l, r]上的每个元素都加上相同的元素 c,可以直接将差分数组的 l 加上 c,r + 1 项减去 c

那么在树中,我们要将路径(a, b)上的每条边都加上 c,需要将差分数组的 a,b 项分别加 c,将 ab 的 lca 项减去 2c (差分数组的每一项表示该项编号对应的点与父结点连接的边)

代码(加了注释)

#include <bits/stdc++.h>

using namespace std;

const int N = 100010, M = 2 * N;

int n, m;
int h[N], e[M], ne[M], idx;
int depth[N]; // 存每个结点深度
int fa[N][17]; // f[i][j]:i往上走2^j步所到达的结点
int d[N]; // 差分数组
queue<int> q;
int ans;

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

void bfs() // 定义depth和fa
{
    memset(depth, 0x3f3f3f, sizeof depth);
    depth[0] = 0, depth[1] = 1; // 哨兵和初始化
    q.push(1);

    while (q.size())
    
    {
        int t = q.front();
        q.pop();

        for (int i = h[t]; ~i; i = ne[i])
        {
            int j = e[i];
            if (depth[j] > depth[t] + 1)
            {
                depth[j] = depth[t] + 1;
                q.push(j);
                fa[j][0] = t;
                for (int k = 1; k <= 16; k ++ )
                    fa[j][k] = fa[fa[j][k - 1]][k - 1];
            }
        }
    }
}

int lca(int a, int b)
{
    if (depth[a] < depth[b]) swap(a, b); // 先调顺序
    for (int k = 16; k >= 0; k -- ) // 再调同一层
        if (depth[fa[a][k]] >= depth[b]) a = fa[a][k];
    if (a == b) return a;
    for (int k = 16; k >= 0; k -- ) // 再同时往上跳
        if (fa[a][k] != fa[b][k])
        {
            a = fa[a][k];
            b = fa[b][k];
        }
    return fa[a][0];
}

int dfs(int u, int father) // 返回以u为根的子树的d之和
{
    int res = d[u];
    for (int i = h[u]; ~i; i = ne[i])
    {
        int j = e[i];
        if (j != father)
        {
            int s = dfs(j, u);
            if (s == 0) ans += m;
            if (s == 1) ans ++ ;
            res += s;
        }
    }

    return res;
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0); cout.tie(0);

    cin >> n >> m;
    memset(h, -1, sizeof h);
    for (int i = 0; i < n - 1; i ++ )
    {
        int a, b;
        cin >> a >> b;
        add(a, b), add(b, a);
    }

    bfs();

    for (int i = 0; i < m; i ++ )
    {
        int a, b;
        cin >> a >> b;
        int p = lca(a, b);
        d[a] ++, d[b] ++, d[p] -= 2;
    }
    dfs(1, -1);
    cout << ans;
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Texcavator

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值