數據結構梳理(6) - 圖

前言

這段時間偷懶了,上次二叉樹寫完之後,很長時間又沒更新博客了,也沒學啥東西,就一直鹹魚,所以今天趕緊脫離舒適區,繼續把數據結構梳理完,目前爲止,已經梳理了線性表、鏈表、棧、隊列、二叉樹,這次輪到圖了,不出意外,圖是數據結構系列的最後一篇,因爲最基本的數據結構也就是這些,當然肯定還有其它各種各樣的數據結構,實際開發中也會用到各種各樣的高級容器,但是目前我的水平還不足夠,對其它更高級的數據結構瞭解不多,等以後工作之後,再把後續高級的容器梳理出來吧,OK,不廢話了,趕緊開始吧。

目錄

1、圖的結構、常見類型、表示方法
2、圖的基本操作
2、基於鄰接矩陣實現圖
4、基於鄰接表實現圖

正文

1、圖的結構、常見類型、重要概念、表示方法

首先來對圖做一個基本的認識,從概念開始,官方給的圖的概念如下:

圖是一種數據元素間爲多對多關係的數據結構,加上一組基本操作構成的抽象數據類型。

呃,讀了一遍之後,發現還是很抽象,無法去形象化,沒關係,我們通過概念只需要留個映像即可,從某種意義上來說,概念只是一種嚴格的定義,我們沒有很大的必要去糾結它,當我們在使用圖一段時間後,如果別人有一天問你,什麼是圖的時候,這時你已經對圖有了一定的認知,但是你不知道怎麼去完整準確的表述這個數據結構,這時候你再回過頭看一下圖的概念定義,你就明白概念存在的意義了。

爲了方便快速建立對圖的認知和學習,我還是以最常用也是最簡單高效的辦法,舉一個圖的例子,從這個例子中我們再來學習圖,Ok,我這裏就直接放出這個例子,然後針對這個例子,再來學習相關知識。

在這裏插入圖片描述

1.1 圖的常見類型和結構

相信這裏其實已經不用我來多贅述了,我們從圖中可以一目瞭然,可以看到圖一般分爲兩種類型,一種是無向圖,一種是有向圖,顧名思義,無向圖中各節點連接的線是沒有明確的方向的,而有向圖中的就有方向,既然有方向就意味着這兩個節點的關係是單向的,無方向就是雙向的,至於這裏提到的兩個節點之間的“關係”具體是指什麼,這個就要看具體業務場景了,比如要抽象出馬路所表示的圖結構,而馬路上的普通車道自然就是無向的,單向車道就是有向的,所以其構建的圖分別就是無向圖和有向圖。

接下來再來看看一個圖的數據結構中主要包含哪些元素,對於一個圖來說,一般的表示方法是使用兩個元素來表示,一個是頂點,一個是,也就是圖由這兩個元素來構成,這裏的頂點其實就是包含數據元素值的節點,邊其實就是這些節點間的關係。

瞭解了結構之後,我們再來看看兩種類型的圖的定義,來加深對圖結構的理解,首先是無向圖,它的定義如下:

無向圖G=<V,E>,其中:
1.V是非空集合,稱爲頂點集。
2.E是V中元素構成的無序二元組的集合,稱爲邊集。

要注意的就是第二點中“無序”兩個字,其它的沒啥了,在看完這個概念之後,再結合上面的圖,怎麼樣,是不是對無向圖有了一個清晰的認識,ok,然後再來看下有向圖的定義:

有向圖是一個二元組<V,E>,其中
1.V是非空集合,稱爲頂點集。
2.E是V×V的子集,稱爲弧集。

從概念中可以看到表述十分的嚴謹,既然是子集,那麼同樣的二元組<1,2>和<2,1>就是不同的,自然也就準確表述出了“有向”的效果。

1.2 圖的其它重要概念

現在我們再來了解下關於圖的一些其它專業名詞,擴展下知識,這些概念可能在各個地方的表述都不一樣,但是爲了在我們深入學習圖的時候,碰到這些詞彙不至於一臉懵,所以我們還是有必要來學習的。

  • 孤立點:V中不與E中任一條邊關聯的點稱爲D的孤立點
  • 簡單圖:在無向圖中,關聯一對頂點的無向邊如果多於1條,則稱這些邊爲平行邊,平行邊的條數稱爲重數。在有向圖中,關聯一對頂點的有向邊如果多於1條,並且這些邊的始點與終點相同(也就是它們的的方向相同),稱這些邊爲平行邊。含平行邊的圖稱爲多重圖,既不含平行邊也不含環的圖稱爲簡單圖
  • 完全無向圖:設G是簡單無向圖,若G中任意節點都與其餘節點鄰接,則稱G爲完全無向圖
  • 完備圖:圖中任兩個頂點a與b之間,恰有兩條有向邊(a,b),及(b,a),則稱該有向圖爲完備圖
  • 基本圖:把有向圖D的每條邊除去定向就得到一個相應的無向圖G,稱G爲D的基本圖,稱D爲G的定向圖
  • 強連通圖:給定有向圖G=(VE),並且給定該圖G中的任意兩個結點u和v,如果結點u與結點v相互可達,即至少存在一條路徑可以由結點u開始,到結點v終止,同時存在至少有一條路徑可以由結點v開始,到結點u終止,那麼就稱該有向圖G是強連通圖
  • 弱連通圖:若至少有一對結點不滿足單向連通,但去掉邊的方向後從無向圖的觀點看是連通圖,則D稱爲弱連通圖
  • 單向連通圖:若每對結點至少有一個方向是連通的,則D稱爲單向連通圖
  • 強連通分支:有向圖G的極大強連通子圖稱爲該有向圖的強連通分支
  • 出度和入度:對有向圖來說,以頂點爲頭的邊的數目稱爲該頂點的入度,以頂點爲尾(這裏的尾指 指向頂點 的一端)的邊的數目稱爲該頂點的出度,一個頂點的入度與出度之和稱爲該頂點的度。

我們沒有必要一下子全部記下它們,只需要留有一個映像,當我們下次見到這些詞彙的時候,能有個初步記憶,然後多回顧幾次就自然而然記下來了。

1.3 圖的表示方法

在瞭解了上面這麼多枯燥的概念之後,我們再來學習一些比較有意思的知識,首先拋出一個問題,假設讓你用數據的方式將一個圖表示出來,你會怎麼做呢,是不是感覺有點無從下手,畢竟圖是一個抽象化的東西,而要使用數據量化的方式去表示還是很有難度的,當然你這時候可能有許多奇思妙想,可能你想出來的表達方式也很棒,不過我們還是來看看“走在我們前面的人”是怎麼想的。

圖的表示方法一般來說有兩種:鄰接矩陣和鄰接表,下面我們來學習下這兩個東西。

1.3.1 鄰接矩陣表示法

首先學習鄰接矩陣表示法,什麼是鄰接矩陣,其分爲兩部分:頂點集合和邊集合。因此,有一個一維數組存放圖中所有頂點數據,然後再用一個二維數組存放頂點間關係(邊或弧)的數據,這個二維數組稱爲鄰接矩陣。鄰接矩陣又分爲有向圖鄰接矩陣和無向圖鄰接矩陣。

看了上面相應的解釋之後,我們還是拿上面的例子來看鄰接矩陣表示法到底是如何表示一個圖的,爲了方便,再次貼上上面的例子圖
在這裏插入圖片描述
剛纔說了,鄰接矩陣表示法邏輯上分爲兩部分,一個一維數組用來存頂點元素,這個非常簡單,聲明一個一維數組存一下頂點即可,就不多說了,我們重點關注的就是另外一個二維數組,首先看左邊的無向圖,對這個圖來說,二維數組的值應該是怎麼樣的呢,在這個圖中,我們一共可以看到有五個頂點,然後我們將這五個頂點按照橫向和縱向排列,形成一個類似座標系的樣子,如下

    1   2   3   4   5
    __________________
1 |
2 |
3 |
4 |
5 |

這樣就形成了一個5*5的二維矩陣,然後矩陣中每個值相當於是對應的橫縱座標值的交叉點,這個交叉點的值怎麼填呢,如果對應的橫縱座標值代表的頂點之間存在邊,那麼該值爲1,如果不存在邊,則爲0,同時,對於對角線上的值,例如1和1的交叉點,也就是自己和自己的交叉點,因爲這裏沒有環,所以就是0,最後,對於左邊的無向圖,其鄰接矩陣如下

    1   2   3   4   5
    __________________
1 | 0   1   1   0   0                                     0   1   1   0   0
2 | 1   0   0   1   1       去掉無關的數,得到鄰接矩陣         1   0   0   1   1
3 | 1   0   0   0   1            ====》》》                1   0   0   0   1 
4 | 0   1   0   0   1                                     0   1   0   0   1
5 | 0   1   1   1   0                                     0   1   1   1   0

我們可以發現對於無向圖來說,其鄰接矩陣有一個很明顯的特點就是以對角線分割的“上三角”和“下三角”是對稱的,那麼這樣可以帶來什麼效果呢,答案就是我們在存儲的時候,可以只需要存儲上三角或者下三角中的數據,而不用存儲整個矩陣,這樣就大大節約了空間開銷。

ok,然後我們再來看看有向圖,由於有向圖中邊(或者叫“弧”)是有方向的,所以這就會導致(1,2)座標處的值爲1,但是(2,1)處的值可能爲0,所以也就不具有無向圖鄰接矩陣的對稱效果,其它就不沒啥了,我們看看例子中這個有向圖的鄰接矩陣是多少,如下

0   1   0   0   0
0   0   0   0   1
1   0   0   0   1
0   1   0   0   0
0   0   0   1   0

有關鄰接矩陣的知識主要就是這些,在這裏要說明一下的是,我們這裏對角線上的值一直是給的0,那是因爲我們的圖中不含有環,如果圖中某個頂點含有環的話,那麼該點在矩陣對角線中的值就是1了,所以不要認爲對角線上的值就固定爲0了。

1.3.2 鄰接表表示法

然後再來看看另外一種表示方法,鄰接表,這個方式我自己感覺沒有鄰接矩陣方式優雅方便,但是既然有這種表示方法,自然是有其獨特的優勢的,我們都要去認真學習,多一種選擇即是多了一種優化方式。

鄰接表這種表現方式也相對簡單點,如果對數據結構中的鏈表有一定了解的話,理解起來就非常容易了,它以圖的頂點開始,將其所有鄰接的點依次以鏈表的形式連接起來,這樣就形成了一個以該頂點爲頭結點的鏈表,對每個頂點都做一遍這個操作,假設有n個頂點,這樣就形成了n個鏈表,這n個鏈表可以用對應類型的長度爲n的數組存儲,最終這個數組就是鄰接表,這個鄰接表就代表了這個圖。

