圖 相關算法~從頭學算法【廣搜、 深搜、 拓撲排序、 並查集、 弗洛伊德算法、迪傑斯特拉算法】

圖的相關主流算法主要有:

廣度優先搜索
深度優先搜索
拓撲排序
並查集
多源最短路徑(弗洛伊德算法)
單源最短路徑(迪傑斯特拉算法)

       其中呢,最基本的是前兩種,也就是平時常用的廣搜和深搜,本文中將概要舉例講解。因爲基礎也很重要啊~~

        圖的算法題當想不出來巧妙的方法時就只有暴力搜了!但是文中還會以例子的形式講解在圖中進行深搜時常用的兩點優化技巧:1.尋找路徑時在遞歸上的優化;2.記憶化搜索降低時間複雜度。剩下4個算法,即TurboSort、UnionFindSet、Floyd以及Dijstra算法根據其算法的功能都有非常明顯的對應場景,本文將會詳細的講解算法的流程和原理。其中使用上較爲巧妙的是並查集,有很多意想不到的算法題用並查集將會帶來巧妙的求解方法!

        同時,本文會對並查集的常用優化,如按高度合併、按重量(秩)合併,以及路徑壓縮進行介紹,它絕對是一個性價比極高的數據結構,應該熟練掌握該數據結構的構建以及原理,會給你帶來意想不到的收穫~~拓撲排序其實是廣度優先搜索的思想,俗稱剝洋蔥,從入度爲0的節點的最外層開始剝,一層一層一層的剝開我的心…但是存在環時就剝不動了,所以拓撲排序一般是用來解決有向無環圖的依賴順序問題的。多源最短路徑的弗洛伊德是支持負權重邊求解的,因爲它採用的是動態規劃的思想,在算法進行結束給出最終結果,而常用的求解單源最短路徑的迪傑斯特拉算法不支持負權重邊,因爲採用的是貪心策略,每加入S集合的點已保證最近距離,這就是二者主要的區別,現在看不知道是說的什麼沒關係,文中會詳細講解,回過來你會呵呵一笑~

       本文結構即以上介紹,分6個章節對幾種主流圖相關算法進行講解,文中舉例我會採用leetcode的典型原題,代碼都是100%AC的,這樣,我們才能從實際應用場景以及算法實現思路和原理上得到最深,最徹底的理解!下面,開始吧~~~

0 講點啥呢?

        爲啥從零開始呢?程序員絕不從一開始!那講點啥呢?應該說點準備工作,那就說一下圖的常用表示方法吧。圖的表示方法就很多種,例如常用的鄰接矩陣表示法、鄰接表表示法以及十字鏈表表示法,總之,各種表示圖的方法都是用不同的方式來描述圖的結構,習慣用哪一種就用哪一種,甚至我們可以都將它轉換成鄰接矩陣,然後進行算法的實現,或者我們很懶,那不處理我們就直接使用題中給出的邊緣列表也可以~下面對幾種常用表示方法進行介紹。

0.1 鄰接矩陣表示法

        臨接矩陣表示方法就是通過一個矩陣中元素的有無來表示對應橫縱座標所對應的的頂點之間是否存在相連接的邊的一種表示方法。對於無向圖、有向圖以及加權圖的表示上有對應的差別:無向圖因爲V0-V2之間的連接無方向,那麼對應的鄰接矩陣中元素arr[0][2]=arr[2][0]=1,因此無向圖的鄰接矩陣是對稱矩陣,而對於有向圖來說,當圖中只存在V0 -> V2的有向邊而不存在V2 -> V0的有向邊時,自然有arr[0][2]=1,arr[2][0]=0,所以有向圖鄰接矩陣一般不對稱,對於加權圖,以有向加權圖爲例,即將鄰接矩陣中對應的邊的權重作爲元素的值。

 

0.2  鄰接表表示法

       鄰接表表示法也很簡單,就是用一個一維數組來存儲圖中所有頂點,而頂點後面都連接一個鏈表,這個鏈表表示了與該頂點相連的其他頂點(對於有向圖是所有弧頂元素)。

 

