資料結構——圖論(java)
一.基本概念
1、頂點(vertex)
表示某個事物或物件。由於圖的術語沒有標準化,因此,稱頂點為點、節點、結點、端點等都是可以的。
2、邊(edge)
通俗點理解就是兩個點相連組合成一條邊,表示事物與事物之間的關係。需要注意的是邊表示的是頂點之間的邏輯關係,粗細長短都無所謂的。包括上面的頂點也一樣,表示邏輯事物或物件,畫的時候大小形狀都無所謂。
3、同構(Isomorphism )
先看看下面2張圖:
首先你的感覺是這2個圖肯定不一樣。但從圖(graph)的角度出發,這2個圖是一樣的,即它們是同構的。前面提到頂點和邊指的是事物和事物的邏輯關係,不管頂點的位置在哪,邊的粗細長短如何,只要不改變頂點代表的事物本身,不改變頂點之間的邏輯關係,那麼就代表這些圖擁有相同的資訊,是同一個圖。同構的圖區別僅在於畫法不同。
4、有向/無向圖(Directed Graph/ Undirected Graph)
最基本的圖通常被定義為“無向圖”,與之對應的則被稱為“有向圖”。兩者唯一的區別在於,有向圖中的邊是有方向性的。有向圖和無向圖的許多原理和演算法是相通的。
5、權重(weight)
邊的權重(或者稱為權值、開銷、長度等),也是一個非常核心的概念,即每條邊都有與之對應的值。例如當頂點代表某些物理地點時,兩個頂點間邊的權重可以設定為路網中的開車距離。有時候為了應對特殊情況,邊的權重可以是零或者負數,也別忘了“圖”是用來記錄關聯的東西,並不是真正的地圖。
6、路徑/最短路徑(path/shortest path)
在圖上任取兩頂點,分別作為起點(start vertex)和終點(end vertex),我們可以規劃許多條由起點到終點的路線。不會來來回回繞圈子、不會重複經過同一個點和同一條邊的路線,就是一條“路徑”。兩點之間存在路徑,則稱這2個頂點是連通的(connected)。
還是上圖的例子,北京->上海->廣州,是一條路徑,北京->武漢->廣州,是另一條路徑,北京—>武漢->上海->廣州,也是一條路徑。而北京->武漢->廣州這條路徑最短,稱為最短路徑。
路徑也有權重。路徑經過的每一條邊,沿路加權重,權重總和就是路徑的權重(通常只加邊的權重,而不考慮頂點的權重)。在路網中,路徑的權重,可以想象成路徑的總長度。在有向圖中,路徑還必須跟隨邊的方向。
值得注意的是,一條路徑包含了頂點和邊,因此路徑本身也構成了圖結構,只不過是一種特殊的圖結構。
7、環(loop)
環,也成為環路,是一個與路徑相似的概念。在路徑的終點新增一條指向起點的邊,就構成一條環路。通俗點說就是繞圈。與路徑一樣,有向圖中的環路也必須跟隨邊的方向。環本身也是一種特殊的圖結構。
8、連通圖/連通分量(connected graph/connected component)
如果在圖G中,任意2個頂點之間都存在路徑,那麼稱G為連通圖(注意是任意2頂點)。上面那張城市之間的圖,每個城市之間都有路徑,因此是連通圖。而下面這張圖中,頂點8和頂點2之間就不存在路徑,因此下圖不是一個連通圖,當然該圖中還有很多頂點之間不存在路徑。
上圖雖然不是一個連通圖,但它有多個連通子圖:0,1,2頂點構成一個連通子圖,0,1,2,3,4頂點構成的子圖是連通圖,6,7,8,9頂點構成的子圖也是連通圖,當然還有很多子圖。我們把一個圖的最大連通子圖稱為它的連通分量。0,1,2,3,4頂點構成的子圖就是該圖的最大連通子圖,也就是連通分量。連通分量有如下特點:
1)是子圖;
2)子圖是連通的;
3)子圖含有最大頂點數。
注意:“最大連通子圖”指的是無法再擴充套件了,不能包含更多頂點和邊的子圖。0,1,2,3,4頂點構成的子圖已經無法再擴充套件了。
顯然,對於連通圖來說,它的最大連通子圖就是其本身,連通分量也是其本身。
二.圖的兩種表示形式
1、鄰接表
鄰接表的核心思想就是針對每個頂點設定一個鄰居表。
以上面的圖為例,這是一個有向圖,分別有頂點a, b, c, d, e, f, g, h共8個頂點。使用鄰接表就是針對這8個頂點分別構建鄰居表,從而構成一個8個鄰居表組成的結構,這個結構就是我們這個圖的表示結構或者叫儲存結構。
a, b, c, d, e, f, g, h = range(8)
N = [{b, c, d, e, f}, # a 的鄰居表
{c, e}, # b 的鄰居表
{d}, # c 的鄰居表
{e}, # d 的鄰居表
{f}, # e 的鄰居表
{c, g, h}, # f 的鄰居表
{f, h}, # g 的鄰居表
{f, g}] # h 的鄰居表
個人覺得在java裡面用map儲存比較好,key用來儲存定點,value用來儲存與頂點相關的其他頂點集合。
2、鄰接矩陣
(2)鄰接矩陣
鄰接矩陣的核心思想是針對每個頂點設定一個表,這個表包含所有頂點,通過True/False來表示是否是鄰居頂點。
還是針對上面的圖,分別有頂點a, b, c, d, e, f, g, h共8個頂點。使用鄰接矩陣就是針對這8個頂點構建一個8×8的矩陣組成的結構,這個結構就是我們這個圖的表示結構或儲存結構。
在鄰接矩陣表示法中,有一些非常實用的特性。
- 首先,可以看出,該矩陣是一個方陣,方陣的維度就是圖中頂點的數量,同時還是一個對稱矩陣,這樣進行處理時非常方便。
- 其次,該矩陣對角線表示的是頂點與頂點自身的關係,一般圖不允許出現自關聯狀態,即自己指向自己的邊,那麼對角線的元素全部為0;
- 最後,該表示方式可以不用改動即可表示帶權值的圖,直接將原來儲存1的地方修改成相應的權值即可。當然, 0也是權值的一種,而鄰接矩陣中0表示不存在這條邊。出於實踐中的考慮,可以對不存在的邊的權值進行修改,將其設定為無窮大或非法的權值,如None,-99999/99999等
三.廣度優先搜尋(BFS)
BFS使用佇列(queue)來實施演算法過程,佇列(queue)有著先進先出FIFO(First Input First Output)的特性,BFS操作步驟如下:
1、把起始點放入queue;
2、重複下述2步驟,直到queue為空為止:
1) 從queue中取出佇列頭的點;
2) 找出與此點鄰接的且尚未遍歷的點,進行標記,然後全部放入queue中
具體流程圖可參考:https://blog.csdn.net/saltriver/article/details/54428983
四.深度優先搜尋(DFS)
DFS的實現方式相比於BFS應該說大同小異,只是把queue換成了stack而已,stack具有後進先出LIFO(Last Input First Output)的特性,DFS的操作步驟如下:
1、把起始點放入stack;
2、重複下述3步驟,直到stack為空為止:
- 從stack中訪問棧頂的點;
- 找出與此點鄰接的且尚未遍歷的點,進行標記,然後全部放入stack中;
- 如果此點沒有尚未遍歷的鄰接點,則將此點從stack中彈出。
具體流程圖可參考:https://blog.csdn.net/saltriver/article/details/54429068
五.最小生成樹
1.Prim演算法
MST(Minimum Spanning Tree,最小生成樹)問題有兩種通用的解法,Prim演算法就是其中之一,它是從點的方面考慮構建一顆MST,大致思想是:設圖G頂點集合為U,首先任意選擇圖G中的一點作為起始點a,將該點加入集合V,再從集合U-V中找到另一點b使得點b到V中任意一點的權值最小,此時將b點也加入集合V;以此類推,現在的集合V={a,b},再從集合U-V中找到另一點c使得點c到V中任意一點的權值最小,此時將c點加入集合V,直至所有頂點全部被加入V,此時就構建出了一顆MST。因為有N個頂點,所以該MST就有N-1條邊,每一次向集合V中加入一個點,就意味著找到一條MST的邊。
用圖示和程式碼說明:
初始狀態:
設定2個數據結構:
lowcost[i]:表示以i為終點的邊的最小權值,當lowcost[i]=0說明以i為終點的邊的最小權值=0,也就是表示i點加入了MST
mst[i]:表示對應lowcost[i]的起點,即說明邊<mst[i],i>是MST的一條邊,當mst[i]=0表示起點i加入MST
我們假設V1是起始點,進行初始化(*代表無限大,即無通路):
lowcost[2]=6,lowcost[3]=1,lowcost[4]=5,lowcost[5]=*,lowcost[6]=*
mst[2]=1,mst[3]=1,mst[4]=1,mst[5]=1,mst[6]=1,(所有點預設起點是V1)
明顯看出,以V3為終點的邊的權值最小=1,所以邊<mst[3],3>=1加入MST
此時,因為點V3的加入,需要更新lowcost陣列和mst陣列:
lowcost[2]=5,lowcost[3]=0,lowcost[4]=5,lowcost[5]=6,lowcost[6]=4
mst[2]=3,mst[3]=0,mst[4]=1,mst[5]=3,mst[6]=3
明顯看出,以V6為終點的邊的權值最小=4,所以邊<mst[6],6>=4加入MST
此時,因為點V6的加入,需要更新lowcost陣列和mst陣列:
lowcost[2]=5,lowcost[3]=0,lowcost[4]=2,lowcost[5]=6,lowcost[6]=0
mst[2]=3,mst[3]=0,mst[4]=6,mst[5]=3,mst[6]=0
明顯看出,以V4為終點的邊的權值最小=2,所以邊<mst[4],4>=4加入MST
此時,因為點V4的加入,需要更新lowcost陣列和mst陣列:
lowcost[2]=5,lowcost[3]=0,lowcost[4]=0,lowcost[5]=6,lowcost[6]=0
mst[2]=3,mst[3]=0,mst[4]=0,mst[5]=3,mst[6]=0
明顯看出,以V2為終點的邊的權值最小=5,所以邊<mst[2],2>=5加入MST
此時,因為點V2的加入,需要更新lowcost陣列和mst陣列:
lowcost[2]=0,lowcost[3]=0,lowcost[4]=0,lowcost[5]=3,lowcost[6]=0
mst[2]=0,mst[3]=0,mst[4]=0,mst[5]=2,mst[6]=0
很明顯,以V5為終點的邊的權值最小=3,所以邊<mst[5],5>=3加入MST
lowcost[2]=0,lowcost[3]=0,lowcost[4]=0,lowcost[5]=0,lowcost[6]=0
mst[2]=0,mst[3]=0,mst[4]=0,mst[5]=0,mst[6]=0
至此,MST構建成功,如圖所示:
int graph[MAX][MAX];
int prim(int graph[][MAX], int n)
{
int lowcost[MAX];
int mst[MAX];
int i, j, min, minid, sum = 0;
//將與第一個點相連的邊的長度都遍歷到lowcost陣列中
for (i = 2; i < n; i++)
{
lowcost[i] = graph[0][i];
mst[i] = 1;
}
mst[1] = 0;
for (i = 2; i < n; i++)
{
min = MAXCOST;
minid = 0;
//找出最小的一條邊
for (j = 2; j < n; j++)
{
if (lowcost[j] < min && lowcost[j] != 0)
{
min = lowcost[j];
minid = j;
}
}
system.out.println("V"+mst[minid]+"-V"+minid+"="+min);
sum += min;
//將已經選中過的邊置為0,下次篩選的時候會過濾
lowcost[minid] = 0;
//遍歷一遍lowcost陣列,倘若新加入終點的邊與lowcost中相同終點的邊的值更小,則替換
for (j = 2; j < n; j++)
{
if (graph[minid][j] < lowcost[j])
{
lowcost[j] = graph[minid][j];
mst[j] = minid;
}
}
}
return sum;
}
轉載:https://blog.csdn.net/yeruby/article/details/38615045
2.Kruskal演算法
1、Kruskal演算法描述
Kruskal演算法是基於貪心的思想得到的。首先我們把所有的邊按照權值先從小到大排列,接著按照順序選取每條邊,如果這條邊的兩個端點不屬於同一集合,那麼就將它們合併,直到所有的點都屬於同一個集合為止。至於怎麼合併到一個集合,那麼這裡我們就可以用到一個工具——-並查集(不知道的同學請移步:Here)。換而言之,Kruskal演算法就是基於並查集的貪心演算法。
2、Kruskal演算法流程
對於圖G(V,E),以下是演算法描述:
-
輸入: 圖G
-
輸出: 圖G的最小生成樹
-
具體流程:
-
(1)將圖G看做一個森林,每個頂點為一棵獨立的樹
-
(2)將所有的邊加入集合S,即一開始S = E
-
(3)從S中拿出一條最短的邊(u,v),如果(u,v)不在同一棵樹內,則連線u,v合併這兩棵樹,同時將(u,v)加入生成樹的邊集E'
-
(4)重複(3)直到所有點屬於同一棵樹,邊集E'就是一棵最小生成樹
我們用現在來模擬一下Kruskal演算法,下面給出一個無向圖B,我們使用Kruskal來找無向圖B的最小生成樹。
首先,我們將所有的邊都進行從小到大的排序。排序之後根據貪心準則,我們選取最小邊(A,D)。我們發現頂點A,D不在一棵樹上,所以合併頂點A,D所在的樹,並將邊(A,D)加入邊集E‘。
我們接著在剩下的邊中查詢權值最小的邊,於是我們找到的(C,E)。我們可以發現,頂點C,E仍然不在一棵樹上,所以我們合併頂點C,E所在的樹,並將邊(C,E)加入邊集E'
不斷重複上述的過程,於是我們就找到了無向圖B的最小生成樹,如下圖所示:
3、Kruskal演算法的時間複雜度
Kruskal演算法每次要從都要從剩餘的邊中選取一個最小的邊。通常我們要先對邊按權值從小到大排序,這一步的時間複雜度為為O(|Elog|E|)。Kruskal演算法的實現通常使用並查集,來快速判斷兩個頂點是否屬於同一個集合。最壞的情況可能要列舉完所有的邊,此時要迴圈|E|次,所以這一步的時間複雜度為O(|E|α(V)),其中α為Ackermann函式,其增長非常慢,我們可以視為常數。所以Kruskal演算法的時間複雜度為O(|Elog|E|)。
作者程式碼的實現可以新增一個節點結束器,每聯合一個節點就加一,當節點數達到圖中的總節點數的時候,就break跳出迴圈;
//自己寫的,程式碼為測試,有問題歡迎指出
class Kruskal{
int nodeNum;
List<Side> listSide;
public Kruskal(String[] node,int[][] graph){
nodeNum = node.length;
//去除重複,取二維陣列的斜三角
for(int i=1;i<nodeNum;i++){
for(int j=0;j<i;j++){
if(graph[i][j]>0){
Side side = new Side();
side.start = i;
side.end = j;
side.length = graph[i][j];
listNode.add(side);
}
}
}
}
//定義邊
class Side{
private int start;
private int end;
private int length;
}
public void init(){
this.father = new int[nodeNum];
for(int i=0;i<nodeNum;i++){
father[i] = -1;
}
}
public int find(int x){
if(father[X] <= 0)
return X;
else
return father[X] = find(father[X]);
}
public void union(int root1,int root2){
if (father[root2]<father[root1]){
father[root1] = root2;
}else{
if (father[root1]==father[root2])
father[root1] --;
father[root2] = root1;
}
}
public int printKruskal(){
//將邊按長度從小到大排序
Sort(listSide);
int currentNodeNum = 1;
int sum = 0;
for(int i=0;i<listSide.size();i++){
int startNode = listSide.get(i).start;
int endNode = listSide.get(i).end;
//這裡使用交併集解決會連城圈的問題
if(find(startNode) == -1 && find(endNode) == -1){
union(startNode,endNode);
nodeNum ++;
sum += listSide.get(i).length;
}else if(find(startNode) != find(endNode)){
union(startNode,endNode);
nodeNum ++;
sum += listSide.get(i).length;
}
if(currentNodeNum == nodeNum) break;
}
}
}
轉載:https://blog.csdn.net/luomingjun12315/article/details/47700237
6.最短路徑
程式碼好理解:https://blog.csdn.net/qibofang/article/details/51594673
文字好理解:https://blog.csdn.net/tianjing0805/article/details/76023080