【模板】边双连通分量

GitHub跳转原题关系图返回列表

先用 Tarjan 找出无向图中的所有桥,再把这些桥删掉,剩下的每个连通块就是一个边双连通分量。

OJ: luogu

题目 ID: P8436

难度:提高+/省选-

标签:图论tarjan双连通分量边双

日期: 2026-06-20 01:49

题意

给一张允许有重边、自环,而且可能不连通的无向图。

要求输出:

  1. 边双连通分量的个数
  2. 每个边双连通分量里有哪些点

这里的边双连通分量可以理解成:

  • 在这个点集内部,任意两点之间至少有两条边不重复的路径
  • 或者等价地说,这个点集内部没有桥作为唯一通道

样例图

下面这张图用样例三来说明“桥把边双隔开”这件事:

graph G {
  1 -- 2;
  1 -- 3;
  2 -- 3;
  2 -- 4 [color=red, penwidth=2];
  4 -- 6 [color=red, penwidth=2];
  5;
}

图中红色边 2-44-6 都是桥。 把它们删掉以后,图就被分成了四块:

  • {1,2,3}
  • {4}
  • {5}
  • {6}

这四个连通块,正好就是这个样例的边双连通分量。

思路

先看一个可以直接验证想法的小数据暴力:

cpp
// brute.cpp:小数据暴力。
// 先枚举每条边判断它是不是桥,再删掉所有桥求连通块,这些连通块就是边双连通分量。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 20;
const int MAXM = 40;
const int MAXE = MAXM * 2 + 5;

struct Edge {
    int u, v;
} edges[MAXM];

int n, m;
int head[MAXN], to[MAXE], nxt[MAXE], edge_cnt;
bool vis[MAXN];
bool is_bridge[MAXM];
vector< vector<int> > answer;

void init_graph() {
    edge_cnt = 0;
    for (int i = 1; i <= n; i++) {
        head[i] = -1;
        vis[i] = false;
    }
}

void add_edge(int u, int v) {
    to[edge_cnt] = v;
    nxt[edge_cnt] = head[u];
    head[u] = edge_cnt;
    edge_cnt++;
}

void build_graph(int ban_edge, bool remove_bridges) {
    init_graph();
    for (int i = 1; i <= m; i++) {
        if (i == ban_edge) {
            continue;
        }
        if (remove_bridges && is_bridge[i]) {
            continue;
        }
        add_edge(edges[i].u, edges[i].v);
        add_edge(edges[i].v, edges[i].u);
    }
}

void dfs_count(int u) {
    vis[u] = true;
    for (int i = head[u]; i != -1; i = nxt[i]) {
        int v = to[i];
        if (!vis[v]) {
            dfs_count(v);
        }
    }
}

int count_components(int ban_edge) {
    build_graph(ban_edge, false);

    int cnt = 0;
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            cnt++;
            dfs_count(i);
        }
    }
    return cnt;
}

void dfs_collect(int u, vector<int> &comp) {
    vis[u] = true;
    comp.push_back(u);

    for (int i = head[u]; i != -1; i = nxt[i]) {
        int v = to[i];
        if (!vis[v]) {
            dfs_collect(v, comp);
        }
    }
}

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

    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        cin >> edges[i].u >> edges[i].v;
        is_bridge[i] = false;
    }

    int base_cc = count_components(0);
    for (int i = 1; i <= m; i++) {
        int cc = count_components(i);
        if (cc > base_cc) {
            is_bridge[i] = true;
        }
    }

    build_graph(0, true);
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            vector<int> comp;
            dfs_collect(i, comp);
            answer.push_back(comp);
        }
    }

    cout << answer.size() << '\n';
    for (size_t i = 0; i < answer.size(); i++) {
        cout << answer[i].size();
        for (size_t j = 0; j < answer[i].size(); j++) {
            cout << ' ' << answer[i][j];
        }
        cout << '\n';
    }

    return 0;
}

暴力做法分两步:

  1. 枚举每一条边,删掉它后重新数连通块个数
  2. 如果连通块数量变多,说明它是桥
  3. 把所有桥都删掉,再做一次 DFS,剩下的每个连通块就是一个边双

这个过程很好理解,但每条边都重新搜一遍图,复杂度太高,只适合小数据。

正式做法沿用你书里的那条主线:

  • 桥是边双之间的边界
  • 去掉所有桥以后,每个连通块就是一个边双

所以我们只要先用 Tarjan 找桥,再忽略桥做第二遍 DFS 即可。

对 DFS 树上的一条树边 u -> v,如果满足:

low[v] > dfn[u]

说明 v 这棵子树没法绕回 uu 的祖先,那么边 u-v 就是桥。

这题还有两个实现细节需要特别注意:

  1. 图里可能有重边,不能只靠 v != fa 来跳过父边,必须记录“进入当前点的是哪条边”,遍历时只跳过它的反向边
  2. 数据范围到 5e5 个点、2e6 条边,递归 DFS 很容易爆栈,所以代码里改成了非递归 Tarjan 和非递归 DFS