0.3十字鏈表表示法

        十字鏈表表示法是爲了彌補鄰接表表示方式中不易計算出頂點入度的缺點而設計的一種圖結構表示方法。它的特點是頂點與鏈表元素的結構稍有不同,其中數組中頂點結構包括data、firIn、firOut,分別表示該頂點元素數據、第一個指向當前頂點的指針,該頂點指向的第一個元素的指針。鏈表節點的結構包括tailVex、headVex、headLink以及tailLink,其表示意義如下:

數組中頂點結構:

data

該頂點元素數據

firIn

指向當前頂點的第一個頂點

firOut

該頂點指向的第一個元素的指針

鏈表中節點結構:

tailVex

指向當前頂點的前一個頂點

headVex

當前頂點

headLink

指向當前頂點的其他頂點

tailLink

前一個頂點指向的下一個頂點

       是不是很繞?看下面的圖就明白了

       額,請叫我靈魂畫師……再解釋一下,其實我們只看藍色字體部分,是不是就是臨接表?然後再看紅色字體部分,以V0爲例,連接了V0 -> [1,0] -> [2,0],說明了V0頂點入度爲2,分別是V1和V2頂點。這就是十字鏈表表示方法,它同時同兩個鏈表表示了節點的出度和入度,所以叫十字交叉鏈表表示……如果看着不爽,可以將它拆成兩個鄰接表表示,即一個用來表示出度,一個用來表示入度,這樣就清晰明瞭了~

1 廣度優先搜索(BFS)

       廣度優先搜索通常使用隊列作爲輔助遍歷的工具,較爲簡單,圖的BFS相對於樹的BFS多出來一個判斷是否已訪問過的步驟,因爲圖是可能成環的,而樹不用擔心這一點,算法很簡單,直接上代碼(以最簡單的鄰接矩陣的形式傳參):

       廣度優先遍歷通常代碼如上結構,我們可以根據題目的要求改變隊列中節點的出隊順序從而控制遍歷順序,該算法較簡單且後面介紹的拓撲排序還會用到廣度優先遍歷,這裏不再舉例題,後面用到時再詳述。

2.深度優先搜索(DFS)

       深度優先搜索通常使用棧作爲輔助工具進行遍歷,當然能用到棧肯定可以用遞歸的方式來做,它的思路是從一個節點開始,按照出度節點的順序依次向下尋找路徑,其他出度的節點暫存到棧中,然後依次搜索達到遍歷全圖的目的。同理,其中也需要進行節點是否訪問的判斷,不然成環時就造成了死循環,示例代碼如下:

(1)以棧形式進行DFS:

(2)以遞歸形式進行DFS:

       DFS的兩種方式都可以,看你習慣用那種方式,值得一提的是用棧實現的DFS和用隊列實現的BFS代碼結構完全相同,只是因爲所使用的數據結構不同帶來了兩種完全不同的遍歷順序。所以算法中數據結構的作用是巨大的!要麼說“數據結構與算法”呢~

