寫在前面
近期一直在刷這方面的題,因為沒法學新知識,但又想寫點什么,就水篇博文吧。
Upd on 2021.6.27:修了下排版和部分錯誤,同時重寫了下代碼。
關於 Tarjan算法
發明者 Robert E.Tarjan 羅伯特·塔揚,美國計算機科學家。
塔老爺子發明過很多算法,而且大多是以他的名字命名的,所以 Tarjan算法 也分很多種。
這里主要講 縮點,割點,割邊,2-SAT 以及如何求 LCA。
引理一
什么是強連通分量?
強連通分量的定義是:極大的強連通子圖。又叫 SCC。
簡單來說,在一個有向圖中,若所有點之間兩兩互相直接可達,則將這個圖成為強連通分量。
求一個圖中的強連通分量可以使用 Tarjan,Kosaraju 或者 Garbow 算法。
引理二
什么是雙聯通分量?
雙聯通分為點雙聯通與邊雙連通兩種。
在一張連通的無向圖中,對於兩個點 \(u\) 和 \(v\),如果無論刪去哪一條邊都不能使它們不連通,我們就說 \(u\) 和 \(v\) 邊雙連通。
在一張連通的無向圖中,對於兩個點 \(u\) 和 \(v\) ,如果無論刪去哪一個除自己之外的點都不能使它們不連通,我們就說 \(u\) 和 \(v\) 點雙連通。
這里有兩個結論:
-
邊雙連通具有傳遞性,即若 \(x\),\(y\) 邊雙連通, \(y\),\(z\) 邊雙連通,則 \(x\),\(z\) 邊雙連通。
-
點雙連通不具有傳遞性。
手玩幾組樣例即可證明,比較顯然。
有向圖縮點
縮點,簡單說就是把一個圖中所有的強連通分量縮成一個點,使之形成一個 DAG。
縮完點后的圖中每個點會有一個新的編號,同處一個強連通分量中的點編號相同。
想要完成這一操作,首先需要知道什么是 DFS 序。
一個結點 \(x\) 的 DFS 序是指深度優先搜索遍歷時改結點被搜索的次序,簡記為 \(dfn_x\)。
然后,再維護另一個變量 \(low_x\)。
\(low_x\) 表示以下節點的 DFS 序的最小值:以 \(x\) 為根的子樹中的結點 和 從該子樹通過一條不在搜索樹上的邊能到達的結點。
根據 DFS 的遍歷原理可以發現:
-
一個結點的子樹內結點的 DFS 序都大於該結點的 DFS 序;
-
從根開始的一條路徑上的 DFS 序嚴格遞增,low 值嚴格非降。
知道了這些,再來看 Tarjan 算法求強連通分量的具體內容。
我們一般只對還沒有確定其 DFS 序的節點進行操作,操作主要包括兩個部分。
第一部分
以 DFS 的形式,處理出當前點 \(x\) 的 \(dfn_x\) 和 \(low_x\)。
對當前點打一個標記表示已經遍歷過,在之后的 DFS 中根據是否遍歷過來進行不同處理,具體方式如下:
設當前枚舉點為 \(u\),\(u\) 連出去的點記為 \(v\)。
-
\(v\) 未被訪問:繼續對 \(v\) 進行深度搜索。在回溯過程中,用 \(low_v\) 更新 \(low_u\)。因為存在從 \(u\) 到 \(v\) 的直接路徑,所以 \(v\) 能夠回溯到的已經在棧中的結點, \(u\) 也一定能夠回溯到。
-
\(v\) 被訪問過,已經在棧中:即已經被訪問過,根據 low 值的定義(能夠回溯到的最早的已經在棧中的結點),則用 \(dfn_v\) 更新 \(low_u\) 。
-
\(v\) 被訪問過,已不在在棧中:說明 \(v\) 已搜索完畢,其所在連通分量已被處理,所以不用對其做操作。
這一部分代碼實現如下:
low[fr]=dfn[fr]=++cnt;vis[fr]=1;
for(int i=head[fr];i;i=e[i].nxt){
int to=e[i].to;
if(!dfn[to]) tarjan(to),low[fr]=min(low[fr],low[to]);
else if(vis[to]) low[fr]=min(low[fr],dfn[to]);
}
第二部分
對於一個連通分量圖,我們很容易想到,在該連通圖中有且僅有一個 \(dfn_x=low_x\)。
該結點一定是在深度遍歷的過程中,該連通分量中第一個被訪問過的結點,因為它的 DFS 序和 low 值最小,不會被該連通分量中的其他結點所影響。
我們可以維護一個棧,存儲所有枚舉到的點。
在回溯的過程中,判定 \(dfn_x=low_x\) 的條件是否成立,如果成立,則從棧中取出一個點,處理它所在的強連通分量的編號以及大小,也可以處理其他的一些操作,這樣直到把所有點處理完為止。
這一部分的代碼實現如下:
zhan[++top]=u;
if(dfn[u]==low[u]){
++t;
int pre=zhan[top--];
vis[pre]=0;
...//相應操作
while(pre!=u){
pre=zhan[top--];
vis[pre]=0;
...//相應操作
}
}
至此,便可以處理出一個點所在的強連通分量,時間復雜度為 \(O(n+m)\)。
無向圖縮點
這里說的其實是求無向圖的雙聯通分量。
可以處理割點與橋以及雙聯通分量相關的一些題。
邊雙連通
因為是無向圖,必須加兩條邊,而加兩條邊后跑 Tarjan 會很麻煩。
這里有另一個處理方法:通過 異或 來一次選中兩條邊。
我們知道 \(0\oplus1=1\),\(1\oplus1=0\);\(2\oplus1=3\),\(3\oplus1=2\);\(4\oplus1=5\),\(5\oplus1=4\)。
而建邊的時候兩條邊的編號相差 \(1\),所以可以每次處理第 \(i\) 條邊的時候處理第 \(i\oplus 1\) 條邊,解決這個問題。
而有向圖和無向圖 Tarjan 的寫法也差不多,low 值的更新方式和縮點的編號等都相同,只有標記的地方不一樣。
代碼實現如下:
void tarjan(int u){
low[u]=dfn[u]=++cnt;zhan[++top]=u;
for(int i=head[u];i;i=e[i].nxt){
if(!vis[i]){
vis[i]=vis[i^1]=1;
int to=e[i].to;
if(!dfn[to]) tarjan(to),low[u]=min(low[u],low[to]);
else low[u]=min(low[u],dfn[to]);
}
}
if(dfn[u]==low[u]){
++t;
int pre=zhan[top--];
...//相應操作
while(pre!=u){
pre=zhan[top--];
...//相應操作
}
}
}
點雙聯通
舍去了對邊的判斷,也不需要處理雙向邊這種問題。
代碼如下:
void tarjan(int u){
zhan[++Top]=u;dfn[u]=low[u]=++cnt;
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
if(!dfn[to]){
tarjan(to);low[u]=min(low[u],low[to]);
if(low[to]>=dfn[u]){
++t;int pre;
do{
pre=zhan[Top--];
...//相應操作
}while(pre!=to);
...//相應操作
}
}
else low[u]=min(low[u],dfn[to]);
}
}
2-SAT
SAT 是適定性(Satisfiability)問題的簡稱。一般形式為 k-適定性問題,簡稱 k-SAT。而當 \(k>2\) 時該問題為 NP 完全的。所以我們只研究 \(k=2\) 的情況。 —— OI Wiki
個人感覺,就是一個實際應用類的知識吧。
就是指定 \(n\) 個集合,每個集合包含兩個元素,給出若干個限制條件,每個條件規定不同集合中的某兩個元素不能同時出現,最后問在這些條件下能否選出 \(n\) 個不在同一集合中的元素。
這個問題一般用 Tarjan 來求解,也可以暴搜,可以參考 OI Wiki 上的說明,這里就只講用 Tarjan 實現。
但這種問題的實現主要不是難在 Tarjan 怎么寫,而是難在圖怎么建。
我們可以考慮怎么通過圖來構造其中的關系。
既然給出了條件 \(a\) 和 \(b\),必須只滿足其中之一,那么存在兩種情況,一是選擇 \(a\) 與 \(\lnot b\),二是選擇 \(b\) 與 \(\lnot a\)。
那我們就可以將 \(a\) 連向 \(\lnot b\),\(b\) 連向 \(\lnot a\),表示選了 \(a\) 必須選 \(\lnot b\),選了 \(b\) 必須選 \(\lnot a\)。
舉個例子,假設這里有兩個集合 \(A=\{x_1,y_1\}\),\(B=\{x_2,y_2\}\),規定 \(x_1\) 與 \(y_2\) 不可同時出現,那我們就建兩條有向邊 \((x_1,y_1)\),\((y_2,x_2)\),表示選了 \(x_1\) 必須選 \(y_1\),,選了 \(y_2\) 必須選 \(x_2\)。
這樣建完邊之后只需要跑一邊 Tarjan 縮點判斷有無解,若有解就把幾個不矛盾的強連通分量拼起來就好了。
這里注意,因為跑 Tarjan 用了棧,根據拓撲序的定義和棧的原理,可以得到跑出來的強連通分量編號是反拓撲序這一結論。
我們就可以利用這一結論,在輸出方案時倒序得到拓撲序,然后確定變量取值即可。
具體形如這樣:
//mem[i] 表示非 i
for(int i=1;i<=n;i++)if(num[i]==num[mem[i]]){printf("無解");return 0;}//若兩條件必須同時選,則不成立
for(int i=1;i<=n*2;i++)if(num[i]<num[mem[i]]) printf("%d\n",i);return 0;//輸出其中一種選擇方案
時間復雜度為 \(O(n+m)\)。
求割點
什么是割點?
如果在一個無向圖中,刪去某個點可以使這個圖的極大連通分量數增加,那么這個點被稱為這個圖的割點,也叫割頂。
求割點比較暴力的做法是,對於每個點嘗試刪除然后判斷圖的連通性,不過顯然復雜度極高。
考慮用 Tarjan 做。
同縮點一樣,用 Tarjan 求割點也需要處理出點的 DFS 序和 low 值。
每次枚舉一個點,判斷這個點是否為割點的依據是:
- 如果它有至少一個兒子的 low 值大於它本身的 DFS 序,那么它就是割點;
- 如果它本身被搜到且有不少於兩個兒子,那么它就是割點。
對於第一個依據的說明是:若一個兒子的 low 值大於它本身的 DFS 序,說明刪去它之后它的這個兒子無法回到祖先點,那么它肯定是割點。
對於第二個依據的說明是:若它的兒子小於兩個,那么刪去他不會造成任何影響,所以它不會是割點。
更新 low 值的方式與縮點相似,但是約束條件不同,放偽代碼感性理解一下:
如果 v 是 u 的兒子 low[u] = min(low[u], low[v]);
否則 low[u] = min(low[u], num[v]);
其實割點 Tarjan 的全部代碼實現有很多別的細節,原理很簡單,代碼實現如下:
void tarjan(int u,int fa){
vis[u]=1;int chi=0;//統計孩子數量
dfn[u]=low[u]=++cnt;
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
if(!vis[to]){
chi++;tarjan(to,u);
low[u]=min(low[to],low[u]);
if(fa!=u&&low[to]>=dfn[u]&&!flag[u]){//第一個依據
flag[u]=1;
res++;//割點數量
}
}
else if(to!=fa)
low[u]=min(low[u],dfn[to]);
}
if(fa==u&&chi>=2&&!flag[u]){//第二個依據
flag[u]=1;
res++;
}
}
但是這樣跑 Tarjan 針對的不是沒有確定 DFS 序的點,而是沒有訪問過的點,並且每次初始父親都是自己。
也就是這樣:
for(int i=1;i<=n;i++) if(!vis[i]) cnt=0,tarjan(i,i);
這樣跑一邊 Tarjan 后,帶有 \(flag\) 標記的點就是割點。
求割邊
按割點的理解方式,割邊應該是刪去后能使無向圖極大連通分量數量增加的邊。
沒錯,就是這樣。
割邊,也叫橋。嚴謹來說,假設有連通圖 \(G=\{V,E\}\),\(e\) 是其中一條邊(即 \(e\in E\)),如果 \(G-e\) 是不連通的,則邊 \(e\) 是圖 \(G\) 的一條割邊(橋)。
原理和割點差不多,實現也差不多,只要改一處:\(low_v>dfn_u\) 就可以了,而且不需要考慮根節點的問題。
與判斷割點的第一條依據類似,當一條邊 \((u,v)\) 的 \(low_v>dfn_u\) 時,刪去這條邊,\(v\) 就無法回到祖先節點,因此滿足此條件的邊就是圖的割邊。
代碼實現如下:
void tarjan(int u,int fat){
fa[u]=fat;
low[u]=dfn[u]=++cnt;
for(int i=head[u];i;i=e[i].nxt){
int v=e[i].to;
if(!dfn[v]){
tarjan(v,u);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]){vis[v]=true;++bri;}//bri 是割邊的數量
}
else if(dfn[v]<dfn[u]&&v!=fat)
low[u]=min(low[u],dfn[v]);
}
}
其中,當 \(vis_x=1\) 時,\((fa_x,x)\) 是一條割邊。
求 LCA
用 Tarjan 來求 LCA,需要用到並查集來維護某個結點的祖先結點。
-
首先接受輸入、查詢。查詢邊其實是虛擬加上去的邊,為了方便,每次輸入查詢邊的時候,將這個邊及其反向邊都存上。
-
然后對其進行一次 DFS 遍歷,同時記錄某個結點是否被訪問過以及當前結點的父親結點。
-
其中涉及到了回溯思想,我們每次遍歷到某個結點的時候,認為這個結點的根結點就是它本身。讓以這個結點為根節點的 DFS 全部遍歷完畢了以后,再將這個結點的根節點 設置為這個結點的父一級結點。
-
回溯的時候,如果以該節點為起點時,查詢邊的另一個結點也恰好訪問過了,則直接更新查詢邊的 LCA 結果。
-
最后輸出結果
Tarjan 算法需要初始化並查集,所以預處理的時間復雜度為 \(O(n)\) ,Tarjan 算法處理所有 \(m\) 次詢問的時間復雜度為 \(O(n+m)\) 。但是 Tarjan 算法的常數比倍增算法大。
需要注意的是,Tarjan 算法中使用的並查集性質比較特殊,在僅使用路徑壓縮優化的情況下,單次調用 find()
函數的時間復雜度為均攤 \(O(1)\) ,而不是 \(O(\log n)\) 。
代碼實現如下:
void tarjan(int u) {
parent[u] = u;
visited[u] = 1;
for (int i = head[u]; i != -1; i = edge[i].next) {
Edge& e = edge[i];
if (!visited[e.toVertex]) {
tarjan(e.toVertex);
parent[e.toVertex] = u;
}
}
for (int i = queryHead[u]; i != -1; i = queryEdge[i].next) {
Edge& e = queryEdge[i];
if (visited[e.toVertex]) {
queryEdge[i ^ 1].LCA = e.LCA = find(e.toVertex);
}
}
}
注:此代碼來自 OI Wiki。
總的來說,Tarjan 算法求 LCA,擁有更優秀的時間復雜度,但它的常數也更大。
倍增來求 LCA 更易理解,也更實用。
例題
「一本通 3.6 例 1」分離的路徑
「一本通 3.6 例 2」礦場搭建
[APIO2009]搶掠計划
[USACO5.3]校園網Network of Schools
[ZJOI2007]最大半連通子圖
[POI2001]和平委員會
寫在后面
Tarjan 雖說為了實現不同的目的有不同的寫法,但是卻沒有固定的模板,可以根據自己喜好來變通,也可以更改其中維護的變量。
還可以對同一個圖跑多種 Tarjan 來求它的各種值等。
最主要的還是理解它的思想。
希望對大家有幫助。