演算法學習筆記:連通圖詳解
阿新 • • 發佈:2020-08-06
## 什麼是連通圖 ?
在圖論中,連通圖基於連通的概念。在一個無向圖 G 中,若從頂點 $i$ 到頂點 $j$ 有路徑相連(當然從 $j$ 到 $i$ 也一定有路徑),則稱 $i$ 和 $j$ 是連通的。如果 G 是有向圖,那麼連線 $i$ 和j的路徑中所有的邊都必須同向。如果圖中任意兩點都是連通的,那麼圖被稱作連通圖。如果此圖是有向圖,則稱為強連通圖(注意:需要雙向都有路徑)。
簡單的來講就是,
強連通的定義是:有向圖 G 強連通是指,G 中任意兩個結點連通。
強連通分量`(Strongly Connected Components,SCC)`的定義是:極大的強連通子圖。
這裡將會介紹的是如何來求強連通分量的演算法、割點和橋 以及雙連通分量。
## Tarjan 演算法發明人
`Robert E. Tarjan `(1948~) 美國人。
你是不是感覺Robert E. Tarjan 這個名字很熟悉?
沒錯,Robert E. Tarjan和John E. Hopcroft就是發明了深度優先搜尋的兩個人——1986年的圖靈獎得主。
除此之外 Tarjan 發明了很多演算法結構。光 Tarjan 演算法就有很多,比如求各種連通分量的 Tarjan 演算法,求 LCA(Lowest Common Ancestor,最近公共祖先)的 Tarjan 演算法。並查集、Splay、Toptree 也是 Tarjan 發明的。
你看牛人們從來都不閒著的。他們到處交流,尋找合作伙伴,一起改變世界。
我們這裡要介紹的是他的在有向圖中求強連通分量的 Tarjan 演算法。
另外,Tarjan 的名字 `j` 不發音,中文譯為塔揚。
### DFS 生成樹
在介紹該演算法之前,先來了解 **DFS 生成樹** ,我們以下面的有向圖為例:
![scc1.png](https://gitee.com//riotian/blogimage/raw/master/img/20200728145634.png)
有向圖的 DFS 生成樹主要有 4 種邊(不一定全部出現):
1. 樹邊(tree edge):綠色邊,每次搜尋找到一個還沒有訪問過的結點的時候就形成了一條樹邊。
2. 反祖邊(back edge):黃色邊,也被叫做回邊,即指向祖先結點的邊。
3. 橫叉邊(cross edge):紅色邊,它主要是在搜尋的時候遇到了一個已經訪問過的結點,但是這個結點 **並不是** 當前結點的祖先時形成的。
4. 前向邊(forward edge):藍色邊,它是在搜尋的時候遇到子樹中的結點的時候形成的。
我們考慮 DFS 生成樹與強連通分量之間的關係。
如果結點 $u$ 是某個強連通分量在搜尋樹中遇到的第一個結點,那麼這個強連通分量的其餘結點肯定是在搜尋樹中以 $u$ 為根的子樹中。 $u$ 被稱為這個強連通分量的根。
反證法:假設有個結點 $v$ 在該強連通分量中但是不在以 $u$ 為根的子樹中,那麼 $u$ 到 $v$ 的路徑中肯定有一條離開子樹的邊。但是這樣的邊只可能是橫叉邊或者反祖邊,然而這兩條邊都要求指向的結點已經被訪問過了,這就和 $u$ 是第一個訪問的結點矛盾了。得證。
### Tarjan 演算法求強連通分量
在 `Tarjan` 演算法中為每個結點 $u$ 維護了以下幾個變數:
1. $dfn[u]$ :深度優先搜尋遍歷時結點 $u$ 被搜尋的次序。
2. $low[u]$ :設以 $u$ 為根的子樹為 $Subtree(u)$ 。 $low[u]$ 定義為以下結點的 $dfn$ 的最小值: $Subtree(u)$ 中的結點;從 $Subtree(u)$ 通過一條不在搜尋樹上的邊能到達的結點。
> ps:每次找到一個新點,這個點$low\ []=dfn\ []$。
一個結點的子樹內結點的 dfn 都大於該結點的 dfn。
從根開始的一條路徑上的 dfn 嚴格遞增,low 嚴格非降。
按照深度優先搜尋演算法搜尋的次序對圖中所有的結點進行搜尋。在搜尋過程中,對於結點 $u$ 和與其相鄰的結點 $v$ (v 不是 u 的父節點)考慮 3 種情況:
1. $v$ 未被訪問:繼續對 $v$ 進行深度搜索。在回溯過程中,用 $low[v]$ 更新 $low[u]$ 。因為存在從 $u$ 到 $v$ 的直接路徑,所以 $v$ 能夠回溯到的已經在棧中的結點, $u$ 也一定能夠回溯到。
2. $v$ 被訪問過,已經在棧中:即已經被訪問過,根據 $low$ 值的定義(能夠回溯到的最早的已經在棧中的結點),則用 $dfn[v]$ 更新 $low[u]$ 。
3. $v$ 被訪問過,已不在在棧中:說明 $v$ 已搜尋完畢,其所在連通分量已被處理,所以不用對其做操作。
將上述演算法寫成虛擬碼:
```cpp
TARJAN_SEARCH(int u)
vis[u]=true
low[u]=dfn[u]=++dfncnt // 為節點u設定次序編號和Low初值
push u to the stack // 將節點u壓入棧中
for each (u,v) then do // 列舉每一條邊
if v hasn't been search then // 如果節點v未被訪問過
TARJAN_SEARCH(v) // 繼續向下搜尋
low[u]=min(low[u],low[v]) // 回溯
else if v has been in the stack then // 如果節點u還在棧內
low[u]=min(low[u],dfn[v])
if (DFN[u] == Low[u]) // 如果節點u是強連通分量的根
repeat v = S.pop // 將v退棧,為該強連通分量中一個頂點
print v
until (u== v)
```
對於一個連通分量圖,我們很容易想到,在該連通圖中有且僅有一個 $dfn[u]=low[u]$ 。該結點一定是在深度遍歷的過程中,該連通分量中第一個被訪問過的結點,因為它的 DFN 值和 LOW 值最小,不會被該連通分量中的其他結點所影響。
因此,在回溯的過程中,判定 $dfn[u]=low[u]$ 的條件是否成立,如果成立,則棧中從 $u$ 後面的結點構成一個 SCC (強連通分量)。
### 實現
```cpp
int dfn[N], low[N], dfncnt, s[N], in_stack[N], tp;
int scc[N], sc; // 結點 i 所在 scc 的編號
int sz[N]; // 強連通 i 的大小
void tarjan(int u) {
low[u] = dfn[u] = ++dfncnt, s[++tp] = u, in_stack[u] = 1;
for (int i = h[u]; i; i = e[i].nex) {
const int &v = e[i].t;
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (in_stack[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
++sc;
while (s[tp] != u) {
scc[s[tp]] = sc;
sz[sc]++;
in_stack[s[tp]] = 0;
--tp;
}
scc[s[tp]] = sc;
sz[sc]++;
in_stack[s[tp]] = 0;
--tp;
}
}
```
時間複雜度 $O(n + m)$ 。
> `Tarjan 演算法` 有許多用途,**常用的例如求[強連通分量](),縮點,還有求 2-SAT 的用途等。**
>
> **縮點:**
>
> 縮點就是要將兩兩之間可以相互到達的點,縮成一個點來表示(即求無向圖的連通分量,或者有向圖的強連通分量),他們的求法都是相同的,都是將無向圖轉化為有向圖。
> 基本上都是以Trajan演算法寫題的。
## Kosaraju 演算法
`Kosaraju` 演算法依靠兩次簡單的 DFS 實現。
第一次 DFS,選取任意頂點作為起點,遍歷所有未訪問過的頂點,並在回溯之前給頂點編號,也就是後序遍歷。
第二次 DFS,對於反向後的圖,以標號最大的頂點作為起點開始 DFS。這樣遍歷到的頂點集合就是一個強連通分量。對於所有未訪問過的結點,選取標號最大的,重複上述過程。
兩次 DFS 結束後,強連通分量就找出來了,Kosaraju 演算法的時間複雜度為 $O(n+m)$ 。
### 實現
```cpp
// g 是原圖,g2 是反圖
void dfs1(int u) {
vis[u] = true;
for (int v : g[u])
if (!vis[v]) dfs1(v);
s.push_back(u);
}
void dfs2(int u) {
color[u] = sccCnt;
for (int v : g2[u])
if (!color[v]) dfs2(v);
}
void kosaraju() {
sccCnt = 0;
for (int i = 1; i <= n; ++i)
if (!vis[i]) dfs1(i);
for (int i = n; i >= 1; --i)
if (!color[s[i]]) {
++sccCnt;
dfs2(s[i]);
}
}
```
## Garbow 演算法
$Tarjan$ 演算法和 $Garbow$ 演算法是同一個思想的不同實現,但是 $Garbow$ 演算法更加精妙,時間更少,不用頻繁更新 $ low $。
## 應用
我們可以將一張圖的每個強連通分量都縮成一個點。
然後這張圖會變成一個 `DAG`(為什麼?)。
DAG 好啊,能拓撲排序了就能做很多事情了。
舉個簡單的例子,求一條路徑,可以經過重複結點,要求經過的不同結點數量最多。
---
瞭解完強連通分量的幾個演算法以後來看看什麼是 **割點和橋**。
割點和橋更嚴謹的定義參見wiki上 [圖論相關概念](https://zh.wikipedia.org/zh-hans/%E5%9B%BE%E8%AE%BA) 。
## 割點
> 對於一個無向圖,如果把一個點刪除後這個圖的極大連通分量數增加了,那麼這個點就是這個圖的割點(又稱割頂)。
### 如何實現?
如果我們嘗試刪除每個點,並且判斷這個圖的連通性,那麼複雜度會特別的高。所以要先序學會常用的演算法:[Tarjan]() 。
首先,我們上一個圖:
![](https://gitee.com//riotian/blogimage/raw/master/img/20200729155129.png)
很容易的看出割點是 2,而且這個圖僅有這一個割點。
首先,我們按照 DFS 序給他打上時間戳(訪問的順序)。
![](https://gitee.com//riotian/blogimage/raw/master/img/20200729155130.png)
這些資訊被我們儲存在一個叫做 `num` 的陣列中。
還需要另外一個數組 `low` ,用它來儲存不經過其父親能到達的最小的時間戳。
例如 `low[2]` 的話是 1, `low[5]` 和 `low[6]` 是 3。
然後我們開始 DFS,我們判斷某個點是否是割點的根據是:對於某個頂點 $u$ ,如果存在至少一個頂點 $v$ ( $u$ 的兒子),使得 $low_v \geq num_u$ ,即不能回到祖先,那麼 $u$ 點為割點。
另外,如果搜到了自己(在環中),如果他有兩個及以上的兒子,那麼他一定是割點了,如果只有一個兒子,那麼把它刪掉,不會有任何的影響。比如下面這個圖,此處形成了一個環,從樹上來講它有 2 個兒子:
![割邊示例圖](https://gitee.com//riotian/blogimage/raw/master/img/20200729155132.png)
我們在訪問 1 的兒子時候,假設先 DFS 到了 2,然後標記用過,然後遞迴往下,來到了 4,4 又來到了 3,當遞歸回溯的時候,會發現 3 已經被訪問過了,所以不是割點。
更新 `low` 的虛擬碼如下:
```cpp
如果 v 是 u 的兒子 low[u] = min(low[u], low[v]);
否則
low[u] = min(low[u], num[v]);
```
### 例題
[洛谷 P3388【模板】割點(割頂)](https://www.luogu.com.cn/problem/P3388)
```cpp
#include
using namespace std;
int n, m; // n:點數 m:邊數
int num[100001], low[100001], inde, res;
// num:記錄每個點的時間戳
// low:能不經過父親到達最小的編號,inde:時間戳,res:答案數量
bool vis[100001], flag[100001]; // flag: 答案 vis:標記是否重複
vector edge[100001]; // 存圖用的
void Tarjan(int u, int father) { // u 當前點的編號,father 自己爸爸的編號
vis[u] = true; // 標記
low[u] = num[u] = ++inde; // 打上時間戳
int child = 0; // 每一個點兒子數量
for (auto v : edge[u]) { // 訪問這個點的所有鄰居 (C++11)
if (!vis[v]) {
child++; // 多了一個兒子
Tarjan(v, u); // 繼續
low[u] = min(low[u], low[v]); // 更新能到的最小節點編號
if (father != u && low[v] >= num[u] &&
!flag[u]) // 主要程式碼
// 如果不是自己,且不通過父親返回的最小點符合割點的要求,並且沒有被標記過
// 要求即為:刪了父親連不上去了,即為最多連到父親
{
flag[u] = true;
res++; // 記錄答案
}
} else if (v != father)
low[u] = min(low[u], num[v]); // 如果這個點不是自己,更新能到的最小節點編號
}
if (father == u && child >= 2 && !flag[u]) { // 主要程式碼,自己的話需要 2 個兒子才可以
flag[u] = true;
res++; // 記錄答案
}
}
int main() {
cin >> n >> m; // 讀入資料
for (int i = 1; i <= m; i++) { // 注意點是從 1 開始的
int x, y;
cin > > x >> y;
edge[x].push_back(y);
edge[y].push_back(x);
} // 使用 vector 存圖
for (int i = 1; i <= n; i++) // 因為 Tarjan 圖不一定連通
if (!vis[i]) {
inde = 0; // 時間戳初始為 0
Tarjan(i, i); // 從第 i 個點開始,父親為自己
}
cout << res << endl;
for (int i = 1; i <= n; i++)
if (flag[i]) cout << i << " "; // 輸出結果
return 0;
}
```
## 割邊
和割點差不多,叫做橋。
> 對於一個無向圖,如果刪掉一條邊後圖中的連通分量數增加了,則稱這條邊為橋或者割邊。嚴謹來說,就是:假設有連通圖 $G=\{V,E\}$ , $e$ 是其中一條邊(即 $e \in E$ ),如果 $G-e$ 是不連通的,則邊 $e$ 是圖 $G$ 的一條割邊(橋)。
比如說,下圖中,
![](https://gitee.com//riotian/blogimage/raw/master/img/20200729155131.png)
紅色箭頭指向的就是割邊。
### 實現
和割點差不多,只要改一處: $low_v> num_u$ 就可以了,而且不需要考慮根節點的問題。
割邊是和是不是根節點沒關係的,原來我們求割點的時候是指點 $v$ 是不可能不經過父節點 $u$ 為回到祖先節點(包括父節點),所以頂點 $u$ 是割點。如果 $low_v=num_u$ 表示還可以回到父節點,如果頂點 $v$ 不能回到祖先也沒有另外一條回到父親的路,那麼 $u-v$ 這條邊就是割邊。
### 程式碼實現
下面程式碼實現了求割邊,其中,當 `isbridge[x]` 為真時, `(father[x],x)` 為一條割邊。
```cpp
int low[MAXN], dfn[MAXN], iscut[MAXN], dfs_clock;
bool isbridge[MAXN];
vector G[MAXN];
int cnt_bridge;
int father[MAXN];
void tarjan(int u, int fa) {
father[u] = fa;
low[u] = dfn[u] = ++dfs_clock;
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][i];
if (!dfn[v]) {
tarjan(v, u);
low[u] = min(low[u], low[v]);
if (low[v] > dfn[u]) {
isbridge[v] = true;
++cnt_bridge;
}
} else if (dfn[v] < dfn[u] && v != fa) {
low[u] = min(low[u], dfn[v]);
}
}
}
```
---
理解了基本的Tarjan演算法、割點和橋的概念以後就可以開始處理雙連通分量問題了。
## 雙連通分量定義
在一張連通的無向圖中,對於兩個點 $u$ 和 $v$ ,如果無論刪去哪條邊(只能刪去一條)都不能使它們不連通,我們就說 $u$ 和 $v$ **邊雙連通** 。
在一張連通的無向圖中,對於兩個點 $u$ 和 $v$ ,如果無論刪去哪個點(只能刪去一個,且不能刪 $u$ 和 $v$ 自己)都不能使它們不連通,我們就說 $u$ 和 $v$ **點雙連通** 。
邊雙連通具有傳遞性,即,若 $x,y$ 邊雙連通, $y,z$ 邊雙連通,則 $x,z$ 邊雙連通。
點雙連通 **不** 具有傳遞性,反例如下圖, $A,B$ 點雙連通, $B,C$ 點雙連通,而 $A,C$ **不** 點雙連通。
![bcc-counterexample.png](https://cdn.jsdelivr.net/gh/Kanna-jiahe/blogimage/img/20200805213558.svg)
## DFS
對於一張連通的無向圖,我們可以從任意一點開始 DFS,得到原圖的一棵生成樹(以開始 DFS 的那個點為根),這棵生成樹上的邊稱作 **樹邊** ,不在生成樹上的邊稱作 **非樹邊** 。
由於 DFS 的性質,我們可以保證所有非樹邊連線的兩個點在生成樹上都滿足其中一個是另一個的祖先。
DFS 的程式碼如下:
```cpp
void DFS(int p) {
visited[p] = true;
for (int to : edge[p])
if (!visited[to]) DFS(to);
}
```
## DFS 找橋並判斷邊雙連通
首先,對原圖進行 DFS。
![bcc-1.png](https://cdn.jsdelivr.net/gh/Kanna-jiahe/blogimage/img/20200805213559.svg)
如上圖所示,黑色與綠色邊為樹邊,紅色邊為非樹邊。每一條非樹邊連線的兩個點都對應了樹上的一條簡單路徑,我們說這條非樹邊 **覆蓋** 了這條樹上路徑上所有的邊。綠色的樹邊 **至少** 被一條非樹邊覆蓋,黑色的樹邊不被 **任何** 非樹邊覆蓋。
我們如何判斷一條邊是不是橋呢?顯然,非樹邊和綠色的樹邊一定不是橋,黑色的樹邊一定是橋。
如何用演算法去實現以上過程呢?首先有一個比較暴力的做法,對於每一條非樹邊,都逐個地將它覆蓋的每一條樹邊置成綠色,這樣的時間複雜度為 $O(nm)$ 。
怎麼優化呢?可以用差分。對於每一條非樹邊,在其樹上深度較小的點處打上 `-1` 標記,在其樹上深度較大的點處打上 `+1` 標記。然後 $O(n)$ 求出每個點的子樹內部的標記之和。對於一個點 $u$ ,其子樹內部的標記之和等於覆蓋了 $u$ 和 $u$ 的父親之間的樹邊的非樹邊數量。若這個值非 $0$ ,則 $u$ 和 $u$ 的父親之間的樹邊不是橋,否則是橋。
用以上的方法 $O(n+m)$ 求出每條邊分別是否是橋後,兩個點是邊雙連通的,當且僅當它們的樹上路徑中 **不** 包含橋。
## DFS 找割點並判斷點雙連通
![bcc-2.png](https://cdn.jsdelivr.net/gh/Kanna-jiahe/blogimage/img/20200805213600.svg)
如上圖所示,黑色邊為樹邊,紅色邊為非樹邊。每一條非樹邊連線的兩個點都對應了樹上的一條簡單路徑。
考慮一張新圖,新圖中的每一個點對應原圖中的每一條樹邊(在上圖中用藍色點表示)。對於原圖中的每一條非樹邊,將這條非樹邊對應的樹上簡單路徑中的所有邊在新圖中對應的藍點連成一個連通塊(這在上圖中也用藍色的邊體現出來了)。
這樣,一個點不是橋,當且僅當與其相連的所有邊在新圖中對應的藍點都屬於同一個連通塊。兩個點點雙連通,當且僅當它們在原圖的樹上路徑中的所有邊在新圖中對應的藍點都屬於同一個連通塊。
藍點間的連通關係可以用與求邊雙連通時用到的差分類似的方法維護,時間複雜度 $O(n+m)$ 。
## Reference
清晰的圖示:https://www.byvoid.com/zhs/blog/scc-tarjan,
視覺化過程(英文講解):https://www.youtube.com/watch?v=TyWtx7q2D7Y
`Garbow`演算法:https://blog.csdn.net/zhouzi2018/article/details/81623747
## 其它
文章開源在 [Github - blog-articles](https://github.com/RivTian/blog-articles),點選 Watch 即可訂閱本部落格。 若文章有錯誤,請在 [Issues](https://github.com/RivTian/blog-articles/issues) 中提出,我會及時回覆,謝謝。
如果您覺得文章不錯,或者在生活和工作中幫助到了您,不妨給個 Star,謝謝。
(文章完)