下面針對DFS介紹一道簡單的LeetCode例題,重點是題中我們會使用到路徑遍歷時用到的一點小的技巧,通過打表的方式遍歷,通過循環的方式省去了枚舉重複的代碼,以及通過緩存已遍歷路徑降低時間複雜度的優化。

       題目如下:

       這裏我們使用DFS的方式解題,思路很明確,我們通過遍歷每一個節點通過DFS尋找最長遞增路徑,也就是兩個for循環裏面調用dfs,值得我們注意的是:這個題在遍歷時還需要visited數據記錄訪問過的節點麼?答案是不需要,因爲題中已經要求嚴格遞增了,所以訪問的下一個節點一定要比上一個節點大,故永遠不會出現死循環。

       下面我們要考慮的是DFS的實現了,對於給定的頂點nums[i][j],在調用dfs時,我們需要考慮該節點需要向上下左右四個方向進行訪問,最終返回當前節點的最長遞增路徑長度,所以用遞歸比較合適。但是問題是四個方向,每一個方向我們都需要判斷是否邊界溢出、不溢出的情況下下一個節點值是否大於當前節點,因此四個判斷條件都不相同,我們需要進行四次枚舉的遞歸調用,這樣子寫出來的DFS如下圖所示,很麻煩,很臃腫!

       那怎麼解決這個問題,以較爲優雅的方式實現這個DFS呢?我們知道,使得代碼變得難看的就是這四個枚舉的過程,我們要做的就是簡化它!那怎麼進行簡化呢?就是讓死的枚舉代碼變活,怎麼變活呢?通過變量進行替代,通過for循環實現遍歷,這樣我們同樣完成了枚舉的效果,保證結果的正確性,但是代碼上舉大大簡化了。代碼如下圖,我們定義一個dir數組,裏面元素的0,1,-1來對方向進行控制從而達到上下左右四個方向的訪問。

       但是本題的代碼還有一個問題,就是效率太低,因爲我們是通過遍歷所有元素進行DFS的,想一下,裏面有太多太多的重複計算了!例如,我們在對第一個元素進行DFS時,當向右可以訪問時,我們已經計算了第二個元素的最長遞增路徑了,但是因爲我們的遍歷依次進行,所以下一次還要重複計算第二個元素的最長遞增路徑。所以,我們要去除這種重複的計算,降低算法的時間複雜度,自然而然的我們想到,原因出在重複計算上,那我們就把已經計算出來的結果緩存下來,下次碰到已經計算過的元素,直接返回即可!對,這就是所謂的記憶化搜索,我們只需要加一個緩存數組就能大大降低該算法的時間複雜度。其實,當我們加上cache數組時,這道題在leetcode上,我們已經可以全A了~整體代碼如下:

       該題很典型,面試頭條時二面手撕的就是這一題,面試官喜歡問一些優化點較多的題,層層遞進,會有很好的區分度~其他的解法我們不再介紹,有興趣可以去查一下或者想一想。

 

3.拓撲排序(TurboSort)

       拓撲排序主要是針對有向無環圖的算法,定義是:通過該算法得出一個序列,使得有向無環圖(DAG)中的任意一對頂點若存在邊<u,v>,則在拓撲排序得到的線性序列中,頂點u一定出現在頂點v之前,即保證依賴關係的順序性。

       可能這麼描述稍微有一點學術,什麼意思呢,通俗上講就是我們要解決依賴關係,比如你想要一個孫子,怎麼辦?首先,你要有一個兒子,兒子哪裏來,得自己生啊,怎麼生呢?你要結婚啊!跟誰結婚呢?你要先有個對象啊!有對象就能結婚了麼?不,你沒車沒房沒彩禮,你丈母孃不能同意啊!所以,要想達到某一個節點,你需要先搞定其中的必要條件,當然,這些必要條件對於同一個節點的入度節點是不要求順序的,比如你先買車還是先買房,你丈母孃都是能同意的,但是你都得買,你必須把你丈母孃的入度減到零,才能搞定丈母孃!所以這個Case的拓撲排序如下圖:

       如上,拓撲排序就是在各種依賴關係中找出一些合理的線性關係來滿足各種依賴,例如我們大學上課,選課時某些課程存在前驅課程,如統計學習的前驅課程有高數,現代等,沒有相關基礎,我們沒辦法學好這些高等課程。下面例題我將會介紹LeetCode上一道這樣的拓撲排序題目。類似的還有項目工程關係,多個項目之間也存在依賴的先後次序。

       值得注意的是,拓撲排序不能應用於有環圖,因爲我們的前提條件是總能至少在圖中找到一個入度爲0的頂點開始拓撲,但是當圖中存在環時,我們在這個環中找不到任意一個入度爲0頂的點。

       之前介紹拓撲排序是利用的廣度優先的思想,也是藉助隊列這個輔助數據結構,具體的算法流程是:首先建立一個countArr數組並統計每一個頂點的入度填到對應數組中;通過遍歷,首先將所有入度爲0的節點入隊,並將節點總數vexCount相應減少,然後利用廣度優先搜索的思路進行循環,裏面注意的操作是:每當出隊一個頂點時,我們將以該頂點爲弧尾的弧頂頂點對應的入度countArr[i]減一,並判斷該countArr[i]是否爲0,爲零則將該元素入隊,並將vexCount減一,循環直至隊列爲空。就這樣一層一層向內剝洋蔥,最後判斷vexCount是否爲0,如果爲0,說明我們洋蔥剝成功,如果不爲零,則說明這個洋蔥有心兒,即成環了,無法徹底剝開。最後輸出的頂點出隊順序就是該圖對應的一個拓撲序列。

       具體流程圖如下:

       這裏還有一點值得我們思考:上一道最長遞增路徑中我們沒有用visited數組,因爲題中要求路徑嚴格遞增,所以不可能存在已訪問元素再次入棧的情況,那麼,這一題我們爲什麼也不需要用到visited數組去標記已訪問的頂點呢?這裏,需要一個解釋!因爲出現死循環的原因是在有向圖中出現了環,但是注意我們的入隊判斷條件,是當某一個頂點的入度爲0,而環中節點的入度永遠不爲0,故環中節點永遠不可能入隊,而不成環節點不會被重複訪問,故不用visited數組進行標記判斷。

       下面以LeetCode的210題進行介紹:

        這一題就是典型的拓撲排序,沒有別的套路,拓撲排序直接上,這題較爲簡單,沒有對拓撲排序的線性序列進行過多的要求,返回一個正確的序列即可。對於較爲難的題我們需要對頂點出隊的順序進行控制,或者進行二次的拓撲排序,以滿足題目對輸入序列額外的要求,如LeetCode的1203題。

       下面是該題對應代碼:

 

