求 SCC 并缩点成 DAG:第一问的答案是入度为 0 的分量数,第二问在分量数大于 1 时是入度 0 与出度 0 分量数的较大值,只有一个分量时为 0。
OJ: roj
题目 ID: 3197
难度:提高
标签:图论强连通分量缩点Kosarajupython
创建: 2026-10-02 01:09
更新: 2026-10-10 13:00
形式化题目
给定有向图
- 求最少的起点集合
,使得从 中每个点出发的可达点并集等于 ; - 求最少添加多少条有向边,使得加边后的图任意两点互相可达(强连通)。
样例的图是
flowchart LR A["SCC A: 1 2 5"] --> B["SCC B: 3"] A --> C["SCC C: 4"]
图中只有
正解
思路
朴素想法
第一问可以直接枚举起点集合,逐个检查能否覆盖全部点;第二问可以枚举要添加的边集合,每加一次就跑一遍强连通判定。第一问要试
但本题真正值得利用的性质不是"枚举得太慢",而是结构本身很规整:传播关系是传递的,"互相可达"这一关系把点天然划成了等价类。
关键观察:缩点后只剩度数
把"互相可达"的点合并成一个强连通分量(SCC),就得到一张 DAG——分量之间不可能再互相可达,否则它们就该被合并。同一个分量内的点,要么全部收到软件,要么全部收不到,因此两个问题都只需要在缩点后的 DAG 上回答。
设缩点后有
第一问的答案是
- 每个源分量都没有入边,外部任何点都到不了它,所以每个源分量都必须自己提供一个起点,起点数
; - 反过来,取每个源分量里任意一点作为起点,DAG 上任意分量
沿反向边一直往回退,由于 DAG 无环必然停在某个入度为 0 的分量上,把这条路径反向就是源分量到 的一条路径,于是 也被覆盖。起点数 。
两边夹起来,答案就是
第二问的答案是:只有一个分量时为
- 下界:加边后图强连通,则每个源分量必须获得至少一条新的入边(它原本一条入边都没有),每个汇分量必须获得至少一条新的出边。而一条新边只能给一个源分量补入度、同时给一个汇分量补出度,所以加边数
。 - 上界(可以构造出来):把
个源分量和 个汇分量首尾配对连成环——给每个源分量牵一条来自某个汇分量的入边,给每个汇分量牵一条通往某个源分量的出边,多余的配额挂到已经连好的分量上,总共 条边。环一旦形成,环上任意两点互相可达;而任何其它分量都能从某个源分量走到、也能走到某个汇分量,因此也被卷进环里,全图强连通。 - 特判:全图本身只有一个 SCC 时
,但此时不需要加任何边,答案是 。所以必须先判"分量个数是否为 1",不能只看 。反例:一条 的链, 却需要补 条边(本题真实数据 input-1 就是这个形状)。
样例对照
样例缩点后只有一个源分量
| 分量 | 包含的点 | 跨分量出边 | 跨分量入边 | 是源? | 是汇? |
|---|---|---|---|---|---|
| 无 | 是 | 否 | |||
| 无 | 否 | 是 | |||
| 无 | 否 | 是 |
表格逐行给出每个分量是否含有跨分量的入边/出边:只有
实现:Kosaraju 两遍 DFS
求 SCC 用 Kosaraju:
- 在原图上 DFS,记录每个点的完成时间(出边全部走完的时刻);
- 按完成时间从晚到早的顺序,在反图上再 DFS,每次 DFS 访问到的点集就是一个强连通分量。
直觉是:完成时间最晚的点一定属于某个"源头侧"的分量,在反图上从它出发只能走回它自己所在的分量内部。
代码里两遍 DFS 都写成迭代式:栈元素是 (点, 下一条待走出去的边的下标),当边的下标走到尽头时弹出该点,就等价于递归版的"函数返回"。这样既不依赖 sys.setrecursionlimit,也避免了深链上的递归风险。分量编号直接取该分量第二遍 DFS 的起点(完成时间最晚的那个点),不同分量的起点互不相同,所以编号天然唯一。
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 学校网络:Kosaraju 求强连通分量,缩点后数入度 / 出度为 0 的分量
#include <cstdio>
#include <vector>
using namespace std;
int n;
vector<vector<int> > g, rg;
// 第一遍 DFS(迭代版):返回按完成时间从早到晚排列的点
void finish_order(vector<int>& order) {
vector<char> seen(n, 0);
for (int root = 0; root < n; root++) {
if (seen[root]) {
continue;
}
seen[root] = 1;
vector<int> sv, si; // 显式栈:(点, 下一条待走出去的边的下标)
sv.push_back(root);
si.push_back(0);
while (!sv.empty()) {
int v = sv.back();
int i = si.back();
if (i == (int)g[v].size()) {
order.push_back(v); // 出边走完,此刻 v 的完成时间最晚
sv.pop_back();
si.pop_back();
continue;
}
si.back() = i + 1;
int u = g[v][i];
if (!seen[u]) {
seen[u] = 1;
sv.push_back(u);
si.push_back(0);
}
}
}
}
int main() {
if (scanf("%d", &n) != 1) {
return 0;
}
g.assign(n, vector<int>());
rg.assign(n, vector<int>());
for (int i = 0; i < n; i++) {
int x;
scanf("%d", &x);
while (x) { // 0 是名单结束符
g[i].push_back(x - 1);
rg[x - 1].push_back(i);
scanf("%d", &x);
}
}
vector<int> order;
finish_order(order);
// 第二遍在反图上按完成时间逆序 DFS
vector<int> comp(n, -1);
for (int t = (int)order.size() - 1; t >= 0; t--) {
int start = order[t];
if (comp[start] != -1) {
continue;
}
comp[start] = start;
vector<int> stk;
stk.push_back(start);
while (!stk.empty()) {
int v = stk.back();
stk.pop_back();
for (int i = 0; i < (int)rg[v].size(); i++) {
int u = rg[v][i];
if (comp[u] == -1) {
comp[u] = start;
stk.push_back(u);
}
}
}
}
vector<char> present(n, 0), has_in(n, 0), has_out(n, 0);
int comps = 0;
for (int v = 0; v < n; v++) {
if (!present[comp[v]]) {
present[comp[v]] = 1;
comps++;
}
for (int i = 0; i < (int)g[v].size(); i++) {
int u = g[v][i];
if (comp[v] != comp[u]) { // 分量内部的边缩点后消失
has_out[comp[v]] = 1;
has_in[comp[u]] = 1;
}
}
}
int sources = 0, sinks = 0;
for (int v = 0; v < n; v++) {
if (present[v]) {
if (!has_in[v]) sources++;
if (!has_out[v]) sinks++;
}
}
int merged = (comps == 1) ? 0 : (sources > sinks ? sources : sinks);
printf("%d\n%d\n", sources, merged);
return 0;
}#!/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-02 01:09
# update_at: 2026-10-02 01:09
import sys
from collections.abc import Iterator
def read_graph(n: int, nums: Iterator[int]) -> list[list[int]]:
"""按题面格式读出邻接表:每所学校的名单以 0 结束,只有一个 0 表示空名单。"""
graph: list[list[int]] = []
for _ in range(n):
cur: list[int] = []
while (x := next(nums)): # 0 是名单结束符,0 之前的都是被支援的学校
cur.append(x - 1) # 题面点号从 1 开始,统一转成 0 开始
graph.append(cur)
return graph
def finish_order(graph: list[list[int]]) -> list[int]:
"""第一遍 DFS(迭代版):返回全部点,按「完成时间」从早到晚排列(越晚完成越靠后)。"""
seen = [False] * len(graph)
order: list[int] = []
for root in range(len(graph)):
if seen[root]:
continue
seen[root] = True
stack = [(root, 0)] # (点, 下一条待走出去的边的下标)
while stack:
v, i = stack[-1]
if i == len(graph[v]): # 出边走完,此刻 v 的完成时间最晚
order.append(v)
stack.pop()
continue
stack[-1] = (v, i + 1) # 先记下"这条边用过了",再决定是否递归下去
if not seen[u := graph[v][i]]:
seen[u] = True
stack.append((u, 0))
return order
def component_of(graph: list[list[int]], order: list[int]) -> list[int]:
"""第二遍在反图上按完成时间逆序 DFS,得到每点所属强连通分量(用分量内代表点编号)。"""
reverse = [[] for _ in graph]
for v, nbrs in enumerate(graph):
for u in nbrs:
reverse[u].append(v)
comp = [-1] * len(graph)
for start in reversed(order):
if comp[start] != -1:
continue
comp[start] = start # 分量编号取该分量里完成最晚的点,天然不重复
stack = [start]
while stack:
for u in reverse[stack.pop()]:
if comp[u] == -1:
comp[u] = start
stack.append(u)
return comp
def condensation_degrees(graph: list[list[int]], comp: list[int]) -> tuple[int, int]:
"""缩点后退化为 DAG,返回入度为 0 的分量数与出度为 0 的分量数。"""
has_in = {c: False for c in comp}
has_out = {c: False for c in comp}
for v, nbrs in enumerate(graph):
for u in nbrs:
if comp[v] != comp[u]: # 分量内部的边缩点后消失,不影响度数
has_out[comp[v]] = True
has_in[comp[u]] = True
return (sum(not has_in[c] for c in has_in), sum(not has_out[c] for c in has_out))
def solve() -> None:
data = map(int, sys.stdin.buffer.read().split())
n = next(data)
graph = read_graph(n, data)
comp = component_of(graph, finish_order(graph))
sources, sinks = condensation_degrees(graph, comp)
# 全图只有一个强连通分量时本身已互通,加 0 条边即可;否则把 max(源,汇) 当作答案
merged = 0 if len(set(comp)) == 1 else max(sources, sinks)
print(sources, merged, sep='\n')
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:两遍 DFS 与一次度数统计都是
,合计 ,其中 是支援关系条数( )。 时开销可忽略。 - 空间复杂度:邻接表、反图、
seen/comp数组与 DFS 栈,合计。
总结
本题的两个问题都是强连通分量缩点后的度数统计:
- 第一问 = 源分量数
。因为源分量既"收不到"别人,又是所有点的可达性来源。 - 第二问 = 分量数大于 1 时
。下界来自"每个源要补入边、每个汇要补出边,一条边只能同时补一对",上界来自"源汇配对连成环"的构造;只有一个分量时答案为 ,必须单独判断。
算法本身(Kosaraju 两遍 DFS)是
图示解析
下面用样例把 Kosaraju 的两遍 DFS 走完,说明"完成时间"和"分量编号"是怎么来的。原图如下,
flowchart LR v1["1"] --> v2["2"] v1 --> v4["4"] v1 --> v3["3"] v2 --> v4 v2 --> v5["5"] v5 --> v1
方框编号就是学校编号,箭头方向就是支援方向。
第一遍在原图上从
| 步骤 | 当前栈 | 刚完成的点 | 累计完成顺序 |
|---|---|---|---|
| 走出 |
|||
第二遍按完成时间从晚到早(即
| 第二遍的起点 | 反图上能走到的点 | 分量编号 |
|---|---|---|
读表时注意两点:完成顺序里