板块:图论
难度:较易
前置知识:图的基本概念与性质、有向无环图(DAG)
定义
- 连通分量:对于一个有向图,对于分量中的任意两点 u , v u,v u,v,必然可以从 u u u 走到 v v v,从 v v v 走到 u u u。
- 强连通分量:极大连通分量(简称SCC)
用途
将任意一个有向图转换为有向无环图。如对于下图,我们通过缩点操作将 2、3、6 合并为一点,所谓缩点,就是将一个强联通分量中的所有点都合并为一个点的操作,一个强联通分量内的各点一定可以互相到达。操作的最终结果就是把每个点都放在一个强联通分量中。
演变为下图:
这样做的好处,一方面,在求最短最长路时可以线性地做;另一方面,很多题目在一个任意有向图中难以完成,但放在一个有向无环图中则会变得简单。因此,强连通分量的相关算法在信息学竞赛中发挥着中重要作用。
算法基本原理与过程
基本原理
首先先介绍一些特殊的边,在实际算法实现中不会涉及,但有助于我们理解算法的原理与过程:
- 树枝边:对于 ( x , y ) (x,y) (x,y),若 x x x 是 y y y 的父节点,则 x x x 与 y y y 相连的边称为树枝边。
- 前向边:对于 ( x , y ) (x,y) (x,y),若 x x x 是 y y y 的祖先节点,则 x x x 与 y y y 相连的边称为前向边。树枝边是特殊的前向边。
- 后向边:对于 ( x , y ) (x,y) (x,y),若 y y y 是 x x x 的祖先节点,则 y y y 与 x x x 相连的边称为后向边。
- 横叉边:对于
(
x
,
y
)
(x,y)
(x,y),若
x
x
x 在
y
y
y 的左侧的其他分支,那么
x
x
x 与
y
y
y 相连的边称为横叉边。根据 DFS 序,横叉边只能向左。
我们要把每个节点都放在一个强联通分量中,那么我们首先应该判断它是否已经在一个强联通分量中。总的来说,如果一个节点可以回到某个祖先节点,那么它已经存在在一个SCC中了。具体地,分为以下两种情况:
- 可以沿着路径回到某个祖先节点(即存在一条后向边)
- 可以先经过某个横叉边后回到某个祖先节点
那么,如果求强联通分量呢? 在该算法中,引入了时间戳的概念,即按照 DFS 序给每一个节点一个编号,就是它的时间戳。
对于每个节点
u
u
u,定义两个数组:
- d f n [ u ] : dfn[u]: dfn[u]:表示遍历到 u u u 的时间戳
- l o w [ u ] : low[u]: low[u]:表示从 u u u 出发,在 u u u 所在的子树里面,所能遍历到的最小时间戳的对应节点。
- i d [ u ] id[u] id[u],节点 u u u 所在的强连通分量编号
在求强联通分量时,我们要求出强连通分量中深度最小(高度最高)的一个点。对于这样一个点,一定满足 d f n [ u ] = l o w [ u ] dfn[u]=low[u] dfn[u]=low[u],因为我无法再往上找到一个子树内的更高节点。
求出一些强联通分量后,就要考虑如何缩点了。对应缩点,我们遍历所有点,再遍历这些点的邻点。如果 ( i , j ) (i,j) (i,j) 不在同一个 SCC 中,则 i d [ i ] id[i] id[i] 和 i d [ j ] id[j] id[j] 之间就要连接一条边,建立新的图。但在实际问题中,我们也会选择直接记录点 i i i 的出度数量等,这取决于需求。
我们用一个变量更新记录 SCC 的编号,SCC编号递减的顺序一定是拓扑序。
Tarjan 算法求强联通分量
在 Tarjan 算法中,我们使用了一个栈,用于记录尚未处理的后继,并且用一个数组来记录某个节点是否还在栈中。
首先遍历当前节点的所有邻点,如果这个点没有被赋予时间戳,也就是没有被遍历过,那么就对其邻点递归,再对 l o w [ u ] low[u] low[u] 与 l o w [ j ] low[j] low[j] 去最小值,因为既然 u u u 可以到 j j j,那么他们就在同一个连通分量中。如果被遍历过但仍还在栈中,那么也要取一次最小值。
然后,如果一个节点 u u u 的 d f n [ u ] = l o w [ u ] dfn[u]=low[u] dfn[u]=low[u],则说明它是当前子树中的最高节点,强连通分量数量加 1,只要还没有取到自己,就一直在栈中取出栈顶元素,因为取到了自己就说明强连通分量结束。取出栈顶元素后,将该元素是否在栈中的标识改为 f a l s e false false,用 i d [ ] id[] id[] 记录该点所在的强连通分量的编号。
void tarjan(int u)
{
dfn[u] = low[u] = ++timestamp;
stk[++top] = u, in_stk[u] = true;
for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
if (!dfn[j])
{
tarjan(j);
low[u] = min(low[u], low[j]);
}
else if (in_stk[j]) low[u] = min(low[u], low[j]);
}
if (dfn[u] == low[u])
{
++scc_cnt;
int y;
do
{
y = stk[top--];
in_stk[y] = false;
id[y] = scc_cnt;
} while (y != u);
}
return;
}
缩点
基本原理中已经叙述,此处不再赘述。
for (int i = 1; i <= n; i++)
{
for (int j = h[i]; ~j; j = ne[j])
{
int k = e[j];
int a = id[i], b = id[k];
if (a != b) add(a, b);
}
}
例题
强连通分量在 OI 中用途广泛,下面我选取一道经典例题来展示其具体使用。
【USACO03FALL / HAOI2006】 受欢迎的牛 G
在该题中,
A
A
A 喜欢
B
B
B,
B
B
B 喜欢
C
C
C,则
A
A
A 就喜欢
C
C
C 的关系是可以建图的。而能够被所有奶牛喜欢,则说明应该是从起点出发,可以在经过所有点之后到达这个点,才能构成被所有奶牛喜欢。这样的点在图中显然就是终点。那是不是只要是根据爱慕关系建的图的终点就可以呢?显然不是,可能会在终点处出现一个环,那么这个环内所有点就都是明星奶牛,想到这里我们就可以考虑强联通分量了。
我们首先根据爱慕关系建好原图,再跑一遍强联通分量的板子,但由于要求最后终点的那个强连通分量中的奶牛个数,我们用一个 s i z e [ ] size[] size[] 数组记录各分量中的奶牛(节点)数量。在缩点的时候,记录每个缩点后的点的出度即可,如果出度为 0,则可以说明这是终点。如果存在大于 1 个的终点,则不存在答案,因为某个终点中的奶牛至少会与另一个终点中的奶牛不存在爱慕关系。
对于唯一的终点,答案就是该终点 SCC 中的节点数量。
AC代码
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#define size Size
using namespace std;
const int N = 10010, M = 50010;
int n, m, dout[N];
int e[M], ne[M], h[N], idx;
int id[N], scc_cnt, size[N];
int stk[N], top;
int dfn[N], low[N], timestamp;
bool in_stk[N];
void add(int a, int b)
{
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
void tarjan(int u)
{
dfn[u] = low[u] = ++timestamp;
stk[++top] = u, in_stk[u] = true;
for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
if (!dfn[j])
{
tarjan(j);
low[u] = min(low[u], low[j]);
}
else if (in_stk[j]) low[u] = min(low[u], low[j]);
}
if (dfn[u] == low[u])
{
++scc_cnt;
int y;
do
{
y = stk[top--];
in_stk[y] = false;
id[y] = scc_cnt;
size[scc_cnt]++;
} while (y != u);
}
return;
}
int main()
{
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
while (m--)
{
int a, b;
scanf("%d%d", &a, &b);
add(a, b);
}
for (int i = 1; i <= n; i++)
{
if (!dfn[i]) tarjan(i);
}
//缩点
for (int i = 1; i <= n; i++)
{
for (int j = h[i]; ~j; j = ne[j])
{
int k = e[j];
int a = id[i], b = id[k];
if (a != b) dout[a]++;
}
}
int zeros = 0, sum = 0;
for (int i = 1; i <= scc_cnt; i++)
{
if (!dout[i])
{
zeros++;
sum += size[i];
if (zeros > 1)
{
sum = 0;
break;
}
}
}
printf("%d", sum);
return 0;
}
文章可能存在笔误,如有错误请在评论区指出。