4.並查集(UnionFindSet)

       並查集是一種比較有意思的數據結構,其使用經常與圖相關算法題相結合,例如本章節將會介紹的兩道LeetCode題目,冗餘連接以及情侶牽手。

       並查集這種數據結構主要用來描述集合,如可以將一堆相關點劃分成幾個獨立的集合以及某個元素是否屬於某個集合,某兩個元素是否在同一集合中。結合圖,那可以解決圖中任意兩個點之間是否存在通路,圖中兩個點之間是否成環等。並查集主要針對無向圖。

       下面介紹並查集數據結構的實現以及幾點優化:

4.1 基本並查集

       前面已經介紹了並查集的思想,下面介紹一個基於數組來實現並查集,最基本的並查集的思路是,數組下標爲當前元素標號,元素值是下標元素所在的組數,當兩個元素合併時,我們以較大組數元素爲大哥,將另一組元素的值全部更新。具體過程如下圖所示:

       該種實現所對應的代碼如下:

       這種實現方式對於查找元素屬於哪個集合的find方法很快,但是Union卻很慢,因爲在merge的時候我們需要遍歷整個數組進行更新。

4.2 快Union,慢Find

       在4.1的基本實現中,我們在merge操作上花費較高的代價,一次merge時間複雜度是O(n),因爲我們的做法是讓同一組所有的小弟都認這一組一個人做大哥,中央集權,很累的啊!一次更新我們需要更新所有小弟的值,那我們這裏換一種方式,不在讓所有組內小弟都向大哥彙報,只要他們間接的能夠找到自己的頂頭大哥就行,從中央集權變成設立分級機構,解放大哥,提高效率!怎麼做呢?看下圖:

       通過這種方式,我們在進行merge操作時,只需要改變一個元素的值,也就是找到兩組各自的帶頭大哥,讓兩個帶頭大哥進行pk,輸的帶頭大哥跟新成另一組的帶頭大哥的組號即可,原組內體制保持不變。這也符合我們的邏輯,兩國交戰,只需將軍PK就行,輸了的將軍以後跟着贏了的將軍就行了,輸將軍的小弟還跟着輸將軍混,避免全部改編制,勞民傷財~這樣效率就提高了

       因此,Find方法也需要做對應的改變,要找帶頭大哥,因爲是樹形結構,我們需要找到根元素的值,這纔是真正的組號。

       因此需要改進的代碼如下圖,即merge方法和find方法:

 

4.3 基於高度的合併(快Union,慢find)

       上面的方法還存在一個問題,就是如圖中,我們在合併的過程中,一直向上認大哥,會形成一個“大哥鏈”,退化成了鏈表,這使得我們在find的時候效率極低。爲了改善這個弊端,我們衍生出兩個方法,即按高度合併和按重量合併,這裏說一下按高度合併,思路十分簡單,就是認大哥的思路是誰組號大,誰就是大哥,這裏爲了改善退化成鏈表的這種情況,我們讓高度較高者當大哥,若高度相同再讓組號大的當大哥。直觀的感受一下其效率上的優化:

       具體代碼如下,需要注意,這是時我們在原並查集的基礎上需要增加一個記錄高度的數組,這是我們merge時的判斷依據,並且在合併時需要根據實際情況看是否需要更新高度。

 