上面的文字描述是我自己以通俗易懂的方式表達的鄰接表,可能還是不夠形象,我們同樣的拿上面的例子來看,最終其鄰接表是什麼樣的,ok,再次貼上剛纔的圖
在這裏插入圖片描述
鄰接表的邏輯結構也是分爲兩部分,一個用於存儲頂點元素的一維數組,還有一個就是鏈表數組,也就是鄰接表,我們要重點關注的也就是這個鏈表數組,先看無向圖,按照剛纔的解釋,我們從頂點1開始,其鄰接了頂點2和頂點3,所以,以頂點1爲頭結點形成的鏈表如下
在這裏插入圖片描述
怎麼樣,是不是很簡單,然後剩下的頂點也是做同樣的處理,我就不贅述了,最終你會得到五條鏈表,這五條鏈表的總體也就是這個圖的鄰接表,無向圖的鄰接表如下
在這裏插入圖片描述
然後就是有向圖,這個和無向圖的鄰接表稍微有點區別,對有向圖來說,在形成鏈表時,鏈接在頭結點後面的頂點必須是由頭結點指向它的,或者換句話說,就是必須是以頂點爲起點,然後其它點爲終點的節點纔可以鏈接在這個頂點形成的鏈表上,對例子中的有向圖來說,例如頂點2,以該點爲起點的鄰接點只有1和4,而頂點5雖然和它鄰接,但是並不是以頂點2爲起點的,所以不能鏈接上去。所以最終這個有向圖的鄰接表如下
在這裏插入圖片描述

1.3.3 兩種表示方法的簡單對比

在上面我們簡單的瞭解了兩種表示圖的基本方法,現在你可能有個疑問,這兩種表示方法該如何做選擇呢,其實這個問題只需要稍微思考下即可,在鄰接矩陣中我們已經提到了,無向圖的存儲空間是要遠遠小於有向圖的,當然這只是其中一個因素,但是鄰接矩陣並不總是在所有情況下都表現最優,假設有一個圖,其包含的邊或者弧很少,或者其含有很多的孤立點,那麼在鄰接矩陣中很多的值就是0,真正有用的數值比例就非常少,但是卻浪費了大量的空間。

反觀鄰接表這種表示方法,我們不難發現其最大的一個特點就是出現了數據冗餘,即便對於上面例子中的“不算複雜”的圖而言,其冗餘情況也比較嚴重,例如1指向2,下面的2又指向1,但是它正好在圖的邊比較少的情況下,更加能直觀和節省空間的表示出圖來,因爲由於邊少,數據冗餘也不會有很多。

所以這兩種表示方法其實真好適用於不同的情況,對於結構複雜、邊較多的圖,適用於鄰接矩陣,對於結構簡單、邊較少的圖,適用於鄰接表

2、圖的基本操作

關於圖的基本操作,主要包括插入刪除一個頂點,插入刪除一條邊,深度優先遍歷,廣度優先遍歷,求最小生成樹,特別的對於有向無環圖的話,還有拓撲排序等操作,下面來簡單瞭解一下這些操作。

2.1 插入刪除一個頂點

首先是插入一個頂點,具體根據是用鄰接矩陣還是鄰接表存儲分爲兩種情況,如果是鄰接矩陣存儲的,那麼插入一個頂點就很簡單了,首先在存頂點的一維數組中加入這個頂點,然後更新鄰接矩陣的二維數組,因爲只是一個點,所以它和其它點不存在邊,所以對應二維數組中行和列的值也是0;如果是鄰接表存儲的,同樣的,先在頂點數組中加入這個頂點,然後在鄰接表中加入以這個頂點爲頭節點的鏈表,因爲它和其它點不存在邊,所以這個鏈表只有一個節點,就是這個頂點本身。

接下來看刪除節點,同樣的分爲兩種情況,如果是鄰接矩陣,那麼刪除一個節點就是在頂點數組中刪除對應位置的節點,然後數組後面的元素前移,同時對於鄰接矩陣中和該頂點有關的行和列全部刪除,相應的後面的行和列前移即可;如果是鄰接表,那麼操作就稍微有點複雜了,這裏主要分爲三步:假設待刪除節點爲a,它在存放頂點的一維數組中的下標爲index,(1)首先刪除鄰接表中以a爲頭結點的對應鏈表(2)然後在存放頂點的一維數組中刪除掉a,然後將在數組中位於index後面的元素前移(3)遍歷鄰接表,也就是所有剩下的鏈表,刪除所有下標爲index的節點,也就是刪除所有遇到的a,同時將所有下標大於index的節點的下標減一

這裏對於鄰接表中刪除節點,有點複雜,我這裏只是進行了文字描述,可能不是那麼的清晰,如果不能理解,可以看看這篇文章,幫助理解,地址:https://blog.csdn.net/bbewx/article/details/25005679

2.2 插入刪除一條邊

這個問題,相信我們在弄明白了插入刪除頂點之後,就不是啥大問題了,基本上是同樣的思路,其核心就是修改鄰接矩陣和鄰接表的結構,只要我們對鄰接矩陣和鄰接表的結構熟悉,那麼這些問題就迎刃而解,不過我這裏還是囉嗦一下吧。

爲了簡化描述,這裏插入的邊的兩個頂點都是已經存在的,如果還包括不存在的頂點,那麼就先按照上面說的方法插入這些不存在的頂點,然後再進行插入邊的操作即可。

