60 分鐘搞定圖論中的 Tarjan 算法(一)

Tarjan 算法是圖論中非常實用 / 常用的算法之一,能解決強連通分量,雙連通分量,割點和橋,求最近公共祖先(LCA)等問題。

關於 Tarjan 算法,筆者將用一系列文章系統介紹 Tarjan 算法的原理以及其主要解決的問題。本篇文章我們主要介紹如何使用 Tarjan 算法求解無向圖的割點與橋

我們先來簡單地瞭解下什麼是 Tarjan 算法?

一、Tarjan 算法

Tarjan 算法是基於深度優先搜索的算法,用於求解圖的連通性問題。Tarjan 算法可以在線性時間內求出無向圖的割點與橋,進一步地可以求解無向圖的雙連通分量;同時,也可以求解有向圖的強連通分量、必經點與必經邊。

如果你對上面的一些術語不是很瞭解,沒關係,我們只要知道 Tarjan 算法是基於深度優先搜索的,用於求解圖的連通性問題的算法 就好了。

提到 Tarjan,不得不提的就是算法的作者 ——Robert Tarjan。他是一名著名的計算機科學家,我們耳熟能詳的 最近公共祖先(LCA)問題、強連通分量 問題、雙連通分量 問題的高效算法都是由他發現並解決的,同時他還參與了開發斐波那契堆伸展樹 的工作。

二、無向圖的割點與橋

什麼是無向圖?簡單來說,若一個圖中每條邊都是無方向的,則稱爲無向圖。

割點

若從圖中刪除節點 x 以及所有與 x 關聯的邊之後,圖將被分成兩個或兩個以上的不相連的子圖,那麼稱 x 爲圖的割點

 


若從圖中刪除邊 e 之後,圖將分裂成兩個不相連的子圖,那麼稱 e 爲圖的割邊

 

三、如何求解圖的割點與橋?

在瞭解了 Tarjan 算法的背景以及圖的割點與橋的基本概念之後,我們下面所面臨的問題就是 —— 如何求解圖的割點與橋?

開門見山,我們直接引出 Tarjan 算法在求解無向圖的割點與橋的工作原理。

時間戳

​時間戳是用來標記圖中每個節點在進行深度優先搜索時被訪問的時間順序,當然,你可以理解成一個序號(這個序號由小到大),用 dfn[x] 來表示。

搜索樹

在無向圖中,我們以某一個節點 x 出發進行深度優先搜索,每一個節點只訪問一次,所有被訪問過的節點與邊構成一棵樹,我們可以稱之爲“無向連通圖的搜索樹”。

追溯值

追溯值用來表示從當前節點 x 作爲搜索樹的根節點出發,能夠訪問到的所有節點中,時間戳最小的值 —— low[x]。那麼,我們要限定下什麼是“能夠訪問到的所有節點”?,其需要滿足下面的條件之一即可:

  • 以 x 爲根的搜索樹的所有節點
  • 通過一條非搜索樹上的邊,能夠到達搜索樹的所有節點

爲了方便理解,讓我們通過動畫的方式來模擬追溯值真實計算過程。

 

在上面的計算過程中,我們可以認爲以序號 2 爲根的搜索樹的節點有 {2,3,4,5}。上面所說的“通過一條非搜索樹上的邊”可以理解成動畫中的(1,5)這條邊,“能夠到達搜索樹的所有節點”即爲節點 1。

 

四、代碼詳解

在瞭解了 Tarjan 算法的基本概念與操作流程之後,我們來看看具體的代碼。這裏推薦大家學習一篇 GitHub 上介紹 Tarjan 算法 的文章。

無向圖的橋判定法則

在一張無向圖中,判斷邊 e (其對應的兩個節點分別爲 u 與 v)是否爲橋,需要其滿足如下條件即可:dfn[u] < low[v]

它代表的是節點 u 被訪問的時間,要優先於(小於)以下這些節點被訪問的時間 —— low[v] 。

  • 以節點 v 爲根的搜索樹中的所有節點
  • 通過一條非搜索樹上的邊,能夠到達搜索樹的所有節點(在追溯值內容中有所解釋)

