Tarjan 算法的應用


寫在前面

近期一直在刷這方面的題,因為沒法學新知識,但又想寫點什么,就水篇博文吧。

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\)

  1. \(v\) 未被訪問:繼續對 \(v\) 進行深度搜索。在回溯過程中,用 \(low_v\) 更新 \(low_u\)。因為存在從 \(u\)\(v\) 的直接路徑,所以 \(v\) 能夠回溯到的已經在棧中的結點, \(u\) 也一定能夠回溯到。

  2. \(v\) 被訪問過,已經在棧中:即已經被訪問過,根據 low 值的定義(能夠回溯到的最早的已經在棧中的結點),則用 \(dfn_v\) 更新 \(low_u\)

  3. \(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 值。

每次枚舉一個點,判斷這個點是否為割點的依據是:

  1. 如果它有至少一個兒子的 low 值大於它本身的 DFS 序,那么它就是割點;
  2. 如果它本身被搜到且有不少於兩個兒子,那么它就是割點。

對於第一個依據的說明是:若一個兒子的 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,需要用到並查集來維護某個結點的祖先結點。

  1. 首先接受輸入、查詢。查詢邊其實是虛擬加上去的邊,為了方便,每次輸入查詢邊的時候,將這個邊及其反向邊都存上。

  2. 然后對其進行一次 DFS 遍歷,同時記錄某個結點是否被訪問過以及當前結點的父親結點。

  3. 其中涉及到了回溯思想,我們每次遍歷到某個結點的時候,認為這個結點的根結點就是它本身。讓以這個結點為根節點的 DFS 全部遍歷完畢了以后,再將這個結點的根節點 設置為這個結點的父一級結點。

  4. 回溯的時候,如果以該節點為起點時,查詢邊的另一個結點也恰好訪問過了,則直接更新查詢邊的 LCA 結果。

  5. 最后輸出結果

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 來求它的各種值等。

最主要的還是理解它的思想。

希望對大家有幫助。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM