(图论) Tarjan 算法

前言

Tarjan 算法是图论中非常著名和常用的算法之一,能解决最近公共祖先(LCA),强连通分量,割点和割边(桥),双连通分量等问题。

Tarjan 是基于dfs搜索的算法,依据递归栈,时间戳等信息能解决多种问题。在dfs中每个点只递归一次,边也只利用一次,因此时间复杂度为 O ( n + m ) {O(n+m)} O(n+m)

本文主要以记录模板为主,不做详细教学,需要读者有一定的dfs基础,希望读者能总结出属于自己的模板。

在求强联通分量处,给出了另一种kosaraju算法的模板。

本文并没有求双联通分量的模板,以后有机会会给出。

img

最近公共祖先 (LCA)

LCA - Lowest Common Ancestors

  • 建树的边 (双向)
  • 建查询的边 (双向)
  • dfs
    • 入 vis 遍历子节点dfs
    • 回 并查集nex存cur
    • 离 枚举查询组 借助并查集的路径压缩

视频讲解:

322 最近公共祖先 Tarjan算法_哔哩哔哩_bilibili

练习题:

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

// P3379 【模板】最近公共祖先(LCA)
#include <bits/stdc++.h>
using namespace std;

const int M = 10 + 500000;
vector<vector<int>> graph(M);             // 存图(树)
vector<vector<pair<int, int>>> query(M);  // 询问

int father[M];  // 并查集
bool vis[M];    // vis
int ans[M];     // 第几组询问的答案

void initUnionFind(int n) {
    for (int i = 0; i <= n; i++) {
        father[i] = i;
    }
}
// 必须路径压缩
int unionFind(int x) {
    return x == father[x] ? x : father[x] = unionFind(father[x]);
}
// Tarjan 算法
void Tarjan(int cur) {
    // dfs 入
    vis[cur] = true;
    for (int& nex : graph[cur]) {
        if (!vis[nex]) {
            Tarjan(nex);
            // dfs 回
            father[nex] = cur;
        }
    }

    // dfs 离开
    for (auto& it : query[cur]) {
        int &to = it.first, &idx = it.second;
        // 若访问过,则可以记录
        if (vis[to]) {
            ans[idx] = unionFind(to);
        }
    }
}

int main() {
    // 边数 询问数 根节点
    int n, m, root;
    scanf("%d %d %d", &n, &m, &root);
    initUnionFind(n);

    // 存无向图
    for (int i = 1, a, b; i <= n - 1; i++) {
        scanf("%d %d", &a, &b);
        graph[a].emplace_back(b);
        graph[b].emplace_back(a);
    }

    // 询问 也要双向存
    for (int i = 1, a, b; i <= m; i++) {
        scanf("%d %d", &a, &b);
        query[a].emplace_back(b, i);
        query[b].emplace_back(a, i);
    }

    Tarjan(root);

    for (int i = 1; i <= m; i++) {
        printf("%d\n", ans[i]);
    }

    return 0;
}

强连通分量

tarjan

视频讲解:

算法轻松掌握tarjan强连通分量_哔哩哔哩_bilibili

思想:

在递归站中的low值相等的点是一个强联通分量

dfn值和low值相等的是该分量的代表点

练习题:

杭电:迷宫城堡 - 1269

洛谷:P3387 【模板】缩点

/**
 * https://www.luogu.com.cn/problem/P3387
 * P3387 【模板】缩点
 *
 * tarjan 求强连通分量
 * 然后缩点构造DAG图
 */
#include <bits/stdc++.h>
#define int long long
using namespace std;

// tarjan 标配
vector<vector<int>> graph;  // 存图
vector<int> dfn;            // dfs被访问的时间点
vector<int> low;            // 通过回溯可以到达的最早时间点
int timestamp = 1;          // 时间戳
// 求强连通分量 标配
vector<int> scc;     // 强联通分量
stack<int> stk;      // 递归栈
vector<bool> inStk;  // 快速辨别是都在递归栈中

void tarjan(int cur) {
    dfn[cur] = low[cur] = timestamp++;
    stk.push(cur);
    inStk[cur] = true;

    for (int& nex : graph[cur]) {
        if (dfn[nex] == 0) {
            // 未访问则搜索一次
            tarjan(nex);
            low[cur] = min(low[cur], low[nex]);
        } else if (inStk[nex]) {
            // 在栈中,也要松弛一次
            low[cur] = min(low[cur], dfn[nex]);
        }
    }

    // 自己的dfn和low相同,则构成一个强联通分量
    if (dfn[cur] == low[cur]) {
        int x = -1;
        do {
            x = stk.top();
            stk.pop();
            inStk[x] = false;
            scc[x] = cur;
        } while (x != cur);
    }
}

