概念
- 连通分量:针对于无向图而言,图内任意两点
u,v
可以相互到达。 - 强连通分量:针对于有向图而言,图内任意两点
u,v
可以相互到达。 - 弱连通分量:针对于有向图而言,将图看作无向图,可以满足连通分量的条件。
- 半连通分量:针对于有向图而言,图内任意两点
u,v
,u→v,v→u
至少满足一个。
求强连通分量往往只是一种手段,常常用于缩点操作,讲强连通分量看作一个点,然后将图变为一个拓扑图(有向无环图DAG)。所以实际上很多强连通分量的题同时也是拓扑序的题, 下面提到的例题也全部和拓扑序有关. 如果有关于拓扑序的知识点不会可以转移到本人的另一篇博客算法专题——拓扑序.
求强连通分量
可以使用dfs
的方法求解强连通分量,即tarjan
算法。
在了解tarjan
算法是什么东西之前,需要先知道几个前置知识,有向图中边的分类。如下图所示,总共有四类边。
- 树枝边:父节点指向子节点的边。
- 前向边:祖先节点指向子孙节点的边。
- 后向边:子孙节点指向祖先节点的边。
- 横叉边:当前节点指向右边已遍历的其他分支的节点的边。
可以发现一个强连通分量必然是由无数个环组词的图,最简单的强连通分量是一个环,而复杂的强连通分量则是无数个环通过组合拼接而成的“环”,借助强连通分量可以互相到达的特点,我们可以先给一个节点标上记号,通过强连通分量环中有向边的传递,仅让同属于一个强连通分量的节点都具有某种标记,从而可以得到强连通分量。
而为了让强连通分量都具有某种标记,可以通过dfs
的方法实现,给每一个节点设定两个属性,一个是在dfs
遍历中节点被访问的序号dfn[i]
,另一个就是节点通过有向边,可以访问到的最小dfn
记为low[i]
,即记录节点所属的强连通分量中节点dfn
最小的值。这里的low
数组就是我们上面提到的记号。
体现在四种类型的边中,存在环的情况仅两种情况。其一,存在后向边指向祖先节点;其二,先走到横叉边,横叉边再走到祖先节点。
tarjan
算法基于一个栈和两个标记数组实现。除了以上的标记方法之外,还要注意对属于一个强连通分量的节点进行标记,这里通过一个栈实现。由于强连通分量是一块块随着dfs
顺序出现的,所以我们可以使用一个栈存储途中遍历到的所有点,当回溯遇到dfn[i] == low[i]
的情况(强连通分量中dfn
最小的节点),就将栈内元素的整个强连通分量弹出。
代码:
void Tarjan(in u) {
dfn[u] = low[u] = +timestamp;
stk[++top] = u, in_stk[u] = true;
for (int i = h[u]; ~i; i = ne[u]) {
int v = e[i];
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
}
else if (in_stk[v])
low[u] = min(low[u], dfn[v]);
}
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); //while将强连通分量弹出
}
}
例题
Popular Cows
题面:
分析:
将题意转换之后可以得到, 要求解有多少个点, 满足图中除节点本身之外的所有点都可以到达该节点. 在一个强连通分量里面, 各个节点之间的点都是可以相互到达的, 所以可以将强连通分量进行缩点, 进而变为DAG, 结合拓扑序的知识点可以发现在DAG中, 要拥有一个节点使得所有点均可以到达该点点, 需要满足该点的出度为0, 且其他节点的出度均不为0.
核心代码:
for (int i = 1; i <= n; i++) //缩点
if (!dfn[i]) Tarjan(i);
for (int u = 1; u <= n; u++) //统计出度
for (int j = h[u]; ~j; j = ne[j])
if (id[u] != id[e[j]])
dout[id[u]]++;
int res = 0;
int flag = false; //标记有多少个出度为0的点
for (int i = 1; i <= scc_cnt; i++)
if (!dout[i]) {
flag = !flag;
res = size[i]; //记录该节点有多少个子节点
if (!flag) { //出现第二个出度为0的点
res = 0;
break;
}
}
cout << res << endl;
Network of Schools
题面:
分析:
题意可以理解为, 节点u向节点v传软件表示节点u可以到达节点v.
一个强连通分量内, 任意两点都可以到达, 进行缩点, 得到DAG. 结合拓扑序的知识点, 可以得到第一问的答案为入度为零的点的个数, 第二问的答案为max(入度为0的点的个数, 出度为0的点的个数)
. 核心代码见下.
for (int u = 1; u <= n; u++) {
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (id[u] != id[v]) {
din[id[v]] ++;
dout[id[u]] ++;
}
}
}
int a = 0, b = 0;
for (int i = 1; i <= scc_cnt; i++) {
if (!din[i]) a++;
if (!dout[i]) b++;
}
cout << a << endl;
if (scc_cnt == 1) cout << 0 << endl; //特判. 注意在推结论的时候最后需要考虑边界情况, 否则可能会wa到哭
else cout << max(a, b) << endl;
最大半连通子图
题面:
分析:
还是先缩点, 可以得到一个DAG, 结合拓扑序的知识点, 可以得到一个强连通分量内的节点可以相互到达, 不用考虑, 而在DAG内, 可以发现半连通分量实际上就是一条链. 所以可以使用拓扑DP, 得到规模最大的一条链, 并记录其贡献, 全部代码以及具体注释见下.
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<unordered_set>
#define ll long long
using namespace std;
//找一个最长的拓扑链
const int MAXN = 1e5 + 10, MAXM = 2e6 + 10;
int n, m, p;
int h[MAXN], hs[MAXN], e[MAXM], ne[MAXM], idx;
int dfn[MAXN], low[MAXN], timestamp;
int stk[MAXN], top;
bool in_stk[MAXN];
int scc_cnt, id[MAXN], size[MAXN];
int dp[MAXN], g[MAXN];
void AddEdge(int h[], 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 v = e[i];
if (!dfn[v]) {
Tarjan(v);
low[u] = min(low[u], low[v]);
}
else if (in_stk[v]) low[u] = min(low[u], dfn[v]);
}
if (dfn[u] == low[u]) {
int y;
scc_cnt++;
do {
y = stk[top--];
in_stk[y] = false;
id[y] = scc_cnt;
size[scc_cnt]++;
} while (y != u);
}
}
int main() {
scanf("%d%d%d", &n, &m, &p);
memset(h, -1, sizeof h);
memset(hs, -1, sizeof hs);
int a, b;
for (int i = 1; i <= m; i++) {
scanf("%d%d", &a, &b);
AddEdge(h, a, b);
}
for (int i = 1; i <= n; i++)
if (!dfn[i]) Tarjan(i);
unordered_set<ll> S; //防止重复建图, 使用一个哈希
for (int u = 1; u <= n; u++) { //建图
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
a = id[u], b = id[v];
ll tmp = a * 1000000ll + b; //哈希函数使用a向前移动再与b并在一起
if (a != b && !S.count(tmp)) {
AddEdge(hs, a, b);
S.insert(tmp);
}
}
}
for (int u = scc_cnt; u; u--) { //利用拓扑进行dp
if (!dp[u]) { //第一个点要特殊处理
dp[u] = size[u];
g[u] = 1;
}
for (int i = hs[u]; ~i; i = ne[i]) { //往后更新, 同时更新dp和g数组
int v = e[i];
if (dp[v] < dp[u] + size[v]) {
dp[v] = dp[u] + size[v];
g[v] = g[u];
}
else if (dp[v] == dp[u] + size[v])
g[v] = (g[v] + g[u]) % p;
}
}
int maxans = 0, sum = 0; //再遍历一遍, 得到最大的dp以及对应的g数组
for (int i = 1; i <= scc_cnt; i++) {
if (dp[i] > maxans) {
maxans = dp[i];
sum = g[i];
}
else if (dp[i] == maxans) sum = (sum + g[i]) % p;
}
printf("%d\n%d\n", maxans, sum);
return 0;
}