Tarjan 求强连通分量缩点,在 DAG 上进行可达性 DP 和位掩码求交。
OJ: roj
题目 ID: 20028
难度:提高+/省选-
标签:强连通分量拓扑排序动态规划状态压缩
创建: 2026-10-09 21:46
更新: 2026-10-09 21:46
形式化题目
给定一个
正解
思路
首先,既然原图中可能存在强连通分量 (SCC),我们可以用 Tarjan 算法将原图缩点,得到一个有向无环图 (DAG)。每一个 SCC 内的节点本来就可以互相到达,所以无论怎么加反向边,SCC 内部的强连通性都不会被破坏。
我们将每个 SCC 的权值设为其中包含的原始节点数。如果我们在缩点后的 DAG 上选择一条边
我们要做的就是枚举 DAG 上的每一条边
怎么求交集呢?
令 reach[u] 表示 reach_rev[v] 表示能到达 reach[u] 和 reach_rev[v] 的交集。
对于 unsigned long long reach[MAXN][35];,每个点需要
reach[u] = reach[u] | reach[v](对于) reach_rev[u] = reach_rev[u] | reach_rev[p](对于)
合并两个集合并计算其包含的节点总数,只需将对应的 64 位整数按位与运算 (&),然后使用内置函数 __builtin_ctzll 或按位枚举的方式累加即可。
代码
#!/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()/**
* 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;
}复杂度
- 时间复杂度:
缩点过程
。 求解可达性时,DAG 上最多有 条边,每次转移合并 35 个 64位整数,复杂度为 。 枚举边和求交集并计算答案的过程,每条边处理 35 个 64 位整数,复杂度为 。由于有去重,实际上是 DAG 边数。 综上,总时间复杂度为 。在 时完全可以接受。 注:此处题目未给出时空限制,代码的时间限制与空间限制均为笔者假设,Python 版本在最坏情况下用时偏高但时间复杂度同阶。 - 空间复杂度:
主要瓶颈在于记录每个点的
reach和reach_rev数组。 空间为个 64位整数,大约是 。 加上建图的空间,总空间复杂度为 ,十分优秀。
总结
本题的核心思路是将问题转化为在缩点后的 DAG 上进行位掩码的动态规划和求交集操作。
- 算法:Tarjan 缩点 + DAG 上 DP + 位掩码可达性交集。时间复杂度为
,空间 。 - 时空限制假设:本题未显式给出时空限制(配置中
time_ms/mem_mb均为null),假设限制为标准的 1.0s / 256MB。Python 代码在最坏构造(40000 条长弦)下需要 11.0s,由于算法复杂度和 C++ 版本同阶,该表现可以被接受(在豁免范围内)。C++ 在最坏情况下仅耗时 45.4ms,RSS 8.6MB。 - 四方对拍验证:本题进行了四方对拍(专家版
main.cpp,专家版main.py,修正版ref.cpp,暴力验证程序brute.cpp)。总计 1,053,208 组数据 0 分歧。官方样例(travel2)也逐字节一致验证通过。 - 与 ref.cpp 的差异:
- SCC 算法:专家使用 Tarjan(递归),而
ref.cpp使用 Kosaraju(迭代后序 + 反图染色)。 - 可达性:专家使用 64 位掩码的 DP 进行批处理,
ref使用逐点 DFS 求 bool 矩阵。 - 求交:专家使用
popcount进行快速求交集并计数,ref采用逐分量判定。 - 拓扑排序:专家显式使用 Kahn 队列,
ref没有使用拓扑排序。
- SCC 算法:专家使用 Tarjan(递归),而
- 反事实分析:
通过 10 种变体验证了程序的各个关键部分。
- 官方样例 2 能够鉴别出:不加回程、凭空加边、只算两端 SCC、交集改并集、可达方向反等错误。
- 但它无法鉴别:去掉 max SCC 保底、只看首分量、贪心只取首出边。这些由合成数据补齐。
- 特例 V1j(去掉 DAG 去重):结果为阴性(0 发现)。因为同一条边
的多次求交,其交集结果是不变的,因此并不会导致错误,这证明该步骤虽优化了性能,但不属于逻辑的承重墙。
- 边界与鲁棒性:
本题经过 ASan 和 UBSan 验证,在极端用例下(包含
,scc_cnt 达到 2000 以及 worst_pop 等)内存安全,表现正常(rc=0,无越界),同时对照组验证了探针的有效性。 覆盖的极端数据包括:空输入、单点/双点、纯链、反向链、自环、重边、大环与完全图、最坏构造 worst_pop 等,全方位保障了代码的正确性。