Python 圖_系列之基於<連結表>實現無向圖最短路徑搜尋

一枚大果殼發表於2022-04-05

圖的常用儲存方式有 2 種:

  • 鄰接炬陣

  • 連結表

鄰接炬陣的優點和缺點都很明顯。優點是簡單、易理解,對於大部分圖結構而言,都是稀疏的,使用炬陣儲存空間浪費就較大。

連結表的儲存相比較鄰接炬陣,使用起來更方便,對於空間的使用是剛好夠用原則,不會產生太多空間浪費。操作起來,也是簡單。

本文將以連結表方式儲存圖結構,在此基礎上實現無向圖最短路徑搜尋。

1. 連結表

連結表的儲存思路:

使用連結表實現圖的儲存時,有主表子表概念。

  • 主表: 用來儲存圖物件中的所有頂點資料。
  • 子表: 每一個頂點自身會維護一個子表,用來儲存與其相鄰的所有頂點資料。

如下圖結構中有 5 個頂點,使用連結表儲存時,會有主表 1 張,子表 5 張。連結表的優點是能夠緊湊地表示稀疏圖。

在這裡插入圖片描述

在這裡插入圖片描述

Python 中可以使用列表巢狀實現連結表,這應該是最簡單的表達方式。

g = [
    ['A0', [('B1', 3), ('D3', 5)]],
    ['B1', [('C2', 4)]],
    ['C2', [('D3', 6), ('E4', 1)]],
    ['D3', [('E4', 2)]],
    ['E4', [('B1', 7)]],
]

在此基礎上,可以做一些簡單的常規操作。

查詢所有頂點:

for node in g:
    print(node[0],end=' ') 

查詢頂點及其相鄰頂點

for node in g:
    print('-------------------')
    print(node[0], ":", end='')
    edges = node[1]
    for e in edges:
        v, w = e
        print(v, w, end=';')
    print()

當頂點和相鄰頂點之間的關係很複雜時,這種層層巢狀的儲存格式會讓人眼花繚亂。即使要使用這種巢狀方式,那也應該選擇 Python 中的字典型別,對於查詢會方便很多。

g = {
    'A0':{'B1': 3, 'D3': 5},
    'B1': {'C2': 4},
    'C2': {'D3': 6, 'E4': 1},
    'D3': {'E4':2},
    'E4': {'B1': 7}
}

如上結構,在查詢時,無論是方便性還是效能,都要強於完全的列表方案。

查詢所有頂點:

for node in g.keys():
    print(node,end=" ")

查詢與某一頂點相鄰的頂點時,只需要提供頂點名稱就可以了。

print("查詢與 A0 項點有連線的其它頂點")
for k, v in g.get('A0').items():
    print((k, v), end=";")

以上的儲存方案,適合於演示,並不適合於開發環境,因頂點本身是具有特定的資料含義(如,可能是城市、公交車站、網址、路由器……),且以上儲存方案讓頂點和其相鄰頂點的資訊過度耦合,在實際運用時,會牽一髮而動全身。

也許一個微不足道的修改,會波動到整個結構的更新。

所以,有必要引於 OOP 設計理念,讓頂點和圖有各自特定資料結構,通過 2 種類型別可以更好地體現圖是頂點的集合,頂點和頂點之間的多對多關係。

項點類:

class Vertex:
    def __init__(self, name, v_id=0):
        # 頂點的編號
        self.v_id = v_id
        # 頂點的名稱
        self.v_name = name
        # 是否被訪問過:False 沒有 True:有
        self.visited = False
        # 與此頂點相連線的其它頂點
        self.connected_to = {}

頂點類結構說明:

  • visited:用於搜尋路徑演算法中,檢查節點是否已經被搜尋過。
  • connected_to:儲存與項點相鄰的頂點資訊。這裡使用了字典,以頂點為鍵,權重為值。

圖類:

class Graph:

    def __init__(self):
        # 一維列表,儲存節點
        self.vert_list = {}
        # 頂點個數
        self.v_nums = 0
        # 使用佇列模擬佇列或棧,用於路徑搜尋演算法
        self.queue_stack = []
        # 儲存搜尋到的路徑
        self.searchPath = []

圖類結構說明:

  • queue_stack:使用佇列模擬棧或佇列。用於路徑搜尋過程中儲存臨時資料。

怎麼使用列表模擬佇列或棧?

列表有 append()pop() 2 個很價值的方法。

append() 用來向列表中新增資料,且每次都是從列表最後面新增。

pop() 預設從列表最後面刪除且彈出資料, pop(引數) 可以提供索引值用來從指定位置刪除且彈出資料。

使用 append()pop() 方法就能模擬棧,從同一個地方進出資料。

使用 append()pop(0) 方法就能模擬佇列,從後面新增資料,從最前面獲取資料

  • searchPath:用於儲存搜尋到的路徑資料。

2. 最短路徑演算法

從圖結構可知,從一個頂點到達另一個頂點,可不止一條可行路徑,在眾多路徑我們總是試圖選擇一條最短路徑,當然,需求不同,衡量一個路徑是不是最短路徑的標準也會不同。

如開啟導航系統後,最短路徑可能是費用最少的那條,可能是速度最快的那條,也可能是量程數最少的或者是紅綠燈是最少的……

無向圖中,以經過的邊數最少的路徑為最短路徑。

在有向加權圖中,會以附加在每條邊上的權重的資料含義來衡量。權重可以是時間、速度、量程數……

2.1 無向圖最短路徑演算法

查詢無向圖中任意兩個頂點間的最短路徑長度,可以直接使用廣度搜尋演算法。如下圖求解 A0 ~ F5 的最短路徑。

Tips: 無向圖中任意 2 個頂點間的最短路徑長度由邊數決定。

在這裡插入圖片描述

廣度優先搜尋演算法流程:

廣度優先搜尋演算法的基本原則:以某一頂點為參考點,先搜尋離此頂點最近的頂點,再搜尋離最近頂點最近的頂點……以此類推,一層一層向目標頂點推進。

如從頂點 A0 找到頂點 F5。先從離 A0 最近的頂點 B1D3 找起,如果沒找到,再找離 B1D3 最近的頂點 C2E4,如果還是沒有找到,再找離 C2E4 最近的頂點 F5

因為每一次搜尋都是採用最近原則,最後搜尋到的目標也一定是最近的路徑。

也因為採用最近原則,所以搜尋過程中,在搜尋過程中所經歷到的每一個頂點的路徑都是最短路徑。最近+最近,結果必然還是最近

在這裡插入圖片描述

顯然,廣度優先搜尋的最近搜尋原則是符合先進先出思想的,具體演算法實施時可以藉助佇列實現整個過程。

演算法流程:

  • 先確定起始點 A0

  • 找到 A0 的 2 個後序頂點 B1D3 (或者說 B1、D3的前序頂點是 A0),壓入佇列中。除去起點 A0B1D3 頂點屬於第一近壓入佇列的節點。

    B1D3 壓入佇列的順序並不影響 A0 ~B1A0 ~ D3 的路徑距離(都是 1)。

    A0~B1 的最短路徑長度為 1

    A0~D3 的最短路徑長度為 1

  • 從佇列中搜尋 B1 時,找到 B1 的後序頂點 C2 並壓入佇列。B1C2 的前序頂點。

    B1 ~ C2 的最短路徑長度為 1,而又因為 A0~B1 的最短路徑長度為 1 ,所以 A0 ~ C2 的最短路徑為 2

  • B1 搜尋完畢後,在佇列中搜尋 B3 時,找到 B3 的後序頂點 E4 ,壓入佇列。因 B1D3 屬於第一近頂點,所以這 2 個頂點的後序頂點 C2E4 屬於第二近壓入佇列,或說 A0-B1-C2A0-D3-E4 的路徑距離是相同的(都為 2)。

  • 當搜尋到 C2 時,沒有後序頂點,此時佇列沒有壓入操作。

  • 當 搜尋到 E4 時,E4 有 2 個後序頂點 C2F5,因 C2 已經壓入過,所以僅壓入 F5。因 F5 是由第二近頂點壓入,所以 F5 是屬於第三近壓入頂點。

    A0-D3-E4-F5 的路徑為 3。

在這裡插入圖片描述

編碼實現廣度優先演算法:

在頂點類中新增如下幾個方法:

