今天我們開始學習目前學習到的最難最複雜的資料結構圖。
簡單回顧一下之前學習的資料結構,陣列、單連結串列、佇列等線性表中資料元素是一對一關係,而樹結構中資料元素是一對多關係,而圖結構中資料元素則是多對多關係,任何兩個資料元素之間都有可能有關係,由此可見圖結構的複雜程度。
希望透過這篇文章可以讓大家很輕鬆的瞭解和學習圖結構並快速入門,把一些晦澀難懂概念透過合理的組織歸類使其簡單明瞭,再配合一些圖例說明,希望可以使大家茅塞頓開。
01、基礎概念
1、定義
圖是一個二元組G=(V(G),E(G))。其中V(G)是非空集,稱為點集,對於V中的每個元素,我們稱其為頂點或節點,簡稱點;E(G)為V(G)各節點之間邊的集合,稱為邊集。
常用G=(V,E)表示圖。
2、組成部分
上面的定義可能較為抽象,我們也可以從另一個角度來理解圖,即圖的內部結構,圖由哪些要素組成的——點與邊。簡單來說圖就是由若干個點以及連線兩點的邊構成的圖形,而上面的定義也只是在說所有的點和所有的邊組成圖,這樣是不是就很容易理解了。
其中點可以代表某種事物,而邊可表示兩個事物之間的關係,這樣我們就可以把一些實際問題轉為圖,然後使用軟體解決問題。
3、分類
我們可以根據邊是否有方向,是否帶權,簡單的將圖分類為無向圖、有向圖、帶權圖,當然還有其他型別的圖,現階段我們就不過多介紹了,容易把自己搞暈。
(1)無向圖
無向圖顧名思義就是邊沒有方向,即兩個點之間沒有方向,沒有順序之分,這樣的邊叫作無向邊,也簡稱邊。其中點也叫作端點。
(2)有向圖
有向圖則指邊有方向,也就代表邊所連線的兩點有順序之分,其中一個為起點,則另一個則為終點,而這樣的邊就叫作有向邊或弧。起點和終點也叫端點。
其中同一個點既可以是起點,也可以是終點。
對於任何圖,與一個點關聯的所有邊數稱為該點的度。而對於有向圖來說,以一個點為起點的邊數稱為該的點出度,以一個點為終點的邊數稱為該點的入度。
如上圖點A的出度為3,入度1。
(3)帶權圖
帶權圖指每個邊都帶有一個權重,代表邊連線的兩點關係的強弱、遠近。同時權只是代表邊的權重,並不代表邊的方向,因此無論無邊圖還是有邊圖都可以是帶權圖。
02、儲存方式
瞭解了圖的基本知識以後,帶來了一個新的問題,這麼複雜的結構我們要怎麼儲存下來呢?
下面我們就來介紹幾種常用的儲存方式鄰接矩陣、鄰接表、逆鄰接表、十字連結串列。
1、鄰接矩陣
鄰接矩陣就是用一個二維陣列來儲存任意兩點之間的關係,其中行列索引表示點,而行列索引所在的位置的值表示兩點關係,其中兩點關係可以用以下數值表示:
(1)0:表示兩點之間沒有邊;
(2)1:表示兩點之間有邊;
(3)權值:表示兩點之間邊的權值;
如果圖存在n個點,則可以用n x n的二維陣列來表示圖,下面我們來看看常見圖的表示方式。
(1)無向圖
對於無向圖,如果點A與點B有邊,則[A,B]與[B,A]都為1,否則都為0,因此無向圖的鄰接矩陣是對稱的,如下圖:
(2)有向圖
對於有向圖,則可以透過把行索引當作邊的起點,把列當作邊的終點,來表示方向,比如[A,B]為1,而[B,A]為0,如下圖:
對於有向圖,我們可以發現關於點的度有以下特性:
點的出度就是第i行元素之和;
點的入度就是第i列元素之和;
點的度就是第i行元素之和 + 第i列元素之和;
(3)帶權圖
對於帶權圖,本質上和無向圖與有向圖相同,只是儲存的值有所差別,如果兩點之間有邊則直接存權值,如果兩點之間無邊則存一個特殊值(如0、無窮),如果可以保證權值中不存在0,可以用0,否則要選一個其他特殊值,如下圖:
總結
優點:
(1)簡單直觀:實現簡單,易於理解,尤其適合小型圖。
(2)快速查詢:便於判斷兩點之間是否有邊,以及各點的度。
缺點:
(1)空間浪費:空間複雜度高為O(n^2),對於稀疏圖,許多元素為零,造成空間浪費。
(2)不易擴充套件:不便於插入和刪除點,需要更新整個矩陣,時間複雜度高為O(n)。
2、鄰接表
對於鄰接矩陣空間浪費以及不易擴充套件的問題,發展出了另一種鏈式儲存方式——「鄰接表」。
鄰接表的儲存思想和前面章節介紹的雜湊的鏈式儲存很像。首先我們用一個陣列儲存所有的點,而每個點元素又作為單連結串列頭,其後繼節點則儲存與頭節點相鄰的點元素。
(1)無向圖
如下圖,圖中所有點都儲存在陣列中,而與其相鄰的點儲存在其後面的連結串列中。
點A相鄰的點為點B和點C;
點C相鄰的點為點A、點D和點E;
點D相鄰的點為點C;
(2)有向圖
與無向圖不同的是有向圖連結串列中儲存的不是所有相鄰的點,而是儲存有方向的點,即以陣列中的點為起的終點元素。
點A為起點的終點為點B和點C;
點B為起點的終點為點E;
點D為起點的終點不存在;
透過上圖可以發現,鄰接表對於有向圖可以很直觀的表示出某個點的出度,但是對於入度獲取就很麻煩。
(3)帶權圖
帶權圖與無向圖和有向圖相比,只需要在元素中多加一個權重屬性即可。
總結
優點:
(1)節省空間:時間複雜度相對較低為O(m+n),m為點數量,n為邊數量,對於稀疏圖儲存效率更高;
(2)操作靈活:插入和刪除點操作方便,時間複雜度為O(1);
(3)出度易取:對於有向圖獲取某個點的出度非常方便,只需要找到這個點所在的陣列元素位置,然後獲取其連結串列中的元素個數即可;
缺點:
(1)不便查詢:判斷兩點之間是否有邊的時間複雜度為O(V), 其中V 是該點的相鄰點數量;
(2)入度難算:對於有向圖點的入度的計算難度較大,時間複雜度為 O(E),其中E是圖中的邊的數量;
3、逆鄰接表
逆鄰接表從名字上就可以看出來和鄰接表是逆的關係,這個逆就體現在入度和出度上。我們知道鄰接表計算出度容易,計算入度難,而逆鄰接表恰恰相反是計算入度容易,計算出度難。
如下圖陣列中儲存點元素,而連結串列中儲存的是以陣列中的點為終點的起點元素。
點A為終點的起點不存在;
點B為終點的起點為點A;
點D為終點的起點為點A和點E;
總結
與鄰接表差異在於在儲存的方向正好相反,所以入度和出度計算難度正好相反,而其他則完全一樣。
4、十字連結串列
鄰接表出度計算容易,逆鄰接表入度計算容易,那麼有沒有一種結構同時計算出度入度都容易呢?答案就是十字連結串列。
十字連結串列是鄰接表和逆鄰接表的結合體,每個點的邊透過雙向連結串列儲存,同時記錄了邊的出度和入度。
下面我們詳細講解一下十字連結串列是怎麼得到的。
(1)合併逆鄰接表與鄰接表
如下圖我們之間把逆鄰接表和鄰接表拼接到一起,得到一個偽十字連結串列。
之所以稱這個結合體為偽十字連結串列,是因為它雖然同時儲存了邊的兩個方向,解決了出度入度計算問題,但是也引發了新的問題——儲存效率低。
從上圖不難看出連結串列中存在嚴重的重複儲存的問題。要解決這個問題,我們先梳理一下我們得到的偽十字連結串列結構。
(2)連結串列由存點改存邊
首先陣列儲存所有點,左側連結串列儲存起點元素集合,右側連結串列儲存終點元素集合;然後我們想為什麼需要兩條連結串列呢?因為一條連結串列就代表一個方向;
那第一步我們是否可以先解決方向的問題呢?而目前的結構節點只有一個點的資訊,顯然沒有方向性,因此我們需要把連結串列節點改造成包含兩個點的結構即起點和終點,這也意味著連結串列由原來儲存點元素變為儲存邊元素。
原來點A出度連結串列儲存點B和點C,現在改為儲存[A->B]邊和[A->C]邊。
原來點B入度連結串列儲存點A,現在改為儲存[A->B]邊。
如下圖:
(3)刪除重複元素
到這裡就有條件解決重複的元素的問題了,比如上面連結串列中有兩個[A->B]邊,如果我們想把點B入度連結串列中[A->B]邊刪除,那麼我們必須要有一個途徑使得點B的入度連結串列可以和點A的出度連結串列中[A->B]邊連結上。
首先陣列元素結構應該至少包含:資料域|入邊頭節點指標|出邊頭節點指標;
然後連結串列節點元素結構應該至少包含:邊起點下標|邊終點下標|下一個入邊節點指標域|下一個出邊節點指標域;
下面我們進行去除重複元素,首先表裡下出度連結串列結構,移除現有入度連結串列,其中入度連結串列中的元素指向到出度連結串列中,最後結果如下圖:
如上圖紅色實線箭頭表示出度連結串列,而彩色虛線箭頭表示入度連結串列。
點A為終點的邊不存在,點A為起點的邊為 [A->B]邊和[A->C]邊;
點B為終點的邊為[A->B]邊(即紅色1號虛線),點B為起點的邊為 [B->E]邊;
點C為終點的邊為[A->C]邊(即綠色2號虛線)和[E->C]邊(即綠色3號虛線),點C為起點的邊為[C->D]邊;
總結
優點:
(1)高效儲存,適合複雜的有向圖,支援快速遍歷;
(2)快速計算出度入度;
缺點:
(1)實現複雜,維護難度高;
注:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner