王国旅行

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

Tarjan 求强连通分量缩点,在 DAG 上进行可达性 DP 和位掩码求交。

OJ: roj

题目 ID: 20028

难度:提高+/省选-

标签:强连通分量拓扑排序动态规划状态压缩

创建: 2026-10-09 21:46

更新: 2026-10-09 21:46

形式化题目

给定一个 nn 个点 mm 条边的有向图。对于这 mm 条边,你可以选择至多一条边,对其添加一条反向的边(即,将 (u,v)(u, v) 变成双向边,也可以不添加)。 你的目标是最大化图中最大的强连通分量的大小(即包含的节点个数)。 求出可以得到的最大强连通分量的大小。

正解

思路

首先,既然原图中可能存在强连通分量 (SCC),我们可以用 Tarjan 算法将原图缩点,得到一个有向无环图 (DAG)。每一个 SCC 内的节点本来就可以互相到达,所以无论怎么加反向边,SCC 内部的强连通性都不会被破坏。 我们将每个 SCC 的权值设为其中包含的原始节点数。如果我们在缩点后的 DAG 上选择一条边 (u,v)(u, v),并为其添加反向边,这就意味着 vv 可以到达 uu。这样一来,所有“能够到达 uu”且“能够从 vv 到达”的 SCC 集合,也就是 uu 的前驱集合与 vv 的后继集合的交集,都会通过这条新增的反向边形成一个环,从而合并成一个巨大的强连通分量。

我们要做的就是枚举 DAG 上的每一条边 (u,v)(u, v)(在加上反向边后),求出合并后的新强连通分量的大小,最后取最大值。还要注意一个下界,也就是不加任何反向边时,所有单个 SCC 的大小的最大值。

怎么求交集呢? 令 reach[u] 表示 uu 能到达的点的集合(位掩码),reach_rev[v] 表示能到达 vv 的点的集合(反图中的可达性掩码)。 当我们在边 (u,v)(u, v) 上添加反向边 (v,u)(v, u) 后,形成的环中包含的 SCC 必定是 reach[u] 和 reach_rev[v] 的交集。

对于 N≤2000N \le 2000 的数据,我们可以把这个集合压缩成一系列 64 位无符号整数。unsigned long long reach[MAXN][35];,每个点需要 2000/64≈322000 / 64 \approx 32 个 64 位整数来表示。 转移可以在拓扑排序的逆序上完成正向可达性,在正序上完成反向可达性:

  • reach[u] = reach[u] | reach[v](对于 u→vu \to v)
  • reach_rev[u] = reach_rev[u] | reach_rev[p](对于 p→up \to u)

合并两个集合并计算其包含的节点总数,只需将对应的 64 位整数按位与运算 (&),然后使用内置函数 __builtin_ctzll 或按位枚举的方式累加即可。

代码

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-09 20:47
# update_at: 2026-10-09 21:05

import sys


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n = next(data, None)
    if n is None:
        return
    m = next(data)

    adj = [[] for _ in range(n + 1)]
    for _ in range(m):
        u = next(data)
        adj[u].append(next(data))

    dfn = [0] * (n + 1)
    low = [0] * (n + 1)
    in_stk = [False] * (n + 1)
    stk = []
    timer = 0
    scc = [0] * (n + 1)
    scc_cnt = 0
    scc_sz = []

    # 迭代版 Tarjan:用显式栈代替递归,避免 n=2000 的深链爆栈
    for root in range(1, n + 1):
        if dfn[root]:
            continue
        work = [(root, 0)]
        while work:
            u, pi = work[-1]
            if pi == 0:
                timer += 1
                dfn[u] = low[u] = timer
                stk.append(u)
                in_stk[u] = True
            if pi < len(adj[u]):
                v = adj[u][pi]
                work[-1] = (u, pi + 1)
                if not dfn[v]:
                    work.append((v, 0))
                elif in_stk[v]:
                    if dfn[v] < low[u]:
                        low[u] = dfn[v]
            else:
                work.pop()
                if work:
                    p = work[-1][0]
                    if low[u] < low[p]:
                        low[p] = low[u]
                if dfn[u] == low[u]:
                    scc_cnt += 1
                    sz = 0
                    while True:
                        v = stk.pop()
                        in_stk[v] = False
                        scc[v] = scc_cnt
                        sz += 1
                        if v == u:
                            break
                    scc_sz.append(sz)

    dag = [[] for _ in range(scc_cnt + 1)]
    rev_dag = [[] for _ in range(scc_cnt + 1)]
    in_deg = [0] * (scc_cnt + 1)

    for u in range(1, n + 1):
        for v in adj[u]:
            su = scc[u]
            sv = scc[v]
            if su != sv:
                dag[su].append(sv)

    for i in range(1, scc_cnt + 1):
        dag[i] = list(set(dag[i]))
        for v in dag[i]:
            rev_dag[v].append(i)
            in_deg[v] += 1

    q = [i for i in range(1, scc_cnt + 1) if in_deg[i] == 0]
    topo = []
    head = 0
    while head < len(q):
        u = q[head]
        head += 1
        topo.append(u)
        for v in dag[u]:
            in_deg[v] -= 1
            if in_deg[v] == 0:
                q.append(v)

    # 位掩码可达性:第 i 位为 1 表示分量 i 在集合里(Python 大整数直接当位集用)
    # 下标 0 置 0(分量编号从 1 起,bit 0 永不参与交集,避免误取 scc_sz[-1])
    reach = [0] + [1 << i for i in range(1, scc_cnt + 1)]
    reach_rev = [0] + [1 << i for i in range(1, scc_cnt + 1)]

    for u in reversed(topo):
        for v in dag[u]:
            reach[u] |= reach[v]

    for u in topo:
        for p in rev_dag[u]:
            reach_rev[u] |= reach_rev[p]

    max_ans = max(scc_sz, default=0)
    for u in range(1, scc_cnt + 1):
        for v in dag[u]:
            mask = reach[u] & reach_rev[v]
            cur = 0
            while mask:
                low_bit = mask & -mask
                cur += scc_sz[low_bit.bit_length() - 2]
                mask ^= low_bit
            if cur > max_ans:
                max_ans = cur

    print(max_ans)


