Java數據結構和算法(十五)——無權無向圖
前面我們介紹了樹這種數據結構,樹是由n(n>0)個有限節點通過連接它們的邊組成一個具有層次關系的集合,把它叫做“樹”是因為它看起來像一棵倒掛的樹,包括二叉樹、紅黑樹、2-3-4樹、堆等各種不同的樹,有對這幾種樹不了解的可以參考我前面幾篇博客。而本篇博客我們將介紹另外一種數據結構——圖,圖也是計算機程序設計中最常用的數據結構之一,從數學意義上講,樹是圖的一種,大家可以對比著學習。
1、圖的定義
我們知道,前面討論的數據結構都有一個框架,而這個框架是由相應的算法實現的,比如二叉樹搜索樹,左子樹上所有結點的值均小於它的根結點的值,右子樹所有結點的值均大於它的根節點的值,類似這種形狀使得它容易搜索數據和插入數據,樹的邊表示了從一個節點到另一個節點的快捷方式。
而圖通常有個固定的形狀,這是由物理或抽象的問題所決定的。比如圖中節點表示城市,而邊可能表示城市間的班機航線。如下圖是美國加利福利亞簡化的高速公路網:
①、鄰接:
如果兩個頂點被同一條邊連接,就稱這兩個頂點是鄰接的,如上圖 I 和 G 就是鄰接的,而 I 和 F 就不是。有時候也將和某個指定頂點鄰接的頂點叫做它的鄰居,比如頂點 G 的鄰居是 I、H、F。
②、路徑:
路徑是邊的序列,比如從頂點B到頂點J的路徑為 BAEJ,當然還有別的路徑 BCDJ,BACDJ等等。
③、連通圖和非連通圖:
如果至少有一條路徑可以連接起所有的頂點,那麽這個圖稱作連通的;如果假如存在從某個頂點不能到達另外一個頂點,則稱為非聯通的。
④、有向圖和無向圖:
如果圖中的邊沒有方向,可以從任意一邊到達另一邊,則稱為無向圖;比如雙向高速公路,A城市到B城市可以開車從A駛向B,也可以開車從B城市駛向A城市。但是如果只能從A城市駛向B城市的圖,那麽則稱為有向圖。
⑤、有權圖和無權圖:
圖中的邊被賦予一個權值,權值是一個數字,它能代表兩個頂點間的物理距離,或者從一個頂點到另一個頂點的時間,這種圖被稱為有權圖;反之邊沒有賦值的則稱為無權圖。
本篇博客我們討論的是無權無向圖。
2、在程序中表示圖
我們知道圖是由頂點和邊組成,那麽在計算機中,怎麽來模擬頂點和邊?
①、頂點:
在大多數情況下,頂點表示某個真實世界的對象,這個對象必須用數據項來描述。比如在一個飛機航線模擬程序中,頂點表示城市,那麽它需要存儲城市的名字、海拔高度、地理位置和其它相關信息,因此通常用一個頂點類的對象來表示一個頂點,這裏我們僅僅在頂點中存儲了一個字母來標識頂點,同時還有一個標誌位,用來判斷該頂點有沒有被訪問過(用於後面的搜索)。
/** * 頂點類 * @author vae */ public class Vertex { public char label; public boolean wasVisited; public Vertex(char label){ this.label = label; wasVisited = false; } }
頂點對象能放在數組中,然後用下標指示,也可以放在鏈表或其它數據結構中,不論使用什麽結構,存儲只是為了使用方便,這與邊如何連接點是沒有關系的。
②、邊:
在前面講解各種樹的數據結構時,大多數樹都是每個節點包含它的子節點的引用,比如紅黑樹、二叉樹。也有用數組表示樹,樹組中節點的位置決定了它和其它節點的關系,比如堆就是用數組表示。
然而圖並不像樹,圖沒有固定的結構,圖的每個頂點可以與任意多個頂點相連,為了模擬這種自由形式的組織結構,用如下兩種方式表示圖:鄰接矩陣和鄰接表(如果一條邊連接兩個頂點,那麽這兩個頂點就是鄰接的)
鄰接矩陣:
鄰接矩陣是一個二維數組,數據項表示兩點間是否存在邊,如果圖中有 N 個頂點,鄰接矩陣就是 N*N 的數組。上圖用鄰接矩陣表示如下:
1表示有邊,0表示沒有邊,也可以用布爾變量true和false來表示。頂點與自身相連用 0 表示,所以這個矩陣從左上角到右上角的對角線全是 0 。
註意:這個矩陣的上三角是下三角的鏡像,兩個三角包含了相同的信息,這個冗余信息看似低效,但是在大多數計算機中,創造一個三角形數組比較困難,所以只好接受這個冗余,這也要求在程序處理中,當我們增加一條邊時,比如更新鄰接矩陣的兩部分,而不是一部分。
鄰接表:
鄰接表是一個鏈表數組(或者是鏈表的鏈表),每個單獨的鏈表表示了有哪些頂點與當前頂點鄰接。
3、搜索
在圖中實現最基本的操作之一就是搜索從一個指定頂點可以到達哪些頂點,比如從武漢出發的高鐵可以到達哪些城市,一些城市可以直達,一些城市不能直達。現在有一份全國高鐵模擬圖,要從某個城市(頂點)開始,沿著鐵軌(邊)移動到其他城市(頂點),有兩種方法可以用來搜索圖:深度優先搜索(DFS)和廣度優先搜索(BFS)。它們最終都會到達所有連通的頂點,深度優先搜索通過棧來實現,而廣度優先搜索通過隊列來實現,不同的實現機制導致不同的搜索方式。
①、深度優先搜索(DFS)
深度優先搜索算法有如下規則:
規則1:如果可能,訪問一個鄰接的未訪問頂點,標記它,並將它放入棧中。
規則2:當不能執行規則 1 時,如果棧不為空,就從棧中彈出一個頂點。
規則3:如果不能執行規則 1 和規則 2 時,就完成了整個搜索過程。
對於上圖,應用深度優先搜索如下:假設選取 A 頂點為起始點,並且按照字母優先順序進行訪問,那麽應用規則 1 ,接下來訪問頂點 B,然後標記它,並將它放入棧中;再次應用規則 1,接下來訪問頂點 F,再次應用規則 1,訪問頂點 H。我們這時候發現,沒有 H 頂點的鄰接點了,這時候應用規則 2,從棧中彈出 H,這時候回到了頂點 F,但是我們發現 F 也除了 H 也沒有與之鄰接且未訪問的頂點了,那麽再彈出 F,這時候回到頂點 B,同理規則 1 應用不了,應用規則 2,彈出 B,這時候棧中只有頂點 A了,然後 A 還有未訪問的鄰接點,所有接下來訪問頂點 C,但是 C又是這條線的終點,所以從棧中彈出它,再次回到 A,接著訪問 D,G,I,最後也回到了 A,然後訪問 E,但是最後又回到了頂點 A,這時候我們發現 A沒有未訪問的鄰接點了,所以也把它彈出棧。現在棧中已無頂點,於是應用規則 3,完成了整個搜索過程。
深度優先搜索在於能夠找到與某一頂點鄰接且沒有訪問過的頂點。這裏以鄰接矩陣為例,找到頂點所在的行,從第一列開始向後尋找值為1的列;列號是鄰接頂點的號碼,檢查這個頂點是否未訪問過,如果是這樣,那麽這就是要訪問的下一個頂點,如果該行沒有頂點既等於1(鄰接)且又是未訪問的,那麽與指定點相鄰接的頂點就全部訪問過了(後面會用算法實現)。
②、廣度優先搜索(BFS)
深度優先搜索要盡可能的遠離起始點,而廣度優先搜索則要盡可能的靠近起始點,它首先訪問起始頂點的所有鄰接點,然後再訪問較遠的區域,這種搜索不能用棧實現,而是用隊列實現。
規則1:訪問下一個未訪問的鄰接點(如果存在),這個頂點必須是當前頂點的鄰接點,標記它,並把它插入到隊列中。
規則2:如果已經沒有未訪問的鄰接點而不能執行規則 1 時,那麽從隊列列頭取出一個頂點(如果存在),並使其成為當前頂點。
規則3:如果因為隊列為空而不能執行規則 2,則搜索結束。
對於上面的圖,應用廣度優先搜索:以A為起始點,首先訪問所有與 A 相鄰的頂點,並在訪問的同時將其插入隊列中,現在已經訪問了 A,B,C,D和E。這時隊列(從頭到尾)包含 BCDE,已經沒有未訪問的且與頂點 A 鄰接的頂點了,所以從隊列中取出B,尋找與B鄰接的頂點,這時找到F,所以把F插入到隊列中。已經沒有未訪問且與B鄰接的頂點了,所以從隊列列頭取出C,它沒有未訪問的鄰接點。因此取出 D 並訪問 G,D也沒有未訪問的鄰接點了,所以取出E,現在隊列中有 FG,在取出 F,訪問 H,然後取出 G,訪問 I,現在隊列中有 HI,當取出他們時,發現沒有其它為訪問的頂點了,這時隊列為空,搜索結束。
③、程序實現
實現深度優先搜索的棧 StackX.class
package com.ys.graph; public class StackX { private final int SIZE = 20; private int[] st; private int top; public StackX(){ st = new int[SIZE]; top = -1; } public void push(int j){ st[++top] = j; } public int pop(){ return st[top--]; } public int peek(){ return st[top]; } public boolean isEmpty(){ return (top == -1); } }
實現廣度優先搜索的隊列Queue.class
package com.ys.graph; public class Queue { private final int SIZE = 20; private int[] queArray; private int front; private int rear; public Queue(){ queArray = new int[SIZE]; front = 0; rear = -1; } public void insert(int j) { if(rear == SIZE-1) { rear = -1; } queArray[++rear] = j; } public int remove() { int temp = queArray[front++]; if(front == SIZE) { front = 0; } return temp; } public boolean isEmpty() { return (rear+1 == front || front+SIZE-1 == rear); } }
圖代碼 Graph.class
package com.ys.graph; public class Graph { private final int MAX_VERTS = 20;//表示頂點的個數 private Vertex vertexList[];//用來存儲頂點的數組 private int adjMat[][];//用鄰接矩陣來存儲 邊,數組元素0表示沒有邊界,1表示有邊界 private int nVerts;//頂點個數 private StackX theStack;//用棧實現深度優先搜索 private Queue queue;//用隊列實現廣度優先搜索 /** * 頂點類 * @author vae */ class Vertex { public char label; public boolean wasVisited; public Vertex(char label){ this.label = label; wasVisited = false; } } public Graph(){ vertexList = new Vertex[MAX_VERTS]; adjMat = new int[MAX_VERTS][MAX_VERTS]; nVerts = 0;//初始化頂點個數為0 //初始化鄰接矩陣所有元素都為0,即所有頂點都沒有邊 for(int i = 0; i < MAX_VERTS; i++) { for(int j = 0; j < MAX_VERTS; j++) { adjMat[i][j] = 0; } } theStack = new StackX(); queue = new Queue(); } //將頂點添加到數組中,是否訪問標誌置為wasVisited=false(未訪問) public void addVertex(char lab) { vertexList[nVerts++] = new Vertex(lab); } //註意用鄰接矩陣表示邊,是對稱的,兩部分都要賦值 public void addEdge(int start, int end) { adjMat[start][end] = 1; adjMat[end][start] = 1; } //打印某個頂點表示的值 public void displayVertex(int v) { System.out.print(vertexList[v].label); } /**深度優先搜索算法: * 1、用peek()方法檢查棧頂的頂點 * 2、用getAdjUnvisitedVertex()方法找到當前棧頂點鄰接且未被訪問的頂點 * 3、第二步方法返回值不等於-1則找到下一個未訪問的鄰接頂點,訪問這個頂點,並入棧 * 如果第二步方法返回值等於 -1,則沒有找到,出棧 */ public void depthFirstSearch() { //從第一個頂點開始訪問 vertexList[0].wasVisited = true; //訪問之後標記為true displayVertex(0);//打印訪問的第一個頂點 theStack.push(0);//將第一個頂點放入棧中 while(!theStack.isEmpty()) { //找到棧當前頂點鄰接且未被訪問的頂點 int v = getAdjUnvisitedVertex(theStack.peek()); if(v == -1) { //如果當前頂點值為-1,則表示沒有鄰接且未被訪問頂點,那麽出棧頂點 theStack.pop(); }else { //否則訪問下一個鄰接頂點 vertexList[v].wasVisited = true; displayVertex(v); theStack.push(v); } } //棧訪問完畢,重置所有標記位wasVisited=false for(int i = 0; i < nVerts; i++) { vertexList[i].wasVisited = false; } } //找到與某一頂點鄰接且未被訪問的頂點 public int getAdjUnvisitedVertex(int v) { for(int i = 0; i < nVerts; i++) { //v頂點與i頂點相鄰(鄰接矩陣值為1)且未被訪問 wasVisited==false if(adjMat[v][i] == 1 && vertexList[i].wasVisited == false) { return i; } } return -1; } /** * 廣度優先搜索算法: * 1、用remove()方法檢查棧頂的頂點 * 2、試圖找到這個頂點還未訪問的鄰節點 * 3、 如果沒有找到,該頂點出列 * 4、 如果找到這樣的頂點,訪問這個頂點,並把它放入隊列中 */ public void breadthFirstSearch(){ vertexList[0].wasVisited = true; displayVertex(0); queue.insert(0); int v2; while(!queue.isEmpty()) { int v1 = queue.remove(); while((v2 = getAdjUnvisitedVertex(v1)) != -1) { vertexList[v2].wasVisited = true; displayVertex(v2); queue.insert(v2); } } //搜索完畢,初始化,以便於下次搜索 for(int i = 0; i < nVerts; i++) { vertexList[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.addEdge(0, 1);//AB graph.addEdge(1, 2);//BC graph.addEdge(0, 3);//AD graph.addEdge(3, 4);//DE System.out.println("深度優先搜索算法 :"); graph.depthFirstSearch();//ABCDE System.out.println(); System.out.println("----------------------"); System.out.println("廣度優先搜索算法 :"); graph.breadthFirstSearch();//ABDCE } }
測試結果:
4、最小生成樹
對於圖的操作,還有一個最常用的就是找到最小生成樹,最小生成樹就是用最少的邊連接所有頂點。對於給定的一組頂點,可能有很多種最小生成樹,但是最小生成樹的邊的數量 E 總是比頂點 V 的數量小1,即:
V = E + 1
這裏不用關心邊的長度,不是找最短的路徑(會在帶權圖中講解),而是找最少數量的邊,可以基於深度優先搜索和廣度優先搜索來實現。
比如基於深度優先搜索,我們記錄走過的邊,就可以創建一個最小生成樹。因為DFS 訪問所有頂點,但只訪問一次,它絕對不會兩次訪問同一個頂點,但她看到某條邊將到達一個已訪問的頂點,它就不會走這條邊,它從來不遍歷那些不可能的邊,因此,DFS 算法走過整個圖的路徑必定是最小生成樹。
//基於深度優先搜索找到最小生成樹 public void mst(){ vertexList[0].wasVisited = true; theStack.push(0); while(!theStack.isEmpty()){ int currentVertex = theStack.peek(); int v = getAdjUnvisitedVertex(currentVertex); if(v == -1){ theStack.pop(); }else{ vertexList[v].wasVisited = true; theStack.push(v); displayVertex(currentVertex); displayVertex(v); System.out.print(" "); } } //搜索完畢,初始化,以便於下次搜索 for(int i = 0; i < nVerts; i++) { vertexList[i].wasVisited = false; } }
5、總結
圖是由邊連接的頂點組成,圖可以表示許多真實的世界情況,包括飛機航線、電子線路等等。搜索算法以一種系統的方式訪問圖中的每個頂點,主要通過深度優先搜索(DFS)和廣度優先搜索(BFS),深度優先搜索通過棧來實現,廣度優先搜索通過隊列來實現。最後需要知道最小生成樹是包含連接圖中所有頂點所需要的最少數量的邊。
Java數據結構和算法(十五)——無權無向圖