連通圖與Tarjan演算法
引言
Tarjan演算法是一個基於深度優先搜尋的處理樹上連通性問題的演算法,可以解決,割邊,割點,雙連通,強連通等問題。
首先要明白Tarjan演算法,首先要知道它能解決的問題的定義。
連通圖
無向圖
由雙向邊構成的圖稱之為無向圖。
割點與橋
給定的無向圖中刪去節點x,無向圖被分割成兩個或兩個以上的不相連子圖,則稱節點x為圖的割點(割頂,關鍵點)。
這是一個無向圖,其中點4就是一個割點,去掉該點,圖會變成,{1,2,3},{5,6}兩個不連續的子圖,如下圖
給定的無向圖中刪去邊e,無向圖被分割成兩個或兩個以上的不相連子圖,則稱邊e為圖的割邊或橋。
這是一個無向圖,其中紅邊都是割邊,去掉任意一個邊,圖都會變成兩個不連續的子圖,如下圖
雙連通
無割點的無向連通圖稱它為點雙連通圖,無割邊的無向連通圖稱它為邊雙連通圖,統稱雙連通圖。
無向圖的極大點雙連通子圖稱它為點雙連通分量v-DCC,無向圖的極大邊雙連通子圖稱它為邊雙連通分量e-DCC,統稱為雙連通分量 DCC(double connected components)
(-分量:圖的一個滿足什麼條件的最大子圖。)
上圖G中,子圖{1,2,4,3}是圖G的一個點雙連通子圖,但不是點雙連通分量,子圖{2,4,5}也只是一個點雙連通子圖,不是點雙連通分量,子圖{1,2,5,4,3},{5,8},{5,6},{6,7}都是G的點雙連通分量。
要注意定義中的極大。
定理
一個無向圖是點雙連通圖,當且僅當滿足以下兩個條件之一
1.圖的頂點數不超過2.
2.圖中任意兩點都同時包含在至少一個簡單環中。
(簡單環:簡單環又稱簡單迴路,圖的頂點序列中,除了第一個頂點和最後一個頂點相同外,其餘頂點不重複出現的迴路叫簡單迴路。或者說,若通路或迴路不重複地包含相同的邊,則它是簡單的,簡單的說就是不自交的環。)
這些都是點雙連通圖
左邊環{1,2,3,4,1}是簡單環,右邊環{1,2,3,5,4,3,1}不是簡單環,右邊環{1,2,3,1}是簡單環。
一個無向圖是邊雙連通圖,當且僅當任意一條邊都包含在至少一個簡單環中。
這些是邊雙連通圖
有向圖
由單向邊構成的圖稱之為有向圖
強連通分量 SCC(strongly connected components)
一個有向圖中任意兩個節點可以互相到達則稱該圖為強連通圖
有向圖的極大強連通子圖被稱為強連通分量
必經點
起點為S,終點為T,若從S到T的每條路徑都經過一個點x,該點就是S到T的一個必經點。
必經邊
同理,起點為S,終點為T,若從S到T的每條路徑都經過一個邊x,該邊就是S到T的一個必經點。
無向圖的Tarjan演算法
Tarjan演算法的複雜度為O(V+E)
這裡講無向圖的Tarjan演算法,以下的“圖”,若無特別說明都預設指無向圖。
首先我們引入一些定義。
時間戳
圖的深度優先搜尋過程中,按照每一個節點依次按1~N給與的一個標記,稱之為時間戳,有的人也叫它dfs序,一般記為dfn[x]。
(時間戳的定義很關鍵,後面一些演算法都有用到,如樹鏈剖分。因為它本身能處理樹上的子樹問題,把複雜的子樹結構變成一串連續的數字,因為它有個特點,時間戳大於等於當前點的時間戳,並且小於等於該子樹最後一個節點的時間戳的節點必定在當前節點的子樹中。)
搜尋樹
對一個圖進行深度優先搜尋,每個節點遍歷一次,所有經過的節點和邊構成的一個子圖就是一顆樹,稱它為搜尋樹,也有人叫它dfs樹。當搜尋的是有向圖的時候,由於有向圖不一定連通,所以會構成多顆搜尋樹,合起來構成搜尋森林。
這是一個圖,對他從點1開始是進行深度優先搜尋,結果如下圖。
從點1開始,順時針尋邊,點上數字即dfs序,紅線是搜尋路徑,紅線加上遍歷的那些點,構成一顆搜尋樹。
我們記在圖中非搜尋樹中的邊,為回溯邊,這時回溯邊有個有意思的性質,被回溯邊相連的點一定有祖孫關係。
證明:
回溯邊的出現條件是,深度優先搜尋過程中遍歷到的一條邊指向已遍歷過的點。若出現回溯邊(a,b)。設dfn(a)>dfn(b) ,此時一定是點b連向a,也就是說正在遍歷點b。如果要使回溯邊連線的兩個點無,祖孫關係,一定要使深度優先搜尋遍歷回退後再遍歷到b,但由於深度優先搜尋的性質,它在搜尋點a時一定會將點a周圍所有邊遍歷後再回退,所以b如果能通過a有一條不經過a的祖先節點的道路相連,a就一定是b的祖先。
(祖孫關係:設樹上兩點a,b,subtree(a)中包含b 或 subtree(b)包含a)
如有個這樣的圖,它的搜尋樹不可能是如下圖,因為在遍歷到3點的時候,會繼續遍歷4點
正確的圖應該如此
追溯值
設subtree(x)表示搜尋樹中以x為根的子樹,追溯值low[x]表示subtree(x)中節點的時間戳和通過subtree(x)中節點中不在搜尋樹中的直接相鄰的邊可以到達的所有節點的時間戳的最小值。
或者說,low[x]表示min(low(subtree(x)))與subtree(x)直接相連的回溯邊連線的時間戳的最小值。其中low(S)表示集合S中所有的元素的追溯值的集合。min(S)表示集合S中的最小值。
很容易知道,當圖是一顆樹的時候low[x]會等於dfn[x]。當非根節點x的追溯值low[x]小於它的父節點f的時間戳dfn[f]時說明,子樹subtree(x)中沒有任意一個點能通過圖中的一條邊到達,
圖中紫色數字是追溯值,也就是low陣列的值,其中low[1]=1,因為subtree(1)中有條邊(2,1)指向時間戳為1的點1,所以點1的追溯值是1,同理,點4、6、3、2的子樹中都有邊(2,1),所以也追溯值也都是1。點5的追溯值是3,因為subtree(5)中沒有任何一條不在搜尋樹中的邊指向最小值,subtree(5)中唯一一條邊(5,4)在搜尋樹上所以不能更新low[5],所以low[5]的值是dfn[5]就是3,subtree(7)中有條回溯邊。
求時間戳和追溯值的程式碼,Tarjan的基本框架。
vector<int>e[N]; int dfn[N], low[N]; int tol = 0; //x表示當前遍歷的點,f表示x的父節點 void tarjan(int x,int f) { dfn[x] = ++tol; low[x] = dfn[x]; for (int i = 0;i< e[x].size(); i++) { int y = e[x][i]; if (!dfn[y]) { tarjan(y, x); low[x] = min(low[x], low[y]); } else if(y!=f){//判斷這條邊是不是搜尋樹上的邊 low[x] = min(low[x], dfn[y]); } } }
程式碼很簡單,圖使用的是std動態陣列實現的鄰接表儲存,但要理解需要對深度優先搜尋的過程有比較深刻的瞭解。這屬於基本功,就不多過解釋程式碼了。
Tarjan求割點割邊
割邊
定理:無向邊(x,y)是割邊,當且僅當搜尋樹上存在點x的一個子節點y,滿足: dfn[x]<low[y]
證明:
根據定義,dfn[x]<low[y],說明,從subtree(y)出發,在不經過邊(x,y)的前提下,不存在一條邊能到達,x或比x更早訪問的節點。再由上文搜尋樹的性質,也不存在一條邊,可以到達非點y祖宗的節點。若把邊(x,y)刪除,則subtree(y)就形成一個孤立的圖,即刪除邊(x,y)把原圖分割成兩個不相連通的子圖,所以根據割邊的定義,此時邊(x,y)為割邊。
性質:割邊一定是搜尋樹的邊,並且一個簡單環中的邊一定不是割邊。
如上圖中邊(4,5)就是一條割邊,因為dfn[4]<low[5]。下圖中紅邊是搜尋樹,黑色數字是點序號同時也是時間戳,紅數字是追溯值。其中只有邊(4,7)是割點,因為,dfn[4]<low[7],即4>7,同時,其他所有邊都在簡單環內。割去邊(4,7)後圖被分割成兩個不相連通的子圖。
割點
定理:非根節點x是割點,當且僅當搜尋樹上存在點x的一個子節點y,滿足: dfn[x]<=low[y]
若節點x為根,那至少要有兩個子節點y1,y2滿足上述情況。
證明與割邊類似,這裡就不在贅述。
在上圖中,節點4、7都為割點,因為dfn[4]=low[5],dfn[7]=low[8]。
程式碼
割邊
vector<int>e[N]; int dfn[N], low[N]; int tol = 0; //x表示當前遍歷的點,f表示x的父節點 void tarjan(int x,int f) { dfn[x] = ++tol; low[x] = dfn[x]; for (int i = 0;i< e[x].size(); i++) { int y = e[x][i]; if (!dfn[y]) { tarjan(y, x); low[x] = min(low[x], low[y]); if (dfn[x] < low[y]) { } } else if(y!=f){//判斷這條邊是不是搜尋樹上的邊 low[x] = min(low[x], dfn[y]); } } }
割點
vector<int>e[N]; int dfn[N], low[N]; int cut[N]; int tol = 0; int n, m; void tarjan(int x, int f) { dfn[x] = ++tol; low[x] = dfn[x]; int flag = 0; ll tmp = 0; for (int i = 0; i < e[x].size(); i++) { int y = e[x][i]; if (!dfn[y]) { tarjan(y, x); low[x] = min(low[x], low[y]); if (dfn[x] <= low[y]) { if (x != 1 || flag) {//特判根節點 cut[x] = 1;//標記點 } flag++; } } else if (y != f) { low[x] = min(low[x], dfn[y]); } } }
Tarjan求雙連通分量與縮點
縮點
縮點顧名思義,就是把圖中多個點根據要求合併成一個點,以此來減少處理問題的複雜度。
和雙連通分量有關的題,大部分情況都要用上縮點, 把每一個雙連通分量縮成一個點,新圖就會變成一顆樹,方便我們處理問題。
縮點的時候要注意處理重邊和自環。
如下圖就是一個簡單的縮點,縮點後把邊合併了。
邊雙連通分量(e-DCC)
邊雙連通分量好處理,先跑一遍tarjan找割邊,再對整張圖進行深度優先搜尋(不經過割邊),對每一個連通塊標記。其中,每一個連通塊就是一個邊雙連通分量。
簡單的說,就是去掉割邊後的所有連通塊都是一個邊雙連通分量。因為要標記邊所以這裡使用鏈式前向星更為方便處理。當然vector實現的鄰接表也能夠處理。
e-DCC縮點
把每一個e-DCC都看做一個節點,把割邊當作新點的邊,連線它們,就會形成一顆樹,這就是e-DCC縮點。
為了方便處理可以在Tarjan的過程在中先儲存割邊,最後好對新圖進行連邊。
下圖過程就是e-DCC割點的過程,先找到割邊,再確定e-DCC,最後將每一個e-DCC縮成一個點。由於e-DCC縮點的程式碼簡單,也比較少用,就不給出程式碼了。
點雙連通分量(v-DCC)
v-DCC與e-DCC不同,並不是去掉割點後的連通塊就是v-DCC,割點也是v-DCC中的一個節點,並且割點還不止是一個v-DCC的節點,上文v-DCC定義處給的例圖應該指出了這種情況,沒有理解清楚,可以看圖重新理解下。
因為以上性質,我們要求出v-DCC就較為麻煩,為了求出v-DCC我們可以在Tarjan的過程中使用棧來幫助處理,並按如下情況維護棧內元素。
1.當一個節點第一次被訪問時,把該節點入棧。
2.dfn[x]<=low[y]時,不斷彈出節點,直到y被彈出。
每一次的連續彈出的節點就是一個v-DCC。(e-DCC其實也可以用一樣的方法得出,學有餘力的讀者試試實現)
原理很簡單,和上文中時間戳的特殊用法同理,按深度優先搜尋入棧的節點不停彈出直到彈至當前節點時,根據上文Tarjan演算法的原理,那些被彈出部分就是一個獨立的連通塊,並且由於該連通塊下方,其他的有割點的區塊都被彈出了,所以當前彈出的部分就是一個v-DCC。
這是一個圖,其中黑色的為搜尋樹,紅色的邊回溯邊。
每一個用紅色粗線圈起來的連通塊都是一個v-DCC
我們將割點編號。
在Tarjan的過程中,首先遍歷到的割點是,左下角第一個割點1.此時滿足條件dfn[x]<=low[y],對棧進行彈出操作,直到彈出的節點為節點1的子節點,把這些點再加上節點1本身,這就是一個v-DCC,這時繼續回退,到下一個割點節點2,因為前面已經把很多節點彈出了,所以這時棧中節點2後的元素,只有節點1和節點1到節點2之間的那些節點,節點1的孫子節點全部被彈出,即subtree(1) 中的節點除了節點1其他的全部被彈出,這樣保證了上方的v-DCC不會被下方的割點所影響。用一樣的步驟我們也同樣能得出下一個v-DCC,即節點2,節點1,與節點1,2之間的那兩個節點。
縮點為如下圖
具體實現見程式碼。
程式碼
vector<int>e[N],dcc[N]; int dfn[N], low[N]; int cut[N]; int tol = 0; stack<int>s; int cnt; int n, m; void tarjan(int x, int f) { dfn[x] = ++tol; low[x] = dfn[x]; int flag = 0; ll tmp = 0; for (int i = 0; i < e[x].size(); i++) { int y = e[x][i]; if (!dfn[y]) { tarjan(y, x); low[x] = min(low[x], low[y]); if (dfn[x] <= low[y]) { if (x != 1 || flag) { cut[x] = 1; } flag++; cnt++; int z; do { z = s.top(); s.pop(); dcc[cnt].emplace_back(z); } while (z != y); dcc[cnt].emplace_back(x); } } else if (y != f) { low[x] = min(low[x], dfn[y]); } } }
v-DCC縮點
v-DCC的縮點較e-DCC的縮點麻煩,但也很簡單,在對每一個連通塊編號後,再對每一個割點編號,然後遍歷連通塊,將與連通塊相連的割點連線起來,要注意的是連通塊中有割點本身,遍歷到割點本身的時候不需要操作。
程式碼
int cut[N];//割點的標記 int id[N];//新節點編號 int cnt;//新節點數量 int num;//節點計數器 for (int i = 1; i <= n; i++) { if (cut[i])id[i] = ++num; } for (int i = 1; i <= cnt; i++) { for (int j = 0; j < dcc[i].size(); j++) { int x = dcc[i][j]; if (cut[x]) { //加邊(i,id[x]) } } }
有向圖的Tarjan演算法
Tarjan求強連通分量
無向圖的Tarjan和有向圖類似,也有它對應的,搜尋樹,時間戳,追溯值,首先還是先講解這些東西的定義。
流圖(Flow Graph)
給定有向圖G,若存在一點r,能到達G中所以點,則稱G為一個流圖。其中稱r為流圖的源點。
該無向圖為一張流圖,流圖的源點為最上方的那點。
搜尋樹
和無向圖類似,對流圖的源點進行深度優先搜尋,每一個點只訪問一次,遍歷過的點與邊產生的以源點為根的樹就是該流圖的搜尋樹
黑線構成的以最上方那點的樹為該流圖的搜尋樹,邊的搜尋順序是入邊開始順時針。
時間戳
同樣,在深度優先搜尋中,按訪問順序給每一個節點從1編號,這些編號被稱為時間戳,同記為dfn[x]。
點邊的黑色數字為該點的時間戳。
流圖邊
流圖上有向邊(x,y)必定分為以下4種。
1.樹枝邊,搜尋樹上的邊,即x為y的父節點。
2.前向邊,x是y的祖先節點。
3.後向邊,y是x的祖先節點。
4.橫叉邊,除以上三種情況的邊。(必定滿足dfn[y]<dfn[x]。)
上圖中
黑色的邊為樹枝邊。
紅色的邊為前向邊。
藍色的邊為後向邊。
綠色的邊為橫叉邊。
追溯值
有向圖的追溯值較無向圖複雜,我們先思考,強連通分量的性質。
如果圖中存在x到y的路徑,也存在y到x的路徑,那x,y就在一條環路上,一個環路一定是強連通圖,環上任意兩點可互相到達。有向圖Tarjan演算法的基本思路就是對每一個點,儘量找到與它一起能構成環的所有節點,也就是找到,x到y的路徑和y到x的路徑。
容易發現,在上面定義中前向邊(x,y)沒有什麼用處,因為搜尋樹中已經存在一條路徑能使x到y。後向邊可以和搜尋樹上的邊直接構成一個環路。雖然橫叉邊(x,y)不能直接和搜尋樹上的邊構成環路,但如果能在圖上找到一條從y出發到x的路徑就是有用的,比如它可能可以和後向邊(y,z),搜尋樹上的路徑(z,x),構成環路{z...x,y,z}。
為了找到橫叉邊和後向邊構成的環路,Tarjan演算法在深度優先搜尋的過程中維護了一個棧。
棧中儲存以下兩類節點。
1.搜尋樹上x的祖先節點,記為anc(x)。
如果此時遍歷到邊(x,y) ,y屬於anc(x),那邊(x,y)就是一條後向邊,後向邊和樹上路徑構成環路。
2.已經訪問過,並且存在一條路徑到達anc(x)的節點。
如果存在點z,從z出發存在一條路徑到達y(y屬於anc(x))。若存在,一條橫叉邊(x,z),則,(x,z)、z到y的路徑、y到x的路徑形成一個環路。
同樣,我們設subtree(x)表示流圖的搜尋樹中以x為根的子樹。x的追溯值low[x]定義為滿足以下條件的節點的最小時間戳。
1.該點在棧中。
2.subtree(x)出發的邊能到達的點。
根據定義我們可有如下方法計算追溯值。
1.當前點x第一次訪問,將x入棧,初始化low[x]=dfn[x]。
2.掃描從x出發的所有邊(x,y),
(1).若點y沒有被訪問,則遞迴訪問y,當y回溯後,令low[x]=min(low[x],low[y])。
(2).若點y被訪問,並且y在棧內,令low[x]=min(low[x],dfn[y])。
3從x回溯前,判斷low[x]=dfn[x],若成立,則彈出節點,直至x出棧。
從棧中連續彈出的節點就組成一個強連通分量。至於證明,以上已經講的比較清晰了,並且與上文中無向圖的也類似,就不予重複證明了。
程式碼
程式碼和無向圖的也類似。
vector<int>e[N]; int dfn[N], low[N]; stack<int>s; int ins[N], c[N];//ins[x] 表示x節點有無在棧中,c[x]表示點x在第幾個scc中 vector<int>scc[N];//scc[x]表示第x個scc的集合 int n, m, tol, cnt; void tarjan(int x) { dfn[x] = ++tol; low[x] = dfn[x]; s.push(x); ins[x] = 1; for (int i = 0; i < e[x].size(); i++) { int y = e[x][i]; if (!dfn[y]) { tarjan(y); low[x] = min(low[x], low[y]); } else if (ins[y]) { low[x] = min(low[x], dfn[y]); } } if (dfn[x] == low[x]) { cnt++; int y; do { y = s.top(); s.pop(); ins[y] = 0; c[y] = cnt; scc[cnt].emplace_back(y); } while (x != y); } }
縮點
與無向圖e-DCC縮點類似,我們把每一個SCC縮成一個點,最後構成一個有向無環圖。
Tarjan求必經點與必經邊
Lenguar-Tarjan演算法通過計算支配樹(Dominator-Tree),能夠在O(nlogn)的時間求出單源的必經點與必經邊。
另外
程式碼中的emplace_back(),方法為c++11的新特性,和push_back()的意思一樣,但它可以減少一次加入時的拷貝操作,理論上速度比push_back()快一倍。