首先是插入一條邊,對於鄰接矩陣來說,非常簡單,只需要更改鄰接矩陣中對應位置的值,例如在頂點a和頂點b間插入一條邊,如果是無向圖的話,得修改兩個地方的值,座標(a,b)和(b,a)處的值都要更新爲1,如果是有向圖,那麼只需要根據插入邊的方向更改其中一個值即可。對於鄰接表來說,也比較簡單,還是假設在頂點a和頂點b間插入一條邊,如果是無向圖,那麼只需在頂點a對應的鏈表尾端鏈接上頂點b的節點,在頂點b對應的鏈表尾端鏈接上頂點a的節點,如果爲有向圖的話,那麼只需要根據方向選擇爲起點的頂點對應鏈表進行修改即可。

插入邊弄明白了,那麼刪除邊其實就是一個逆操作,大致思路都是相同的,這裏就不再贅述了。

2.3 深度優先遍歷

看到這個操作,相信大家都會有點似曾相識,沒錯,這個操作在講二叉樹的時候也提到過,這裏對於圖的深度優先遍歷的思想其實和在二叉樹中的前序遍歷差不多,只不過是換了個遍歷的對象而已,現在是圖了。

然後我們看下深度優先搜索的官方標準解釋:

假設給定圖G的初態是所有頂點均未曾訪問過。在G中任選一頂點v爲初始出發點(源點),則深度優先遍歷可定義如下:首先訪問出發點v,並將其標記爲已訪問過;然後依次從v出發搜索v的每個鄰接點w。若w未曾訪問過,則以w爲新的出發點繼續進行深度優先遍歷,直至圖中所有和源點v有路徑相通的頂點(亦稱爲從源點可達的頂點)均已被訪問爲止。若此時圖中仍有未訪問的頂點,則另選一個尚未訪問的頂點作爲新的源點重複上述過程,直至圖中所有頂點均已被訪問爲止。

這段定義已經解釋的非常詳細了,如果代碼能力好的話,基本就可以根據這段定義來寫出對應的代碼了,最後,使用深度優先搜索來遍歷圖其實就是深度優先遍歷了。

爲了加深對這個操作的理解,以及形象的理解深度優先遍歷中的“深度”二字,我們可以通過對二叉樹的前序遍歷來理解,如果還是覺得抽象,我們就舉個例子來看一下,如下是一個無向圖:
在這裏插入圖片描述
按照上面深度優先遍歷的思想,我們現在來遍歷一遍上面這個圖,首先任意選取一個頂點,假設爲A,接下來訪問它的鄰接頂點,一共有三個B、C、D,我們假設爲B,那麼再以B爲出發點,訪問它的鄰接頂點,它一共有兩個鄰接頂點C、E,我們假設爲C,再以C爲出發點,它一共有兩個鄰接頂點A、B,但是A和B均被訪問過了,所以這裏回溯到以B爲出發點,訪問B的另一個鄰接頂點E,接着再以E爲出發點,訪問F和G,最後回溯到訪問A的鄰接頂點D,至此,整個遍歷過程結束,最終深度優先遍歷的結果是ABCEFGD。

從這個過程中,我們可以明顯的知道,深度優先遍歷的結果並不是唯一的,上面說的最後的序列也只是其中的一種,只要遍歷的時候,符合深度優先遍歷的思想即可。

最後關於深度優先遍歷在代碼實現方面,只需要記住核心:使用棧

2.4 廣度優先遍歷

學習了深度優先遍歷之後,我們再來學習廣度優先遍歷,同樣的,對於這個操作,我們其實也可以類比到二叉樹中,沒錯,就是二叉樹的層序遍歷,在這裏,我們也可以發現很多事物都具有通性,類比學習不失爲提高學習效率的一種好辦法,OK,我們還是先來讀一讀官方對於廣度優先搜索的定義,如下:

在圖G中任意選擇某個頂點V0出發,並訪問此頂點;然後從V0出發,訪問V0的各個未曾訪問的鄰接點W1,W2,…,Wk;然後,依次從W1,W2,…,Wk出發訪問各自未被訪問的鄰接點,直到全部頂點都被訪問爲止。

上面這段話的理解應該比較簡單,我就不過多贅述了,我自己最初在學習這種遍歷方式的時候,冒出來的第一個感受就是:暴力,我們可以明顯感覺到這種遍歷方式就是從起點開始,呈輻射狀波及開來,直至覆蓋整個圖。然後,我們還是拿深度優先遍歷中的這個例子圖來看,對這張圖來說,它的廣度優先遍歷的結果又是什麼呢

首先任意選取一個頂點,假設爲A,然後訪問A,接下來訪問A的所有鄰接點,A一共有三個鄰接點B、C、D,所以接下來訪問B、C、D,然後以B爲出發點,訪問B的鄰接點C、E,但是C已經被訪問過了,所以訪問E,然後以E爲出發點,訪問F、G,至此,整個過程結束,最終廣度優先遍歷的序列爲ABCDEFG。

同樣的,關於廣度優先遍歷的代碼實現方面,我們只需要記住核心:使用隊列

2.5 求最小生成樹

求最小生成樹是圖這個數據結構比較重要的一部分,我們先來了解下什麼是最小生成樹,不過在這之前,我們先看什麼是生成樹,從生成樹的概念開始,如下:

一個有 n 個結點的連通圖的生成樹是原圖的極小連通子圖,且包含原圖中的所有 n 個結點,並且有保持圖連通的最少的邊。

概念比較簡單,也說的很清楚,爲了加深映像,我們拿上面例子中的圖來看,它的生成樹是什麼樣子,如下
在這裏插入圖片描述
我們可以明顯的發現,對於生成樹來說,肯定不是隻有一種,但是隻要滿足生成樹的性質,就可以稱作這個圖的生成樹。

