Tarjan演算法及其應用 總結+詳細講解+詳細程式碼註釋

KarmaticEnding發表於2024-09-22

\(\text{Tarjan}\)

1.引入概念

1.強連通分量

1.定義

在有向圖 \(G\) 中,強連通分量就是滿足以下條件的 \(G\) 的子圖:

  • 從任意一點出發,都有到達另一個點的路徑。
  • 不存在一個或者幾個點,使得這個子圖加入這些點後,這個子圖還滿足上一個性質。

為什麼要限定”在有向圖中“而不是”在任何一個圖中“?這是因為在無向圖中,只要圖連通,那麼任意一點都有到另外一點的路徑,可以用並查集維護,這個時候 \(\text{Tarjan}\) 就沒有存在的必要了。

當然,後面 \(\text{Tarjan}\) 會有在無向圖中的用處。

2.性質

我們想一想,一個強連通分量中,既然每一個點都有到達另外一個點的路徑,那麼對於這個強連通分量中的任意一條邊 \(u\rightarrow v\) 的兩個端點來說,都存在 \(v\)\(u\) 的路徑,那麼這個強連通分量一定在一個”大環“上。

當然了,這個”大環”不是嚴格意義上的環,它可以經過重複的點,就像這樣:

.png

這個圖告訴了我們什麼?一個強連通分量要麼是一個大環,要麼是由幾個有交點的小環組成的。

後面的講解中,我們會反覆提到“大環”,請注意這個概念。“大環”表示一個子圖中,從任意一個點出發,不重不漏地經過每條邊一次,最後能夠回到這個點。“大環”和強連通分量、環的關係是這樣的:環 \(\subsetneq\) “大環”,“大環” \(\subsetneq\) 強連通分量。

2.時間戳

1.定義

按照 \(\text{DFS}\) 遍歷的過程,以每個結點第一次被訪問時的順序,依次給 \(n\) 個結點標上 \(1\sim n\) 的標記,那麼這個標記就叫做時間戳,記作 \(dfn\)

2.作用

這個在講 \(\text{Tarjan}\) 的縮點演算法邏輯的時候會講到。

3.邊的分類

1.深度優先樹的定義

我們在執行 \(\text{DFS}\) 演算法的時候,會經過 \(n-1\) 條邊和所有 \(n\) 個點,這些邊和點會構成一棵樹,叫做深度優先樹。

就比如下面這個圖,它的深度優先樹就是紅色邊:

graph.png

這裡說明一個事情:本篇中所有形如 “\(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;//其是割點
	}
}

相關文章