signed main() {
    int n, m;
    cin >> n >> m;

    graph.resize(n + 1);
    dfn.resize(n + 1);
    low.resize(n + 1);
    timestamp = 1;
    scc.resize(n + 1, -1);
    inStk.resize(n + 1);

    vector<int> val(n + 1);   // 点权
    vector<int> from(m + 1);  // 出发点
    vector<int> to(m + 1);    // 到达点

    // 点权
    for (int i = 1; i <= n; i++) {
        cin >> val[i];
    }
    // 建图,单向图
    for (int i = 1; i <= m; i++) {
        cin >> from[i] >> to[i];
        graph[from[i]].emplace_back(to[i]);
    }

    // 跑tarjan 获得强连通分量
    for (int i = 1; i <= n; i++) {
        if (dfn[i] == 0) {
            tarjan(i);
        }
    }

    /** ******** tarjan 跑完,获得强连通分量 ****************************/
    /** ******** 根据强连通分量,缩点构造DAG图 ***************************/

    // 先将每个强联通分量的点权集中到代表点上
    for (int i = 1; i <= n; i++) {
        if (scc[i] != i) {  // 代表点不用重复加
            val[scc[i]] += val[i];
        }
    }

    unordered_map<int, vector<int>> dagGraph;
    for (int i = 1; i <= m; i++) {
        int &u = from[i], &v = to[i];
        // 不在一个分量中
        if (scc[u] != scc[v]) {
            dagGraph[scc[u]].emplace_back(scc[v]);
        }
    }

    function<int(int)> dfsDag = [&](int cur) -> int {
        int sum = 0;
        for (int& nex : dagGraph[cur]) {
            // 获得子树的最大值,还可以记忆化优化下
            sum = max(sum, dfsDag(nex));
        }
        return val[cur] + sum;
    };

    int maxx = 0;
    // 遍历每个点,可能有某点的是单独的分量没有边
    for (int i = 1; i <= n; i++) {
        if (scc[i] == i) {
            maxx = max(maxx, dfsDag(scc[i]));
        }
    }

    cout << maxx << endl;

    return 0;
}

kosaraju

另一种求强连通分量的方法

  • 建立正向和反向图
  • 先dfs反向图
  • 在正向图中逆序dfs反向图的结果,并记录每个点在哪个联通分量中
/**
 * https://www.luogu.com.cn/problem/P3387
 * P3387 【模板】缩点
 *
 * kosaraju 求强连通分量
 * 然后缩点构造DAG图
 */
#include <bits/stdc++.h>
#define int long long
using namespace std;

// kosaraju 标配
vector<vector<int>> forwardGraph;  // 正向图
vector<vector<int>> reverseGraph;  // 反向图
vector<int> scc;                   // 强连通分量
vector<int> vis;                   // vis标记
stack<int> stk;                    // 反图入栈

void reverseDFS(int cur) {
    vis[cur] = true;
    for (int& nex : reverseGraph[cur]) {
        if (!vis[nex]) {
            reverseDFS(nex);
        }
    }
    // 访问的点依次入栈
    stk.push(cur);
}

void forwardDFS(int cur, int father) {
    vis[cur] = true;
    scc[cur] = father;  // 记录是哪个强连通分量
    for (int& nex : forwardGraph[cur]) {
        if (!vis[nex]) {
            forwardDFS(nex, father);
        }
    }
}

signed main() {
    int n, m;
    cin >> n >> m;

    forwardGraph.resize(n + 1);
    reverseGraph.resize(n + 1);
    scc.resize(n + 1);
    vis.resize(n + 1);

    vector<int> val(n + 1);   // 点权
    vector<int> from(m + 1);  // 出发点
    vector<int> to(m + 1);    // 到达点

    // 记录点权
    for (int i = 1; i <= n; i++) {
        cin >> val[i];
    }

    // 正向反向图同时建立
    for (int i = 1; i <= m; i++) {
        cin >> from[i] >> to[i];
        forwardGraph[from[i]].push_back(to[i]);
        reverseGraph[to[i]].push_back(from[i]);
    }

    fill(vis.begin(), vis.end(), false);
    // 反向图遍历
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            reverseDFS(i);
        }
    }
    fill(vis.begin(), vis.end(), false);
    // 逆序遍历反向图的结果
    // 目的是获得强连通分量scc
    while (!stk.empty()) {
        int cur = stk.top();
        stk.pop();
        if (!vis[cur]) {
            forwardDFS(cur, cur);
        }
    }

    /** ************** kosaraju 跑完,获得强连通分量 *********************/
    /** ******** 根据强连通分量,缩点构造DAG图 ***************************/

    // 先将每个强联通分量的点权集中到代表点上
    for (int i = 1; i <= n; i++) {
        if (scc[i] != i) {  // 代表点不用重复加
            val[scc[i]] += val[i];
        }
    }

    unordered_map<int, vector<int>> dagGraph;
    for (int i = 1; i <= m; i++) {
        int &u = from[i], &v = to[i];
        // 不在一个分量中
        if (scc[u] != scc[v]) {
            dagGraph[scc[u]].emplace_back(scc[v]);
        }
    }

    function<int(int)> dfsDag = [&](int cur) -> int {
        int sum = 0;
        for (int& nex : dagGraph[cur]) {
            // 获得子树的最大值,还可以记忆化优化下
            sum = max(sum, dfsDag(nex));
        }
        return val[cur] + sum;
    };

    int maxx = 0;
    // 遍历每个点,可能有某点的是单独的分量没有边
    for (int i = 1; i <= n; i++) {
        if (scc[i] == i) {
            maxx = max(maxx, dfsDag(scc[i]));
        }
    }

    cout << maxx << endl;

    return 0;
}

