AIGC标识 连通性问题

Tarjan 连通性算法模板

比赛速查版:覆盖有向图强连通分量、无向图桥与边双连通分量、割点与点双连通分量。代码使用 C++17。

以下实现使用:

  • dfn[u]:DFS 首次访问 u 的时间戳。
  • low[u]:从 u 的 DFS 子树出发,沿 DFS 树边向下,再至多使用一条合适的非树边,能到达的最早祖先的 dfn。
  • 无向图邻接表保存 (邻点, 边编号)。用边编号跳过父边,因此支持平行边;只跳过父节点会把平行边误判。

low 的更新取决于问题:

  • 有向图 SCC:只用仍在 Tarjan 栈中的已访问点更新 low。
  • 无向图桥、割点:对返祖边 u -> v,通常只在 dfn[v] < dfn[u] 时用 dfn[v] 更新;不能把已经完成的 DFS 子树当作返祖边。

Tarjan 算法整体为 \(O(V+E)\)。下方递归版代码要求 DFS 深度能承受运行环境的栈;极深图应改成显式栈迭代 DFS。

1. 有向图:强连通分量 SCC

强连通分量是极大的点集,其中任意两点互相可达。Tarjan 用一个栈保存尚未归属最终 SCC 的点。

当 low[u] == dfn[u] 时,u 是当前 SCC 的根;从栈顶弹出直到 u,这些点构成一个 SCC。不同 SCC 缩点后得到 DAG。

模板:SCC 编号、分量大小

struct SCC {
    int n, timer = 0, scc_count = 0;
    vector<vector<int>> g;
    vector<int> dfn, low, stk, comp, comp_size;
    vector<char> in_stack;

    explicit SCC(int n)
        : n(n), g(n), dfn(n), low(n), comp(n, -1), in_stack(n) {}

    void add_edge(int u, int v) {
        g[u].push_back(v);
    }

    void dfs(int u) {
        dfn[u] = low[u] = ++timer;
        stk.push_back(u);
        in_stack[u] = true;

        for (int v : g[u]) {
            if (!dfn[v]) {
                dfs(v);
                low[u] = min(low[u], low[v]);
            } else if (in_stack[v]) {
                // 只允许通过仍在栈中的点回到未完成部分。
                low[u] = min(low[u], dfn[v]);
            }
        }

        if (low[u] == dfn[u]) {
            int id = scc_count++;
            int size = 0;
            while (true) {
                int v = stk.back();
                stk.pop_back();
                in_stack[v] = false;
                comp[v] = id;
                ++size;
                if (v == u) break;
            }
            comp_size.push_back(size);
        }
    }

    int run() {
        for (int u = 0; u < n; ++u) {
            if (!dfn[u]) dfs(u);
        }
        return scc_count;
    }
};

使用约定:顶点编号为 0..n-1。调用 run() 后,comp[u] 是 SCC 编号,comp_size[id] 是该 SCC 的点数。编号顺序取决于 DFS,不应假设特定方向的拓扑序;若需要拓扑序,对缩点 DAG 再做拓扑排序。

缩点建 DAG

vector<vector<int>> dag(scc.scc_count);
for (auto [u, v] : edges) {
    int a = scc.comp[u];
    int b = scc.comp[v];
    if (a != b) dag[a].push_back(b);
}

如果后续算法要求 DAG 中没有重边,可排序并去重每个邻接表,或用集合去重。SCC 解决强连通;若只问忽略方向后的弱连通分量,应把有向边当无向边后 DFS/并查集,不需要 SCC。

2. 无向图:桥与边双连通分量

桥是删除后会增加连通分量数的边。对 DFS 树边 (u,v),其中 u 是父亲、v 是儿子:

\[(u,v)\text{ 是桥}\iff low[v]>dfn[u]. \]

若 low[v] == dfn[u],说明子树能通过返祖边回到 u,该树边不是桥。

删除所有桥后得到的连通分量称为边双连通分量(2-edge-connected components)。把每个分量缩成一点、每座桥缩成一条边,所得图是森林;原图连通时是树,称桥树。

模板:找桥、边双染色、桥森林

struct BridgeDecomposition {
    struct Edge { int to, id; };

