tarjan—演算法的神(一)

Aqr_Rn發表於2024-09-09

本篇包含 tarjan 求強連通分量、邊雙連通分量 部分。
tarjan 求割點、點雙連通分量

偉大的 Robert Tarjan 創造了眾多被人們所熟知的演算法及資料結構,最著名的如:(本文的)連通性相關的 tarjan 演算法,Splay-Tree,Toptree,tarjan 求 lca 等等。

注:有向圖的強連通分量、無向圖的雙連通分量、tarjan 求最近公共祖先 被稱為 tarjan 三大演算法。

所以在本篇部落格開篇,%%% Tarjan Dalao.

基礎概念:

強連通分量

對於一個有向圖 G,存在一個子圖使得從該子圖中每個點出發都可以到達該子圖中任意一點,則稱此子圖為 G 的強連通分量。特別的,一個點也是一個強連通分量。

如下圖紅框部分都是強連通分量:(顯然,紅框中的點 1、2、3、5 可以互相到達)

image

割點:woaihuge

對於一個無向連通圖 G,若去除 G 中的一個節點 u,並刪去與該節點相連的所有邊後,G 變得不再連通,則稱該點 u 為割點。

如下圖示紅的 4 號點就是割點:

割邊(橋):

在一個連通分量中,如果刪除某一條邊,會把這個連通分量分成兩個連通分量,那麼這個邊稱為割邊(橋)。

如此圖中的紅邊

image

點/邊雙連通分量:

若一個無向圖中的去掉任意一個節點(一條邊)都不會改變此圖的連通性,即不存在割點(橋),則稱作點(邊)雙連通圖。分別簡稱點雙、邊雙。

此圖既是一個點雙又是邊雙(顯然它不含割點也不含橋):



前置知識:

建議先自行了解

dfs 生成樹

圖片摘自oi-wiki

鄰接連結串列