ok,明白了什麼是生成樹之後,最小生成樹就是在這基礎上加了些“枝葉”而已,最小生成樹的簡稱是最小權重生成樹,現在假設我們上面例子中的無向圖的邊帶有權值,那麼相信你也猜出來了,最小生成樹就是權值最小的那一顆生成樹,現在我們給例子中的無向圖加上權值,那麼它的最小生成樹就是下面這個樣子了
在這裏插入圖片描述
ok,現在我們瞭解了最小生成樹,那麼最關鍵的問題就來了,我們如何求最小生成樹呢,其實這個問題早就被我們智慧的前輩們解決了,主要涉及到是兩個算法,kruskal(克魯斯卡爾)算法和prim(普里姆)算法。我們現在來學習這兩個算法的核心細想。

首先是kruskal算法,它的官方描述是:先構造一個只含 n 個頂點,而邊集爲空的子圖,若將該子圖中各個頂點看成是各棵樹上的根結點,則它是一個含有 n 棵樹的一個森林。之後,從網的邊集 E 中選取一條權值最小的邊,若該條邊的兩個頂點分屬不同的樹,則將其加入子圖,也就是說,將這兩個頂點分別所在的兩棵樹合成一棵樹;反之,若該條邊的兩個頂點已落在同一棵樹上,則不可取,而應該取下一條權值最小的邊再試之。依次類推,直至森林中只有一棵樹,也即子圖中含有 n-1條邊爲止。

ok,我們現在按照kruskal算法的思路,來分析上圖中的最小生成樹是怎麼得來的,首先我們找權值最小的邊,發現是連接2-5的邊權值最小爲1,同時頂點2和頂點5此時不屬於同一棵樹,所以把它們合併,接下來找權值第二小的,我們發現是連接2-1的邊權值爲2,由於此時頂點1和頂點2不屬於同一棵樹,所以把頂點1合併到頂點2、5所在的樹中,接下來找權值第三小的邊,發現是連接2-4的邊權值爲3,然後合併頂點4,依次類推,最後合併頂點5,至此,所有的頂點現在形成了一棵樹,算法結束,這棵樹也就是最小生成樹。怎麼樣,只要掌握了算法核心思想,求最小生成樹是不是很簡單。

接下來我們再看看prim算法,同樣的,先看它的核心思想:首先在圖中任意選取一個頂點a,然後將a加入集合V中,然後以該頂點爲出發點,選取權值最小的邊,得到頂點b,再將頂點b加入集合V中,然後以a、b爲出發點,找尋權值最小的邊,得到頂點c,再將頂點c加入到集合V中,如此不斷循環,直至集合V中包含圖中所有頂點。

然後我們同樣的按照prim算法的思想來分析下上圖中的例子,首先把任意選取一個頂點,假設爲頂點2,先把頂點2加入到集合V中,然後以頂點2爲出發點找權值最小的邊,發現一共有三條,而連接2-5的邊權值最小爲1,所以將頂點5加入到集合V中,接下來以頂點2和頂點5爲出發點,找權值最小的邊,我們發現是連接2-1的邊權值最小爲2,所以我們將頂點1加入到集合V中,依次類推,先後通過連接2-4的邊將頂點4加入到集合V中,通過連接1-3的邊將頂點3加入到集合中,至此發現集合V中已經包含了所有頂點,算法結束,得到右邊的最小生成樹。

現在我們再看看一個額外的問題,最小生成樹是唯一的嗎?可能在上圖的例子中會容易感覺最小生成樹是唯一的,因爲畢竟要總權值最小,因爲最小所以會覺得是唯一的,其實並不是,如果權值相同的話,例如所有的邊權值都爲1,那麼最小生成樹就不是唯一的了。

2.6 拓撲排序

到現在爲止,我們把圖的基本操作都學習的差不多了,現在再來了解一下我覺得關於圖比較有意思的一個東西,它就是拓撲排序,它是一種針對有向無環圖的操作,它按照特定的規則將有向無環圖轉換成一個序列。現在我們來看看它的官方描述

對一個有向無環圖G進行拓撲排序,是將G中所有頂點排成一個線性序列,使得圖中任意一對頂點u和v,若邊(u,v)∈E(G),則u在線性序列中出現在v之前。通常,這樣的線性序列稱爲滿足拓撲次序的序列,簡稱拓撲序列。簡單的說,由某個集合上的一個偏序得到該集合上的一個全序,這個操作稱之爲拓撲排序。

這個概念比較好理解,我就不贅述了,那麼這個特定的序列有什麼用呢?這個就要看實際的業務場景了,比如在大學裏,我們一共要修滿固定的學分,至於選什麼課是由學生決定的,只要學分足夠即可,但是如果想修有些課程必須先修其它課程,例如想修數據結構課程,必須先修C語言課程,那麼求一個學生可以修滿學分的課程路線就可以使用圖的拓撲排序來解決。

說完了拓撲排序的概念和應用,那麼我們該如何來實現呢,主要步驟如下:(1)在有向圖中選擇一個入度爲0的頂點,並輸出。(2)將該頂點在圖中刪除,並刪除與之關聯的所有邊,同時更新鄰接頂點的入度減一。(3)重複步驟1和2,直至所有的頂點都刪除。

如果不好理解,可以看看下面這個例子圖,整個過程可以用下圖的過程來表示
在這裏插入圖片描述
但是這種方式時間複雜度過高,每次都要掃描圖中所有的頂點,複雜度爲O(n*n),有一種改進的方式是使用隊列,類似二叉樹的層序遍歷,主要步驟如下:
(1)遍歷圖中所有的頂點,將入度爲0的頂點 入隊列。(2)從隊列中出一個頂點,打印頂點,更新該頂點的鄰接點的入度減1,如果鄰接點的入度減1之後變成了0,則將該鄰接點入隊列。(3)重複執行步驟2,直至隊列爲空。
改進之後,效率提高了很多,相當於空間換時間,時間複雜度接近O(V+E)。

好了,關於圖的基本操作就到這裏了,由於篇幅的限制,接下來的代碼實現裏,主要實現無向圖深度優先遍歷和廣度優先遍歷,以及求生成樹這三個操作,這幾個操作是相對核心的方法,其它的操作我就不在代碼裏寫了,相信只要明白了操作的原理和核心實現思路,代碼實現都不是啥大問題,ok,接下來就是代碼時刻!!!

3、基於鄰接矩陣實現圖

首先實現基於鄰接矩陣實現無向不帶權圖,其中包括的操作主要有添加頂點,添加邊,深度優先遍歷,廣度優先遍歷,求生成樹。代碼中最後的測試用例使用的圖如下:
在這裏插入圖片描述
好了,現在上代碼:

public class Graph {

	// 頂點
	private class Vertex {
		char label;// 如A,B,C
		boolean wasVisited;// 標識是否訪問過此頂點

		public Vertex(char vertex) {
			this.label = vertex;
			wasVisited = false;
		}
	}

	static final int MAX_VERTEX = 20;// 最多20個頂點
	Vertex[] vertex;// 頂點數組
	int[][] adjacency;// 鄰接矩陣
	int numOfVertex;// 當前圖中頂點的數量

	public Graph() {
		vertex = new Vertex[MAX_VERTEX];
		adjacency = new int[MAX_VERTEX][MAX_VERTEX];
		numOfVertex = 0;
		// 初始化鄰接矩陣
		for (int i = 0; i < MAX_VERTEX; i++) {
			for (int j = 0; j < MAX_VERTEX; j++)
				adjacency[i][j] = 0;
		}
	}

	// 添加頂點
	public void addVertex(char v) {
		vertex[numOfVertex++] = new Vertex(v);
	}

	// 無向圖 添加邊
	public void addEdge(int start, int end) {
		adjacency[start][end] = 1;
		adjacency[end][start] = 1;
	}

	// 打印某個頂點
	public void showVertex(int index) {
		System.out.print(vertex[index].label);
	}

	// 打印鄰接矩陣
	public void show() {
		for (int i = 0; i < numOfVertex; i++) {
			for (int j = 0; j < numOfVertex; j++) {
				System.out.print(adjacency[i][j] + "  ");
			}
			System.out.println();
		}
	}

	
	//找到與某一頂點鄰接而未被訪問的頂點,步驟如下
 	//在鄰接矩陣中,找到指定頂點所在的行,從第一列開始向後尋找值爲1的列,列號是鄰接頂點的號碼	
	//檢查此頂點是否訪問過。
	//如果該行沒有值爲1而又未訪問過的點,則此頂點的鄰接點都訪問過了。
	public int getUnVisitedVertex(int index) {
		for (int i = 0; i < numOfVertex; i++)
			if (adjacency[index][i] == 1 && vertex[i].wasVisited == false)
				return i;
		return -1;
	}

	// 圖的深度優先遍歷
	public void dfs() {
		vertex[0].wasVisited = true;// 從頭開始訪問
		showVertex(0);
		Stack<Integer> stack = new Stack<>();
		stack.push(0);
		
		//1.用peek()方法獲取棧頂的頂點 2.試圖找到這個頂點的未訪問過的鄰接點 
		//3.如果沒有找到這樣的頂點,出棧 4.如果找到,訪問之,入棧
		while (!stack.isEmpty()) {
			int index = getUnVisitedVertex((int) stack.peek());
			if (index == -1)// 沒有這個頂點
				stack.pop();
			else {
				vertex[index].wasVisited = true;
				showVertex(index);
				stack.push(index);
			}
		}
		// 棧爲空,遍歷結束,標記位重新初始化
		for (int i = 0; i < numOfVertex; i++)
			vertex[i].wasVisited = false;
	}

	// 圖的廣度優先遍歷
	public void bfs() {
		vertex[0].wasVisited = true;
		showVertex(0);
		Queue<Integer> queue = new ArrayDeque<>();
		queue.add(0);
		int v2;
		while (!queue.isEmpty()) {// 直到隊列爲空
			int v1 = (int) queue.remove();
			// 直到點v1沒有未訪問過的鄰接點
			while ((v2 = getUnVisitedVertex(v1)) != -1) {
				// 取到未訪問過的點,訪問之
				vertex[v2].wasVisited = true;
				showVertex(v2);
				queue.add(v2);
			}
		}
		for (int i = 0; i < numOfVertex; i++)
			vertex[i].wasVisited = false;
	}

	// 求生成樹
	public void st() {
		vertex[0].wasVisited = true;
		Stack<Integer> stack = new Stack<>();
		stack.push(0);
		while (!stack.isEmpty()) {
			int currentVertex = stack.peek();
			int v = getUnVisitedVertex(currentVertex);
			if (v == -1)
				stack.pop();
			else {
				vertex[v].wasVisited = true;
				stack.push(v);
				// 當前頂點與下一個未訪問過的鄰接點
				showVertex(currentVertex);
				showVertex(v);
				System.out.print("  ");
			}
		}
		for (int i = 0; i < numOfVertex; i++)
			vertex[i].wasVisited = false;
	}

