圖是一種抽象數據結構,本質和樹結構是一樣的。
圖與樹相比較,圖具有封閉性,可以把樹結構看成是圖結構的前生。在樹結構中,如果把兄弟節點之間或子節點之間橫向連接,便構建成一個圖。
樹適合描述從上向下的一對多的數據結構,如公司的組織結構。
圖適合描述更復雜的多對多數據結構,如復雜的群體社交關系。
1. 圖理論
借助計算機解決現實世界中的問題時,除了要存儲現實世界中的信息,還需要正確地描述信息之間的關系。
如在開發地圖程序時,需要在計算機中正確模擬出城市與城市、或城市中各道路之間的關系圖。在此基礎上,才有可能通過算法計算出從一個城市到另一個城市、或從指定起點到目標點間的最佳路徑。
類似的還有航班路線圖、火車線路圖、社交交系圖。
圖結構能很好的對現實世界中如上這些信息之間的復雜關系進行映射。以此可使用算法方便的計算出如航班線路中的最短路徑、如火車線路中的最佳中轉方案,如社交圈中誰與誰關系最好、婚姻網中誰與誰最般配……
1.1 圖的概念
頂點:頂點也稱為節點,可認為圖就是頂點組成的集合。頂點本身是有數據含義的,所以頂點都會帶有附加信息,稱作"有效載荷"。
頂點可以是現實世界中的城市、地名、站名、人……
邊: 圖中的邊用來描述頂點之間的關系。邊可以有方向也可以沒有方向,有方向的邊又可分為單向邊和雙向邊。
如下圖(項點1)到(頂點2)之間的邊只有一方向(箭頭所示為方向),稱為單向邊。類似現實世界中的單向道。
(頂點1)到(頂點2)之間的邊有兩個方向(雙向箭頭),稱為雙向邊。 城市與城市之間的關系為雙向邊。
權重: 邊上可以附加值信息,附加的值稱為權重。有權重的邊用來描述一個頂點到另一個頂點的連接強度。
如現實生活中的地鐵路線中,權重可以描述兩個車站之間時間長度、公里數、票價……
邊描述的是頂點之間的關系,權重描述的是連接的差異性。
路徑:
先了解現實世界中路徑概念
如:從一個城市開車去另一個城市,就需要先確定好路徑。也就是 從出發地到目的地要經過那些城市?要走多少里程?
可以說路徑是由邊連接的頂點組成的序列。因路徑不只一條,所以,從一個項點到另一個項點的路徑描述也不指一種。
在圖結構中如何計算路徑?
無權重路徑的長度是路徑上的邊數。
有權重路徑的長度是路徑上的邊的權重之和。
如上圖從(頂點1)到(頂點3)的路徑長度為 8。
環: 從起點出發,最后又回到起點(終點也是起點)就會形成一個環,環是一種特殊的路徑。如上 (V1, V2, V3, V1)
就是一個環。
圖的類型:
綜上所述,圖可以分為如下幾類:
- 有向圖: 邊有方向的圖稱為有向圖。
- 無向圖: 邊沒有方向的圖稱為無向圖。
- 加權圖: 邊上面有權重信息的圖稱為加權圖。
- 無環圖: 沒有環的圖被稱為無環圖。
- 有向無環圖: 沒有環的有向圖,簡稱 DAG。
1.2 定義圖
根據圖的特性,圖數據結構中至少要包含兩類信息:
-
所有頂點構成集合信息,這里用 V 表示(如地圖程序中,所有城市構在頂點集合)。
-
所有邊構成集合信息,這里用 E 表示(城市與城市之間的關系描述)。
如何描述邊?
邊用來表示項點之間的關系。所以一條邊可以包括 3 個元數據(起點,終點,權重)。當然,權重是可以省略的,但一般研究圖時,都是指的加權圖。
如果用 G
表示圖,則 G = (V, E)
。每一條邊可以用二元組 (fv, ev)
也可以使用 三元組 (fv,ev,w)
描述。
fv
表示起點,ev
表示終點。且fv
,ev
數據必須引用於V
集合。
如上的圖結構可以描述如下:
# 5 個頂點
V={A0,B1,C2,D3,E4}
# 7 條邊
E={ (A0,B1,3),(B1,C2,4),(C2,D3,6),(C2,E4,1),(D3,E4,2),(A0,D3,5),(E4,B1,7)}
1.3 圖的抽象數據結構
圖的抽象數據描述中至少要有的方法:
-
Graph ( )
: 用來創建一個新圖。 -
add_vertex( vert )
:向圖中添加一個新節點,參數應該是一個節點類型的對象。 -
add_edge(fv,tv )
:在 2 個項點之間建立起邊關系。 -
add_edge(fv,tv,w )
:在 2 個項點之間建立起一條邊並指定連接權重。 -
find_vertex( key )
: 根據關鍵字 key 在圖中查找頂點。 -
find_vertexs( )
:查詢所有頂點信息。 -
find_path( fv,tv)
:查找.從一個頂點到另一個頂點之間的路徑。
2. 圖的存儲實現
圖的存儲實現主流有 2 種:鄰接矩陣和鏈接表,本文主要介紹鄰接矩陣。
2.1 鄰接矩陣
使用二維矩陣(數組)存儲頂點之間的關系。
如 graph[5][5]
可以存儲 5 個頂點的關系數據,行號和列號表示頂點,第 v 行的第 w 列交叉的單元格中的值表示從頂點 v 到頂點 w 的邊的權重,如 grap[2][3]=6
表示 C2 頂點和 D3 頂點的有連接(相鄰),權重為 6。
相鄰矩陣的優點就是簡單,可以清晰表示那些頂點是相連的。因不是每兩兩個頂點之間會有連接,會導致大量的空間閑置,稱這種矩陣為”稀疏“的。
只有當每一個頂點和其它頂點都有關系時,矩陣才會填滿。所以,使用這種結構存儲圖數據,對於關系不是很復雜的圖結構而言,會產生大量的空間浪費。
鄰接矩陣適合表示關系復雜的圖結構,如互聯網上網頁之間的鏈接、社交圈中人與人之間的社會關系……
2.2 編碼實現鄰接矩陣
因頂點本身有數據含義,需要先定義頂點類型。
頂點類:
"""
節(頂)點類
"""
class Vertex:
def __init__(self, name, v_id=0):
# 頂點的編號
self.v_id = v_id
# 頂點的名稱
self.v_name = name
# 是否被訪問過:False 沒有 True:有
self.visited = False
# 自我顯示
def __str__(self):
return '[編號為 {0},名稱為 {1} ] 的頂點'.format(self.v_id, self.v_name)
頂點類中 v_id
和 v_name
很好理解。為什么要添加一個 visited
?
這個變量用來記錄頂點在路徑搜索過程中是否已經被搜索過,避免重復搜索計算。
圖類:圖類的方法較多,這里逐方法介紹。
- 初始化方法
class Graph:
"""
nums:相鄰矩陣的大小
"""
def __init__(self, nums):
# 一維列表,保存節點,最多只能有 nums 個節點
self.vert_list = []
# 二維列表,存儲頂點及頂點間的關系(權重)
# 初始權重為 0 ,表示節點與節點之間還沒有建立起關系
self.matrix = [[0] * nums for _ in range(nums)]
# 頂點個數
self.v_nums = 0
# 使用隊列模擬隊列或棧,用於廣度、深度優先搜索算法
self.queue_stack = []
# 保存搜索到的路徑
self.searchPath = []
# 暫省略……
初始化方法用來初始化圖中的數據類型:
-
一維列表
vert_list
保存所有頂點數據。 -
二維列表
matrix
保存頂點與頂點之間的關系數據。 -
queue_stack
使用列表模擬隊列或棧,用於后續的廣度搜索和深度搜索。怎么使用列表模擬隊列或棧?
列表有
append()
、pop()
2 個很價值的方法。append()
用來向列表中添加數據,且每次都是從列表最后面添加。pop()
默認從列表最后面刪除且彈出數據,pop(參數)
可以提供索引值用來從指定位置刪除且彈出數據。使用
append()
和pop()
方法就能模擬棧,從同一個地方進出數據。使用
append()
和pop(0)
方法就能模擬隊列,從后面添加數據,從最前面獲取數據 -
searchPath
: 用來保存使用廣度或深度優先路徑搜索中的結果。
- 添加新節(頂)點方法:
"""
添加新頂點
"""
def add_vertex(self, vert):
if vert in self.vert_list:
# 已經存在
return
if self.v_nums >= len(self.matrix):
# 超過相鄰矩陣所能存儲的節點上限
return
# 頂點的編號內部生成
vert.v_id = self.v_nums
self.vert_list.append(vert)
# 數量增一
self.v_nums += 1
上述方法注意一點,節點的編號由圖內部邏輯提供,便於節點編號順序的統一。
-
添加邊方法
此方法是鄰接矩陣表示法的核心邏輯。
'''
添加節點與節點之間的邊,
如果是無權重圖,統一設定為 1
'''
def add_edge(self, from_v, to_v):
# 如果節點不存在
if from_v not in self.vert_list:
self.add_vertex(from_v)
if to_v not in self.vert_list:
self.add_vertex(to_v)
# from_v 節點的編號為行號,to_v 節點的編號為列號
self.matrix[from_v.v_id][to_v.v_id] = 1
'''
添加有權重的邊
'''
def add_edge(self, from_v, to_v, weight):
# 如果節點不存在
if from_v not in self.vert_list:
self.add_vertex(from_v)
if to_v not in self.vert_list:
self.add_vertex(to_v)
# from_v 節點的編號為行號,to_v 節點的編號為列號
self.matrix[from_v.v_id][to_v.v_id] = weight
添加邊信息的方法有 2 個,一個用來添加無權重邊,一個用來添加有權重的邊。
-
查找某節點
使用線性查找法從節點集合中查找某一個節點。
'''
根據節點編號返回節點
'''
def find_vertex(self, v_id):
if v_id >= 0 or v_id <= self.v_nums:
# 節點編號必須存在
return [tmp_v for tmp_v in self.vert_list if tmp_v.v_id == v_id][0]
- 查詢所有節點
'''
輸出所有頂點信息
'''
def find_only_vertexes(self):
for tmp_v in self.vert_list:
print(tmp_v)
此方法僅為了查詢方便。
- 查詢節點之間的關系
'''
迭代節點與節點之間的關系(邊)
'''
def find_vertexes(self):
for tmp_v in self.vert_list:
edges = self.matrix[tmp_v.v_id]
for col in range(len(edges)):
w = edges[col]
if w != 0:
print(tmp_v, '和', self.vert_list[col], '的權重為:', w)
- 測試代碼:
if __name__ == "__main__":
# 初始化圖對象
g = Graph(5)
# 添加頂點
for _ in range(len(g.matrix)):
v_name = input("頂點的名稱( q 為退出):")
if v_name == 'q':
break
v = Vertex(v_name)
g.add_vertex(v)
# 節點之間的關系
infos = [(0, 1, 3), (0, 3, 5), (1, 2, 4), (2, 3, 6), (2, 4, 1), (3, 4, 2), (4, 1, 7)]
for i in infos:
v = g.find_vertex(i[0])
v1 = g.find_vertex(i[1])
g.add_edge(v, v1, i[2])
# 輸出頂點及邊a
print("-----------頂點與頂點關系--------------")
g.find_vertexes()
'''
輸出結果:
頂點的名稱( q 為退出):A
頂點的名稱( q 為退出):B
頂點的名稱( q 為退出):C
頂點的名稱( q 為退出):D
頂點的名稱( q 為退出):E
[編號為 0,名稱為 A ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 3
[編號為 0,名稱為 A ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 5
[編號為 1,名稱為 B ] 的頂點 和 [編號為 2,名稱為 C ] 的頂點 的權重為: 4
[編號為 2,名稱為 C ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 6
[編號為 2,名稱為 C ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 1
[編號為 3,名稱為 D ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 2
[編號為 4,名稱為 E ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 7
'''
3. 搜索路徑
在圖中經常做的操作,就是查找從一個頂點到另一個頂點的路徑。如怎么查找到 A0 到 E4 之間的路徑長度:
從人的直觀思維角度查找一下,可以找到如下路徑:
{A0,B1,C2,E4}
路徑長度為 8。{A0,D3,E4}
路徑長度為 7。{A0,B1,C2,D3,E4}
路徑長度為 15。
人的思維是知識性、直觀性思維,在路徑查找時不存在所謂的嘗試或碰壁問題。而計算機是試探性思維,就會出現這條路不通,再找另一條路的現象。
所以路徑算法中常常會以錯誤為代價,在查找過程中會走一些彎路。常用的路徑搜索算法有 2 種:
- 廣度優先搜索。
- 深度優先搜索。
3.1 廣度優先搜索
先看一下廣度優先搜索的示意圖:
廣度優先搜索的基本思路:
- 確定出發點,本案例是 A0 頂點。
- 以出發點相鄰的頂點為候選點,並存儲至隊列。
- 從隊列中每拿出一個頂點后,再把與此頂點相鄰的其它頂點做為候選點存儲於隊列。
- 不停重復上述過程,至到找到目標頂點或隊列為空。
使用廣度搜索到的路徑與候選節點進入隊列的先后順序有關系。如第 1 步確定候選節點時 B1
和 D3
誰先進入隊列,對於后面的查找也會有影響。
上圖使用廣度搜索可找到 A0~E4
路徑是:
{A0,B1,D3,C2,E4}
其實 {A0,B1,C2,E4}
也是一條有效路徑,有可能搜索不出來,這里因為搜索到 B1
后不會馬上搜索 C2
,因為 B3
先於 C2
進入,廣度優先搜索算法只能保證找到路徑,而不能保存找到最佳路徑。
編碼實現廣度優先搜索:
廣度優先搜索需要借助隊列臨時存儲選節點,本文使用列表模擬隊列。
在圖類中實現廣度優先搜索算法的方法:
class Graph():
# 省略其它代碼
'''
廣度優先搜索算法
'''
def bfs(self, from_v, to_v):
# 查找與 fv 相鄰的節點
self.find_neighbor(from_v)
# 臨時路徑
lst_path = [from_v]
# 重復條件:隊列不為空
while len(self.queue_stack) != 0:
# 從隊列中一個節點(模擬隊列)
tmp_v = self.queue_stack.pop(0)
# 添加到列表中
lst_path.append(tmp_v)
# 是不是目標節點
if tmp_v.v_id == to_v.v_id:
self.searchPath.append(lst_path)
print('找到一條路徑', [v_.v_id for v_ in lst_path])
lst_path.pop()
else:
self.find_neighbor(tmp_v)
'''
查找某一節點的相鄰節點,並添加到隊列(棧)中
'''
def find_neighbor(self, find_v):
if find_v.visited:
return
find_v.visited = True
# 找到保存 find_v 節點相鄰節點的列表
lst = self.matrix[find_v.v_id]
for idx in range(len(lst)):
if lst[idx] != 0:
# 權重不為 0 ,可判斷相鄰
self.queue_stack.append(self.vert_list[idx])
廣度優先搜索過程中,需要隨時獲取與當前節點相鄰的節點,find_neighbor()
方法的作用就是用來把當前節點的相鄰節點壓入隊列中。
測試廣度優先搜索算法:
if __name__ == "__main__":
# 初始化圖對象
g = Graph(5)
# 添加頂點
for _ in range(len(g.matrix)):
v_name = input("頂點的名稱( q 為退出):")
if v_name == 'q':
break
v = Vertex(v_name)
g.add_vertex(v)
# 節點之間的關系
infos = [(0, 1, 3), (0, 3, 5), (1, 2, 4), (2, 3, 6), (2, 4, 1), (3, 4, 2), (4, 1, 7)]
for i in infos:
v = g.find_vertex(i[0])
v1 = g.find_vertex(i[1])
g.add_edge(v, v1, i[2])
print("----------- 廣度優先路徑搜索--------------")
f_v = g.find_vertex(0)
t_v = g.find_vertex(4)
g.bfs(f_v,t_v)
'''
輸出結果
頂點的名稱( q 為退出):A
頂點的名稱( q 為退出):B
頂點的名稱( q 為退出):C
頂點的名稱( q 為退出):D
頂點的名稱( q 為退出):E
----------- 廣度優先路徑搜索--------------
找到一條路徑 [0, 1, 3, 2, 4]
找到一條路徑 [0, 1, 3, 2, 3, 4]
'''
使用遞歸實現廣度優先搜索算法:
'''
遞歸方式實現廣度搜索
'''
def bfs_dg(self, from_v, to_v):
self.searchPath.append(from_v)
if from_v.v_id != to_v.v_id:
self.find_neighbor(from_v)
if len(self.queue_stack) != 0:
self.bfs_dg(self.queue_stack.pop(0), to_v)
3.2 深度優先搜索算法
先看一下深度優先算法的示意圖。
深度優先搜索算法與廣度優先搜索算法不同之處:候選節點是放在棧中的。因棧是先進后出,所以,搜索到的節點順序不一樣。
使用循環實現深度優先搜索算法:
深度優先搜索算法需要用到棧,本文使用列表模擬。
'''
深度優先搜索算法
使用棧存儲下一個需要查找的節點
'''
def dfs(self, from_v, to_v):
# 查找與 from_v 相鄰的節點
self.find_neighbor(from_v)
# 臨時路徑
lst_path = [from_v]
# 重復條件:棧不為空
while len(self.queue_stack) != 0:
# 從棧中取一個節點(模擬棧)
tmp_v = self.queue_stack.pop()
# 添加到列表中
lst_path.append(tmp_v)
# 是不是目標節點
if tmp_v.v_id == to_v.v_id:
self.searchPath.append(lst_path)
print('找到一條路徑:', [v_.v_id for v_ in lst_path])
lst_path.pop()
else:
self.find_neighbor(tmp_v)
測試:
if __name__ == "__main__":
# 初始化圖對象
g = Graph(5)
# 添加頂點
for _ in range(len(g.matrix)):
v_name = input("頂點的名稱( q 為退出):")
if v_name == 'q':
break
v = Vertex(v_name)
g.add_vertex(v)
# 節點之間的關系
infos = [(0, 1, 3), (0, 3, 5), (1, 2, 4), (2, 3, 6), (2, 4, 1), (3, 4, 2), (4, 1, 7)]
for i in infos:
v = g.find_vertex(i[0])
v1 = g.find_vertex(i[1])
g.add_edge(v, v1, i[2])
# 輸出頂點及邊a
print("-----------頂點與頂點關系--------------")
g.find_vertexes()
print("----------- 深度優先路徑搜索--------------")
f_v = g.find_vertex(0)
t_v = g.find_vertex(4)
g.dfs(f_v, t_v)
'''
輸出結果
頂點的名稱( q 為退出):A
頂點的名稱( q 為退出):B
頂點的名稱( q 為退出):C
頂點的名稱( q 為退出):D
頂點的名稱( q 為退出):E
-----------頂點與頂點關系--------------
[編號為 0,名稱為 A ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 3
[編號為 0,名稱為 A ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 5
[編號為 1,名稱為 B ] 的頂點 和 [編號為 2,名稱為 C ] 的頂點 的權重為: 4
[編號為 2,名稱為 C ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 6
[編號為 2,名稱為 C ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 1
[編號為 3,名稱為 D ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 2
[編號為 4,名稱為 E ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 7
----------- 深度優先路徑搜索--------------
找到一條路徑: [0, 3, 4]
找到一條路徑: [0, 3, 1, 2, 4]
'''
使用遞歸實現深度優先搜索算法:
'''
遞歸實現深度搜索算法
'''
def def_dg(self, from_v, to_v):
self.searchPath.append(from_v)
if from_v.v_id != to_v.v_id:
# 查找與 from_v 節點相連的子節點
lst = self.find_neighbor_(from_v)
if lst is not None:
for tmp_v in lst[::-1]:
self.def_dg(tmp_v, to_v)
"""
查找某一節點的相鄰節點,以列表方式返回
"""
def find_neighbor_(self, find_v):
if find_v.visited:
return
find_v.visited = True
# 查找與 find_v 節點相鄰的節點
lst = self.matrix[find_v.v_id]
return [self.vert_list[idx] for idx in range(len(lst)) if lst[idx] != 0]
遞歸實現時,不需要使用全局棧,只需要獲到當前節點的相鄰節點便可。
4. 總結
圖是一種很重要的數據結構,因這個世界中萬事萬物之間的關系並不是簡單的你和我,我和你的關系,本質都是錯綜復雜的。
圖能准確的映射現實世界的這種錯綜復雜關系,為計算機處理現實世界的問題提供了可能,也拓展了計算機在現實世界的應用領域。