    int n, m = 0, timer = 0, comp_count = 0;
    vector<vector<Edge>> g;
    vector<pair<int, int>> edges;
    vector<int> dfn, low, comp, comp_size;
    vector<char> is_bridge;
    vector<vector<int>> bridge_forest;

    explicit BridgeDecomposition(int n)
        : n(n), g(n), dfn(n), low(n), comp(n, -1) {}

    int add_edge(int u, int v) {
        int id = m++;
        edges.push_back({u, v});
        g[u].push_back({v, id});
        g[v].push_back({u, id});
        return id;
    }

    void dfs_bridge(int u, int parent_edge) {
        dfn[u] = low[u] = ++timer;
        for (auto [v, id] : g[u]) {
            if (id == parent_edge) continue;

            if (!dfn[v]) {
                dfs_bridge(v, id);
                low[u] = min(low[u], low[v]);
                if (low[v] > dfn[u]) is_bridge[id] = true;
            } else {
                // 只用指向祖先的非树边更新。
                low[u] = min(low[u], dfn[v]);
            }
        }
    }

    void paint(int start, int id) {
        stack<int> st;
        st.push(start);
        comp[start] = id;

        while (!st.empty()) {
            int u = st.top();
            st.pop();
            ++comp_size[id];

            for (auto [v, eid] : g[u]) {
                if (is_bridge[eid] || comp[v] != -1) continue;
                comp[v] = id;
                st.push(v);
            }
        }
    }

    int run() {
        is_bridge.assign(m, false);
        for (int u = 0; u < n; ++u) {
            if (!dfn[u]) dfs_bridge(u, -1);
        }

        comp_count = 0;
        comp_size.clear();
        for (int u = 0; u < n; ++u) {
            if (comp[u] != -1) continue;
            comp_size.push_back(0);
            paint(u, comp_count++);
        }

        bridge_forest.assign(comp_count, {});
        for (int id = 0; id < m; ++id) {
            if (!is_bridge[id]) continue;
            auto [u, v] = edges[id];
            int a = comp[u], b = comp[v];
            bridge_forest[a].push_back(b);
            bridge_forest[b].push_back(a);
        }
        return comp_count;
    }
};

支持断开图、平行边和孤立点。自环不会成为桥,且代码可处理自环;题目若有特殊多重边定义,仍应以题面为准。调用 run() 后,is_bridge[id]、comp[u]、comp_size[id] 和 bridge_forest 可用。

常见结论:连通无向图是边双连通(任意两点间有两条边不相交路径)当且仅当没有桥。这里说的是边不相交,不是点不相交。

3. 无向图:割点与点双连通分量

割点是删除后会增加连通分量数的顶点。对 DFS 树边 (u,v):

  • 若 u 不是 DFS 根,u 是割点当且仅当存在儿子 v 满足 low[v] >= dfn[u]。
  • 若 u 是 DFS 根,它是割点当且仅当 DFS 树儿子数至少为 \(2\)。

注意等号:low[v] == dfn[u] 时,子树只能回到 u,删掉 u 仍会断开,所以 u 是割点。

点双连通分量(vertex-biconnected components / blocks)通常按边划分:每条非自环边恰属于一个点双分量;不同分量可以共享割点。下面的模板用边栈,在 low[v] >= dfn[u] 时弹出一个分量。点双分量里收集的顶点会去重。

模板:割点、点双分量、圆方树

该模板假设图无自环;支持平行边和断开图。孤立点会作为单点点双分量输出。

struct VertexBiconnected {
    struct Edge { int to, id; };

    int n, m = 0, timer = 0;
    vector<vector<Edge>> g;
    vector<pair<int, int>> edges;
    vector<int> dfn, low;
    vector<char> is_cut;
    vector<int> edge_stack;
    vector<vector<int>> blocks; // 每个点双分量的顶点集合
    vector<int> mark;
    int mark_id = 0;

    explicit VertexBiconnected(int n)
        : n(n), g(n), dfn(n), low(n), is_cut(n), mark(n) {}

    int add_edge(int u, int v) {
        int id = m++;
        edges.push_back({u, v});
        g[u].push_back({v, id});
        g[v].push_back({u, id});
        return id;
    }