是不是上面的兩個條件很眼熟?對,其實就是前文提到的追溯值 —— low[v]。

C++ 實現

// tarjan 算法求無向圖的橋
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
​
using namespace std;
​
const int SIZE = 100010;
int head[SIZE], ver[SIZE * 2], Next[SIZE * 2];
int dfn[SIZE], low[SIZE];
int n, m, tot, num;
bool bridge[SIZE * 2];
​
void add(int x, int y) {
    ver[++tot] = y, Next[tot] = head[x], head[x] = tot;
}
​
void tarjan(int x, int in_edge) {
    dfn[x] = low[x] = ++num;
    for (int i = head[x]; i; i = Next[i]) {
        int y = ver[i];
        if (!dfn[y]) {
            tarjan(y, i);
            low[x] = min(low[x], low[y]);
            if (low[y] > dfn[x])
                bridge[i] = bridge[i ^ 1] = true;
        }
        else if (i != (in_edge ^ 1))
            low[x] = min(low[x], dfn[y]);
    }
}
​
int main() {
    // [[0,1],[1,2],[2,0],[1,3]]
    cin >> n >> m;
    tot = 1;
    for (int i = 1; i <= m; i++) {
        int x, y;
        scanf("%d%d", &x, &y);
        add(x, y), add(y, x);
    }
    for (int i = 1; i <= n; i++)
        if (!dfn[i]) tarjan(i, 0);
    for (int i = 2; i < tot; i += 2)
        if (bridge[i])
            printf("%d %d\n", ver[i ^ 1], ver[i]);
}

首先,我們來看看變量的含義。

const int SIZE = 100010;
int head[SIZE], ver[SIZE * 2], Next[SIZE * 2];
int dfn[SIZE], low[SIZE];
int n, m, tot, num;
bool bridge[SIZE * 2];
  • SIZE —— 節點的個數,可以基於實際的節點個數增加一些冗餘度
  • 關於 head / Next / ver 變量的介紹,可以參閱下面的動畫配合理解
  • 假設場景,有一個節點 u,以及其直接相連的若干節點 v1,v2,v3
  • head[u] 代表節點 u 直接相鄰的第一個節點 v1 的序號(tot1)
  • Next[tot1] 代表節點 u 直接相鄰的下一個節點的序號(tot2)
  • ver[tot1] 代表序號爲 tot1 的節點的值(v1)
  • dfn[x] 代表節點 x 對應的時間戳,low[x] 代表節點 x 的追溯值
  • n 與 m 代表程序的標準輸入的參數,n 代表節點的個數,m 代表邊的個數
  • tot 代表每一個節點的序號
  • num 代表每一個節點的時間戳
  • bridge[tot] 代表序號爲 tot 的邊是否爲橋

針對於 head、Next、ver 變量的關係還是比較難懂。那麼,我們用圖像的方式,把節點 u、v1、v2 與 v3 畫出來看看。

通過上圖我們可以形象地理解這三個變量之間的關係。那麼,針對於 add 方法,想必也可以很順利地想明白 —— 建立並維護節點 x、y 形成的邊。

接着,我們看看 Tarjan 算法的主體

// x 代表當前搜索樹的根節點,in_edge 代表其對應的序號(tot)
void tarjan(int x, int in_edge) {
    // 在搜索之前,先初始化節點 x 的時間戳與追溯值
    dfn[x] = low[x] = ++num;
    // 通過 head 變量獲取節點 x 的直接連接的第一個相鄰節點的序號
    // 通過 Next 變量,迭代獲取剩下的與節點 x 直接連接的節點的序號
    for (int i = head[x]; i; i = Next[i]) {
        // 此時,i 代表節點 y 的序號
        int y = ver[i];
        // 如果當前節點 y 沒有被訪問過
        if (!dfn[y]) {
            // 遞歸搜索以 y 爲跟的子樹
            tarjan(y, i);
            // 計算 x 的追溯值
            low[x] = min(low[x], low[y]);
            // 橋的判定法則
            if (low[y] > dfn[x])
                bridge[i] = bridge[i ^ 1] = true; // 標記當前節點是否爲橋(具體見下文)
        }
        else if (i != (in_edge ^ 1)) // 當前節點被訪問過,且 y 不是 x 的“父節點”(具體見下文)
            low[x] = min(low[x], dfn[y]);
    }
}

 

