本文可转载,转载请注明出处:www.cnblogs.com/collectionne/p/6847240.html 。本文未完,若是不在博客园(cnblogs)发现此文章,请访问以上连接查看最新文章。html
前言:以前翻译过一篇英文的关于割点的文章(英文原文、翻译),可是本身还有一些不明白的地方,这里就再次整理了一下。有兴趣能够点我给的两个连接。算法
在无向连通图中,若是将其中一个点以及全部链接该点的边去掉,图就再也不连通,那么这个点就叫作割点(cut vertex / articulation point)。数组
例如,在下图中,0、3是割点,由于将0和3中任意一个去掉以后,图就再也不连通。若是去掉0,则图被分红一、2和三、4两个连通份量;若是去掉3,则图被分红0、一、2和4两个连通份量。优化
最容易想到的方法就是依次删除每一个割点,而后DFS,但这种方法效率过低,这里不作讨论。spa
首先须要了解一些关于DFS树(DFS tree)的概念。如下图为例:翻译
从点1开始搜索整个图, 对于每一个点相邻的顶点,按照顶点编号从小到大搜索(也能够按其它顺序)。所以上图的搜索顺序以下:code
第1步,与1相邻的点有{2, 4},选2。htm
第2步,与2相邻的点有{1, 3, 4},1访问过,选3。blog
第3步,与3相邻的点有{2, 5},2访问过,选5。get
第4步,与5相邻的点有{3},访问过,退出。
退回第3步,与3相邻的点有{2, 5},都访问过,退出。
退回第2步,与2相邻的点有{1, 3, 4},一、3访问过,选4。
第5步,与4相邻的点有{1, 2},都访问过,退出。
退回第2步,与2相邻的点有{1, 3, 4},都访问过,退出。
退回第1步,与1相邻的点有{2, 4},都访问过,退出。
至此,访问结束。
把访问顶点的路径表示出来就是这样的(访问已访问过的顶点时加上删除线并再也不访问,end表示与某个顶点相邻的顶点遍历完毕,{}里是与一个顶点相邻的全部顶点)。
1 {2,4}
2 {1,3,4}
1
3 {2,5}
2
5 {3}
3
end
end
4 {1,2}
1
2
end
end
4
end
访问路径能够绘制成下图(绿边为访问未访问顶点时通过的边,红边为访问已访问节点是通过的边):
咱们把上图称为DFS搜索树(DFS tree),上图中的绿边称为树边(tree edge),红边称为回边(back edge)。经过回边能够从一个点返回到之间访问过的顶点。
你可能会有疑问,“访问已访问节点时所通过的边叫回边”,咱们上面不是没有访问吗?实际上是有的,可是为方便就不写了,并且遇到已访问的边(在后面的算法里)只是简单计算一下,再也不继续DFS了。
注意,在上图中,若是与一个顶点相邻A的顶点B是A的父节点,不表示出来,接下来的算法遇到这种状况也不计算。
可使用Tarjan算法求割点(注意,还有一个求连通份量的算法也叫Tarjan算法,与此算法相似)。(Tarjan,全名Robert Tarjan,美国计算机科学家。)
首先选定一个根节点,从该根节点开始遍历整个图(使用DFS)。
对于根节点,判断是否是割点很简单——计算其子树数量,若是有2棵即以上的子树,就是割点。由于若是去掉这个点,这两棵子树就不能互相到达。
对于非根节点,判断是否是割点就有些麻烦了。咱们维护两个数组dfn[]和low[],dfn[u]表示顶点u第几个被(首次)访问,low[u]表示顶点u及其子树中的点,经过非父子边(回边),可以回溯到的最先的点(dfn最小)的dfn值(但不能经过链接u与其父节点的边)。对于边(u, v),若是low[v]>=dfn[u],此时u就是割点。
但这里也出现一个问题:怎么计算low[u]。
假设当前顶点为u,则默认low[u]=dfn[u],即最先只能回溯到自身。
有一条边(u, v),若是v未访问过,继续DFS,DFS完以后,low[u]=min(low[u], low[v]);
若是v访问过(且u不是v的父亲),就不须要继续DFS了,必定有dfn[v]<dfn[u],low[u]=min(low[u], dfn[v])。
先回忆一下怎么用DFS遍历一个图,代码以下:
bool vis[N]; // 顶点是否访问过 vector<int> g[N]; // 邻接表表示的图 // 调用dfs()前需将整个vis[]设为false void dfs(int u) { vis[u] = true; for (int v: g[u]) { if (!vis[v]) dfs(v); } }
首先假设u是根节点。若是u有两棵以上的子树,则u为割点。代码:
int children = 0; for (int v: g[u]) { if (!vis[v]) { children++; dfs(v); // 继续DFS } } if (children >= 2) // u是割点
非根节点呢?按照前面的描述,代码以下:
// 默认u不能回溯到任何前面的点 low[u] = dfn[u]; for (int v: g[u]) { // (u, v)为树边 if (!vis[v]) { // 设置v的父亲为u parent[v] = u; // 继续DFS,遍历u的子树 dfs(v); // u子树遍历完毕,low[v]已求出,low[u]取最小值 low[u] = min(low[u], low[v]); if (low[v] >= dfn[u]) // u是割点 } // (u, v)为回边,且v不是u的父亲 else if (v != parent[u]) low[u] = min(low[u], dfn[v]); }
综合起来,加上一些其它部分,Tarjan算法的代码以下:
const int V = 20; int dfn[V], low[V], parent[V]; bool vis[V], ap[V]; vector<int> g[V]; void dfs(int u) { static int count = 0; // 子树数量 int children = 0; // 默认low[u]等于dfn[u] dfn[u] = low[u] = ++count; vis[u] = true; // 遍历与u相邻的全部顶点 for (int v: g[u]) { // (u, v)为树边 if (!vis[v]) { // 递增子树数量 children++; // 设置v的父亲为u parent[v] = u; // 继续DFS dfs(v); // DFS完毕,low[v]已求出,若是low[v]<low[u]则更新low[u] low[u] = min(low[u], low[v]); // 若是是根节点且有两棵以上的子树则是割点 if (parent[u] == -1 && children >= 2) cout << "Articulation point: " << u << endl; // 若是不是根节点且low[v]>=dfn[u]则是割点 else if (parent[u] != -1 && low[v] >= dfn[u]) cout << "Articulation point: " << u << endl; } // (u, v)为回边,且v不是u的父亲 else if (v != parent[u]) low[u] = min(low[u], dfn[v]); } }
不过有一个问题:可能会重复输出一个割点。例如一个图里有(1, 2)、(1, 3)、(1, 4)和(1, 5)四条边(取1为根节点),发现(1, 3)时就已经输出了1,但发现(1, 4)和(1, 5)时就又输出了两遍。因此须要使用一个数组ap[]来记录割点。
还有一个能够优化的地方:咱们使用vis[]来记录一个点是否访问过。可是咱们想一下,不是只有访问过的点才会分配dfn吗?固然,没有访问过的顶点,dfn[]里也有值,但这里dfn[]是全局的,所以它的每一个元素最初都是0。所以彻底能够取消vis[]数组并把!vis[v]改为!dfn[v]。
最后一个点:下面的代码:
if (parent[u] == -1 && children >= 2) cout << "Articulation point: " << u << endl; else if (parent[u] != -1 && low[v] >= dfn[u]) cout << "Articulation point: " << u << endl;
能够合起来写成:
if (parent[u] == -1 && children >= 2 || parent[u] != -1 && low[v] >= dfn[u]) cout << "Articulation point: " << u << endl;
固然,还须要加上对ap[]的检查。
对算法的详细理解
首先,“根节点有n棵子树”这句话,是说这n棵子树是独立的,没有根节点不能互相到达。所以n不必定等于与根节点相邻的顶点数。所以加入了vis[v]为false的条件,由于若是(u, v1)和(u, v2)在一棵子树里,对v1进行DFS,必定能去到v2,vis[v2]就会为true,此时就不会children++了。
对于边(u, v),若是low[v]>=dfn[u],即v即其子树可以(经过非父子边)回溯到的最先的点,最先也只能是u,要到u前面就须要u的回边或u的父子边。也就是说这时若是把u去掉,u的回边和父子边都会消失,那么v最先可以回溯到的最先的点,已经到了u后面,没法到达u前面的顶点了,此时u就是割点。