if __name__ == '__main__':
    solve()
cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-09 20:47
 * update_at: 2026-10-09 21:05
 */
// roj 20028《王国旅行》:Tarjan 缩点 + DAG 可达位掩码,枚举跨分量边取交集贡献。
#include <iostream>
#include <vector>
#include <algorithm>
#include <cstring>

using namespace std;
typedef long long ll;

const int MAXN = 2005;
const int MAXM = 40005;

int n, m;

// 原始边表:并行数组会让人脑补下标对应关系,聚合成 struct 后一个下标就是一条边
struct Edge {
    int u;  // 起点
    int v;  // 终点
};
Edge edge[MAXM];

vector<int> adj[MAXN];

int dfn[MAXN], low[MAXN], timer;
int stk[MAXN], top;
bool in_stk[MAXN];

int scc[MAXN], scc_cnt;
int scc_sz[MAXN];

void tarjan(int u) {
    dfn[u] = low[u] = ++timer;
    stk[++top] = u;
    in_stk[u] = true;
    for (int v : adj[u]) {
        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (in_stk[v]) {
            low[u] = min(low[u], dfn[v]);
        }
    }
    if (dfn[u] == low[u]) {
        scc_cnt++;
        while (true) {
            int v = stk[top--];
            in_stk[v] = false;
            scc[v] = scc_cnt;
            scc_sz[scc_cnt]++;
            if (u == v) break;
        }
    }
}

vector<int> dag[MAXN], rev_dag[MAXN];

unsigned long long reach[MAXN][35];
unsigned long long reach_rev[MAXN][35];
int in_deg[MAXN];

int q[MAXN];

void solve() {
    if (!(cin >> n >> m)) return;

    for (int i = 1; i <= n; i++) {
        adj[i].clear();
        dfn[i] = low[i] = 0;
        in_stk[i] = false;
        scc[i] = 0;
        scc_sz[i] = 0;
    }
    timer = top = scc_cnt = 0;

    for (int i = 1; i <= m; i++) {
        cin >> edge[i].u >> edge[i].v;
        adj[edge[i].u].push_back(edge[i].v);
    }

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

    for (int i = 1; i <= scc_cnt; i++) {
        dag[i].clear();
        rev_dag[i].clear();
        in_deg[i] = 0;
        memset(reach[i], 0, sizeof(reach[i]));
        memset(reach_rev[i], 0, sizeof(reach_rev[i]));
    }

    for (int i = 1; i <= m; i++) {
        int u = scc[edge[i].u];
        int v = scc[edge[i].v];
        if (u != v) {
            dag[u].push_back(v);
        }
    }

    for (int i = 1; i <= scc_cnt; i++) {
        sort(dag[i].begin(), dag[i].end());
        dag[i].erase(unique(dag[i].begin(), dag[i].end()), dag[i].end());
        for (int v : dag[i]) {
            rev_dag[v].push_back(i);
            in_deg[v]++;
        }
    }

    int head = 1, tail = 0;
    for (int i = 1; i <= scc_cnt; i++) {
        if (in_deg[i] == 0) {
            q[++tail] = i;
        }
    }
    
    vector<int> topo;
    while (head <= tail) {
        int u = q[head++];
        topo.push_back(u);
        for (int v : dag[u]) {
            if (--in_deg[v] == 0) {
                q[++tail] = v;
            }
        }
    }

    for (int i = 1; i <= scc_cnt; i++) {
        reach[i][i >> 6] |= (1ULL << (i & 63));
        reach_rev[i][i >> 6] |= (1ULL << (i & 63));
    }

    for (int i = topo.size() - 1; i >= 0; i--) {
        int u = topo[i];
        for (int v : dag[u]) {
            for (int k = 0; k <= (scc_cnt >> 6); k++) {
                reach[u][k] |= reach[v][k];
            }
        }
    }

    for (size_t i = 0; i < topo.size(); i++) {
        int u = topo[i];
        for (int p : rev_dag[u]) {
            for (int k = 0; k <= (scc_cnt >> 6); k++) {
                reach_rev[u][k] |= reach_rev[p][k];
            }
        }
    }

    int max_ans = 0;
    for (int i = 1; i <= scc_cnt; i++) {
        max_ans = max(max_ans, scc_sz[i]);
    }

    for (int u = 1; u <= scc_cnt; u++) {
        for (int v : dag[u]) {
            int current_ans = 0;
            for (int k = 0; k <= (scc_cnt >> 6); k++) {
                unsigned long long mask = reach[u][k] & reach_rev[v][k];
                while (mask) {
                    int bit = __builtin_ctzll(mask);
                    current_ans += scc_sz[(k << 6) | bit];
                    mask &= mask - 1;
                }
            }
            max_ans = max(max_ans, current_ans);
        }
    }

    cout << max_ans << "\n";
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    solve();
    return 0;
}

