在上一篇博客判斷有向圖是否有圈中從遞歸的角度簡單感性的介紹了如何修改深度優先搜索來判斷一個有向圖是否有圈。事實上, 它的實質是利用了深度優先生成樹(depth-first spanning tree)的性質。那么什么是深度優先生成樹?顧名思義,這顆樹由深度優先搜索而生成的,由於無向圖與有向圖的深度優先生成樹有差別,下面將分別介紹。
一. 無向圖的深度優先生成樹
無向圖的深度優先生成樹的生成步驟:
- 深度優先搜索第一個被訪問的頂點為該樹的根結點。
- 對於頂點v,其相鄰的邊w如果未被訪問,則邊(v, w)為該樹的樹邊,用實線表示;若w已經被訪問,則邊(v, w)為該樹的回退邊(back edge),用虛線表示(代表這條邊實際上不是樹的一部分)。
下面是一個無向圖和它對應的深度優先生成樹:
不難發現,該樹的先序遍歷過程就是DFS過程,利用該樹我們可以更好的理解DFS。而對無向圖而言,深度優先生成樹一個重要的應用是解決
雙連通性問題(該問題在通訊網絡,運輸網絡等有重要應用)。當然,我們首先需要了解雙連通性問題的相關概念。
- 如果一個連通的無向圖中的任一頂點被刪除后,剩下的圖仍然連通,那么這樣的無向連通圖就稱作是雙連通的(biconnected)。(上圖的無向圖是雙連通的)
- 如果一個圖不是雙連通的,也就是說存在一些頂點,將其刪除后圖將不在連通,我們把那些頂點稱為割點或者關節點(articulation point)。
下圖是一個不是雙連通的圖,其中頂點C和D為割點。
利用深度優先生成樹求連通圖中的所有割點算法如下:
- 通過先序遍歷深度優先生成樹獲得每個頂點的先序編號(也是深度優先編號),不妨把頂點v的先序編號記為num(v);
- 計算深度優先生成樹上的每一個頂點的最小編號,所謂最小編號是取頂點v和w的先序編號的較小者,其中的w是從v點沿着零條或多條樹邊到v的后代x(可能是v本身),以及可能沿着任意一條回退邊(x,w)所能達到w的所有頂點,記為low(v)。由low(v)的定義可知low(v)是:(1). num(v);(2). 所有回退邊(v, w)中的最小num(w);(3). 所有樹邊(v, w)中的最小low(w)三者中的最小值。由(3)可知我們必須先求出v的所有孩子的最小編號,故需要用后序遍歷計算low(v)。
- 求出所有割點:
- 第一類割點:根節點是割點當且僅當他有兩個或兩個以上的孩子。因為如果根節點有多個孩子時,刪除根使得其他的節點分布在不同的子樹上,而每一棵子樹就對應一個連通圖,所以整個圖就不連通了;而但根只有一個孩子時,刪除它還是只有一棵子樹。
- 第二類割點:對於除根節點以外的節點v,它是割點當且僅當它有某個孩子使得low(w) >= num(v),即以v為根節點的子樹中的所有節點均沒有指向v的祖先的背向邊,這樣若刪除v,其子樹就和其他部分分開了。(注意:節點v一定不是葉節點因為刪除葉節點還是一棵樹,而根節點之所有單獨拿出來是因為任何情況下若v為根節點,一定滿足low(w) >= num(v),因為num(v)是最小先序編號)。
下面是分別從A與C開始遍歷上圖生成的樹:
c++實現代碼如下:
/* 數據結構:鄰接表存儲圖 程序說明:為簡單起見,設節點的類型為整型,設visited[],num[].low[],parent[]為全局變量, 為求得先序編號num[],設置全局變量counter並初始化為1。為便於單獨處理根節點設置root變量。 */ #include <cstdio> #include <vector> #include <algorithm>
using namespace std; const int MAX_N = 100; vector<int> graph[MAX_N]; vector<int> artPoint; int num[MAX_N], low[MAX_N], parent[MAX_N]; int counter = 1; int root; bool visited[MAX_N]; void Init(); //初始化圖
void FindArt(int v); //找到第二類割點
void PrintArtPoint(); //打印所有割點(第一類割點在此單獨處理)
int main() { Init(); FindArt(root); PrintArtPoint(); return 0; } void PrintArtPoint() { int rootChild = 0; //根節點的孩子個數
for (int i = 0; i < graph[root].size(); i++) //計算根節點的孩子個數
{ if (parent[graph[root][i]] == root) rootChild++; } if (rootChild > 1) //根節點孩子個數大於1則為割點
artPoint.push_back(root); for (int i = 0; i < artPoint.size(); i++) printf("%d\n", artPoint[i]); } void Init() { int a, b; root = 1; while (scanf("%d%d", &a, &b) != EOF) { graph[a].push_back(b); graph[b].push_back(a); visited[a] = false; visited[b] = false; } } void FindArt(int v) { visited[v] = true; low[v] = num[v] = counter++; //情況(1)
for (int i = 0; i < graph[v].size(); i++) { int w = graph[v][i]; if (!visited[w]) //樹邊
{ parent[w] = v; FindArt(w); if (low[w] >= num[v] && v != root) artPoint.push_back(v); low[v] = min(low[v], low[w]); //情況(3)
} else if (parent[v] != w) //回退邊
{ low[v] = min(low[v], num[w]); //情況(2)
} } }
測試運行結果如下:
二. 有向圖的深度優先生成樹
我們知道有向圖同樣可以和無向圖一樣進行深度優先搜索。但是,由於有向圖的特點:邊的方向性導致即使兩個頂點有邊相連也不一定是可達的,有向圖的深度優先生成樹的邊有了更多的情況,包括樹邊(tree edges), 回退邊(back edges),向前邊(forward edges), 橫邊(cross edges)。其中后三者是樹實際不存在的邊,通向的是已經被訪問過的點。下面用一張圖來直觀感受一下這幾種情況:
事實上,有以下結論(其中num[]保存的是樹節點的先序序列即DFS序列):
1、若num[v] < num[w],即v在w之后被訪問,則(v,w)是樹邊或向前邊;
此時,若visited[v]= true, visited[w] = false,(v,w)為 樹邊;
若visited[v]= true, visited[w] = true,(v,w) 為 向前邊;比如上圖的第2種情況,訪問到節點3時,節點1已經被訪問,且num[1]<num[3],故邊(1, 3)是向前邊。
2、若num[v] > num[w],即v在w之后被訪問,故visited[v] = true則visited[w] = true,則(v,w)是回退邊或橫邊;
當產生樹邊(i,j) 時,同時記下j的父節點:parent[j] = i, 於是對圖中任一條邊(v,w),由結點v沿着樹邊向上(parent中)查找w(可能直到根);
若找到w,則(v,w)是回退邊,否則是橫邊。比如上圖第一種情況parent[3] = 1,故邊(3, 1)為回退邊,而第3種情況節點3無父節點,故為橫邊。
到此我們就知道了如下法則:一個有向圖是無圈圖當且僅當它沒有回退邊。
查找強連通分量(SCC: Strong Connected Components)
有向圖的深度優先生成樹除了可以用於判斷有向圖是否有邊,還可以用來查找強連通分量。首先給出相關概念:
強連通圖:一個有向圖中任意兩個頂點是可以互達的。
強連通分量:對於一個非強連通圖,我們可得到頂點的一些子集,使得它們到自身是強連通的。
查找強連通分量的算法:
1. Kosaraju-Sharir算法
- 首先對輸入的圖G進行一次DFS:后序遍歷深度優先生成森林,將圖G的頂點標號。然后將圖G所有邊反向,得到Gr。
- 每次在圖Gr中還未訪問的頂點中從編號最大的頂點開始對Gr進行DFS,每進行一次DFS得到的深度優先生成樹中的所有頂點就是一個強連通分量;如此直到所有點被訪問。
理解該算法:如果兩個頂點v和w都在一個強連通分支中,則原圖G中就存在v到w和w到v的路徑,所以Gr也存在。 而兩個頂點互達與這兩個頂點在Gr中 的同一棵深度優先生成樹等價。所以步驟2每次DFS都能得到一個強連通分量。
代碼如下:
/* 數據結構;鄰接表 程序說明:1. 每對Gr進行一次DFS,生成一個強連通分量,topSort++, 所以topSort相同的頂點即在同一個強連通分量中。 2. 為便於得到最大編號對應的頂點,設置node[],其下標為后序編號,值為對應頂點 */ #include <cstdio> #include <vector> #include <cstring> #include <cstdlib>
using namespace std; const int MAX_N = 100; vector<int> G[MAX_N]; //原圖
vector<int> Gr[MAX_N]; //反轉后的圖
vector<int> topSort[MAX_N]; //下標為所屬強分支的拓撲序
int counter = 0; //用於編號
int node[MAX_N]; //后序遍歷標號,下標為編號
bool visited[MAX_N]; int vNum; //圖的頂點數
void DFS(int v); void RDFS(int v, int k); //參數k為v所在的強連通分量的拓撲序
int SCC(); //返回強連通分量的個數
void Init(); //初始化圖G和Gr
int main() { Init(); int sccNum = SCC(); printf("%d\n", sccNum); for (int i = 0; i < sccNum; i++) { int j; printf("{"); for (j = 0; j < topSort[i].size()-1; j++) printf("%d, ", topSort[i][j]); printf("%d}\n", topSort[i][j]); } return 0; } void Init() { scanf("%d", &vNum); int u, v; while (scanf("%d%d", &u, &v) != EOF) { G[u].push_back(v); Gr[v].push_back(u); //反向
} } void DFS(int v) { visited[v] = true; for (int i = 0; i < G[v].size(); i++) { if (!visited[G[v][i]]) DFS(G[v][i]); } node[counter++] = v; //后序遍歷
} void RDFS(int v, int k) { visited[v] = true; topSort[k].push_back(v); //將屬於同一強連通分量放一起
for (int i = 0; i < Gr[v].size(); i++) { if (!visited[Gr[v][i]]) RDFS(Gr[v][i], k); } } int SCC() { memset(visited, false, sizeof(visited)); for (int v = 1; v <= vNum; v++) { if (!visited[v]) DFS(v); } memset(visited, false, sizeof(visited)); int k = 0; //初始化第一個強連通分量的拓撲序為1
for (int i = --counter; i >= 0; i--) //從編號最大開始
{ if (!visited[node[i]]) RDFS(node[i], k++); } return k; }
測試運行結果:
2. Tarjan算法
Tarjan算法和上文所說的雙連通性問題的算法非常相似。它也是通過求出深度優先生成樹的先序編號num[]和low[]。利用的性質是當num[v] == low[v]時,則以v為根節點的深度優先生成樹中所有的節點為一個強連通分量,而為了獲得強連通分量,我們需要用一個棧來記錄。
Tarjan算法的偽碼描述如下:
Tarjan(u) { num[u]=low[u] = counter //情況(1)
Stack.push(u) // 將節點u壓入棧中
for each (u, v) in E // 枚舉每一條邊
if (v is not visted) // 如果節點v未被訪問過
Tarjan(v) // 繼續向下找
low[u] = min(low[u], low[v]) //情況(3)
else if (v in Stack) // 如果節點v還在棧內
Low[u] = min(low[u], num[v]) //情況(2)
if (num[u] == low[u]) // 如果節點u是強連通分量的根
repeat v = Stack.pop // 將v退棧,為該強連通分量中一個頂點
print v until (u== v) }
c++代碼:

/* 數據結構:鄰接表存儲圖 */ #include <cstdio> #include <vector> #include <algorithm> #include <stack> #include <cstring>
using namespace std; const int MAX_N = 100; vector<int> graph[MAX_N]; vector<int> topSort[MAX_N]; //下標為所屬強分支的拓撲序
stack<int> scc; int num[MAX_N], low[MAX_N]; int counter = 1; int numSCC = 0; //強連通分量個數
int vNum; //頂點個數
bool inStack[MAX_N]; //判斷頂點是否在棧中
bool visited[MAX_N]; void Init(); //初始化圖
void Tarjan(int v); //tarjan算法查找SCC
void PrintSCC(); //打印所有SCC
void SCC(); int main() { Init(); SCC(); PrintSCC(); return 0; } void SCC() { memset(visited, false, sizeof(visited)); for (int i = 1; i <= vNum; i++) { if (!visited[i]) Tarjan(i); } } void PrintSCC() { for (int i = 0; i < numSCC; i++) { int j; printf("{"); for (j = 0; j < topSort[i].size() - 1; j++) printf("%d, ", topSort[i][j]); printf("%d}\n", topSort[i][j]); } } void Init() { int u, v; scanf("%d", &vNum); while (scanf("%d%d", &u, &v) != EOF) { graph[u].push_back(v); } } void Tarjan(int v) { low[v] = num[v] = ++counter; //情況(1)
inStack[v] = true; visited[v] = true; scc.push(v); for (int i = 0; i < graph[v].size(); i++) { int w = graph[v][i]; if (!visited[w]) { Tarjan(w); low[v] = min(low[v], low[w]); //情況(3)
} else if (inStack[w]) { low[v] = min(low[v], num[w]); //情況(2)
} } if (num[v] == low[v]) { int w; do { w = scc.top(); scc.pop(); inStack[w] = false; topSort[numSCC].push_back(w); } while (w != v); numSCC++; } }
參考資料:《數據結構與算法分析-C語言描述》
《挑戰程序設計競賽》
博客:https://www.byvoid.com/blog/scc-tarjan/