Tarjan算法找桥(包括重边的两种处理方案)

Tarjan算法找桥

P1656 炸铁路

题目描述

A 国派出将军 uim,对 B 国进行战略性措施,以解救涂炭的生灵。

B 国有 $n$ 个城市,这些城市以铁路相连。任意两个城市都可以通过铁路直接或者间接到达。

uim 发现有些铁路被毁坏之后,某两个城市无法互相通过铁路到达。这样的铁路就被称为 key road。

uim 为了尽快使该国的物流系统瘫痪,希望炸毁铁路,以达到存在某两个城市无法互相通过铁路到达的效果。

然而,只有一发炮弹(A 国国会不给钱了)。所以,他能轰炸哪一条铁路呢?

输入格式

第一行 $n,m\ (1 \leq n\leq 150$,$1 \leq m \leq 5000)$,分别表示有 $n$ 个城市,总共 $m$ 条铁路。

以下 $m$ 行,每行两个整数 $a, b$,表示城市 $a$ 和城市 $b$ 之间有铁路直接连接。

输出格式

输出有若干行。

每行包含两个数字 $a,b$,其中 $a<b$,表示 $\lang a,b\rang$ 是 key road。

请注意:输出时,所有的数对 $\lang a,b\rang$ 必须按照 $a$ 从小到大排序输出;如果$a$ 相同,则根据 $b$ 从小到大排序。

输入输出样例 #1

输入 #1

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

输出 #1

1 2
5 6

Response:

下面我们来详细讨论如何在一个连通无向图中找出所有的 “key road”(桥)。思路基于 Tarjan 算法,通过一次深度优先搜索(DFS)即可在线性时间内找出所有桥。


算法思路

  1. 图的表示

    • 用邻接表 vector<int> G[N] 存储无向图,N 为最大节点数。
  2. DFS 序与 low 数

    • 对于每个节点 u,用 dfn[u] 记录它在 DFS 遍历中的访问次序(Timestamp)。

    • low[u] 表示从 u 出发,沿着 DFS 树边或一条背边(返回到祖先的边),能到达的最小 dfn 值。

    • 初始化时所有 dfn[u] = 0,未访问。

  3. 桥的判定

    • 在 DFS 过程中,若遍历到一条树边 (u, v)vu 的某个子节点),且满足 low[v] > dfn[u],则这条边就是桥;因为子树 v 无法绕过 u 以上的节点回到 u 或其祖先。
  4. 实现要点

    • 要避免把父节点当作“背边”处理,可在递归时传入父节点 fa,并在遍历邻居时跳过 fa

    • 最后将所有桥按题目要求的 (a, b) 格式(保证 a < b)存入数组,再统一排序输出。

  5. 复杂度分析

    • 时间复杂度:DFS 一次,O(n + m)。

    • 空间复杂度:邻接表存图 O(n + m),加上常数大小的辅助数组。


详细 C++ 实现

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

const int MAXN = 155;      // n ≤ 150
const int MAXM = 5005;     // m ≤ 5000

int n, m;
vector<int> G[MAXN];
int dfn[MAXN], low[MAXN], timestamp;
vector<pair<int,int>> bridges;

// 深度优先搜索,u 当前节点,fa 是父节点
void dfs(int u, int fa) {
    dfn[u] = low[u] = ++timestamp;
    for (int v : G[u]) {
        if (!dfn[v]) {
            // v 尚未访问,继续递归
            dfs(v, u);
            // 回溯时更新 low[u]
            low[u] = min(low[u], low[v]);
            // 如果 low[v] > dfn[u],则 (u,v) 是桥
            if (low[v] > dfn[u]) {
                int a = min(u, v), b = max(u, v);
                bridges.emplace_back(a, b);
            }
        } else if (v != fa) {
            // 遇到一条向上的“背边”
            low[u] = min(low[u], dfn[v]);
        }
    }
}

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

    // 读取输入
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> a >> b;
        G[a].push_back(b);
        G[b].push_back(a);
    }

    // 初始化
    timestamp = 0;
    memset(dfn, 0, sizeof(dfn));
    memset(low, 0, sizeof(low));
    bridges.clear();

    // 题目保证图是连通的,所以从 1 开始 DFS 即可
    dfs(1, 0);

    // 按题意排序后输出
    sort(bridges.begin(), bridges.end());
    for (auto &e : bridges) {
        cout << e.first << " " << e.second << "\n";
    }

    return 0;
}

代码详解

  1. dfn[]low[] 数组

    • dfn[u]:记录节点 u 第几次被访问到。

    • low[u]:表示从 u 出发,能通过子树或一条“背边”返回的最小 dfn

  2. 递归函数 dfs(u, fa)

    • 首先给 u 打上时间戳:dfn[u] = low[u] = ++timestamp

    • 遍历所有邻居 v

      • 如果 v 未访问(dfn[v] == 0),递归 dfs(v, u),然后 low[u] = min(low[u], low[v])

      • 回溯后若 low[v] > dfn[u],说明子树 v 无法经由其他路径回到 u 或其祖先,故 (u,v) 是桥。

      • 如果 v 已访问且 v != fa,则说明遇到一条向上的“背边”,更新 low[u] = min(low[u], dfn[v])

  3. 排序与输出

    • 桥边按 (a, b) 存储时保证 a < b,最后统一排序,满足题目输出格式。

