图:找关键点
图上的关键点(articulation points)
我们之前介绍过 图的基本概念,我们知道如果一个图是连通的,表明图上任意的一个节点都可以通过遍历来访问到图上的其他节点。
在此基础上,我们在做一下延伸,在一个连通图中,如果移除任意一个节点都不会破环其连通性,那么这个的图就被称为 双向连通的(biconnected)。比如下图就是一个双向连通图:
这和我们今天讲的东西有什么联系?要知道双向连通这个要求是比较严苛的,那么对于那些不是双向连通的图,我们就需要清楚哪些地方,或者说哪些节点的移除会导致图不再连通,这样的节点就被称为 关键节点(articulation points)。比如下图中的 C
以及 D
就是关键节点:
C
的移除会导致节点 G
与原图脱离,D
的移除会导致节点 E
以及 F
与原图脱离。
找关键节点有什么实际的意义?这里你可以把节点想象成是网络中的路由,道路中的枢纽,以及机场或是火车站,公交站,关键节点的缺失势必会造成图上一部分的节点和其余节点脱离,这是我们不希望看到的。找到关键节点,对其进行严格保护以及监控预警才会让整个系统更加地稳定。
具体算法
那么如何找呢?通过观察,可以发现那些关键节点本身的特征并不明显,但是与其相连的节点要想互联,那么就只能经由它。比如上面的例子中的 C
,与其相连的 G
要想去到其他节点,那么就必须经由 C
。
但这些都只是直观上的感受,如何把其落实到实际的算法中呢?图的问题一般都是通过遍历来解决,如果我们用深度优先搜索来遍历这张图,我们其实可以得到一个类似树的结构:
图上的实线是遍历的路径,同样的节点我们不需要进行二次遍历。虚线表示的是 返回路径,表明现在正在遍历的节点是有另外一条路径通到之前遍历过的节点(父节点除外,因为这其实是同一条边),这个信息对我们寻找关键点很重要。
我们按照遍历的顺序从小到大给每个节点都填上编号:
如果把这个图看作是树,那么这样的编号方式其实是 前序遍历,也就是每到一个节点我们就编号。
有了这个编号还不够,我们的目的是找到一个节点是否可以连通到其他的节点。从图中可以很容易地观察到,如果一个节点不管是通过返回路径,或是其他路径能够通到其父节点之外的节点,那么说明该节点很可能有其他的通路。
我们这里给每个节点再定义一个最小路径编号,至于为什么是最小,我们待会再说,它是下面这些编号取最小值:
- 节点编号
- 所有返回路径上的节点编号
- 所有路径中的节点的最小路径编号
最小路径编号依然可以通过遍历得到,我们将其列在编号的后面:
如果还是把这个图看作是树,这个遍历就是 后序遍历,每个节点的编号都需要依赖其子节点的结果,因而节点的编号在其子节点之后。
最小路径编号可以告诉我们什么呢?通过比较一个节点的编号与其子节点的最小路径编号,我们就可以知道该节点是不是关键节点。如果子节点的最小路径编号比该节点的编号小,说明子节点可以通过其他的路径通到前面遍历过的节点,所以即使该节点被移除,那么子节点也不会受影响(因为有通路可以避开该节点),反之,说明子节点并没有其他的路径来避开该节点,那么这个该节点就是关键节点。
当然,有个例外就是根节点,也就是遍历最开始的点,它的编号肯定是最小的,如果按照上面的方法,其子节点的最小路径编号不可能比它还小。因此对根节点来说,除了要用上面的方法进行检验外,我们还需要看它是不是有多个子节点,如果仅有一个子节点,那么即使把它移除,其余图依然连通,它就不是关键节点。反之,如果根节点有两个或者两个以上的子节点,则表明其是关键节点,比如我们从节点 C
开始遍历,我们就需要这个检测:
算法具体实现如下
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
46
47
48
49
50
51
52
53
54
55
56
class Vertex {
Vertex parent;
List<Vertex> adjacents;
boolean visited;
int num; // 节点编号
int low; // 最小路径编号
}
class Graph {
private int counter = 1;
// 前序遍历算出每个节点的编号
public void assignNum(Vertex v) {
v.num = counter++;
v.visited = true;
for (Vertex n : v.adjacents) {
if (!w.visited) {
w.parent = v;
assignNum(w);
}
}
}
// 后序遍历计算最小路径编号,找到关键节点
public void assignLow(Vertex v) {
v.low = v.num; // 1. 节点编号
for (Vertex n : v.adjacents) {
if (w.num > v.num) {
assignLow(w);
if (w.low >= v.num) {
System.out.println(v + " is an articulation point");
}
v.low = Math.min(v.low, w.low); // 3. 所有路径中的节点的最小路径编号
} else if (v.parent != w) { // 返回路径
v.low = Math.min(v.low, w.num); // 2. 所有返回路径上的节点编号
}
}
}
public void findArt(Vertex v) {
assignNum(v);
assignLow(v);
// 对根节点进行额外的检测
int numOfChildren = 0;
for (Vertex n : v.adjacents) {
if (n.parent == v) {
numOfChildren++;
}
}
if (numOfChildren > 1) {
System.out.println(v + " is an articulation point");
}
}
}
上面是把前序和后序遍历分开来进行,合在一起也是可行的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void findArt(Vertex v) {
v.visited = true;
v.low = v.num = counter++; // 1. 节点编号
for (Vertex n : v.adjacents) {
if (!w.visited) {
w.parent = v;
findArt(w);
if (w.low >= v.num) {
System.out.println(v + " is an articulation point");
}
v.low = Math.min(v.low, w.low); // 3. 所有路径中的节点的最小路径编号
} else if (v.parent != w) { // 返回路径
v.low = Math.min(v.low, w.num); // 2. 所有返回路径上的节点编号
}
}
}
因为要遍历图上的所有节点和路径,时间复杂度是 O(V + E)