Python 圖_系列之基於鄰接炬陣實現廣度、深度優先路徑搜尋演算法

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

圖是一種抽象資料結構,本質和樹結構是一樣的。

圖與樹相比較,圖具有封閉性,可以把樹結構看成是圖結構的前生。在樹結構中,如果把兄弟節點之間或子節點之間橫向連線,便構建成一個圖。

樹適合描述從上向下的一對多的資料結構,如公司的組織結構。

圖適合描述更復雜的多對多資料結構,如複雜的群體社交關係。

在這裡插入圖片描述
在這裡插入圖片描述

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 表示終點。且 fvev 資料必須引用於 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_idv_name 很好理解。為什麼要新增一個 visited

這個變數用來記錄頂點在路徑搜尋過程中是否已經被搜尋過,避免重複搜尋計算。

圖類:圖類的方法較多,這裡逐方法介紹。

  1. 初始化方法
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 : 用來儲存使用廣度或深度優先路徑搜尋中的結果。

  1. 新增新節(頂)點方法:
    """
    新增新頂點
    """
    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. 新增邊方法

    此方法是鄰接炬陣表示法的核心邏輯。

  '''
    新增節點與節點之間的邊,
    如果是無權重圖,統一設定為 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 個,一個用來新增無權重邊,一個用來新增有權重的邊。

  1. 查詢某節點

    使用線性查詢法從節點集合中查詢某一個節點。

    '''
    根據節點編號返回節點
    '''
    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]
  1. 查詢所有節點
  '''
    輸出所有頂點資訊
    '''
    def find_only_vertexes(self):
        for tmp_v in self.vert_list:
            print(tmp_v)

此方法僅為了查詢方便。

  1. 查詢節點之間的關係
    '''
    迭代節點與節點之間的關係(邊)
    '''
    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(v, '和', self.vert_list[col], '的權重為:', w)
  1. 測試程式碼:
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
    -----------頂點與頂點關係--------------
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 3
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 5
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 2,名稱為 C ] 的頂點 的權重為: 4
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 6
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 1
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 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 步確定候選節點時 B1D3 誰先進入佇列,對於後面的查詢也會有影響。

上圖使用廣度搜尋可找到 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
    -----------頂點與頂點關係--------------
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 3
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 5
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 2,名稱為 C ] 的頂點 的權重為: 4
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 6
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 1
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 2
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 7
    ----------- 廣度優先路徑搜尋--------------
    找到一條路徑 [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
    -----------頂點與頂點關係--------------
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 1,名稱為 B ] 的頂點 的權重為: 3
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 5
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 2,名稱為 C ] 的頂點 的權重為: 4
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 3,名稱為 D ] 的頂點 的權重為: 6
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 4,名稱為 E ] 的頂點 的權重為: 1
    [編號為 4,名稱為 E ] 的頂點 和 [編號為 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. 總結

圖一種很重要的資料結構,因這個世界中萬事萬物之間的關係並不是簡單的你和我,我和你的關係,本質都是錯綜複雜的。

圖能準確的對映現實世界的這種錯綜複雜關係,為計算機處理現實世界的問題提供了可能,也擴充了計算機在現實世界的應用領域。

相關文章