强连通分量

定义

强连通的定义是:有向图 G 强连通是指,G 中任意两个结点连通。
强连通分量(Strongly Connected Components,SCC)的定义是:极大的强连通子图,即彼此两两可达的节点集合。如下图,强连通分量可以表示为[[2, 3, 4, 5], [1], [6, 8, 9], [7]]。

img

算法

为每个节点维护两个变量:

  • dfn:该节点被dfs最初访问到的时间
  • low:该节点能够通过其出边回溯到的最小时间

容易得到结论:

  • 子树节点的dfn一定大于当前节点的dfn
  • 从根节点开始的一条路径上,dfn严格递增,low严格非降

算法流程:
按照深度优先搜索算法搜索的次序对图中所有的结点进行搜索,维护每个结点的 dfnlow 变量,且让搜索到的结点入栈。每当找到一个强连通元素,就按照该元素包含结点数目让栈中元素出栈。在搜索过程中,对于结点u和与其相邻的结点v(v不是u的父节点)考虑 3 种情况:

  1. v未被访问:继续对v进行深度搜索。在回溯过程中,用low[v]更新 low[u]。因为存在从u到v的直接路径,所以v能够回溯到的已经在栈中的结点,u也一定能够回溯到。
  2. v被访问过,已经在栈中:根据 low 值的定义,用low[v]更新low[u] 。
  3. v被访问过,已不在栈中:说明v已搜索完毕,其所在连通分量已被处理,所以不用对其做操作。

一个连通分量有且仅有一个节点的dfn等于low。该节点一定是在深度遍历的过程中,该连通分量中第一个被访问过的节点,因为它的 dfn 和 low 值最小,不会被该连通分量中的其他结点所影响。因此在回溯的过程中如果dfn == low,说明该节点及栈中节点构成一个SCC。
注意如果原图并非无向连通图,需要在循环内多次调用tarjan,确保每个点都被访问到。

模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Tarjan求强连通分量板子
// 全局变量
int time = 1; // 当前时间戳
Deque<Integer> stk = new ArrayDeque<>();
int[] dfn; // 某节点被dfs访问到的时间
int[] low; // 某节点能通过其出边回溯到的最早时间
boolean[] inStack; // 节点是否在栈中,用于处理存在自环且除自环外无出边的点
public List<List<Integer>> tarjan(List<Integer>[] g){
int n = g.length;
dfn = new int[n];
low = new int[n];
inStack = new boolean[n];
List<List<Integer>> ret = new ArrayList<>();
for(int i = 0;i < n;++i) if(dfn[i] == 0) dfs(g, i, ret);
return ret;
}

public void dfs(List<Integer>[] g, int cur, List<List<Integer>> ret){
dfn[cur] = time;
low[cur] = time;
++time;
stk.push(cur);
inStack[cur] = true;
for(int nxt: g[cur]){
if(dfn[nxt] == 0) {
dfs(g, nxt, ret);
low[cur] = Math.min(low[cur], low[nxt]);
}
else if(inStack[nxt]){
low[cur] = Math.min(low[cur], low[nxt]);
}
}
// 当节点的low与dfn相等,说明它无法回溯到更早的时间点,它就是一个强连通分量的顶点
if(low[cur] == dfn[cur]){
List<Integer> ls = new ArrayList<>();
int top = -1;
do{
top = stk.pop();
ls.add(top);
inStack[top] = false;
}while(top != cur);
ret.add(ls);
}
}

时间复杂度:O(V+E)

注:Tarjan求强连通分量适用于有向图,无向图的连通分量直接用并查集就行。

题目

802. 找到最终的安全状态 - 力扣(LeetCode)(本题Tarjan并非最优解法)

割点/割边

定义

割点:对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点。
割边:对于一个无向图,如果删掉一条边后图中的连通分量数增加了,则称这条边为桥或者割边。

算法

类似强连通分量。
割点判据有两条:1.根节点且有至少两个子树;2.非根节点且存在一个儿子nxt满足low[nxt] >= dfn[cur]。
割边的判据只有一条:low[nxt] > dfn[cur],说明cur-nxt这条边是割边。

注意当发现已经访问过的节点时,需要用dfn[nxt]更新low[cur],而不是像求解SCC时使用low[nxt]更新。
考虑无向图结构如下,从节点1出发,如果使用的是low[nxt]更新,会导致节点5和节点6的low被更新为1,算法会认为节点5和6不需要经过节点2就能回溯到更早的节点,进而认为节点2不是割点。

tarjan

也就是说更新low[cur]时不能通过nxt的其它边继续回溯。

模板

适用于无向连通图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int time = 1;
int[] dfn;
int[] low;
List<List<Integer>> edges = new ArrayList<>();
List<Integer> points = new ArrayList<>();
Set<Integer> seenPoint = new HashSet<>();
public void Tarjan(List<Integer>[] g){
int n = g.length;
dfn = new int[n];
low = new int[n];
dfs(g, 0, -1);
}

public void dfs(List<Integer>[] g, int cur, int fa){
dfn[cur] = time;
low[cur] = time;
time++;
int sonCnt = 0;
for(int nxt: g[cur]){
if(nxt == fa) continue;
if(dfn[nxt] == 0){
sonCnt++;
dfs(g, nxt, cur);
low[cur] = Math.min(low[cur], low[nxt]);
// 割边判据1:low[nxt] > dfn[cur],则cur-nxt为割边
if(low[nxt] > dfn[cur]){
List<Integer> edge = new ArrayList<>();
edge.add(cur);
edge.add(nxt);
edges.add(edge);
}
// 割点判据1:cur非根节点,且存在一个孩子nxt满足low[nxt] >= dfn[cur]
if(seenPoint.contains(cur)) continue;
if(fa != -1 && low[nxt] >= dfn[cur]){
points.add(cur);
seenPoint.add(cur);
}
}
else{
low[cur] = Math.min(low[cur], dfn[nxt]); // 注意这里不要用low[nxt]更新
}
// 割点判据2:cur为根节点,且存在至少两个子树
if(fa == -1 && sonCnt >= 2) points.add(cur);
}
}

缩点

定义:把连通分量看成是一个大点,保留那些不在强连通分量里的边,这样的图就是缩点后的图。
缩点后的图保留了所有不在分量里的边,而且对于有向连通图,缩点后的图是一个有向无环图(DAG),可以进行拓扑排序。

题目

1192. 查找集群内的关键连接 - 力扣(LeetCode)----------割边

1489. 找到最小生成树里的关键边和伪关键边 - 力扣(LeetCode)----------割边、缩点

注意本题在缩点后的图中,是可能存在重复边的,因此不能用父节点作为continue的依据,而应该使用来路的那条边作为continue的依据

__END__