    void emit_block(int stop_edge) {
        ++mark_id;
        vector<int> vertices;
        while (true) {
            int id = edge_stack.back();
            edge_stack.pop_back();
            auto [u, v] = edges[id];

            if (mark[u] != mark_id) {
                mark[u] = mark_id;
                vertices.push_back(u);
            }
            if (mark[v] != mark_id) {
                mark[v] = mark_id;
                vertices.push_back(v);
            }
            if (id == stop_edge) break;
        }
        blocks.push_back(move(vertices));
    }

    void dfs(int u, int parent_edge) {
        dfn[u] = low[u] = ++timer;
        int child_count = 0;

        for (auto [v, id] : g[u]) {
            if (id == parent_edge) continue;

            if (!dfn[v]) {
                ++child_count;
                edge_stack.push_back(id);
                dfs(v, id);
                low[u] = min(low[u], low[v]);

                if (low[v] >= dfn[u]) {
                    if (parent_edge != -1 || child_count >= 2) {
                        is_cut[u] = true;
                    }
                    emit_block(id);
                }
            } else if (dfn[v] < dfn[u]) {
                // 每条返祖边只在较深端压栈一次。
                edge_stack.push_back(id);
                low[u] = min(low[u], dfn[v]);
            }
        }

        // 根节点是否为割点由 DFS 树儿子数单独判定。
        if (parent_edge == -1) {
            is_cut[u] = (child_count >= 2);
        }
    }

    void run() {
        for (int u = 0; u < n; ++u) {
            if (dfn[u]) continue;
            if (g[u].empty()) {
                dfn[u] = low[u] = ++timer;
                blocks.push_back({u});
            } else {
                dfs(u, -1);
            }
        }
    }

    // 建立圆方树:原圖頂點為 0..n-1,点双分量节点为 n..n+B-1。
    // 原顶点与包含它的每个点双分量相连。结果是森林。
    vector<vector<int>> build_block_cut_forest() const {
        int B = (int)blocks.size();
        vector<vector<int>> forest(n + B);
        for (int b = 0; b < B; ++b) {
            int block_node = n + b;
            for (int v : blocks[b]) {
                forest[v].push_back(block_node);
                forest[block_node].push_back(v);
            }
        }
        return forest;
    }
};

调用 run() 后:is_cut[u] 表示割点,blocks 保存每个点双分量的顶点集合。若只需要割点,不需要边栈和 blocks,可以写更轻量的 DFS。

圆方树也常称 block-cut forest:原点是圆点、点双分量是方点。对连通图它是一棵树;对断开图它是森林。割点会连接多个方点,非割点通常只连接一个方点。利用它可处理“删除某点后是否断开”“两点间经过哪些割点”等路径问题。

4. 三类结果的快速区分

目标 图类型 核心判定/定义 典型缩点结构
强连通分量 SCC 有向图 分量内任意两点互相可达 DAG
边双连通分量 无向图 任意两点间有两条边不相交路径;分量间分隔边是桥 桥森林
点双连通分量 无向图 分量内任意两点间有两条内部点不相交路径(常用 block 定义) 圆方树 / block-cut forest

“双连通”一词在不同题目里可能指边双或点双。读题时确认是不共边还是不共点;算法条件分别是桥与割点,不能混用。

5. 比赛检查清单

  • 无向图跳过父边时用边编号,保证平行边正确。
  • SCC 的已访问边只有指向 in_stack 点时才能更新 low。
  • 无向图只把返祖边(指向祖先)压入点双边栈一次。
  • 桥条件是 low[child] > dfn[parent];割点非根条件是 low[child] >= dfn[parent]。
  • DFS 根的割点条件单独按树儿子数至少 \(2\) 判断。
  • 断开图要从所有未访问点启动 DFS。
  • 深度可能达到 \(10^5\) 或更多时,评估递归栈限制;必要时改迭代版。
  • 缩点后若题目要求简单 DAG/简单森林,记得去重;多数 DP 不应默认输入没有平行边。
  • 点双模板按常见 block 定义处理孤立点为单点分量;自环通常需要按题目定义单独处理,示例模板假设无自环。
posted @ 2026-09-26 21:55  haze1231  阅读(3)  评论(0)    收藏  举报