最后第二遍遍历时,所有桥边都直接跳过。这样搜到的一整块点,就是同一个边双连通分量。

代码

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

const int MAXN = 500000 + 5;
const int MAXM = 4000000 + 5;

int n, m;

// 链式前向星存图,边编号从 0 开始,方便用 i ^ 1 找反向边。
int head[MAXN], to[MAXM], nxt[MAXM], edge_cnt;

// Tarjan 找桥需要的时间戳数组。
int dfn[MAXN], low[MAXN], dfs_clock;
bool is_bridge[MAXM];

// 非递归 DFS 需要记录当前处理到哪一条边。
int iter_edge[MAXN];
int parent_node[MAXN], parent_edge[MAXN];

// 第二遍忽略桥做 DFS 染色。
bool vis[MAXN];
int comp_iter[MAXN];

vector< vector<int> > answer;

void init_graph(int n) {
    edge_cnt = 0;
    dfs_clock = 0;
    answer.clear();

    for (int i = 1; i <= n; i++) {
        head[i] = -1;
        dfn[i] = 0;
        low[i] = 0;
        vis[i] = false;
    }
}

void add_edge(int u, int v) {
    to[edge_cnt] = v;
    nxt[edge_cnt] = head[u];
    head[u] = edge_cnt;
    edge_cnt++;
}

// 非递归 Tarjan:先找出所有桥。
void tarjan_bridge() {
    vector<int> st;
    st.reserve(n);

    for (int start = 1; start <= n; start++) {
        if (dfn[start]) {
            continue;
        }

        parent_node[start] = 0;
        parent_edge[start] = -1;
        st.push_back(start);

        while (!st.empty()) {
            int u = st.back();

            if (!dfn[u]) {
                dfn[u] = low[u] = ++dfs_clock;
                iter_edge[u] = head[u];
            }

            int &i = iter_edge[u];
            if (i != -1) {
                int e = i;
                i = nxt[i];
                int v = to[e];

                if (e == (parent_edge[u] ^ 1)) {
                    continue;
                }

                if (!dfn[v]) {
                    parent_node[v] = u;
                    parent_edge[v] = e;
                    st.push_back(v);
                    continue;
                }

                // 已访问点只在它是祖先时更新 low。
                if (dfn[v] < dfn[u]) {
                    low[u] = min(low[u], dfn[v]);
                }
                continue;
            }

            st.pop_back();
            if (parent_edge[u] != -1) {
                int p = parent_node[u];
                low[p] = min(low[p], low[u]);

                if (low[u] > dfn[p]) {
                    is_bridge[parent_edge[u]] = true;
                    is_bridge[parent_edge[u] ^ 1] = true;
                }
            }
        }
    }
}

// 第二遍 DFS:忽略所有桥,遍历顺序尽量保持和递归版一致。
void collect_component(int start) {
    vector<int> comp;
    vector<int> st;

    vis[start] = true;
    comp.push_back(start);
    comp_iter[start] = head[start];
    st.push_back(start);

    while (!st.empty()) {
        int u = st.back();
        int &i = comp_iter[u];
        bool advanced = false;

        while (i != -1) {
            int e = i;
            i = nxt[i];
            int v = to[e];

            if (is_bridge[e] || vis[v]) {
                continue;
            }

            vis[v] = true;
            comp.push_back(v);
            comp_iter[v] = head[v];
            st.push_back(v);
            advanced = true;
            break;
        }

        if (!advanced && i == -1) {
            st.pop_back();
        }
    }

    answer.push_back(comp);
}

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

    cin >> n >> m;
    init_graph(n);

    for (int i = 0; i < 2 * m; i++) {
        is_bridge[i] = false;
    }

    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        add_edge(u, v);
        add_edge(v, u);
    }

    tarjan_bridge();

    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            collect_component(i);
        }
    }

    cout << answer.size() << '\n';
    for (size_t i = 0; i < answer.size(); i++) {
        cout << answer[i].size();
        for (size_t j = 0; j < answer[i].size(); j++) {
            cout << ' ' << answer[i][j];
        }
        cout << '\n';
    }

    return 0;
}

复杂度

设点数为 n,边数为 m

Tarjan 找桥一遍 O(n+m)O(n+m),删桥后再搜连通块也是一遍 O(n+m)O(n+m),所以:

  • 时间复杂度 O(n+m)O(n+m)
  • 空间复杂度 O(n+m)O(n+m)

总结

这题最核心的一句话就是:

  • 桥把不同边双隔开

因此“求边双”可以转成:

  1. 先求所有桥
  2. 再把桥删掉
  3. 剩下每个连通块就是答案

理解了这件事,后面的边双缩点、桥树等题都会顺很多。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析