概念
-
连通分量:如果一对顶点\((u, v)\)之间有一条无向边,则称\(u\)和\(v\)连通。如果一个无向图\(G\)中的任意一对顶点均连通,则无向图\(G\)为一个连通图。连通分量指无向图的极大连通子图,可近似理解成连通块。
-
强连通分量:如果一对顶点\((u, v)\)之间有一条有向边,则称\(u\)和\(v\)强连通。如果一个有向图\(G\)中的任意一对顶点均强连通,则有向图\(G\)为一个强连通图。强连通分量指有向图的极大强连通子图。只有有向图才有强连通分量。
-
横插边:如果树上一对顶点\((u, v)\)之间有连边,且\(u\)和\(v\)不是父子关系,则边\((u, v)\)称为横插边。
-
前向边:如果树上一对顶点\((u, v)\)之间有连边,且\(u\)是\(v\)的祖先(非父子关系),则边\((u, v)\)为前向边。
-
后向边(返祖边):如果树上一对顶点\((u, v)\)之间有连边,且\(v\)是\(u\)的祖先,则边\((u, v)\)为后向边(返祖边)。
-
树枝边:如果树上一对顶点\((u, v)\)之间有连边,且\(u\)和\(v\)是父子关系,则边\((u, v)\)是树枝边。
\(tarjan\)
算法思想
\(tarjan\)算法是一种用于求解有向图的强连通分量的算法,由计算机科学家\(Robert\) \(Tarjan\)提出。它的时间复杂度优秀,为\(O(n + m)\),其中\(n\)为点数,\(m\)为边数。它可以求出每个强连通分量的大小、属于其的顶点和强连通分量的总数。
概念定义
给图中的每一个顶点赋予一个新的编号,如果一个顶点\(v\)在\(dfs\)树中第\(i\)个遍历,则顶点\(v\)的编号为\(i\),称为时间戳,用\(dfn\)表示。时间戳是唯一的,顶点对应的时间戳也是唯一的。
定义\(low_{i}\)表示顶点\(i\)不经过其父结点可以到达的最小的时间戳,即\(i\)和\(i\)的子树可以到达的最小的时间戳,也描述为\(i\)在栈中可以追溯到的最小的时间戳。
强连通分量的性质
注意到一个强连通分量满足以下性质:
-
一个强连通分量中必定有环。
证明:一棵有\(n\)个结点、\(n - 1\)条边的树一定是连通的(无向边)。如果加上若干条边,则树中一定会出现环。因为\(n\)个结点的图至少需要\(n - 1\)条边连接才能连通,所以一个强连通分量可以看做是一棵树加上若干条边。 -
如果一个顶点\(v\)的\(low\)等于它的时间戳,则该顶点一定是所属强连通分量的“根”(强连通分量中时间戳最小的结点)。
证明:如果\(v\)可以到达一个时间戳更小的顶点\(u\),且\(u\)也同样在\(v\)所属的强连通分量内,则\(v\)和\(v\)的子树一定可以到达\(u\)。此时\(v\)虽然不满足条件,但是\(u\)成为了新的"\(v\)",故此性质成立。
基本思想
故而我们可以用一个栈来描述这棵树。如果\(u\)没有被深度优先遍历到,则更新\(u\)的\(dfn\)和\(low\)(一个结点至少能够走到它本身,自成一个强连通分量)。对于\(u\)连出的每一条有向边\((u, v)\),分几种情况:
-
\(v\)没有被深度优先遍历过,遍历\(v\)。因为\(v\)能到达的点,\(u\)一定可以通过有向边\((u, v)\)到达\(v\)后间接到达,所以\(low_{u} = min(low_{u}, low_{v})\)。我们通过递归先得出\(low_{v}\),再通过回溯更新\(low_{u}\)。
-
\(v\)已经被遍历过,即在栈内。因为深度优先遍历是从根结点到子结点,且\(v\)被遍历过,说明这条边是一条返祖边。虽然\(u\)可以到达\(v\),\(v\)或许可以到达一个时间戳更小的结点\(\alpha\),但是\(\alpha\)、\(u\)和\(v\)不一定形成一个环,也就不在同一个强连通分量内。故而我们只能使用\(dfn_{v}\)来更新,而非\(low_{v}\)。\(low_{u} = min(low_{u}, dfn_{v})\)。
如果我们已经发现了一个\(low\)与\(dfn\)相等的顶点\(v\),那么\(v\)和\(v\)以后入栈的顶点一定属于同一个强连通分量内。我们不断将元素出栈,标记其所在的强连通分量,更新其所在的强连通分量大小,直到\(v\)也出栈,\(v\)所属的强连通分量已经求出。
枚举图中的顶点\(v\),如果\(dfn_{v} = 0\),说明\(v\)属于一个新的强连通分量,从\(v\)开始\(tarjan\)算法。
模板
P1726的参考代码如下:
#include <cstdio>
#include <stack>
#include <algorithm>
using namespace std;
#define maxn 5005
#define maxm 100005
struct node
{
int to, nxt;
}edge[maxm];
int n, m;
int cnt, cnt_node, cntn;
int head[maxn], dfn[maxn], low[maxn], size[maxn], id[maxn];
bool in_stack[maxn];
stack<int> s;
inline void add_edge(int u, int v)
{
cnt++;
edge[cnt].to = v;
edge[cnt].nxt = head[u];
head[u] = cnt;
}
//tarjan算法
void tarjan(int u)
{
cnt_node++;
low[u] = cnt_node;
dfn[u] = cnt_node;
s.push(u);
in_stack[u] = true;
for (int i = head[u]; i; i = edge[i].nxt)
{
//树枝边
if (!dfn[edge[i].to])
{
tarjan(edge[i].to);
low[u] = min(low[u], low[edge[i].to]);
}
//返祖边
else if (in_stack[edge[i].to])
low[u] = min(low[u], dfn[edge[i].to]);
}
//找到新的强连通分量
if (low[u] == dfn[u])
{
cntn++;
while (s.top() != u)
{
//出栈
int v = s.top();
s.pop();
in_stack[v] = false;
//更新强连通分量信息
id[v] = cntn;
size[cntn]++;
}
int tp = s.top();
s.pop();
in_stack[tp] = false;
id[tp] = cntn;
size[cntn]++;
}
}
int main()
{
int u, v, op;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++)
{
scanf("%d%d%d", &u, &v, &op);
add_edge(u, v);
if (op == 2)
add_edge(v, u);
}
for (int i = 1; i <= n; i++)
if (!dfn[i])
tarjan(i);
//找出最大的强连通分量的大小
int mx = -1, target = 0;
for (int i = 1; i <= n; i++)
mx = max(mx, size[id[i]]);
//找出最大的强连通分量的编号
for (int i = 1; i <= n, !target; i++)
if (size[id[i]] == mx)
target = id[i];
printf("%d\n", mx);
//输出最大的强连通分量中的顶点
for (int i = 1; i <= n; i++)
if (id[i] == target)
printf("%d ", i);
printf("\n");
return 0;
}
\(kosaraju\)
\(kosaraju\)算法是一种时间复杂度与\(tarjan\)相同,但是代码实现比\(tarjan\)简单的算法。它有一个重要的特点:求出的强连通分量是按拓扑序排列的。由于\(kosaraju\)算法证明的困难和篇幅,本文不再深入证明,具体证明可参考证明。
基本概念
- 反图:对于一个有向图\(G\),将\(G\)中的每一条边\((u, v)\)都倒置一条有向边\((v, u)\),最终得到的图称为\(G\)的反图,通常记作\(G^{t}\)。
算法思想
\(kosaraju\)算法是由两遍\(dfs\)组成的:
第一遍\(dfs\),我们使用原图。我们从未访问的顶点集合中随意挑选出一个顶点\(u\),并访问与\(u\)相邻的结点\(v\)。在递归后、回溯前,我们给\(u\)编上一个序号。即,做\(u\)的后序遍历。最后,我们把顶点\(u\)入栈。
第二遍\(dfs\),我们使用反图。我们不断弹出栈顶\(u\),更新\(u\)所属的强连通分量\(color_{u}\)为\(cnt\),接着更新与\(u\)相邻的顶点。
\(kosaraju\)算法的时间复杂度同样是\(O(n + m)\),其中\(n\)为点集,\(m\)为边集。
模板
#include <cstdio>
#include <stack>
using namespace std;
#define maxn 10005
#define maxm 100005
struct node
{
int to, nxt;
}edge1[maxm], edge2[maxm];
int n, m, cnt;
int head1[maxn], head2[maxn];
int color[maxn], size[maxn];
bool vis[maxn];
stack<int> s;
void add_edge1(int u, int v, int x)
{
edge1[x].to = v;
edge1[x].nxt = head1[u];
head1[u] = x;
}
void add_edge2(int u, int v, int x)
{
edge2[x].to = v;
edge2[x].nxt = head2[u];
head2[u] = x;
}
void dfs1(int u)
{
vis[u] = true;
for (int i = head1[u]; i; i = edge1[i].nxt)
if (!vis[edge1[i].to])
dfs1(edge1[i].to);
s.push(u);
}
void dfs2(int u)
{
color[u] = cnt;
for (int i = head2[u]; i; i = edge2[i].nxt)
if (!color[edge2[i].to])
dfs2(edge2[i].to);
}
void kosaraju()
{
for (int i = 1; i <= n; i++)
if (!vis[i])
dfs1(i);
while (!s.empty())
{
int v = s.top();
s.pop();
if (!color[v])
{
cnt++;
dfs2(v);
}
}
}
int main()
{
int u, v;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++)
{
scanf("%d%d", &u, &v);
add_edge1(u, v, i);
add_edge2(v, u, i);
}
kosaraju();
for (int i = 1; i <= cnt; i++)
{
printf("scc %d:\n", i);
for (int j = 1; j <= n; j++)
if (color[j] == i)
printf("%d ", j);
puts("");
}
return 0;
}
缩点
将一个强连通分量缩成一个单独的点,称为缩点。缩点通常用于点数多、要判断某种性质且强连通分量内的结点满足该性质的情况。换句话说,如果某种性质从大规模到小规模具有传递性,可以使用缩点。具体实现只需要在枚举每一条边的两端,如果它们所属的强连通分量不相同,则在这两个强连通分量之间连边即可。
值得注意的是,上述方法建出的缩点图可能会出现重边。
参考代码如下:
#include <cstdio>
#include <vector>
#include <stack>
using namespace std;
const int maxn = 1e4 + 5;
const int maxm = 5 * 1e4 + 5;
int n, m;
int cnt_node, cnt_edge, cnt_scc;
int low[maxn], dfn[maxn], color[maxn], size[maxn];
bool in_stack[maxn];
vector<int> g[maxn], point[maxn];
stack<int> s;
void tarjan(int u) {
cnt_node++;
dfn[u] = cnt_node;
low[u] = cnt_node;
s.push(u);
in_stack[u] = true;
for (int i = 0; i < g[u].size(); i++) {
int v = g[u][i];
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (in_stack[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (low[u] == dfn[u]) {
cnt_scc++;
while (s.top() != u) {
int tp = s.top();
s.pop();
in_stack[tp] = false;
color[tp] = cnt_scc;
size[cnt_scc]++;
}
int tp = s.top();
s.pop();
in_stack[tp] = false;
color[tp] = cnt_scc;
size[cnt_scc]++;
}
}
void build_edge() {
for (int i = 1; i <= n; i++) {
for (int j = 0; j < g[i].size(); j++) {
int v = g[i][j];
if (color[i] != color[v]) {
point[color[i]].push_back(color[v]);
}
}
}
}
int main() {
int u, v;
int deg_cnt = 0, scc_id;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &u, &v);
g[u].push_back(v);
}
for (int i = 1; i <= n; i++) {
if (!color[i]) {
tarjan(i);
}
}
build_edge();
for (int i = 1; i <= cnt_scc; i++) {
if (!point[i].size()) {
scc_id = i;
deg_cnt++;
}
}
if (deg_cnt == 1) {
printf("%d\n", size[scc_id]);
} else {
printf("%d\n", 0);
}
return 0;
}
总结
能使用强连通分量解决的题目通常含有以下要素:
-
题目给出一个有向图;
-
点与点之间满足某种性质,并且同一个强连通分量内的任意一对顶点一定都满足这种性质;
-
试求满足要求的最小点权(或边权)之和;
-
需要维护图的连通性。