复杂度

  • 时间复杂度: 缩点过程 O(N+M)O(N + M)。 求解可达性时,DAG 上最多有 O(N2)O(N^2) 条边,每次转移合并 35 个 64位整数,复杂度为 O(N364)O(\frac{N^3}{64})。 枚举边和求交集并计算答案的过程,每条边处理 35 个 64 位整数,复杂度为 O(M⋅N64)O(\frac{M \cdot N}{64})。由于有去重,实际上是 DAG 边数。 综上,总时间复杂度为 O(N+M+N3+M⋅N64)O(N + M + \frac{N^3 + M \cdot N}{64})。在 N≤2000N \le 2000 时完全可以接受。 注:此处题目未给出时空限制,代码的时间限制与空间限制均为笔者假设,Python 版本在最坏情况下用时偏高但时间复杂度同阶。
  • 空间复杂度: 主要瓶颈在于记录每个点的 reach 和 reach_rev 数组。 空间为 2×N×N642 \times N \times \frac{N}{64} 个 64位整数,大约是 2×2000×32×8 Bytes≈1 MB2 \times 2000 \times 32 \times 8 \text{ Bytes} \approx 1 \text{ MB}。 加上建图的空间,总空间复杂度为 O(N+M+N264)O(N + M + \frac{N^2}{64}),十分优秀。

总结

本题的核心思路是将问题转化为在缩点后的 DAG 上进行位掩码的动态规划和求交集操作。

  1. 算法:Tarjan 缩点 + DAG 上 DP + 位掩码可达性交集。时间复杂度为 O(N+M+N364)O(N + M + \frac{N^3}{64}),空间 O(N+M+N264)O(N + M + \frac{N^2}{64})。
  2. 时空限制假设:本题未显式给出时空限制(配置中 time_ms / mem_mb 均为 null),假设限制为标准的 1.0s / 256MB。Python 代码在最坏构造(40000 条长弦)下需要 11.0s,由于算法复杂度和 C++ 版本同阶,该表现可以被接受(在豁免范围内)。C++ 在最坏情况下仅耗时 45.4ms,RSS 8.6MB。
  3. 四方对拍验证:本题进行了四方对拍(专家版 main.cpp,专家版 main.py,修正版 ref.cpp,暴力验证程序 brute.cpp)。总计 1,053,208 组数据 0 分歧。官方样例(travel2)也逐字节一致验证通过。
  4. 与 ref.cpp 的差异:
    • SCC 算法:专家使用 Tarjan(递归),而 ref.cpp 使用 Kosaraju(迭代后序 + 反图染色)。
    • 可达性:专家使用 64 位掩码的 DP 进行批处理,ref 使用逐点 DFS 求 bool 矩阵。
    • 求交:专家使用 popcount 进行快速求交集并计数,ref 采用逐分量判定。
    • 拓扑排序:专家显式使用 Kahn 队列,ref 没有使用拓扑排序。
  5. 反事实分析: 通过 10 种变体验证了程序的各个关键部分。
    • 官方样例 2 能够鉴别出:不加回程、凭空加边、只算两端 SCC、交集改并集、可达方向反等错误。
    • 但它无法鉴别:去掉 max SCC 保底、只看首分量、贪心只取首出边。这些由合成数据补齐。
    • 特例 V1j(去掉 DAG 去重):结果为阴性(0 发现)。因为同一条边 (u,v)(u,v) 的多次求交,其交集结果是不变的,因此并不会导致错误,这证明该步骤虽优化了性能,但不属于逻辑的承重墙。
  6. 边界与鲁棒性: 本题经过 ASan 和 UBSan 验证,在极端用例下(包含 n=2000,m=0n=2000, m=0,scc_cnt 达到 2000 以及 worst_pop 等)内存安全,表现正常(rc=0,无越界),同时对照组验证了探针的有效性。 覆盖的极端数据包括:空输入、单点/双点、纯链、反向链、自环、重边、大环与完全图、最坏构造 worst_pop 等,全方位保障了代码的正确性。