圖算法(一)遍歷,拓撲排序

本文介紹圖的幾種基本操作:BFS,DFS,求有向圖連通分量的Tarjan算法以及拓撲排序。

圖的表示

一張圖是由若干頂點和頂點之間的邊組成的,可以形式化為G(V, E),V代表頂點集合,E代表邊集,本文中為了書寫方便,定義頂點數|V|=n,邊數|E|=m。根據邊是否有方向,可以分為有向圖和無向圖,本文討論的內容都是有向圖。下面是一個無向圖的示例。

graph.png

在編碼中,圖的存儲方式常見的有兩種:鄰接表和鄰解矩陣,在C++中的寫法是:

// adjacency matrix
const int MAXV = 1e5;
int G1[MAXV][MAXV];

//adjacency lists
struct Edge1 {
    int dst, dis;
};
std::vector<std::vector<Edge1> > G2;

這兩種存儲方法對應的基本操作時間復雜度如下:

空間復雜度 查看u,v之間邊的權值 遍歷所有的邊
鄰接表 O(m+n) O(degree(u)) O(m + n)
鄰接矩陣 O(n^2) O(1) O(n^2)

一般來說,鄰接表的方式使用的多一些,因為稠密圖并不是很常見,在圖的遍歷上鄰接表有優勢。但也有特定場景鄰接矩陣會更方便,如Floyd算法的實現。

圖的遍歷(BFS & DFS)

圖算法中最基礎的就是圖的遍歷了,基本方法有兩種:廣度優先搜索(BFS)和深度優先搜索(DFS)。為了方便描述,我們定義圖上從頂點i到頂點j的一條簡單路徑為一系列的點i, k1, k2, ..., j,其中沒有重復出現的頂點,連續出現的點之間有邊相連,路徑上點的個數減1代表路徑的長度。

BFS的大致思想就是先遍歷和起點最短路徑長度為0的點(起點本身),再遍歷長度為1的點(從起點出發1步可達的點),再遍歷長度為2的點....直到所有點都被訪問過。時間復雜度O(m+n),下圖是一個更形式化的描述:

bfs.png

在C++中,BFS常配合隊列(queue)這一數據結構實現:

std::vector<std::vector<Edge> > g;
bool vis[MAXV];

// implementation of breath-first search
std::vector<int> bfs(int s) {
    std::vector<int> seq;
    std::queue<int> q;
    memset(vis, 0, sizeof(vis));  // initialize data
    q.push(s); vis[s] = true;   // insert starting node into queue
    while(!q.empty()) {
        int hd = q.front();
        q.pop();
        seq.push_back(hd);
        for(int i = 0; i < g[hd].size(); i++) {
            int next = g[hd][i];
            if(vis[next]) continue;
            q.push(next); vis[next] = true;
        }
    }
    return seq;
}

DFS的大致思想是從當前點出發,沿著一條簡單路徑走到沒有點可以訪問,再回溯到之前訪問過的節點,沿著另一條簡單路徑走下去。直到所有點都被訪問一遍,時間復雜度同樣是O(m+n)。DFS通常通過遞歸實現。

std::vector<std::vector<Edge> > g;
std::vector<int> seq;   // store the visiting order
bool vis[MAXV];

// implementation of depth-first search
void dfs(int s) {
    if(vis[s]) return;
    seq.push_back(s); vis[s] = true;
    for(int i = 0; i < g[s].size(); i++)
        dfs(g[s][i]);
}

回顧BFS/DFS的搜索過程,除了起點,每個點都是通過父節點指向它的一條邊被引入的,如果把這個過程建圖,那么這個圖中一共有n個節點和n-1條邊,且整個圖是連通的(假設圖中的邊是無向的),滿足這兩條性質的無向圖稱之為,通過BFS/DFS得到的樹被稱為搜索樹。

最后補充一點,在狀態數很多的搜索問題中,BFS被認為是完備的,即解如果存在,一定可以搜到,DFS則不是,可能需要和迭代加深這些策略配合。

Tarjan算法

在一次BFS或DFS中,我們其實并不能保證一定訪問到圖中的所有節點,因為有些圖可能是不連通的。我們把從一個點出發,所有可達點的集合稱為這個點所在的連通分量。給定一個無向圖,我們找所有連通分量的方法叫做灌水法(Flood Fill),其實就是對當前未訪問過的點做BFS/DFS,直到所有的點都被訪問過1次。
Tarjan算法是為了解決有向圖中類似的問題提出的。只不過有向圖中我們可以定義強連通分量,有向圖中一個強連通分量中的任意兩個點u,v都是強連通的,即存在從u到v的路徑,也存在從v到u的路徑。

strong-connected.png

很明顯,Flood Fill并不能用來求強連通分量。但只使用BFS/DFS,我們可以給出一個求給定點所在強連通分量的方法:1) 從該點出發做一次BFS/DFS;2) 把所有邊反向,再從這個點做一次BFS/DFS;3) 把兩次搜索訪問的頂點集合做一次交,就可以得到該點所在的極大強連通分量。如果用這種方法求所有強連通分量的話,需要對每個點做兩次BFS/DFS,時間復雜度為O(n^2)。更好的方法是Kosaraju算法或Tarjan算法,這里只介紹Tarjan。

Tarjan算法中,圖中每個節點維護兩個屬性:

  1. dfu(i):節點i在DFS中第dfu(i)個被訪問到(時間戳);
  2. low(i):DFS搜索樹中,以節點i為根節點的子樹中的節點集合記為T(i)。T(i)中的點在原圖中所指向的點的集合記為S(i)。S(i)中最小的時間戳就是low(i),low(i)可以用下面的遞歸式表示:
    low(i) = min(dfn(i), dfn(j), low(j)) (j為i的子節點)