存邊方式,自行學習(



tarjan 求強連通分量(必看):

需要維護兩個陣列:

\(dfn_i\):表示 \(i\) 這個點的 dfs 序(即深度優先搜尋遍歷時點 \(i\) 被搜尋的次序)

\(low_i\):表示從以點 \(i\) 為根節點的子樹中任意一點透過一條返祖邊能到達的節點的最小值

演算法思路:

維護一個棧,存目前正在找的強連通分量的節點。

對圖的每個節點進行深度優先搜尋,同時維護每個點的 \(low、dfn\) 值,每次搜尋到一個點將其加入到棧中。每當找到一個強連通元素(如果一個點的 \(low\) 值和 \(dfn\) 值相等,我們稱這個點為強連通元素)時,其實便找全了一個強連通分量。

思路分析(難懂疑問):

  • 如何更新 \(low\) 值?

對於搜尋過程中搜到的的點 \(u\) 和它連向的點 \(v\),有以下三種情況:

1 . 點 \(v\) 還未被訪問:此時我們繼續對 \(v\) 進行搜尋。在回溯過程中用 \(low_v\) 更新 \(low_u\)。 因為 \(v\) 的子樹中的點其實也就是 \(u\) 的子樹中點,那麼 \(v\) 子樹中一個點能到達 \(x\) 點,即為 \(u\) 子樹中的點到達了 \(x\) 點。


2 . 點 \(v\) 被訪問過並且已經在棧中:(即在當前強連通分量中),被訪問過說明 \(v\) 在搜尋樹中是 \(u\) 的祖先節點,那麼從 \(u\) 走到 \(v\) 的邊便是我們更新 \(low\) 要用的那條返祖邊,所以用 \(dfn_v\) 值更新 \(low_u\)


3 . 點 \(v\) 被訪問過但不在棧中:說明該點所在強連通分量已經被找到了,且該點不在現在正找的強連通分量裡,那麼不用進行操作。


  • 為什麼找到強聯通元素的時候當前的就找全了一個強連通分量呢?

我們知道強聯通元素 \(x\)\(low_x = dfn_x\),說明以 \(x\) 為根的子樹中所有點都到達不了 \(x\) 之前的(同一強連通分量中的)點,那麼 \(x\) 便是該強連通分量的“起點”。


根據棧先進後出的性質,可以知道棧中從 \(x\) 到棧頂的點都是 \(x\) 子樹內的點,並且是和 \(x\) 屬於同一個強連通分量的。那麼找到 \(x\) 這個強連通元素後,棧中從 \(x\) 到棧頂所有點(這些點是當前的強連通分量中的點)取出即可。

虛擬碼:

tarjan(點 x){
    low[x] = dfn[x] = ++th; // 更新 dfs 序
    把 x 入棧;
    for(列舉 x 相鄰的點 y){
        if(y 未被搜尋過){
            tarjan(y);
            low[x] = min(low[x], low[y]);
        }
        if(y 被搜尋過且已在棧中){
            low[x] = min(low[x], dfn[y]);
        }
    }
    if(x 為強連通元素){
        scc++; //scc 為強連通分量個數
        將棧中元素從 x 到棧頂依次取出;
    }
}

演算法演示:

黑邊為樹邊,藍邊為返祖邊

在此連通圖中,我們以 1 號點為根進行 dfs,所有點的標號即為它們的 dfs 序:

我們從 1 開始 dfs,把 1 加進棧中,找到 1 相鄰的點 2,發現 2 還未被搜尋過,那麼遞迴 dfs 2;

把 2 加進棧中,找到與 2 相鄰的點 3,3 同樣未被搜尋,再遞迴搜尋 3;

同樣把 3 加進棧中,發現 3 相鄰的點 1 已經被搜尋過且在已在棧中,那麼從 3 到 1 這條邊就是一條返祖邊,用 \(dfn_1\) 更新 \(low_3\),回溯;

回溯過程中,分別用 \(low_3\) 更新 \(low_2\)\(low_2\) 更新 \(low_1\)

回溯到 1 號節點時,有 \(low_1 = dfn_1\),所以 1 號節點為強連通元素,那麼棧中從 1 到棧頂所有元素即為一個強連通分量。

演算法程式碼:

int th, top, scc; //分別表示 dfs 的時間戳、棧頂、強連通分量個數
int s[N], ins[N]; //s 為手寫棧,ins[i] 表示 i 這個點是否在棧中
int low[N], dfn[N], belong[N]; //belong[i] 表示 i 這個點所屬的強連通分量的標號

void tarjan(int x){
    low[x] = dfn[x] = ++th;
    s[++top] = x, ins[x] = true;
    for(int i=head[x]; i; i=nxt[i]){ //鏈式前向星存邊
        int y = to[i];
        if(!dfn[y]){ //若 y 還沒被搜尋過
            tarjan(y); // 搜尋 y 
            low[x] = min(low[x], low[y]);
        }
        else if(ins[y]){ // y 在棧中
            low[x] = min(low[x], dfn[y]);
        }
    }
    if(low[x] == dfn[x]){
        ++scc; 
        do{ //將棧中從 x 到棧頂所有元素取出
            belong[s[top]] = scc;
            ins[s[top]] = false;
        }while(s[top--] != y);
    }
}

但是,大多數題目並不是給定一張聯通圖,所以一張圖可能會分成多個強連通分量,所以主函式中應這樣寫(來保證每個強連通分量都被跑過 tarjan):

    for(int i=1; i<=n; i++)
        if(!dfn[i]) tarjan(i);

例題:

不要著急看下面的內容,建議做一兩道例題熟悉演算法原理和程式碼後再繼續學習。

The Cow Prom S[USACO06JAN](絕對的板子)

板!

資訊傳遞[NOIP2015 提高組]

特殊的最小環問題,因為這個題保證每個點的出度為 1,所以這個題可以用 tarjan 求強連通分量來做,具體可以去看其他題解(比如這個)。

受歡迎的牛[USACO03FALL / HAOI2006]

縮點的思想,但很好理解,求出強聯通分量,把每個強連通分量看做一個大點,計算每個大點的出度,若有一個出度為 0 的大點,則這個大點包含的所有奶牛都為明星牛;若有兩個及以上出度為 0 的大點(則這些大點裡的愛慕都無法傳播出去)就 G 了,便不存在明星牛。

具體實現看程式碼吧
#include<bits/stdc++.h>
using namespace std;

const int N = 2e5 + 10;

int n, m, out[N];
int low[N], dfn[N], ins[N], th;
int s[N], belong[N], top, scc, size[N];
int head[N], to[N], nxt[N], tot;

void addedge(int x, int y){
    to[++tot] = y;
    nxt[tot] = head[x];
    head[x] = tot;
}

void tarjan(int x){
    low[x] = dfn[x] = ++th;
    s[++top] = x, ins[x] = true;
    for(int i=head[x]; i; i=nxt[i]){
        int y = to[i];
        if(!dfn[y]){
            tarjan(y);
            low[x] = min(low[x], low[y]);
        }
        else if(ins[y]){
            low[x] = min(low[x], dfn[y]);
        }
    }
    if(low[x] == dfn[x]){
        ++scc;
        do{
            size[scc]++;
            belong[s[top]] = scc;
            ins[s[top]] = false;
        }while(s[top--] != x);
    }
}

int main(){ 
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);

    cin>>n>>m;
    for(int i=1; i<=m; i++){
        int x, y; cin>>x>>y;
        addedge(x, y);
    }

    for(int i=1; i<=n; i++){
        if(!dfn[i]) tarjan(i);
    }

    for(int i=1; i<=n; i++){
        for(int j=head[i]; j; j=nxt[j]){
            int y = to[j];
            if(belong[i] != belong[y]) out[belong[i]]++;
        }
    }

    int cnt = 0, ans = 0;
    for(int i=1; i<=scc; i++){
        if(out[i]) continue;
        cnt++;
        if(cnt > 1){cout<<0; return 0;}
        ans = size[i];
    }

    cout<<ans;


    return 0;
}