4.4 基於重量的合併(快Union,慢find)

       基於重量合併的思路和基於高度一樣,只是判斷依據從高度變成了重量,所謂重量也就是組員數量,誰的小弟多誰當老大,這樣做的好處是可以方便的通過weight數組查找每一個集合的元素總數,另外,這在加速find沒有多大效果,但是我們可以通過後面的路徑壓縮來降低高度進行彌補,所以總體利大於弊~思路簡單不再畫圖,直接上代碼:

 

4.5 路徑壓縮(按重量進行合併)

       所謂路徑壓縮,就是降低高度,縮短長度,怎麼縮短呢?也就是對我們的集合內小弟的元素的編制進行改變,比如將高度爲5的編制通過兩次操作壓縮成3,那在哪裏進行操作呢,我們的目的是爲了改善find的效率,一般就在find中進行修正。怎麼改編制去降低高度呢,很簡單,我們讓小弟升官,讓小弟直接成爲大哥的大哥的小弟,那小弟原來的大哥就變成小弟的同級,高度自然降低了。可能說的不太形象,直接看下圖:

       代碼實現上也很簡單,我們只需要在慢find方法上加上一行讓小弟升官的命令即可:id[ele] = id[id[ele]],代碼如下:

 

       至此,我們對並查集數據結構的優化介紹暫告一段落,下面通過兩個例題介紹一個並查集在算法題中的使用場景:

       第一個是LeetCode第684題 冗餘鏈接:

       這一題要求我們輸入第一個冗餘的邊,也就是成環的第一條邊。一種笨方法是我們通過構造鄰接矩陣,然後通過DFS找出圖中存在的環,然後再截取出成環的組成頂點,倒序遍歷輸入邊序列,找到環中出現的最後一條邊輸出即可,這種方式也能夠100%AC,但是效率較低且編程較爲複雜。

       其實,這一題我們可以用並查集解題。成環,說明該環所有元素在一個集合中,我們遍歷輸入邊列表,依次將元素進行合併,當第一個出現的重複合併的兩個元素時,說明這兩個元素本來已經在一個集合中了,再次合併意味着成環了!這條邊就是應該輸出的冗餘邊~ 所以我們要對並查集進行修改,讓merge操作返回boolean類型的值,當返回false時,直接輸出該對應邊即可。代碼如下(並查集採用上面的按重量合併、路徑壓縮的方式):

並查集只截取修改的merge方法:

 

另一個例題是LeetCode的第765題,題目描述如下圖 :一起來感受一下並查集給我們解題帶來意想不到的簡便吧~

       這一題看着較爲複雜,實際上我們可以割裂開看,假設存在2N個人,其中情侶如果坐在一起,則不需要換位置。如果兩對情侶相互坐錯了位置,那麼只需要交換一次即可。如果三隊情侶互相坐錯了位置,即(CP_1_a,CP_2_b), (CP_2_a,CP_3_b), (CP_3_a,CP_1_b)那麼需要交換兩次。可以發現,我們只需要找出最後一共有多少個集合即可。當N對情侶形成N-1個集合,說明有一對情侶坐錯了位置,我們需要進行一次交換,當有N-2和集合時,說明有兩對情侶和別的情侶坐錯了位置,我們需要兩次交換,當有i個集合時,我們需要進行N-i次交換。

       所以我們的目的是找出合併後有多少個集合,毫無疑問,直接採用並查集進行合併,最後返回集合數即可。還需要對並查集做一點修改,即在merge代碼中加入計算集合數的操作,代碼如下:

並查集只截取修改的merge方法:

 

並查集還有很多的應用場景,例如,判斷至少要修多少條鐵路使得圖聯通等,應該熟練掌握這一個性價比極高的數據結構~

 

