视频加载失败

强连通分量

1738 字
9 分钟
强连通分量

我们可以使用 tarjan 来求取一张有向图中强连通分量的个数,以及每个强连通分量包含的点的编号.

强连通#

在一张有向图中,如果存在由一些点组成的集合,满足这个集合中的点都可以两两连通,那么称这些点和边为原图的强连通子图

如果对于一个强连通子图 TT,不存在任意一个其他点在加入 TT 后,能够仍然保持强连通子图的性质,那么称 TT 为原图的强连通分量.通俗来说,强连通分量就是极大的强连通图.

DFS 生成树#

在有向图上,以任意一个节点为根节点进行一次 DFS,我们可以使用DFS 生成树记录其过程.对于一个节点 A,其子节点 B 满足没有在图上出现过、且有一条边从 A 连接到 B.

DFS 生成树可以辅助将图转换为树 + 边.

示例:

A

B

D

C

E

F

H

G

A

B

D

C

E

F

H

G

对于上面这条边,如果以 A 为根节点建 DFS 生成树,一种是这样的:

A

B

C

E

H

F

G

D

A

B

C

E

H

F

G

D

在这张图中,有四种边:

  • 树边:原有的,构成生成树的边 除此以外,还有三种额外的边(假定这些边是从节点 uu 连接到节点 vv 的):
  • 反祖边vvuu 的祖先节点.
  • 前向边uu 不是 vv 的父节点,但是是 vv 的祖先节点.
  • 横叉边:不是树边、反祖边前向边的边.

不难发现,每一个强连通分量都至少有一条反祖边

如何在 DFS 的同时判断一条边是什么边?

我们需要定义一些数据:

  • bool in_stack[i] 表示点 ii 是否在当前 DFS 的路径上.
  • bool vis[i] 表示点 ii 是否被访问过.
in_stack 成立in_stack 不成立
vis[i] 成立反祖边前向边横叉边
vis[i] 不成立不可能树边

Tarjan 求强连通分量#

Tarjan 只需要进行一次 DFS 搜索就能办到.

为了解决问题,我们定义了以下数据:

  • bool in_stack[i] 表示点 ii 是否在当前 DFS 的路径上.
  • stack<int> s 当前强连通分量中的节点编号.
  • int dfn[i]ii 个点先序遍历到达时间.
  • int low[i]ii 个点通过当前子树内返祖边(即不经过其父节点)回到的最小时间.
  • 无需定义 vis[i],应为 vis[i] 等效于 dfn[i] == 0

不难发现:

  1. 祖先 dfn 一定小于当前 dfn
  2. 如果一个点 uu 能够到达一个点 vv,点 u 的 dfn 大于点 v 的 dfn,那么这样的一组构成了一个强连通分量

对于到达一个新的节点 uu,我们会进行以下操作:

  • 初始化操作
    • 初始化 dfn[u]
    • 初始化 low[u] = dfn[u]
    • 将当前节点入栈 s,并且标记 in_stack
  • 遍历所有起点为 uu,终点为 vv 的边
    • 如果这条边是反祖边,将 low[u] 进行更新.
    • 如果这条边是树边,DFS 搜索节点 vv,然后将 low[u] 进行更新.
    • 如果这条边是横叉边前向边,那么说明 vv 已搜索完毕,其所在连通分量已被处理,所以不用对其做操作.
  • 检查当前节点是不是一个新的强连通分量的根节点(low[u] == dfn[u]
    • 如果是,那么当前 s 中栈首直到节点 uu 存储的就是一个强连通分量.弹出 suu 以上的元素和 uu,还原 in_stack 来处理下一个强连通分量.

时间复杂度分析#

  • 初始化操作:O(n)O(n)
  • 遍历边操作:O(m)O(m)
  • 检查节点是不是强连通分量的根节点:O(n)O(n)

综合一下,时间复杂度 O(m+n)O(m+n)

标程#

下面这个程序求解了图中的强连通分量数量和每一个强连通分量的组成部分.

#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 100;
int m, n, ans;
vector<int> e[N];
// bool in_stack[i]表示点i是否在当前DFS的路径上.
// stack<int> s当前强连通分量中的节点编号.
// int dfn[i]第i个点先序遍历到达时间.
// int low[i]第i个点通过当前子树内返祖边回到的最小时间.
int dfn[N], low[N], cnt;
bool in_stack[N];
stack<int> s;
void tarjan(int u)
{
if (dfn[u] != 0)
return;
// 初始化
dfn[u] = ++cnt;
low[u] = dfn[u];
in_stack[u] = true;
s.push(u);
// 搜索边
for (auto v : e[u])
{
// 如果是返祖边
if (in_stack[v])
{
// 那么刷新low[u]
low[u] = min(low[u], low[v]);
}
// 如果是树边
else if (!dfn[v])
{
// 递归处理
tarjan(v);
low[u] = min(low[u], low[v]);
}
// 如果是横插边或前向边
else
{
// 节点v已经被处理或将被处理
// 什么也不用做
}
}
// 检查现在的节点是不是强连通分量的根节点.
if (low[u] == dfn[u]) // 如果说这个节点最小只能到达自己
{
// 那么这个节点就是强连通分量的根节点.
ans++;
vector<int> v;
while (s.top() != u)
{
v.push_back(s.top());
in_stack[s.top()] = false;
s.pop();
}
v.push_back(s.top());
in_stack[s.top()] = false;
s.pop();
cout << v.size() << " ";
for (auto it : v)
{
cout << it << " ";
}
cout << endl;
}
}
signed main()
{
cin >> n >> m;
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
e[u].push_back(v);
}
for (int i = 1; i <= n; i++)
{
tarjan(i);
}
cout << ans << endl;
return 0;
}

缩点#

在求出强连通分量之后,我们可以将其缩为一个点,这样缩完之后的图就是一个 DAG 了.

具体来说,可以使用一个数组 belong(常缩写为 bel)记录每一个点所属的强连通分量编号,接下来,对于每一条边,检查其两个连接点,若两个连接点属于不同的强连通分量,则在新图中在这两个强连通分量中代表的点之间连一条边.

for (int u = 0; u < n; u++) {
for (auto v : e[u]) {
int a = bel[u], b = bel[v.v];
if (a != b) {
g[a].push_back(b);
}
}
}

操作完之后,新的图就是一个有向无环图.

缩点图的逆拓扑序性质

不知道你有没有发现,通过这种方式生成的缩点图中的点排列顺序实际上是逆拓扑序,即所有新图之间的边都满足从编号大强连通分量的连向编号小的,这是由 tarjan 的 dfs 特性决定的.

若根据编号从大到小枚举所有的点,则可以保证在枚举一个点时,所有能够到达它的点都已经被枚举完成了.

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

强连通分量
https://blog.jerrylab.top/posts/graph/scc/
作者
Jerry
发布于
2026-03-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Jerry
Hello, I'm Jerry.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
最新动态
站点统计
文章
80
分类
3
标签
35
总字数
89,198
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.16.5
文章许可
CC BY-NC-SA 4.0