五、語句詳解

語句一:i != (in_edge ^ 1)

首先,我們先明確下“y 不是 x 的父節點”的情況是什麼?

 

如上面視頻的過程,以 x' 節點出發進行深度優先搜索,緊接着搜索到節點 x。此時,以 x 爲根進行遞歸搜索,計算出其下一個節點爲 y。如果此時 y 與 x' 是一個節點的話 —— y 是 x 的“父節點”,需要忽略這種情況對於追溯值的計算。​

我們知道,在建立邊的關係時(add),我們爲每一條邊的兩個節點創建了兩個相鄰的序號值。又因我們 tot 是從 2 開始計數的,故每一條邊的兩個節點的序號肯定是一奇一偶偶數爲小。比如,2 與 3,4 與 5,而不會出現 5 與 6 這樣的情況。

在明確了上面的情況之後,我們看看一個數 x 與 1 進行異或的結果是什麼?

  • 如果 x 爲偶數(2),那麼 x ^ 1 = 2 ^ 1 = 3
  • 如果 x 爲奇數(3),那麼 x ^ 1 = 3 ^ 1 = 2

最後,我們來想想如何判定兩個點是否屬於一條邊的兩個端點?是不是隻要滿足 a ^ 1 == b 條件,那麼 a 與 b 就是一條邊的兩個端點了?對,就是這樣!

 

語句二:bridge[i] = bridge[i ^ 1] = true

這句話是爲了標記某個節點對應的邊是橋。而又因爲我們在建立邊時是成對地,那麼相鄰的兩個節點都應該被標記。

 

​七、實踐題

力扣 1192. 查找集羣內的「關鍵連接」

題目描述

力扣數據中心有 n 臺服務器,分別按從 0 到 n-1 的方式進行了編號。

它們之間以「服務器到服務器」點對點的形式相互連接組成了一個內部集羣,其中連接connections是無向的。

從形式上講,connections[i] = [a, b] 表示服務器a和b之間形成連接。任何服務器都可以直接或者間接地通過網絡到達任何其他服務器。

「關鍵連接」是在該集羣中的重要連接,也就是說,假如我們將它移除,便會導致某些服務器無法訪問其他服務器。

請你以任意順序返回該集羣內的所有 「關鍵連接」。

示例 1

 

 

輸入:n = 4, connections = [[0,1],[1,2],[2,0],[1,3]]
輸出:[[1,3]]
解釋:[[3,1]] 也是正確的。

 

​提示

  • 1 <= n <= 10^5
  • n-1 <= connections.length <= 10^5
  • connections[i][0] != connections[i][1]
  • 不存在重複的連接

這道題描述非常直接 —— 求解無向圖的橋,大家可以試試使用上述的 Tarjan 算法進行求解。

上面的代碼,大家可以保留下來作爲一個模板來學習或使用。

 

總結

想必通過對 Tarjan 算法的基本概念,無向圖以及其橋與割點的概念,Tarjan 算法求解橋與割點的代碼的詳細介紹,大家已經能夠掌握了這個知識點。

在學習 Tarjan 算法的過程中,很多小夥伴會遇到無法理解代碼的窘境。在這裏教大家一個小技巧:可以使用一個簡單的例子,在白板上進行一個簡單的模擬演練,把代碼的執行過程可視化形象化。

最後留一道思考題

無向圖的橋與割點的判定在實際生活中有哪些應用?可以在評論區留言。

 

本文作者:胡小旭

聲明:本文歸 “力扣” 版權所有,如需轉載請聯繫。文中圖片和視頻爲作者“胡小旭”製作,未經允許嚴禁修改和翻版使用。

發佈於 01-09

https://zhuanlan.zhihu.com/p/101923309

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章