5.多源最短路徑(佛洛依德算法)

       對於加權的圖,難免的,我們要計算圖的最短路徑,常用的方法是針對多源最短路徑的佛洛依德算法以及針對單源的最短路徑算法,迪傑斯特拉算法。而且的區別在前言中說過,不是有無向,而是是否支持負權重邊以及採用的思路和時間複雜度。Floyd算法支持負權重,採用動態規劃的思路,多源,時間複雜度O(n3),Dijstra算法不支持負權重,採用貪心策略,單源,時間複雜度O(n2)。

       首先介紹一個佛洛依德算法,該算法是採用動態規劃的思想對路徑距離進行N次更新,N是頂點數,其中對頂點(i , j)之間的以k頂點更新的條件是:如果dis[i][j] > dis[i][k]+dis[k][j],則將dis[i][j]更新成dis[i][k]+dis[k][j]。更新完成後我們得到的是整個圖上任意兩點之間的最短距離。

       具體更新方式看下圖(引自zhangvae的博客):

http://static.oschina.net/uploads/img/201409/19193311_5TGB.jpg

       上面我們只是通過Floyd更新鄰接矩陣得到了圖中任意兩點間的最短距離,我們還應該能夠通過對Floyd算法進行處理,得到任意兩點間最短距離的路徑。這裏面我們需要初始化一個記錄兩個節點間中間節點的路經點的矩陣path[][],在Floyd更新距離時,將該對應成功更新兩點間距離的點記錄在path矩陣對應的位置上。在進行完迭代後,可以通過遞歸的方式從path矩陣中取出任意兩點間的路徑。以下圖爲例:

https://img-blog.csdnimg.cn/2019052813322920.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODQyNjcx,size_16,color_FFFFFF,t_70

圖中我們進行佛洛依德更新之後,得到填充後的path矩陣,例如我們需要尋找頂點(0, 6)之間的最短路徑,其實path[0][6]可能是圖中3或4頂點,根據更新的順序的不同而不同,但是並沒有關係,並不影響我們最終結果的正確性。因爲假設path[0][6]=3,那麼下一步遞歸findPath中,我們將要尋找path[3][6],將會得到4節點,反之亦然,我們得到的最終路徑一定是0 -> 3 -> 4 -> 6。

具體代碼如下圖所示:

       其中我們對path矩陣都初始化爲-1,所以findPath的終止條件是mid==-1。

       例題我們將會介紹LeetCode上第743題 網絡延遲時間,不過不放在本節,而是放到下一節介紹完迪傑斯特拉之後一起採用這兩種方法解決該題,也比較一下不同。

6.單源最短路徑(迪傑斯特拉算法)

       迪傑斯特拉算法是用來求解單源最短路徑的,採用的是貪心的策略,具體的算法思路是設置兩個集合,S集合存放已經求得最短路徑的頂點集合,T集合時還未求得最短路徑的集合,設我們要求的最短路徑的起點是source,圖的頂點數是vexNum,則需要進行vexNum-1次貪心操作,將所有頂點放入S集合。每次尋找S集合外距離點source最近的頂點index,加入到S集合,然後以這個index頂點爲中介更新matrix矩陣中各個頂點與source頂點之間的距離(這裏注意:如果是有向圖,實際上是更新了source這一行,如果是無向圖,則還更新了對稱部分)。進行vexNum-1操作後,如果是連通圖,則所有頂點都已放入S集合。思路很簡單,不再對上述步驟畫圖,不太清楚的話可以在網上找一下詳細的圖示,很多很好的圖~~

       這裏需要注意的是,我們對迪傑斯特拉算法也應該會求source到各個頂點的最短路徑,在本算法中,path的記錄比上一節介紹的Floyd算法要簡單很多,因爲我們是依次貪心地將頂點加入到S集合並用該index頂點進行更新的,那麼我們只需要在更新時將使用index頂點更新最短距離的點追加到對應的path[i]上即可。

代碼實現如下圖:

       下面我們介紹一道LeetCode上的第743題 網絡延遲時間:

       這種題目非常的直接,就是讓我們求出頂點K到圖中各個頂點的最短距離,並取出其中的最長距離。第一個想法肯定是單源最短路徑的迪傑斯特拉算法,當然用佛洛依德算法也可以求解,時間複雜度稍高,但是弗洛伊德算法編程更加簡單直接。下面用兩種方法分別求解:

1.佛洛依德算法:

2.迪傑斯特拉算法:

       二者由於時間複雜度不同,如下圖可以看到使用Djistra算法的耗時要比Floyd低很多,對於大Case,我們還是用Dijstra算法進行求解才能全A。

 

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