到目前為止,我們所提過的所有數據結構,不是線性表,就是樹。即使是散列表、優先隊列、AVL樹這樣看似特殊的數據結構,其實也沒逃出線性表與樹的范疇,那么,在數據結構方面(如果是說算法方面,那么與線性表、樹相關的算法可講不完),還有什么我們尚未探討的情況嗎?
答案是肯定的,那就是:圖。不過在進一步介紹圖之前,我想先回顧一下樹與線性表的關系。不難發現,其實線性表就是一棵特殊的樹:“無叉樹”。而樹也可以看作是將線性表要求放寬后得出的數據結構:元素的后繼個數不再有限制。那么,將“放寬要求”的思想再次應用於樹之上,我們可以得出什么樣的數據結構呢?那就是圖:
1.元素的前驅,即父親的個數不限(樹中有限制)
2.元素的后繼,即孩子的個數不限
3.元素X可以既是Y的前驅,又是Y的后繼。(樹中有限制)
不過需要注意的是,如果一個元素既沒有前驅也沒有后繼,那它是一個“獨立”的“集合”,換句話說就是一個無前驅也無后繼的元素不屬於某個已存在的圖。這一點在樹、線性表中也是一樣的。
下面是一個圖的例子:
上例中的圖,既有無前驅的元素:v0,也有無后繼的元素:v5,而v3則顯然是一個有多前驅、多后繼的元素。
上例中的圖體現出了圖的第1、2條特點,而下面的圖則體現出了圖的第3個特點:
在第一個例子的基礎上,我們令每個元素都成為自己前驅的前驅,從而有了第二個例子。
圖的直觀概念有了之后,我們接下來要談一下和圖相關的一些術語。首先,在圖中,我們將元素稱作“頂點”(vertex,例子中的v即其簡寫),而頂點間的“有箭頭的線段”我們稱之為“有向邊”,有向邊由兩個頂點確定並表示:(vx,vy),其中vx表示有向邊的起點,vy表示有向邊的終點。
有向邊(vx,vy)和其反向邊(vy,vx)可以結合起來作為一條“無向邊”,或者叫“雙向邊”,如果圖中任意一條邊都有反向邊,或者說所有邊都是無向邊,那么我們稱這個圖是“無向圖”,否則是“有向圖”,顯然第二個例子是一個無向圖,第一個例子不是。而對於無向圖,我們可以以更簡單的形式將其畫出,即將所有有向邊與其反向邊結合為一條邊,並去掉箭頭,比如第二個例子可以畫作這樣:
圖的可能用處是顯而易見的,城市間公路地圖就可以用無向圖來模擬,用頂點代表城市,無向邊表示兩個城市間的公路。而一項工程的流程則可以用有向圖來模擬:可能某步驟需要先完成其他幾個步驟才能做,比如組裝發動機得在制造好活塞、缸體等組件后才能進行,這就是“多前驅”的情況,也可能好多個步驟都需要等某個步驟做完后才能繼續,比如安裝輪胎、安裝座椅等工作都得在車架制造好之后才能進行,這就是“多后繼”的情況。
知道了圖的概念和可能用處之后,下一個問題是:如何存儲一個圖?首先需要明確的是,無向圖總是可以用有向圖來表示,只要將一條無向邊拆成兩條方向相反的有向邊即可。所以我們只要關注如何存儲有向圖,就可以順帶解決無向圖的存儲問題。
一個簡單的想法是假設所有頂點用自然數從0開始逐一編號,然后構造一個二維數組:
bool graph[numVertex][numVertex];
其中graph[x][y]若為true,則表示存在有向邊(x,y),否則表示不存在有向邊(x,y)。而無向邊[a,b]則可以拆成有向邊(a,b)和(b,a),從而實現存儲。
如果采用這樣的方式存儲圖,那么我們第一個例子中的有向圖將會這樣存儲(v0-v6對應下標0-6,縱向為數組一維下標,橫向為二維下標):
而第二個例子中的有向圖則可以這樣存儲:
這樣的存儲方式我們稱之為鄰接矩陣(兩個頂點之間存在邊即這兩個頂點鄰接)。鄰接矩陣簡單、快捷,但是存在很大的浪費,對於第一個例子,可以說我們用了49個變量,卻只有12個(約24.5%)真正起了作用,剩下37個(約75.5%)都是“用了真實的內存來表示不存在的東西”,即便是對於第二個例子,我們也浪費了一半的空間。所以,如果圖不夠“稠密”(即邊的數量接近頂點數量的平方),我們一般不采用鄰接矩陣來存儲圖,而是用鄰接表來存儲圖。文字表達鄰接表是什么非常麻煩,也不容易懂,所以我們先直接給出第一個例子用鄰接表存儲的抽象表示,再來解釋鄰接表的實現方法:
從上圖可以看出,鄰接表長得非常像分離鏈接式的散列表,首先我們依然假設頂點用數字表示,然后我們用一個數組graph來存儲所有頂點(圖中左側),接着對於每一條有向邊(x,y),我們創建一個“代表y的結點”,然后將該結點插入到以graph[x]為頭的鏈表中去,抽象理解即將邊(x,y)插入了進去:
//"結點"的定義 typedef struct node{ size_t vertex; struct node *next; }Node; //存儲所有頂點的數組(即圖) //所有以頂點x作為起點的有向邊的終點,均存儲在以graph[x]為頭的鏈表中 Node *graph[numVertex]; //邊的定義 typedef struct edge{ size_t start; size_t end; }Edge; /*構造圖的函數,假設所有邊已放入數組allEdges,graph數組的大小是正確的,且graph數組每個元素都已初始化為NULL,numEdge為邊的個數*/ void buildGraph(Node **graph,Edge *allEdges,size_t numEdge) { size_t start; Node *newNode; for(int i=0;i<numEdge;++i) { start=allEdges[i].start; newNode=(Node *)malloc(sizeof(Node)); newNode->vertex=allEdges[i].end; newNode->next=graph[start]; graph[start]=newNode; } }
這樣一來,所有以x為起點的邊(x,y)都可以通過遍歷以graph[x]為頭的鏈表來找到,不過在該鏈表中我們只存儲了邊的終點y,因為起點就是鏈表頭的下標。
如果需要令圖支持動態變化,那么將graph數組轉換成一個鏈表即可,方便起見,我們暫且用數組來存儲頂點。
接下來我們要討論的東西是拓撲排序。但是在討論拓撲排序前,有兩個概念需要理解:路徑、圈。所謂路徑,就是從圖中某個頂點出發,沿着邊,到達另一個頂點后,經過的邊的集合。舉例來說,看下圖
這個圖中,從v0到v6的路徑可以是:
(v0,v3)-(v3,v6)
簡寫成這樣也行,只要能表達出意思即可:
v0-v3-v6
顯然,從一個頂點到另一個頂點的路徑可能不止一條,比如上圖中v0到v6還可以這樣走:v0-v1-v4-v6
路徑的長度一般用路徑中邊的條數表示,比如路徑v0-v3-v6長度不是3,而是2。此外,v0-v0我們也認為是一條路徑,其長度為0。
那么什么是圈呢?圈就是一種特殊的路徑,該路徑的起點和終點是同一個頂點,而且路徑長度大於0(也就是說路徑vx-vx不算圈,我們稱之為“環”)。比如下圖
其中路徑v0-v1-v4-v3-v0就是一個圈。
知道了什么是圈后,我們就可以開始討論拓撲排序了。拓撲排序就是對圖中頂點進行的排序,其要求是:若存在從vx到vy的路徑,那么排序結果中vx必須在vy之前。這個要求其實就暗含着另一個要求,那就是:進行拓撲排序的圖必須是有向無圈圖。在無向圖中,若存在邊(vx,vy)則必存在邊(vy,vx),那么依拓撲排序的要求,vx就必須在vy的前面,同時vy又必須在vx前面,這顯然是矛盾的,所以拓撲排序只能用於有向圖。而在有向有圈圖中,比如上圖,其中的圈v0-v1-v4-v3-v0就暗含着兩條子路徑:v0-v1-v4和v4-v3-v0,依前一條路徑而言,排序結果中v0必須在v4前面,而依后一條路徑而言,v4又必須在v0前面,這顯然也是矛盾的,所以拓撲排序只能用於有向無圈圖。
接下來對拓撲排序的討論依照下圖進行
顯然上圖是一個有向無圈圖,那么其拓撲排序之一是這樣的:
v0,v1,v4,v3,v2,v6,v5
注意,一個圖的拓撲排序結果可能不是唯一的,比如上圖的另一個拓撲排序結果是:
v0,v1,v4,v3,v6,v2,v5
有了拓撲排序結果后,我們可以試着換一個角度來理解拓撲排序:對於排序結果中的任意兩個頂點vx和vy,若vy在vx之后,則圖中必然沒有從vy到vx的路徑。
可是,洋洋灑灑說了那么多,拓撲排序有什么用呢?前面我們說過,工藝流程可以用有向圖來模擬,那么如果我們對一個工藝流程圖進行拓撲排序,我們就能確定各個步驟按照怎樣的順序去做就絕對不會出現做完了一個步驟,卻因為還有某個步驟沒完成,從而不能做下一個步驟的情況。類似的,游戲中的任務系統也可以用圖模擬,比如一些游戲中存在隱藏任務,可能一個隱藏任務需要完成多個普通任務才會觸發,而只有完成了這個隱藏任務,你才可以去接收更多的隱藏任務,此時也可能運用到拓撲排序。
總而言之,如果我們有步驟A和B,且A需要在B之前完成,那么我們就可以將A、B視為頂點,B對A的“依賴關系”視為邊(A,B),當我們知道大量的步驟和局部的依賴關系時,我們就可以將其構建成一個完整的圖,然后通過拓撲排序確定整體的依賴關系。當然,拓撲排序也可以用於判斷一個圖有沒有圈,並且后面對圖的進一步討論時我們也將利用拓撲排序實現一些改進。
接下來的問題顯然就是如何實現拓撲排序,在說明如何進行拓撲排序之前,我們先了解一下有向無圈圖的兩個特點:
1.若圖有向無圈,則必然存在一個入度為0的頂點。
2.若圖有向無圈,則去掉其入度為0的頂點及相連邊(必為以該頂點為起點的有向邊)后,圖依然是有向無圈圖。
所謂頂點的入度,即以該頂點為終點的有向邊個數,比如頂點vy的入度即邊(vx,vy)的個數(其中x!=y)。
知道了有向無圈圖的特點后,一種簡單的拓撲排序思路就出來了:在用有向邊表示依賴關系的圖中,若一個頂點的入度為0,就說明該頂點不依賴其他頂點,所以這個頂點可以直接輸出到排序結果中,而這個頂點輸出了,就意味着其所代表的步驟“做完了”,所以依賴於其的頂點不再依賴於其,可將其相連邊均去除,然后再找圖中的下一個入度為0的頂點。
用代碼來表示就是這樣(藍色字體為偽代碼):
void topSort(graph* g,size_t numVertex,size_t topResult) { //兩個表示頂點的變量,后面用 size_t tempV,adjV; //存儲各頂點入度的數組,頂點x的入度為indegree[x] size_t indegree[numVertex]; 偽:根據圖g初始化indegree數組 for(int i=0;i<numVertex;++i) { 偽:從indegree中找到一個入度為0的頂點,存入tempV if(偽:沒找到入度為0的頂點) 偽:報錯、返回 topResult[i]=tempV; 偽:通過g[tempV]遍歷tempV為起點的邊的終點,存入adjV indegree[adjV]--; } }
顯然,上述拓撲排序算法還有一定的改進空間,我們在尋找入度為0的頂點時每次都要遍歷整個indegree數組,這使得整個算法的時間復雜度達到了O(n2)(n為頂點個數)。然而實際上我們可以先遍歷一次indegree數組,然后將找到的所有入度為0的頂點存入一個隊列中,然后通過隊列出隊來獲取入度為0的頂點,而當我們減少某個頂點的入度時(indegree[adjV]--時)則判斷一下它是否已達到入度為0,若是則將其入隊。
void topSort(graph* g,size_t numVertex,size_t topResult) { //兩個表示頂點的變量,后面用 size_t tempV,adjV; //存儲各頂點入度的數組,頂點x的入度為indegree[x] size_t indegree[numVertex]; 偽:根據圖g初始化indegree數組 偽:根據indegree數組,創建一個zeroIndegree隊列,隊列中的頂點入度為0 size_t i=0; while(偽:zeroIndegree不為空) { tempV=Dequeue(zeroIndegree); topResult[i]=tempV; 偽:通過g[tempV]遍歷以tempV為起點的邊的終點,存入adjV { indegree[adjV]--; if(indegree[adjV]==0) Inqueue(zeroIndegree,adjV); } }
if(i!=numVertex-1)
偽:報錯
}
這樣一來,拓撲排序的時間復雜度就降到了O(nv+ne)(nv為頂點個數,ne為邊條數)。
作為本文的結尾,我們最后來說一說一個不容忽視的問題:如果圖的元素的關鍵字不是自然數怎么辦?很直白的想法是,如果元素的關鍵字不是自然數(比如字符串),我們就將其轉換為自然數。實現這一點的方法就是通過散列表得出元素關鍵字的散列值,而后用該散列值(即頂點)代表該元素。不過這樣做又會帶來另一個問題:如何根據頂點(即散列值),找回對應元素的關鍵字?這個問題的粗暴解法就是,在將元素插入到散列表中時,將元素在散列表中的內存地址存下來,比如存入一個名為inverseHash的鏈表中,而后在需要時通過inverseHash來找到一個頂點(散列值)所對應元素的位置。此外,因為元素的散列值可能並非按自然數順序生成的,所以存儲頂點時也不該再使用graph數組,而應該將其改為其他數據結構(鏈表甚至樹)。當然,還有其他的存儲思路,但根本思想都是基於鄰接表:先存儲所有頂點,再將以某頂點為起點的邊存儲到以該頂點為頭的數據結構中。
在介紹圖的可能用處時,我們不僅提到了圖可以表示工藝流程,還提到了圖可以表示“本來就是圖”的地圖,而對於表示地圖的圖,一個很明顯的問題就是:如何找到兩個頂點間的最短路徑?這個問題我們在下一篇博客介紹解決方法。