1 从「互相可达」说起
有向图里,两点之间的关系不是对称的:
在有向图
非强连通的有向图中,极大的强连通子图称为强连通分量(strongly connected component,SCC)。「极大」指点集不能再加进任何一个点而保持强连通。每个点恰好属于一个强连通分量。
「每个点恰好属于一个」这句话值得停一下:它说明求 SCC 等价于给每个点染一个颜色,同色点两两互相可达,异色点至少有一个方向走不通。把一张图按颜色收缩(每个分量当成一个点),得到的图里再没有环——这就是下文「缩点」的由来。
查看 DOT 源码
// 两个强连通分量:{1,2,3} 与 {4,5,6},虚线是跨分量的边
digraph G {
rankdir=LR;
nodesep=0.35;
ranksep=0.55;
node [shape=circle, fixedsize=true, width=0.5, fontname="Noto Serif SC", fontsize=14];
edge [fontname="Noto Serif SC", fontsize=12];
1 -> 2;
2 -> 3;
3 -> 1;
3 -> 4;
4 -> 5;
5 -> 6;
6 -> 4;
3 -> 5 [style=dashed];
}
2 朴素做法,以及它为什么慢
要判断
每个点都做一次,代价是
更好的方法是 Kosaraju(正图、反图各跑一次 DFS,按出栈顺序挑起点)和 Tarjan(一次 DFS 用栈和两个数组直接划出分量),都是
3 DFS 树上,一个分量连成一片
Tarjan 的全部内容都建立在 DFS 树的性质上,先把这条性质说清楚,否则后面的 low 会变成背公式。
若
证明只需要一句:设
由此,一个分量在 DFS 树上表现为「以某个点 dfn 最小的点,本文称它为这个分量的根。
但请注意:
DFS 时把点按访问顺序压栈;一个点
4 dfn、low 与栈
维护三样东西:
dfn[u]:被首次访问的序号,从 开始,DFS 每进入一个新点就加一。 low[u]:从出发、只沿 DFS 树往下走再借助至多一条非树边,能到达的仍在栈中的点的最小 dfn。直观说,它是的子树所能「退回」的最早位置。 - 一个栈:按访问顺序压入,尚未确定分量的点都留在里面。
遍历
| 边的类型 | 判断条件 | 处理 |
|---|---|---|
| 树枝边( |
vis[v] == 0 |
先递归 dfsscc(v),回来再 low[u] = min(low[u], low[v]) |
| 返祖边( |
vis[v] && in_sta[v] |
low[u] = min(low[u], dfn[v]) |
| 指向已出栈的点(横叉边) | vis[v] && !in_sta[v] |
忽略: |
两类更新为什么不一样?树枝边下去的是自己子树里的点,子树的信息已经算全了,所以取 low[v];返祖边指向的是祖先,祖先的 low 可能因为别的原因被拉得更早,但这里只需要「dfn[v]。
low[u] = min(low[u], low[v])不少文章这样写,也常常能过题。但成立需要额外论证「dfn[v] 的语义是封闭的——只用到「dfn[v] 的写法。
退出
设正在处理
证明:dfn 比
这条引理说清了 Tarjan 的底气:low 只会被「栈里更早的点」拉低,而栈里更早的点一定是祖先,祖先与当前点之间天生有返回路径,于是互相可达成立。
low[u] = dfn[u] 当且仅当 dfn 最小的点)。
两个方向分别论证。
方向一:low[u] < dfn[u],则 dfn[y] < dfn[u],所以
方向二:low[u] < dfn[u]。 设所在分量的根为 dfn[r] < dfn[u]),取一条从 dfn 小于 dfn[u] 的点,则
关键一步:low[u] 只会被 low[u] = dfn[u]。
判据成立时弹出栈:从栈顶一直弹到
5 走一遍例子
输入格式:第一行
6 8
1 2
2 3
3 5
3 4
3 1
4 5
5 6
6 4
DFS 过程中状态变化如下(栈写成「底 → 顶」):
| 事件 | dfn |
low |
栈 | 说明 |
|---|---|---|---|---|
| 进入 1 | 1 | 1 | 1 |
|
| 进入 2 | 2 | 2 | 1 2 |
|
| 进入 3 | 3 | 3 | 1 2 3 |
|
| 走 |
3 | 1 | 1 2 3 |
1 在栈中,low[3] = min(3, dfn[1]) = 1 |
| 进入 4 | 4 | 4 | 1 2 3 4 |
走 |
| 进入 5 | 5 | 5 | 1 2 3 4 5 |
|
| 进入 6 | 6 | 6 | 1 2 3 4 5 6 |
|
| 走 |
6 | 4 | 1 2 3 4 5 6 |
4 在栈中,low[6] = 4 |
| 退出 6 | 6 | 4 | 1 2 3 4 5 6 |
low ≠ dfn,不是根,不出栈 |
| 退出 5 | 5 | 4 | 1 2 3 4 5 6 |
low[5] = min(5, low[6]) = 4 |
| 退出 4 | 4 | 4 | 1 2 3 4 5 6 |
dfn = low,是根,弹出 6 5 4 → 分量① |
| 回到 3 | 3 | 1 | 1 2 3 |
low[3] = min(1, low[4]) = 1,不受影响 |
| 走 |
3 | 1 | 1 2 3 |
5 已出栈,属于别的分量,忽略 |
| 退出 3 | 3 | 1 | 1 2 3 |
low ≠ dfn,不是根 |
| 退出 2 | 2 | 1 | 1 2 3 |
不是根 |
| 退出 1 | 1 | 1 | 1 |
dfn = low,是根,弹出 3 2 1 → 分量② |
结果:
这张表里三种边都出现了,可以逐行对着看:
、 、 、 是树枝边; 、 是返祖边(环就是靠它们被发现的); 是横叉边,而且 已经出栈,所以它对 low[3]毫无影响——如果这里错误地写成「访问过就更新」,所在分量的信息就会污染 的 low。
6 代码模板
模板把图存成链式前向星(linkList),栈自己用数组维护(mystack),递归函数是 dfsscc。
//Author by [Rainboy](https://github.com/rainboylvx)
//date: 2024-12-5 10:28
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 1e6+5;
const int maxe = 1e6+5;
int n,m;
int dfn[maxn],low[maxn]; // dfn: 访问序号 low: 能退回的最早序号
bool vis[maxn]; // 是否访问过
bool in_sta[maxn]; // 是否在栈内
int _time; // 访问计时器
int color_idx; // 强连通分量个数
int color[maxn]; // color[i]: 点 i 属于哪个强连通分量
//图的存储结构,linkList
struct linkList {
typedef struct {int u,v,w,next;} edge;
edge e[maxe];
int h[maxn],edge_cnt=0;
linkList(){
edge_cnt=0;
memset(h,-1,sizeof(h));
}
//遍历点u 周围点
template<typename U>
void for_each(int u,U func){
for(int i = h[u] ; i !=-1;i = e[i].next)
func(e[i].u,e[i].v,e[i].w); //u v w
}
void add(int u,int v,int w=0){
e[edge_cnt] = {u,v,w,h[u]};
h[u] = edge_cnt++;
}
void add2(int u,int v,int w=0){
add(u,v,w);
add(v,u,w);
}
//下标访问
edge& operator[](int i){ return e[i]; }
//返回head[u]
int operator()(int u){ return h[u]; }
} e;
template<typename T = int,int siz = maxn>
struct mystack{
T sta[siz+5];
int head = 0;
void clear() { head = 0;}
void push(T a) { sta[head++] = a;}
void pop(){head--;}
T top() { return sta[head-1];}
bool empty() { return head == 0;}
int size() { return head;}
};
mystack sta; //栈
//读取数据
void init(){
std::cin >> n >> m;
for(int i = 1;i <= m ;++i ) // i: 1->m
{
int u,v;
std::cin >> u >> v;
e.add(u,v);
}
}
void dfsscc(int u) {
vis[u] = 1;
dfn[u] = low[u] = ++_time;
sta.push(u); //入栈
in_sta[u] = 1;
for(int i = e(u);i != -1 ;i= e[i].next)
{
int v = e[i].v;
if( vis[v] == 0)
{
dfsscc(v);
//孩子能通过返祖边到的最早祖先,更新自己
low[u] = min(low[u],low[v]);
}
else if( in_sta[v] )
{
// 一条返祖边
low[u] = min(low[u],dfn[v]);
}
}
//退出点u时,进行检查
if( low[u] == dfn[u]) { // 回不到更早点
//设定点u为根的子树上的点强连通的编号
int t;
color_idx++;
do {
t = sta.top();
sta.pop();
in_sta[t] = 0;
color[t] = color_idx;
}while( t != u);
}
}
int main () {
std::ios::sync_with_stdio(false); cin.tie(0); cout.tie(0); // 关闭io同步
init();
for(int i =1;i<=n;i++)
{
if( vis[i] == 0) //如果没有访问过
dfsscc(i);
}
//输出每个点的强连通编号
for(int i = 1;i <= n ;++i ) // i: 1->n
{
cout << "node " << i << ": " << color[i] << endl;
}
return 0;
}
几处要点:
e.add(u,v)是头插,所以e(u)遍历出边的顺序与输入顺序相反。这不会影响结果(分量与遍历顺序无关),但会让你看到的dfn顺序与「按输入顺序想象」的不一样。vis[u]与in_sta[u]是两件事:前者表示「进过一次 DFS」,后者表示「还没出栈,即分量未定」。第三种情况(访问过但已出栈)就靠in_sta排除。- 退栈时先
in_sta[t]=0再染色,两者顺序无所谓,但都要在t != u的循环体里执行。
for (int i = 1; i <= n; i++)
if (vis[i] == 0)
dfsscc(i); // 不能写成 dfsscc(1)
写成 dfsscc(1) 时,只有 color 全是
dfsscc 的递归层数最多是 maxn 调到实际数据范围、用 ulimit -s 或 -Wl,--stack 放大栈、或者改成手写栈迭代版。
复杂度:每个点只入栈、出栈一次,每条边只看一次,时间 dfn/low/color/vis/in_sta 与栈都是
7 缩点:把分量当成一个点
既然分量内部任意两点互相可达,把它们压成一个点不丢失任何关于可达性的信息。这就是缩点,也是 Tarjan 在题目里最常被用到的形态。
把每个强连通分量缩成一个点、分量之间的边照原样保留,得到的图一定是有向无环图。
若缩点后有环
有了 DAG,就能接上拓扑排序上的一切:入度为
int in_deg[maxn] = {0};
for (int u = 1; u <= n; u++) {
for (int i = e(u); i != -1; i = e[i].next) {
int v = e[i].v;
if (color[u] != color[v]) // 跨分量的边,才计入度
in_deg[color[v]]++;
}
}
同一条跨分量边重复出现时,in_deg 会被重复累加——如果题目只问「有多少个分量的入度为 bool 数组按分量对去重)。分量内部的边(包括自环)在缩点后消失,color[u] == color[v] 直接跳过即可。
8 练习题目
先在模板题上把 dfn/low/栈的手感打熟,再用缩点做「把分量当成点」的题。这里的分组是训练顺序,不是各平台的官方难度。
8.1 第一组:把模板跑对
1. luogu P2863: [USACO06JAN] The Cow Prom S
求点数大于
缩点之后在新图上求一条点权和最大的路径。写这题要回答一个问题:为什么要先缩点再拓扑 DP?因为在有环的图上,「路径」这个概念本身没有定义。
8.2 第二组:缩点后的入度与出度
3. luogu P2746: [IOI 1996 / USACO5.3] 校园网 Network of Schools
经典的入度/出度计数。第一问是入度为
4. luogu P2341: [USACO03FALL / HAOI2006] 受欢迎的牛 G
问有多少头牛被所有牛认为「受欢迎」。缩点后只有一个分量出度为
8.3 第三组:把权值带到分量上
5. luogu P1262: [POI 1996 R3] 间谍网络
可以收买部分点,问是否能让所有人被掌握,以及最小代价。缩点后每个分量的代价是分量内可收买点的最小代价,入度为
8.4 可以继续做的
- luogu P2002 消息扩散:缩点后入度为
的分量个数(题库里暂时没有这题,可自行搜索)。 - luogu P2812 校园网络【USACO Network of Schools 加强版】:是 P2746 的强化数据,注意
的写法。 - luogu P2272: [ZJOI2007] 最大半连通子图:缩点 + DAG 上计数,容易在重边去重上翻车。
9 相关题解
暂无相关题解