	public static void main(String[] args) {
		Graph graph = new Graph();
		graph.addVertex('A');
		graph.addVertex('B');
		graph.addVertex('C');
		graph.addVertex('D');
		graph.addVertex('E');
		graph.addVertex('F');
		graph.addVertex('G');
		graph.addEdge(0, 1);
		graph.addEdge(0, 2);
		graph.addEdge(0, 3);
		graph.addEdge(1, 2);
		graph.addEdge(1, 4);
		graph.addEdge(4, 5);
		graph.addEdge(4, 6);
		System.out.println("圖的鄰接矩陣爲:");
		graph.show();
		System.out.println("深度優先遍歷爲:");
		graph.dfs();
		System.out.println();
		System.out.println("廣度優先遍歷爲:");
		graph.bfs();
		System.out.println();
		System.out.println("其中一個生成樹爲:");
		graph.st();
	}

}

最後的運行結果也貼一下吧,如下
在這裏插入圖片描述
代碼中相關的註釋已經寫的非常清楚了,所以這裏就不過多的解釋代碼,如果難以理解,基本上自己動手寫一遍,遇到問題了再看看很快就能明白。

4、基於鄰接表實現圖

同樣的,基於鄰接表,我們也實現一遍,最後的測試用例使用的圖和上面鄰接矩陣實現圖使用的是一樣的測試圖,ok,上代碼。

//基於鄰接表實現圖
public class AdjacencyListGraph {
	// 鄰接表中對應的鏈表的節點
	private class ENode {
		int ivex; // 該邊所指向的頂點的在頂點數組mVexs中的下標
		ENode nextNode; // 指向下一條邊的指針

		public ENode() {
			ivex = 0;
			nextNode = null;
		}
	}

	// 鄰接表中對應的鏈表,其實就是一個節點+頂點信息
	private class VNode {
		char data; // 頂點信息
		boolean wasVisited; // 標記是否訪問過此頂點,默認沒有訪問
		ENode firstEdge; // 指向第一條依附該頂點的弧

		public VNode() {
			data = '0';
			wasVisited = false;
			firstEdge = null;
		}
	};

	private VNode[] mVexs; // 頂點數組

	public AdjacencyListGraph(char[] vexs, char[][] edges) {
		// 初始化"頂點數"和"邊數"
		int vlen = vexs.length;
		int elen = edges.length;

		// 初始化"頂點" 也就是鄰接表的鏈表
		mVexs = new VNode[vlen];
		for (int i = 0; i < vlen; i++) {
			mVexs[i] = new VNode();
			mVexs[i].data = vexs[i];
			mVexs[i].firstEdge = null;
		}

		// 初始化"邊"
		for (int i = 0; i < elen; i++) {
			// 獲取邊的起始頂點和結束頂點
			char c1 = edges[i][0];
			char c2 = edges[i][1];
			// 獲取邊的起始頂點和結束頂點的下標
			int p1 = getPosition(c1);
			int p2 = getPosition(c2);
			// 初始化 結束頂點
			ENode endNode = new ENode();
			endNode.ivex = p2;
			// 將endNode鏈接到"p1所在鏈表的末尾"
			if (mVexs[p1].firstEdge == null)// 如果p1所在鏈表爲空,那麼直接鏈接在p1後面即可
				mVexs[p1].firstEdge = endNode;
			else
				linkLast(mVexs[p1].firstEdge, endNode);
			// 初始化 開始頂點
			ENode startNode = new ENode();
			startNode.ivex = p1;
			// 將startNode鏈接到"p2所在鏈表的末尾"
			if (mVexs[p2].firstEdge == null)
				mVexs[p2].firstEdge = startNode;
			else
				linkLast(mVexs[p2].firstEdge, startNode);

		}
	}

	// 將node節點鏈接到鏈表的最後
	private void linkLast(ENode firstNode, ENode node) {
		ENode tempNode = firstNode;

		while (tempNode.nextNode != null)
			tempNode = tempNode.nextNode;
		tempNode.nextNode = node;
	}

	// 返回頂點的下標值
	private int getPosition(char ch) {
		for (int i = 0; i < mVexs.length; i++)
			if (mVexs[i].data == ch)
				return i;
		return -1;
	}

	// 打印鄰接表
	public void print() {
		System.out.println("圖的鄰接表爲:");
		for (int i = 0; i < mVexs.length; i++) {
			System.out.print(i + "(" + mVexs[i].data + ") --> ");
			ENode node = mVexs[i].firstEdge;
			while (node != null) {
				if (node.nextNode != null) {
					System.out.print(node.ivex + "(" + mVexs[node.ivex].data + ") --> ");
				} else {
					System.out.print(node.ivex + "(" + mVexs[node.ivex].data + ")");
				}

				node = node.nextNode;
			}
			System.out.printf("\n");
		}

	}

