\(\text{Tarjan}\)
1.引入概念
1.強連通分量
1.定義
在有向圖 \(G\) 中,強連通分量就是滿足以下條件的 \(G\) 的子圖:
- 從任意一點出發,都有到達另一個點的路徑。
- 不存在一個或者幾個點,使得這個子圖加入這些點後,這個子圖還滿足上一個性質。
為什麼要限定”在有向圖中“而不是”在任何一個圖中“?這是因為在無向圖中,只要圖連通,那麼任意一點都有到另外一點的路徑,可以用並查集維護,這個時候 \(\text{Tarjan}\) 就沒有存在的必要了。
當然,後面 \(\text{Tarjan}\) 會有在無向圖中的用處。
2.性質
我們想一想,一個強連通分量中,既然每一個點都有到達另外一個點的路徑,那麼對於這個強連通分量中的任意一條邊 \(u\rightarrow v\) 的兩個端點來說,都存在 \(v\) 到 \(u\) 的路徑,那麼這個強連通分量一定在一個”大環“上。
當然了,這個”大環”不是嚴格意義上的環,它可以經過重複的點,就像這樣:
這個圖告訴了我們什麼?一個強連通分量要麼是一個大環,要麼是由幾個有交點的小環組成的。
後面的講解中,我們會反覆提到“大環”,請注意這個概念。“大環”表示一個子圖中,從任意一個點出發,不重不漏地經過每條邊一次,最後能夠回到這個點。“大環”和強連通分量、環的關係是這樣的:環 \(\subsetneq\) “大環”,“大環” \(\subsetneq\) 強連通分量。
2.時間戳
1.定義
按照 \(\text{DFS}\) 遍歷的過程,以每個結點第一次被訪問時的順序,依次給 \(n\) 個結點標上 \(1\sim n\) 的標記,那麼這個標記就叫做時間戳,記作 \(dfn\)。
2.作用
這個在講 \(\text{Tarjan}\) 的縮點演算法邏輯的時候會講到。
3.邊的分類
1.深度優先樹的定義
我們在執行 \(\text{DFS}\) 演算法的時候,會經過 \(n-1\) 條邊和所有 \(n\) 個點,這些邊和點會構成一棵樹,叫做深度優先樹。
就比如下面這個圖,它的深度優先樹就是紅色邊:
這裡說明一個事情:本篇中所有形如 “\(u\) 的子樹”的描述,都包括 \(u\) 本身,除非特殊說明。以及,所有形如“\(u\) 的祖先”的描述,都包括 \(u\) 本身,除非特殊說明。
2.邊的分類
樹邊:指深度優先樹中的邊。
後向邊:在一棵深度優先樹中,從一個點 \(u\) 出發,連線到其祖先 \(anc\) 的邊,這裡可能有 \(anc=u\)。
前向邊:在一棵深度優先樹中,從一個點 \(u\) 出發,連線到其子樹中一個點 \(v\) 的邊,這裡不可能有 \(v=u\)。
橫叉邊:指其它所有的邊。
4.割點、點雙連通、割邊、邊雙連通
1.定義
在一個圖 \(G\) 中:
割點:刪去一個點 \(u\) 及所有與 \(u\) 相關的邊,圖就不連通了,那麼 \(u\) 稱為 \(G\) 的割點。
點雙連通:圖中沒有割點,就說 \(G\) 是點雙連通的。
割邊:刪去一條邊 \(e\) 後,圖就不連通了,那麼稱 \(e\) 為 \(G\) 的割邊。
邊雙連通:圖中沒有割邊,就說 \(G\) 是邊雙連通的。
2.性質
割點、割邊都是無向圖中的概念。
一個圖的割點不止一個,這就是 \(\text{Tarjan}\) 演算法存在於無向圖中的意義。
如果一條邊 \(e=u\rightarrow v\) 是割邊,那麼 \(u\) 和 \(v\) 是割點。
2.\(\text{Tarjan}\) 的縮點演算法邏輯
1.定義
\(\text{Tarjan}\) 的經典用處就是拿來縮點。
首先,我們需要了解為什麼要縮點。在一個強連通分量中,每個點都可以互相到達,那麼這個強連通分量就可以縮成一個點,每個強連通分量的子問題內部處理。這就是縮點的原因。
2.不同型別的邊在強連通分量中的作用
樹邊:以下的討論都是基於樹邊展開的。
後向邊:十分有用。一條後向邊 \(u\rightarrow anc\) 可以和深度優先樹上 \(anc\) 到 \(u\) 的路徑形成一個環,而環上每個點都是可以互相到達的。
前向邊:沒啥用。因為其不能構成環,對縮點沒有幫助。
橫叉邊:部分有用。對於一條橫叉邊 \(u\rightarrow v\) 而言,如果有一條路徑,從 \(v\) 出發,到達一個點 \(anc\),而這個 \(anc\) 又恰好是 \(u\) 的祖先結點,那麼 \(u\rightarrow v\) 這條邊就在一個環上了。
3.主演算法邏輯
看了上面,你應該就知道 \(\text{Tarjan}\) 要幹嘛了。它的作用,就是對於每一個點 \(u\),找到與 \(u\) 能夠構成”大環“的所有結點。
那麼怎麼找?前面說過,”大環“只能由三種邊構成,樹邊、後向邊和橫叉邊。為了找到透過“後向邊”和“橫叉邊”構成的環,\(\text{Tarjan}\) 演算法在 \(\text{DFS}\) 的過程中維護了一個棧。
當訪問到某一個點 \(u\) 的時候,棧中需要保留以下兩類結點:
- 深度優先樹上 \(u\) 的祖先結點。這是為了維護後向邊。
- 已經訪問過,且存在一條路徑到達 \(u\) 的祖先結點的結點。這是為了維護橫叉邊。
一句話概括,就是能夠到達 \(u\) 且已經訪問過的結點。
我們想想,對於一個在棧中的點 \(t\),什麼情況下,\(u\) 和 \(t\) 才會同在一個”大環“上?一定要有一條 \(u\rightarrow t\) 的邊才行嗎?不是的,只要有一條 \(u\rightarrow t\) 的路徑就可以了。什麼時候才會有一條 \(u\rightarrow t\) 的路徑?
對於所有從 \(u\) 出發的邊 \(u\rightarrow v\),有兩種情況:
- \(v\) 沒有被訪問過。這說明 \(v\) 在 \(u\) 的子樹中,\(u\rightarrow v\) 這條邊是樹邊。只要 \(v\) 的子樹中有一條邊連向 \(t\),說明 \(u,v,t\) 在一個“大環”上。
- \(v\) 被訪問過。如果 \(v\) 不在棧中,那麼這條邊並沒有什麼用處;否則 \(u,v,t\) 在一個“大環”上。
問題似乎已經解決了,不是嗎?我們知道什麼情況下 \(u,v,t\) 在一個大環上。那這要怎麼寫進程式碼呢?
注意,我們的思路已經完成,下面的一切定義和概念,都不是思路里面本身就存在的,只是為了實現程式碼方便。
我們定義一個追溯值 \(low\),那麼對於一個結點 \(u\),其追溯值 \(low_u\) 定義為滿足以下條件結點 \(v\) 的最小時間戳:
- \(v\) 在棧中。
- 存在一條從 \(u\) 的子樹出發的邊,以 \(v\) 為終點。
接下來就徹底到程式碼部分了。
4.程式碼邏輯
首先,日常敲一個空殼。
void Tarjan(int u){
}
接下來,我們既然已經訪問到 \(u\) 這個結點了,當然要把 \(u\) 入棧,並且初始化 \(u\) 的 \(dfn\) 和 \(low\)。
void Tarjan(int u){
low[u]=dfn[u]=++tim;//tim表示訪問到的時間
stk[++tp]=u;//用陣列模擬棧
vis[u]=true;//vis[u]表示u是否在棧中
}
初始化完了,我們應該進入到主演算法部分。回頭再看看主演算法邏輯,你會發現主演算法的核心就是掃描每一條邊,後面的任何處理都只跟邊有關。我用的是 \(\texttt{vector}\) 存圖,看不慣的同學可以看註釋瞭解一下什麼意思。
void Tarjan(int u){
low[u]=dfn[u]=++tim;//tim表示訪問到的時間
stk[++tp]=u;//用陣列模擬棧
vis[u]=true;//vis[u]表示u是否在棧中
for(int v:g[u]){//掃描每一條從u出發到v結束的邊
}
}
我們的主演算法邏輯中,邊是不是分兩種?我們先來討論第一種,也就是 \(v\) 沒有被訪問過的情況。
void Tarjan(int u){
low[u]=dfn[u]=++tim;//tim表示訪問到的時間
stk[++tp]=u;//用陣列模擬棧
vis[u]=true;//vis[u]表示u是否在棧中
for(int v:g[u]){//掃描每一條從u出發到v結束的邊
if(!dfn[v]){//v還沒有被訪問過
Tarjan(v);//掃描v的子樹
}
}
}
掃描完了 \(v\) 的子樹,總不能對 \(u\) 啥都不幹吧?我們想想 \(low\) 值此時該怎麼更新。
由於 \(v\) 的子樹一定是 \(u\) 的子樹,所以 \(low_u=\min(low_u,low_v)\)。
void Tarjan(int u){
low[u]=dfn[u]=++tim;//tim表示訪問到的時間
stk[++tp]=u;//用陣列模擬棧
vis[u]=true;//vis[u]表示u是否在棧中
for(int v:g[u]){//掃描每一條從u出發到v結束的邊
if(!dfn[v]){//v還沒有被訪問過
Tarjan(v);//掃描v的子樹
low[u]=min(low[u],low[v]);//更新u的low值
}
}
}
那麼我們現在來討論第二種情況,也就是 \(v\) 被訪問且 \(v\) 在棧中。這種情況下,這個結點 \(v\) 滿足“存在一條從 \(u\) 出發的邊,以 \(v\) 為終點”,所以更新。
void Tarjan(int u){
low[u]=dfn[u]=++tim;//tim表示訪問到的時間
stk[++tp]=u;//用陣列模擬棧
vis[u]=true;//vis[u]表示u是否在棧中
for(int v:g[u]){//掃描每一條從u出發到v結束的邊
if(!dfn[v]){//v還沒有被訪問過
Tarjan(v);//掃描v的子樹
low[u]=min(low[u],low[v]);//更新u的low值
}
else if(vis[v]){//v被訪問過且在棧中
low[u]=min(low[u],dfn[v]);//更新u的low值
}
}
}
這個時候“是否在一個強連通分量中”已經判完了,但是我們總是要用一個點來代表一個強連通分量的呀,並不是每個點都有資格代表一個強連通分量的。
注意到之前沒有自主的出棧操作,也就是說,else if(vis[v])
這一塊可能存在前向、後向、橫叉三種邊。
對於前向邊 \(u\rightarrow v\),\(dfn_v>dfn_u\) 恆成立,所以 \(low_u\) 不會更新。
對於後向邊 \(u\rightarrow v\),\(dfn_v<dfn_u\) 恆成立,所以 \(low_u\) 可能更新。
對於橫叉邊 \(u\rightarrow v\),由於“\(v\) 被訪問過且在棧中”,所以 \(v\) 比 \(u\) 先訪問,所以 \(dfn_v<dfn_u\) 恆成立,\(low_u\) 可能更新。
我們要找到一個點“能夠代表一個強連通分量”,這個點應該滿足什麼性質?當然是以它出發的邊中,既無橫叉邊,又無後向邊。這樣的點在每一個強連通分量中只有一個,所以具有代表性。
下證為什麼這樣的點在每一個強連通分量中只有一個,懂得的同學可以不看。
很簡單,如果有兩個,以其中一個為祖先開始沿深度優先樹 \(\text{DFS}\) 遍歷,另一個點是連不回來的,沒有向回的路徑,也就是說這兩個點不是能互相到達的。與“強連通分量”矛盾,舍。
這裡就體現了 \(low\) 陣列的作用了。什麼情況下,以一個點 \(u\) 出發的邊中,既無橫叉邊,又無後向邊?只有 \(low_u=dfn_u\) 的時候。
在棧中,這個點 \(u\) 到棧頂的所有點都能構成一個“強連通分量”。也就是說,當且僅當 \(dfn_u=low_u\) 的時候,我們才把 \(u\) 以上的所有點出棧,使它們構成一個強連通分量。
為什麼這樣是對的?因為棧中從 \(u\) 到棧頂所有點,除 \(u\) 以外,都是有橫叉邊或者後向邊的。也正因為如此,它們才會被壓在棧中。
現在 \(u\) 出現了,它可以代表一個強連通分量,於是棧中所有點都找到了自己的“歸屬”。
於是,至此 \(\text{Tarjan}\) 縮點功德圓滿。
void Tarjan(int u){
low[u]=dfn[u]=++tim;//tim表示訪問到的時間
stk[++tp]=u;//用陣列模擬棧
vis[u]=true;//vis[u]表示u是否在棧中
for(int v:g[u]){//掃描每一條從u出發到v結束的邊
if(!dfn[v]){//v還沒有被訪問過
Tarjan(v);//掃描v的子樹
low[u]=min(low[u],low[v]);//更新u的low值
}
else if(vis[v]){//v被訪問過且在棧中
low[u]=min(low[u],dfn[v]);//更新u的low值
}
}
if(dfn[u]==low[u]){//能夠代表一個強連通分量的點
int v;
++scc_cnt;//增加一個強連通分量
do{
v=stk[tp--];//出棧
vis[v]=false;//不再在棧中
scc[v]=scc_cnt;//v所屬的最大強連通分量是scc_cnt
}while(u!=v);
}
}
給道例題
3.\(\text{Tarjan}\) 的割點割邊演算法邏輯
1.定義
首先,我們要明白,這一類問題是多種多樣的,而我們要用一個演算法全部解決。
這類問題可不是簡簡單單判個雙連通就能夠結束的。圖中有多少個割點?多少條割邊?刪去割點最多剩下多少連通塊?
2.主演算法邏輯
首先我們來說割點。
割點演算法的起步當然是判定割點。我們還是以深度優先樹為基,討論:什麼情況下一個點才是割點?
考慮割點定義。如果把某個點 \(u\) 刪除,圖不連通,那麼 \(u\) 就是割點。在深度優先樹上,這條性質表現為:在 \(u\) 的兒子集合 \(son_u\) 中,如果存在一個點 \(v\in son_u\),使得 \(v\) 的子樹中沒有點能夠到達 \(u\) 的祖先(不包括 \(u\)),那麼自然而然 \(u\) 就是一個割點。
欸,但是有一個特例。如果 \(u\) 本身就是深度優先樹的根節點,那一定沒有點可以到達 \(u\) 的祖先(不包括 \(u\)),是不是 \(u\) 一定是割點呢?不是的。如果 \(u\) 只有一個子樹,那麼 \(u\) 不是割點;如果 \(u\) 有兩個以上的子樹,說明 \(u\) 就一定是割點了。
那要怎麼寫進程式碼呢?上一次我說這句話的時候,引出了追溯值 \(low\),這次也不會例外。
我們定義一個追溯值 \(low\),那麼對於一個結點 \(u\),它的追溯值 \(low_u\) 就表示其子樹中所有結點能夠抵達的時間戳最小的結點。
我們想想,是不是當 \(dfn_u\le low_v(v\in son_u)\) 的時候,就說明 \(u\) 是一個割點了?因為沒有方式能夠從 \(v\) 的子樹到達 \(u\) 的祖先(後向邊)或者離開 \(u\) 的子樹通向外界(橫叉邊)。這裡 \(u\) 的祖先不包括 \(u\)。
為什麼 \(dfn_u=low_v\) 時 \(u\) 依然是割點呢?因為 \(u\) 刪去後,和 \(u\) 所有相關的邊都會刪去,就算能夠到達 \(u\) 也沒關係。
那麼割點就這樣愉快地判完了,比縮點簡單得多。
割邊與割點的判定十分相似,甚至一模一樣。如果沒有方式能夠從 \(v\) 的子樹到達 \(u\) 的祖先(後向邊)或者離開 \(u\) 的子樹通向外界(橫叉邊),說明 \(e=u\rightarrow v\) 就是一條割邊了。\(u\) 的子樹不包括 \(u\)。
我們觀察到這裡實際上和割點判定只有一個條件不一樣,也就是“\(u\) 的子樹不包括 \(u\)”。那麼,在割點中,就算 \(dfn_u=low_v\), \(u\) 依然是割點,原因已經解釋。但是,在割邊中,\(dfn_u=low_v\) 的時候,\(e\) 就不是一條割邊了,因為 \(u\) 並不會隨 \(e\) 的刪去而刪去,\(v\) 子樹中的結點仍然可以到達 \(u\)。所以,割邊的判定條件是:\(dfn_u<low_v\)。
3.程式碼
這割點割邊可比縮點好寫多了,也好理解多了……
在這裡只放一個割點的程式碼:
void Tarjan(int u){
dfn[u]=low[u]=++tim;//初次訪問
int cnt=0;//砍掉u後圖分裂成cnt連通塊
for(int v:g[u]){//掃描u的每一個兒子v
if(!dfn[v]){
Tarjan(v);//掃描v子樹
low[u]=min(low[v],low[u]);//更新low值
if(dfn[u]<=low[v]){
++cnt;
}
}
else{
low[u]=min(low[u],dfn[v]);//更新u子樹中能夠到達的結點dfn最小值
}
}
if(u!=anc){
++cnt;
}
if(cnt>=2){
iscut[u]=true;//其是割點
}
}