割点 割边

视频讲解:

算法轻松掌握tarjan割点&桥算法_哔哩哔哩_bilibili

算法轻松掌握tarjan割点&桥算法_5_code实现_哔哩哔哩_bilibili

割点:

  • cur != root && cur 有儿子 && low[nex] >= dfn[cur]
  • cur == root && cur 有儿子数量 >= 2

割边:

  • low[nex] > dfn[cur]

来自:邋遢大哥233的个人空间_哔哩哔哩_bilibili

在这里插入图片描述

割点

练习题:

洛谷:P3388 【模板】割点(割顶)

// P3388 【模板】割点(割顶)
// 写法挺多的,以后总结出自己的模板
#include <bits/stdc++.h>
using namespace std;

const int M = 10 + 2 * 10000;

vector<vector<int>> graph(M);  // 存图
int dfn[M];                    // dfs被访问的时间点
int low[M];                    // 通过回溯可以到达的最早时间点
int father[M];                 // 记录父节点
bool cut[M];                   // 是否是割点

int timestamp = 1;  // 时间戳

void tarjan(int cur) {
    dfn[cur] = low[cur] = timestamp++;
    int child = 0;

    for (auto& nex : graph[cur]) {
        if (dfn[nex] == 0) {
            // 未访问则搜索一次
            child++, father[nex] = cur;
            tarjan(nex);
            // 是根节点,并且孩子个数大于等于2
            if (-1 == father[cur] && child >= 2) {
                cut[cur] = true;
            }
            // 不是根节点,但是孩子不可以回溯的更高
            if (-1 != father[cur] && low[nex] >= dfn[cur]) {
                cut[cur] = true;
            }

            // // 是割边(桥)
            // if (low[nex] > dfn[cur]) {
            //     // 每条边只会被访问一次,直接存
            //     //
            //     子树的回溯值比当权点的时间戳大于等于,则表示不会回溯到更先的节点,则是割边
            // }

            low[cur] = min(low[cur], low[nex]);
        } else if (nex != father[cur]) {
            // 访问过,但是并不是父节点
            low[cur] = min(low[cur], dfn[nex]);
        }
    }
}

int main() {
    memset(dfn, 0, sizeof(dfn));
    memset(low, 0, sizeof(low));
    memset(father, -1, sizeof(father));
    memset(cut, false, sizeof(cut));

    int n, m;
    scanf("%d %d", &n, &m);

    // 无向图
    for (int i = 1, from, to; i <= m; i++) {
        scanf("%d %d", &from, &to);
        graph[from].emplace_back(to);
        graph[to].emplace_back(from);
    }

    for (int i = 0; i <= n; i++) {
        if (dfn[i] == 0) {
            tarjan(i);
        }
    }

    // 统计割点数量
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += cut[i];
    }
    printf("%d\n", sum);
    for (int i = 1; i <= n; i++) {
        if (cut[i]) {
            printf("%d ", i);
        }
    }

    return 0;
}

割边 (桥)

练习题:

力扣:1192. 查找集群内的「关键连接」

class Solution {
private:
    vector<vector<int>> graph;
    vector<int> dfn;  // 时间戳
    vector<int> low;  // 回溯值
    int timestamp = 1;

    vector<vector<int>> ans;

    void tarjan(int cur, int pre) {
        dfn[cur] = low[cur] = timestamp++;

        for (int& nex : graph[cur]) {
            // 因为是图,防止环
            if (nex == pre) {
                continue;
            }
            if (dfn[nex] == 0) {
                // 还未dfs过
                tarjan(nex, cur);
                // 子节点回溯不到cur之前
                // 注意没有=因为可能有环
                if (low[nex] > dfn[cur]) {
                    ans.emplace_back(vector<int>{cur, nex});
                }
                // 利用子节点的回溯值更新
                low[cur] = min(low[cur], low[nex]);
            } else {
                // 不是父节点
                low[cur] = min(low[cur], dfn[nex]);
            }
        }
    }

public:
    vector<vector<int>> criticalConnections(int n, vector<vector<int>>& connections) {
        graph.resize(n);
        dfn.resize(n);
        low.resize(n);

        // 存无向图
        for (vector<int>& arr : connections) {
            graph[arr[0]].emplace_back(arr[1]);
            graph[arr[1]].emplace_back(arr[0]);
        }

        for (int i = 0; i < n; i++) {
            if (dfn[i] == 0) {
                tarjan(i, -1);
            }
        }

        return ans;
    }
};

其他相关资料

60 分钟搞定图论中的 Tarjan 算法(一) - 知乎 (zhihu.com)

tarjan算法_百度百科 (baidu.com)

END

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值