	// 深度優先遍歷
	public void dfs() {
		System.out.println("深度優先遍歷爲:");
		// 從下標爲0的位置開始遍歷
		VNode node = mVexs[0];
		// 更新起始點的訪問狀態
		node.wasVisited = true;
		// 打印起始點的值
		System.out.print(node.data);
		// 看到深度優先遍歷,直接掏出 棧 即可
		Stack<VNode> stack = new Stack<>();
		stack.push(node);

		while (!stack.isEmpty()) {
			VNode startNode = stack.peek();
			// 如果該點有鄰接點
			if (startNode.firstEdge != null) {
				// 找到該鏈表中下一個沒有被訪問過的點
				ENode nextUnVisitedNode = startNode.firstEdge;
				while (mVexs[nextUnVisitedNode.ivex].wasVisited == true && nextUnVisitedNode.nextNode != null) {
					nextUnVisitedNode = nextUnVisitedNode.nextNode;
				}
				// 如果沒有 未被訪問的節點,換句話說就是,走到了鏈表的尾端依然沒找到,
				// 那麼說明該節點的所有鄰接點都已經訪問過了,然後將該節點出棧
				if (mVexs[nextUnVisitedNode.ivex].wasVisited == true) {
					stack.pop();
				} else {// 如果找到了未被訪問的鄰接點
					// 更新對應的節點狀態爲 已訪問
					mVexs[nextUnVisitedNode.ivex].wasVisited = true;
					// 打印相應的值
					System.out.print(mVexs[nextUnVisitedNode.ivex].data);
					// 將這個已訪問的節點入棧
					stack.push(mVexs[nextUnVisitedNode.ivex]);
				}
			} else {// 如果該點沒有鄰接點,直接出棧
				stack.pop();
			}
		}
		// 遍歷完之後,重置訪問狀態
		for (int i = 0; i < mVexs.length; i++) {
			mVexs[i].wasVisited = false;
		}
		System.out.println();
	}

	// 廣度優先遍歷
	public void bfs() {
		System.out.println("廣度優先遍歷爲:");
		// 從下標爲0的位置開始遍歷
		VNode node = mVexs[0];
		// 更改起始點的訪問狀態
		node.wasVisited = true;
		// 打印起始點
		System.out.print(node.data);
		// 看到廣度優先遍歷,直接掏出 隊列 即可
		Queue<VNode> queue = new ArrayDeque<>();
		queue.add(node);
		while (!queue.isEmpty()) {
			VNode startNode = queue.remove();
			// 如果該點沒有鄰接點,也就是孤立點,那麼直接退出循環即可
			if (startNode.firstEdge == null) {
				System.out.println("貌似找了個孤立點,換一個點試試");
				break;
			}
			// 找到該鏈表中所有沒有被訪問過的點,然後依次打印
			ENode nextUnVisitedNode = startNode.firstEdge;
			// 循環走到鏈表的尾端
			while (nextUnVisitedNode != null) {
				// 如果下一個節點未被訪問
				if (mVexs[nextUnVisitedNode.ivex].wasVisited == false) {
					// 更新訪問狀態
					mVexs[nextUnVisitedNode.ivex].wasVisited = true;
					// 入隊
					queue.add(mVexs[nextUnVisitedNode.ivex]);
					// 打印
					System.out.print(mVexs[nextUnVisitedNode.ivex].data);
				}
				// 如果下一個節點被訪問了,那麼我們無需做任何操作,直接進入下次循環,繼續判斷即可
				if (nextUnVisitedNode.nextNode != null) {
					nextUnVisitedNode = nextUnVisitedNode.nextNode;
				} else {
					break;
				}
			}
		}
		// 遍歷完之後,重置訪問狀態
		for (int i = 0; i < mVexs.length; i++) {
			mVexs[i].wasVisited = false;
		}
		System.out.println();
	}

	// 求生成樹
	public void mst() {
		System.out.println("其中一顆生成樹爲:");
		VNode node = mVexs[0];
		node.wasVisited = true;
		Stack<VNode> stack = new Stack<>();
		stack.push(node);
		while (!stack.isEmpty()) {
			VNode startNode = stack.peek();
			if (startNode.firstEdge != null) {
				ENode nextUnVisitedNode = startNode.firstEdge;
				while (mVexs[nextUnVisitedNode.ivex].wasVisited == true && nextUnVisitedNode.nextNode != null) {
					nextUnVisitedNode = nextUnVisitedNode.nextNode;
				}
				if (mVexs[nextUnVisitedNode.ivex].wasVisited == true) {
					stack.pop();
				} else {
					mVexs[nextUnVisitedNode.ivex].wasVisited = true;
					System.out.print(""+startNode.data+mVexs[nextUnVisitedNode.ivex].data+" ");
					stack.push(mVexs[nextUnVisitedNode.ivex]);
				}
			} else {
				stack.pop();
			}
		}
		for (int i = 0; i < mVexs.length; i++) {
			mVexs[i].wasVisited = false;
		}
		System.out.println();
	}

	public static void main(String[] args) {
		char[] vexs = { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };
		char[][] edges = new char[][] { { 'A', 'B' }, { 'A', 'C' }, { 'A', 'D' }, { 'B', 'C' }, { 'B', 'E' },
				{ 'E', 'F' }, { 'E', 'G' } };
		AdjacencyListGraph graph = new AdjacencyListGraph(vexs, edges);

		graph.print(); // 打印圖
		graph.dfs(); //深度優先遍歷
		graph.bfs(); //廣度優先遍歷
		graph.mst(); //求生成樹
	}
}

代碼最後的運行結果爲
在這裏插入圖片描述
最後要提一下的是,上面兩種方式的實現中,求生成樹這裏,其實就是使用的深度優先遍歷來求解的路徑,如果要是求最小生成樹的話,那麼這樣肯定是錯的,具體的方法在上面也說過了,就是那兩個算法,同時不要忘了更新節點的結構,加上一個權值,有興趣的可以寫寫對應的代碼鍛鍊一下。

結語

呼,到這裏,圖算是告一段落了,圖應該是數據結構裏比較複雜的,但是再複雜的只要靜下心來,都是可以解決的,然後開頭也說了,這一篇應該是數據結構系列的最後一篇,過段時間,再準備下一波內容,好了,就這樣!!!

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