class Vertex:
    def __init__(self, v_name, v_id=0):
        # 頂點的編號
        self.v_id = v_id
        # 頂點的名稱
        self.v_name = v_name
        # 是否被訪問過:False 沒有 True:有
        self.visited = False
        # 與此頂點相連線的其它頂點
        self.connected_to = {}

    '''
    新增鄰接頂點
    nbr_ver:相鄰頂點
    weight:無向無權重圖,權重預設設定為 1
    '''
    def add_neighbor(self, nbr_ver, weight=1):
        # 以相鄰頂點為鍵,權重為值
        self.connected_to[nbr_ver] = weight

    '''
    顯示與當前頂點相鄰的頂點
    '''
    def __str__(self):
        return '與 {0} 頂點相鄰的頂點有:{1}'.format(self.v_name,
                                           str([(key.v_name, val) for key, val in self.connected_to.items()]))

    '''
    得到相鄰頂點的權重
    '''
    def get_weight(self, nbr_v):
        return self.connected_to[nbr_v]

    '''
    判斷給定的頂點是否和當前頂點相鄰
    '''
    def is_neighbor(self, nbr_v):
        return nbr_v in self.connected_to     

頂點類用來構造一個新頂點,並維護與相鄰頂點的關係。

對圖類中的方法做一下詳細解釋:

初始化方法:

class Graph:
    def __init__(self):
        # 一維列表,儲存節點
        self.vert_list = {}
        # 頂點個數
        self.v_nums = 0
        # 使用佇列模擬佇列或棧,用於路徑搜尋演算法
        self.queue_stack = []
        # 儲存搜尋到的路徑
        self.searchPath = []

為圖新增新頂點方法:

   def add_vertex(self, vert):
        if vert.v_name in self.vert_list:
            # 已經存在
            return
        # 頂點的編號內部生成
        vert.v_id = self.v_nums
        # 所有頂點儲存在圖所維護的字典中,以頂點名為鍵,頂點物件為值
        self.vert_list[vert.v_name] = vert
        # 數量增一
        self.v_nums += 1

頂點的編號由圖物件內部指定,便於統一管理。

所有頂點儲存在一個字典中,以頂點名稱為鍵,頂點物件為值。也可以使用列表直接儲存頂點,根據需要決定。

提供一個根據頂點名稱返回頂點的方法:

 	'''
    根據頂點名找到頂點物件
    '''
    def find_vertex(self, v_name):
        if v_name in self.vert_list:
            return self.vert_list.get(v_name)
    # 查詢所有頂點
    def find_vertexes(self):
        return [str(ver) for ver in self.vert_list.values()]

新增頂點與相鄰頂點的關係:此方法屬於一個封裝方法,本質是呼叫頂點自身的新增相鄰頂點方法。

    '''
    新增節點與節點之間的關係(邊),
    如果是無權重圖,統一設定為 1 
    '''
    def add_edge(self, from_v, to_v, weight=1):
        # 如果節點不存在
        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.add_neighbor(to_v, weight)

圖中核心方法:用來廣度優先搜尋演算法查詢頂點與頂點之間的路徑

    '''
    廣度優先搜尋
    '''
    def bfs_nearest_path(self, from_v, to_v):
        tmp_path = []
        tmp_path.append(from_v)
        # 起始頂點不用壓入佇列
        from_v.visited = True
        # from_v 頂點的相鄰頂點壓入佇列
        self.push_queue(from_v)
        while len(self.queue_stack) != 0:
            # 從佇列中獲取頂點
            v_ = self.queue_stack.pop(0)
            if from_v.is_neighbor(v_):
                # 如果 v_ 是 from_v 的後序相鄰頂點,則連線成一條中路徑資訊 
                tmp_path.append(v_)
                # 新增路徑資訊
                self.searchPath.append(tmp_path)
                tmp_path = tmp_path.copy()
                tmp_path.pop()
            else:
                for path_ in self.searchPath:
                    tmp_path = path_.copy()
                    tmp = tmp_path[len(tmp_path) - 1]
                    if tmp.is_neighbor(v_):
                        tmp_path.append(v_)
                        self.searchPath.append(tmp_path)
            if v_.v_id == to_v.v_id:
                break
            else:
                self.push_queue(v_)

    '''
     把某一頂點的相鄰頂點壓入佇列
     '''
    def push_queue(self, vertex):
        # 獲取 vertex 頂點的相鄰頂點
        for v_ in vertex.connected_to.keys():
            # 檢查此頂點是否壓入過
            if v_.visited:
                continue
            vertex.visited = True
            self.queue_stack.append(v_)

廣度優先搜尋演算法有一個核心點,當搜尋到某一個頂點後,需要找到與此頂點相鄰的其它頂點,並壓入佇列中。push_queue() 方法就是做些事情的。如果某一個頂點曾經進過佇列,就不要再重複壓入佇列了。

測試程式碼:

'''
測試無向圖最短路徑
'''

if __name__ == '__main__':
    # 初始化圖
    graph = Graph()
    # 新增節點
    for v_name in ['A', 'B', 'C', 'D', 'E', 'F']:
        v = Vertex(v_name)
        graph.add_vertex(v)

    # 新增頂點之間關係
    v_to_v = [('A', 'B'), ('A', 'D'), ('B', 'C'), ('C', 'E'), ('D', 'E'), ('E', 'F')]
    # 無向圖中每 2 個相鄰頂點之間互為關係
    for v in v_to_v:
        f_v = graph.find_vertex(v[0])
        t_v = graph.find_vertex(v[1])
        graph.add_edge(f_v, t_v)
        graph.add_edge(t_v, f_v)

    # 輸出所有頂點
    print('-----------頂點及頂點之間的關係-------------')
    for v in graph.find_vertexes():
        print(v)

    # 查詢路徑
    print('-------------廣度優先搜尋--------------------')
    # 起始點
    f_v = graph.find_vertex('A')
    # 目標點
    t_v = graph.find_vertex('F')
    # 廣度優先搜尋
    graph.bfs_nearest_path(f_v, t_v)
    for path in graph.searchPath:
        weight = 0
        for idx in range(len(path)):
            if idx != len(path) - 1:
                weight += path[idx].get_weight(path[idx + 1])
            print(path[idx].v_name, end='-')
        print("的最短路徑長度,", weight)

輸出結果:

-----------頂點及頂點之間的關係-------------
與 A 頂點相鄰的頂點有:[('B', 1), ('D', 1)]
與 B 頂點相鄰的頂點有:[('A', 1), ('C', 1)]
與 C 頂點相鄰的頂點有:[('B', 1), ('E', 1)]
與 D 頂點相鄰的頂點有:[('A', 1), ('E', 1)]
與 E 頂點相鄰的頂點有:[('C', 1), ('D', 1), ('F', 1)]
與 F 頂點相鄰的頂點有:[('E', 1)]
-------------廣度優先搜尋--------------------
A-B-的最短路徑長度, 1
A-D-的最短路徑長度, 1
A-B-C-的最短路徑長度, 2
A-D-E-的最短路徑長度, 2
A-B-C-E-的最短路徑長度, 3
A-D-E-的最短路徑長度, 2
A-B-C-E-的最短路徑長度, 3
A-D-E-F-的最短路徑長度, 3
A-B-C-E-F-的最短路徑長度, 4
A-D-E-F-的最短路徑長度, 3
A-B-C-E-F-的最短路徑長度, 4

廣度優先搜尋演算法也可以使用遞迴方案:

    '''
    遞迴實現
    '''

    def bfs_nearest_path_dg(self, from_v, to_v):

        # 相鄰頂點
        self.push_queue(from_v)
        tmp_v = self.queue_stack.pop(0)
        if not tmp_v.visited:
            self.searchPath.append(tmp_v)
        if tmp_v.v_id == to_v.v_id:
            return

        self.bfs_nearest_path_dg(tmp_v, to_v)

在無向圖中,查詢起始點到目標點的最短路徑,使用廣度優先搜尋演算法便可實現,但如果是有向加權圖,可能不會稱心如願。因有向加權圖中的邊是有權重的。所以對於有向加權圖則需要另擇方案。

3. 總結

圖資料結構的實現過程中會涉及到其它資料結構的運用。學習、使用圖資料結構對其它資料結構有重新認識和鞏固作用。

相關文章