簡介
在閱讀下列內容之前,請務必瞭解 圖論相關概念 中的基礎部分。
強連通的定義是:有向圖 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 生成樹 ,我們以下面的有向圖為例:
有向圖的 DFS 生成樹主要有 4 種邊(不一定全部出現):
- 樹邊(tree edge):綠色邊,每次搜尋找到一個還沒有訪問過的結點的時候就形成了一條樹邊。
- 反祖邊(back edge):黃色邊,也被叫做回邊,即指向祖先結點的邊。
- 橫叉邊(cross edge):紅色邊,它主要是在搜尋的時候遇到了一個已經訪問過的結點,但是這個結點 並不是 當前結點的祖先時形成的。
- 前向邊(forward edge):藍色邊,它是在搜尋的時候遇到子樹中的結點的時候形成的。
我們考慮 DFS 生成樹與強連通分量之間的關係。
如果結點 \(u\) 是某個強連通分量在搜尋樹中遇到的第一個結點,那麼這個強連通分量的其餘結點肯定是在搜尋樹中以 \(u\) 為根的子樹中。 \(u\) 被稱為這個強連通分量的根。
反證法:假設有個結點 \(v\) 在該強連通分量中但是不在以 \(u\) 為根的子樹中,那麼 \(u\) 到 \(v\) 的路徑中肯定有一條離開子樹的邊。但是這樣的邊只可能是橫叉邊或者反祖邊,然而這兩條邊都要求指向的結點已經被訪問過了,這就和 \(u\) 是第一個訪問的結點矛盾了。得證。
Tarjan 演算法求強連通分量
在 Tarjan
演算法中為每個結點 \(u\) 維護了以下幾個變數:
-
\(dfn[u]\) :深度優先搜尋遍歷時結點 \(u\) 被搜尋的次序。
-
\(low[u]\) :設以 \(u\) 為根的子樹為 \(Subtree(u)\) 。 \(low[u]\) 定義為以下結點的 \(dfn\) 的最小值: \(Subtree(u)\) 中的結點;從 \(Subtree(u)\) 通過一條不在搜尋樹上的邊能到達的結點。
ps:每次找到一個新點,這個點\(low\ []=dfn\ []\)。
一個結點的子樹內結點的 dfn 都大於該結點的 dfn。
從根開始的一條路徑上的 dfn 嚴格遞增,low 嚴格非降。
按照深度優先搜尋演算法搜尋的次序對圖中所有的結點進行搜尋。在搜尋過程中,對於結點 \(u\) 和與其相鄰的結點 \(v\) (v 不是 u 的父節點)考慮 3 種情況:
- \(v\) 未被訪問:繼續對 \(v\) 進行深度搜尋。在回溯過程中,用 \(low[v]\) 更新 \(low[u]\) 。因為存在從 \(u\) 到 \(v\) 的直接路徑,所以 \(v\) 能夠回溯到的已經在棧中的結點, \(u\) 也一定能夠回溯到。
- \(v\) 被訪問過,已經在棧中:即已經被訪問過,根據 \(low\) 值的定義(能夠回溯到的最早的已經在棧中的結點),則用 \(dfn[v]\) 更新 \(low[u]\) 。
- \(v\) 被訪問過,已不在在棧中:說明 \(v\) 已搜尋完畢,其所在連通分量已被處理,所以不用對其做操作。
將上述演算法寫成虛擬碼:
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 (強連通分量)。
實現
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)\) 。
Kosaraju 演算法
Kosaraju
演算法依靠兩次簡單的 DFS 實現。
第一次 DFS,選取任意頂點作為起點,遍歷所有未訪問過的頂點,並在回溯之前給頂點編號,也就是後序遍歷。
第二次 DFS,對於反向後的圖,以標號最大的頂點作為起點開始 DFS。這樣遍歷到的頂點集合就是一個強連通分量。對於所有未訪問過的結點,選取標號最大的,重複上述過程。
兩次 DFS 結束後,強連通分量就找出來了,Kosaraju 演算法的時間複雜度為 \(O(n+m)\) 。
實現
// 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 好啊,能拓撲排序了就能做很多事情了。
舉個簡單的例子,求一條路徑,可以經過重複結點,要求經過的不同結點數量最多。
推薦題目
相關文章推薦
清晰的圖示: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,點選 Watch 即可訂閱本部落格。 若文章有錯誤,請在 Issues 中提出,我會及時回覆,謝謝。
如果您覺得文章不錯,或者在生活和工作中幫助到了您,不妨給個 Star,謝謝。
(文章完)