Tarjan算法的描述如下:

  1. 初始化一個空的棧和一個每訪問一個節點加1的計時器
  2. 對圖做DFS,每次訪問新的頂點時,設定dfn(i)為當前時間,把該節點壓棧,接著求low(i):對于還沒被訪問的后繼節點j,遞歸訪問j,low(i) = min(dfn(i), low(j)),對于已經訪問過的后繼節點k,low(i) = min(low(i), dfn(j))。如果最終得到的low(i) = dfn(i),就把棧中當前節點以上的節點全部彈出,這些節點就是一個極大強連通分量

Tarjan算法只需要做一遍DFS,所以一定會終止,時間復雜度O(m+n)。

C++實現:

void tarjan(int u) {
    dfn[u] = low[u] = ++cnt;
    st[++top] = u;
    instack[u] = true;

    for(Edge *p = e[u]; p; p = p->next) {
        int v = p->dst;
        if(!dfn[v]) {
            tarjan(v);
            low[u] = std::min(low[u], low[v]);
        }
        else if(instack[v])
            low[u] = std::min(low[u], dfn[v]);
    }
    
    if(dfn[u] == low[u]) {
        cluster++;
        int hd;
        do {
            hd = st[top--];
            instack[hd] = false;
            belong[hd] = cluster;
        }
        while(hd != u);
    }
}

(PS:一道經典的求強連通分量的題 Networks of School

拓撲排序

在圖論中,我們經常討論有向無環圖DAG(Directed Acyclic Graph),這類圖常用來描述節點之間的依賴關系(先修課程、軟件包的安裝依賴)。對于DAG,我們可以對其進行拓撲排序。
一個DAG的拓撲排序是圖中所有頂點的一個排列:v1, v2, ..., vn,對于原圖中每條邊(vi, vj)都有i < j。下圖就展示了一個拓撲排序的例子:

topological.png

拓撲排序可以通過一個很直觀的策略求得:選取當前所有入度為0的點,把它們加入拓撲序列中,再把這些點的出邊刪去,反復這兩個操作,直到所有的點都加入拓撲序列中。對應的C++實現如下:

std::vector<int> topsort(const std::vector<std::vector<int> > &g) {
    int n = g.size();
    std::vector<int> d(n);
    for(int i = 0; i < n; i++)
        for(int j = 0; j < g[i].size(); j++)
            d[g[i][j]]++;
    
    std::queue<int> q;
    std::vector<int> seq;
    for(int i = 0; i < n; i++)
        if(!d[i]) q.push(i);
    while(!q.empty()) {
        int hd = q.front();
        q.pop();
        seq.push_back(hd);
        for(int i = 0; i < g[hd].size(); i++) {
            int dst = g[hd][i];
            d[dst]--;
            if(!d[dst]) q.push(dst);
        }
    }
    return seq;
}

正確性證明:
結論1:算法一定會終結。隊列中最多會壓入n個頂點,每次循環都會取出第一個元素,因此最多循環n次,同時,每次循環內會訪問當前隊列第一個點的所有出邊,這個點不會再加入隊列,因此每條邊最多被訪問一次,算法總的復雜度O(m+n)。
結論2:對于連通的DAG,算法一定會返回一個長度為n的序列。如果當前序列長度不足n,說明連通圖中還有點沒有被加入序列,而此時如果隊列為空,則說明沒有入度為0的點了,這在無環圖是不可能的。這同時提醒我們,對于沒有保證連通性的圖,需要多次的拓撲排序已確保每個連通分量中的點都加入了序列。同時,如果考慮所有連通分量后,最終返回序列的長度小于n,那么說明原圖中有環。
結論3:對于返回的序列v1, v2, ..., vn,原圖中不存在邊(vi, vj)使得i > j。假如有這樣一條邊從vi指向vj,且在拓撲序列中vj在vi之前。算法中vj在被刪除時,vi還沒被刪除,因此vj的入度不會為0,矛盾!

(PS:一道不錯的拓撲排序的題 All Discs Considered

本文圖片來自 Lecture Slides for Alogorithm Design by Jon Kleinberg and éva Tardos.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,156評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,401評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,069評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,873評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,635評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,128評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,203評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,365評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,881評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,733評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,935評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,475評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,172評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,582評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,821評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,595評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,908評論 2 372

推薦閱讀更多精彩內容

  • 課程介紹 先修課:概率統計,程序設計實習,集合論與圖論 后續課:算法分析與設計,編譯原理,操作系統,數據庫概論,人...
    ShellyWhen閱讀 2,329評論 0 3
  • 歸去來兮。 1.1 說明 本篇為《挑戰程序設計競賽(第2版)》[http://www.ituring.com.cn...
    尤汐Yogy閱讀 14,399評論 0 160
  • https://zh.visualgo.net/graphds 淺談圖形結構https://zh.visualgo...
    狼之獨步閱讀 4,180評論 0 0
  • 1 序 2016年6月25日夜,帝都,天下著大雨,拖著行李箱和同學在校門口照了最后一張合照,搬離寢室打車去了提前租...
    RichardJieChen閱讀 5,115評論 0 12
  • 【我的感悟】 11 酒店的硬件設施還算不錯。本打算第二天起來看日出,在手機上定了凌晨4點的鬧鐘,當聽到聒噪的鬧鈴響...
    劉旭暉閱讀 244評論 0 1