这样,我们就能在线性时间 O(n+m) 内,准确地找出所有会使图不再连通的关键边。希望对你理解 Tarjan 桥算法有所帮助!

Prompt:

重边情况错误
image

参考 https://oi-wiki.org/graph/cut/ 里面重边两种解决方案

一种思路是将参数 fa 改为刚刚走过的边的编号(每条边的编号一致)即可,即将「不用父节点更新」改为「不用来时的边更新」。

另一种更简单的思路是设立一个标记判断是否已有一条边抵达父节点,标记后再访问到父节点时正常更新。

Response:

你指出得非常对!这个题目中存在重边的可能,而原始 Tarjan 算法默认图中没有重边


🎯 问题分析:重边导致误判为桥

Tarjan 算法判断是否为桥的条件是:

如果 low[v] > dfn[u],说明 (u, v) 是桥。

但如果边 (u, v)重边(出现多次),即使删除一条,也不一定会让图不连通,所以不能将重复边判为桥

为了处理多条平行边,具体做法是:

  1. 给每条输入边一个唯一编号 eid(即使是平行边也有不同的 eid)。

  2. 邻接表中存储 (neighbor, eid)

  3. DFS 递归时传入「来自哪条 eid」而不是「来自哪个父节点」。

  4. 在遍历相邻边 (u→v, eid) 时,如果 eid 是父边的编号就跳过;否则即使 v 已访问,也会把这条边当作「背边」来更新 low[u]

这样,对于平行边 u–v,当你从 u 先走到 v 的那一条边上去后,回到 u 的另一条平行边就会被当作「背边」,从而保证 low[v] = min(low[v], dfn[u])。也就不会误判任何一条平行边为桥。


精简版代码

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

const int MAXN = 155;
int n, m;
vector<pair<int,int>> G[MAXN]; // 存 (邻居, 边编号)
int dfn[MAXN], low[MAXN], tmr;
vector<pair<int,int>> bridges;

// u:当前节点,peid:父边的编号
void dfs(int u, int peid) {
    dfn[u] = low[u] = ++tmr;
    for (auto [v, eid] : G[u]) {
        if (!dfn[v]) {
            dfs(v, eid);
            low[u] = min(low[u], low[v]);
            if (low[v] > dfn[u]) {
                // (u,v) 是桥
                bridges.emplace_back(min(u,v), max(u,v));
            }
        }
        // 已访问且不是来自父边,更新 low
        else if (eid != peid) {
            low[u] = min(low[u], dfn[v]);
        }
    }
}

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

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

    // 初始化并从 1 开始 DFS
    tmr = 0;
    memset(dfn, 0, sizeof(dfn));
    dfs(1, -1);

    sort(bridges.begin(), bridges.end());
    for (auto &e : bridges)
        cout << e.first << " " << e.second << "\n";

    return 0;
}

Prompt:

是否可以通过一个bool变量做区分

Response:

完全可以。思路是在每次从 u 遍历它的邻居列表时,用一个局部的 bool skippedParent = false;,只“跳过”第一个通往父节点 fa 的边,后续再遇到同一对平行边时,就会被当作“背边”更新 low[u]

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

const int MAXN = 155;
int n, m;
vector<int> G[MAXN];
int dfn[MAXN], low[MAXN], tmr;
vector<pair<int,int>> bridges;

// u:当前节点,fa:父节点
void dfs(int u, int fa) {
    dfn[u] = low[u] = ++tmr;
    // 每个 u 都单独维护一个跳过父边的标志
    bool skippedParent = false;
    for (int v : G[u]) {
        if (!dfn[v]) {
            // 树边
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            if (low[v] > dfn[u]) {
                bridges.emplace_back(min(u,v), max(u,v));
            }
        }
        else {
            // 遇到已访问的节点 v
            if (v == fa && !skippedParent) {
                // 第一次遇到通往父节点的边,跳过
                skippedParent = true;
                continue;
            }
            // 其余情况都当作“背边”来更新 low[u]
            low[u] = min(low[u], dfn[v]);
        }
    }
}

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

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

    tmr = 0;
    memset(dfn, 0, sizeof(dfn));
    memset(low, 0, sizeof(low));
    bridges.clear();

    dfs(1, 0);

    sort(bridges.begin(), bridges.end());
    for (auto &e : bridges)
        cout << e.first << " " << e.second << "\n";

    return 0;
}

关键点

  1. 局部 skippedParent

    • 对每个 u,只跳过一次与父节点相连的那条边。
  2. 平行边自动成为“背边”

    • 当第二次遇到同样的 v == fa 时,由于 skippedParent 已被置 true,就会进入 low[u] = min(low[u], dfn[v]),从而把平行边视作能回溯到父节点的背边,避免误判为桥。

这样既保持了代码简洁,又能正确处理多条平行边的情况。

posted @ 2025-05-13 17:58  katago  阅读(86)  评论(0)    收藏  举报