tarjan 求邊雙連通分量

有一種實現是先跑出割橋再找邊雙,本篇不對此方法進行介紹。

其實我們可以發現邊雙連通分量就是強連通分量搞到無向圖中,求邊雙的思路也和強連通分量一樣。(看程式碼理解即可,非常簡單)

和強連通分量的不同之處在程式碼中標出了。

演算法程式碼:

void tarjan(int x, int p){
    low[x] = dfn[x] = ++th;
    s[++top] = x, ins[x] = true;
    for(int i=head[x]; i; i=nxt[i]){
        int y = to[i];
        if(y == p) continue;//因為雙向邊所以搜尋時加個判父親節點
        if(!dfn[y]){
            tarjan(y, x); 
            low[x] = min(low[x], low[y]);
        }
        else low[x] = min(low[x], dfn[y]);
        //因為在無向圖中,所以若 y 已經被搜尋過則一定是 x 的祖先,不用再判 ins
    }
    if(dfn[x] == low[x]){
        ++scc;
        do{
            belong[s[top]] = scc;
            SCC[scc].emplace_back(s[top]); //將邊雙存起來,可根據題目需要選擇寫這句話
        }while(s[top--] != x);
    }
}

但注意題目要求,有時題目可能會有重邊時,搜尋過程中就不能只特判父親節點,而是應該記一下邊的編號判邊。因為有重邊的話是可以回到父親節點的。

例題:

【模板】邊雙聯通分量

板子題,但注意題目可能會有重邊,所以需要記一下邊防止走“回頭路”。

看程式碼就知道了
#include <bits/stdc++.h>
using namespace std;

const int N = 5e6 + 10;

int n, m, top, th, cnt;
int s[N], low[N], dfn[N], id[N];
int tot, head[N], to[N], nxt[N];

vector<vector<int> >ans;

inline void addedge(int x, int y){
    to[++tot] = y;
    nxt[tot] = head[x];
    head[x] = tot;
    id[tot] = cnt; //id 來記一下邊的編號
}

inline void tarjan(int x, int p){
    low[x] = dfn[x] = ++th;
    s[++top] = x;
    for(int i=head[x]; i; i=nxt[i]){
        int y(to[i]), edge = id[i];
        if(edge == p) continue; //解決重邊問題
        if(!dfn[y]){
            tarjan(y, edge);
            low[x] = min(low[x], low[y]);
        }
        else low[x] = min(low[x], dfn[y]);
    }
    if(low[x] == dfn[x]){
        vector<int>scc;
        do scc.emplace_back(s[top]);
        while(s[top--] != x);
        ans.emplace_back(scc);
    }
}

int main(){
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
    
    cin>>n>>m;
    for(int i=1; i<=m; i++){
        int x, y; cin>>x>>y;
        if(x == y) continue; ++cnt;
        addedge(x, y), addedge(y, x);
    }

    for(int i=1; i<=n; i++)
        if(!dfn[i]) tarjan(i, 0);

    cout<<ans.size()<<"\n";
    for(auto x : ans){
        cout<<x.size()<<" ";
        for(auto i : x){
            cout<<i<<" ";
        }
        cout<<"\n";
    }


    return 0;
}



相關文章