【數據結構】圖相關算法及Python代碼

一、圖的存儲結構

1. 鄰接矩陣

有 N 個圖頂點,則鄰接矩陣就是 N x N 大小的表示頂點間鄰接關係的矩陣。鄰接矩陣是圖的 順序存儲結構,無向圖的鄰接矩是對稱的。

2. 鄰接表

鄰接表是一種 鏈式存儲結構,對圖中每個頂點 i 建立一個單鏈表,單鏈表的頭節點存放圖頂點的信息,其餘節點存放與當前頂點鄰接的頂點信息,其中鄰接頂點存放的順序無所謂。

二、圖的遍歷

1. 深度優先搜索遍歷(DFS)

基本思想:任取一個圖節點,訪問之,然後檢查這個頂點所有的鄰接頂點,遞歸訪問其中未被訪問過的頂點。

圖的深度優先搜索遍歷類似於 二叉樹的先序遍歷,區別僅在於二叉樹先序遍歷只需要遞歸地訪問兩個分支,而圖的深度優先搜索遍歷則是遞歸地訪問多個分支。

將圖的深度優先搜索遍歷過程中所經過的所有邊保留,其餘邊刪掉,就會形成一棵樹,即 深度優先搜索生成樹

2. 廣度優先搜索遍歷(BFS)

基本思想:首先訪問起始頂點 v,然後選取與 v 鄰接的全部頂點 w1,…,wn 進行訪問,再依次訪問與 w1,…,wn 鄰接的全部頂點(已經訪問過的除外),以此類推,直到所有頂點被訪問過爲止。

圖的廣度優先搜索遍歷類似於 樹的層次遍歷,所以需要隊列的輔助。

  • 任取一個圖節點,入隊,並標記爲已訪問
  • 當隊列不爲空時循環執行:1)出隊,2)依次檢查出隊節點的所有鄰接節點,訪問沒有被訪問過的鄰接節點並將其入隊
  • 當隊列爲空時跳出循環,廣度優先搜索完成

三、圖的拓撲排序

首先介紹一個概念,AOV(Activity On Vertex network, AOV)網,是一種可以表示活動先後次序的網,基於有向圖表示。在該有向圖中,圖節點表示活動,邊表示活動的先後次序,且整個網中沒有迴路,所以 AOV 網是一個 有向無環圖

有向無環圖的拓撲排序過程

  • 從有向圖中選擇一個沒有前驅(入度爲0)的頂點輸出
  • 刪除前一步選擇的頂點,並刪除從該頂點發出的全部邊
  • 重複前兩步,直到剩餘的網中不存在沒有前驅的頂點爲止

有向無環圖的拓撲排序序列可能不唯一,因爲在選擇入度爲 0 的頂點時,是沒有其他要求的,如果某一步有多個入度爲 0 的頂點,就會造成排序序列不唯一。

有向無環圖的逆拓撲排序過程

  • 在有向圖中選擇一個沒有後繼(出度爲0)的頂點輸出
  • 刪除前一步選擇的頂點,並刪除到達該頂點的全部邊
  • 重複前兩步,直到剩餘的網中不存在沒有後繼的頂點爲止

四、最小代價生成樹

最小代價生成樹是一顆 由圖導出的樹,包含 圖的所有頂點,但僅包含 圖的部分邊

應用:在 n 個城市間鋪設電纜,且任意兩個城市間鋪設會有不同的 cost,求最小 cost 的問題。

構建最小代價生成樹主要是普里普算法和克魯斯卡爾算法,它們都是針對 無向圖的。

1. 普里姆算法(prim)

思想:從圖中任意取出一個頂點,把它當作一棵樹(此時樹只有一個根節點),然後從與這棵樹相接的邊中選取一條權值最小的邊,並將這條邊及其所連接的頂點也納入這棵樹中,此時得到了一顆有兩個頂點的樹。然後繼續從這棵樹相接的邊中選取一條權值最小的邊,並將該邊和所連頂點納入樹中,得到一顆有三個頂點的樹。以此類推,直到圖中所有頂點都被納入樹中爲止,此時得到的生成樹就是最小生成樹。

使用普里姆算法構建最小生成樹,需要額外建立兩個數組:vest[]vest[]lowcost[]lowcost[]vest[i]=1vest[i] = 1 表示頂點 ii 已被納入生成樹中,vest[i]=0vest[i] = 0 表示頂點 ii 未被納入生成樹中。lowcost[]lowcost[] 數組存放當前生成樹到剩餘各頂點最短邊的權值。

普里姆算法執行過程:從樹中某一個頂點 v0v_0 開始

1)將 v0v_0 到其他頂點的所有邊當作侯選邊
2)重複一下步驟 n-1 次,使得剩餘 n-1 個頂點被納入到生成樹中:

  • 從侯選邊中選一個權值最小的邊輸出,並將與該邊另一端相連的頂點 vv 也納入到生成樹中
  • 考察所有剩餘頂點 viv_i,如果 (v,vi)(v, v_i) 的權值比 lowcost[vi]lowcost[v_i] 小,則用 (v,vi)(v, v_i) 的權值更新 lowcost[vi]lowcost[v_i]

時間複雜度O(n2)O(n^2),普里姆算法時間複雜度僅與圖頂點個數有關,與邊數無關,故普里姆算法適合用於稠密圖

2. 克魯斯卡爾算法(kruskal)

思想:每次找出侯選邊中權值最小的邊,將該邊納入生成樹中。重複此過程直到所有邊都被檢測完爲止。

克魯斯卡爾算法執行過程:將圖中邊按照權值從小到大排序,然後從最小的邊開始掃描,檢查當前邊併入是否會產生迴路,如果不會構成迴路,則將當前邊併入生成樹中,直到所有邊都被掃描完爲止。其中判斷是否會產生迴路需要用到並查集。

時間複雜度:主要由選取的排序算法決定,排序算法所處理的數據規模由圖的邊數決定,與頂點數無關,因此克魯斯卡爾算法適用於稀疏圖

五、圖的最短路徑

1. 迪傑斯特拉算法(Dijkstra)

Dijkstra 用於求解 圖中某一頂點到其餘各頂點的最短路徑。Dijkstra 基於 “貪心思想”,其思路就是維護一個集合 S,S 內的點是已經確定的最短路徑的點,可以視爲一個大整體,每次從剩餘節點中找出一個距離該大集合最近的點加入集合中,並確定它的最短路徑是前驅節點的最短路徑 + 該邊權值,存放在 dist 數組中。

注意Dijkstra 只適用於正權圖,不適用於存在負權值的圖!

實現 Dijkstra 算法需要 3 個輔助數組:dist、path 和 set

  • dist[vi] 表示當前已找到的從 v0 到每個終點 vi 的最短路徑的長度,其初始化爲:若從 v0vi 有邊,則 dist[vi] 爲邊上的權值,否則置爲正無窮,表示不可達
  • path[vi] 表示從 v0vi 最短路徑上 vi 的前一個頂點,其初始化爲,若 v0vi 有邊,則 path[vi] = v0,否則 path[vi] = -1
  • set[] 爲標記數組,set[vi] = 0 表示 vi 沒有被併入最短路徑,set[vi] = 1 表示 vi 已經被併入最短路徑中,其初始化爲 set[v0] = 1,其餘元素全爲 0

具體實現:用鄰接矩陣存儲有向圖,使用雙重循環,外層循環用於保證將圖的所有節點添加進最短路徑中,內層循環包含兩個,一是查找當前路徑最短的節點,二是更新剩餘節點的最短路徑。在循環開始前,要記得先初始化 distpathset 數組。

Python 代碼實現

class Solution:
    def Dijkstra(self, G, v):
        """
        G: Graph G
        v: The first node
        """
        n = len(G)
        dist = [inf for _ in range(n)]  # [1,N], N = number of node
        path = [-1 for _ in range(n)]  # [1,N]
        set = [0 for _ in range(n)]   # [1,N]
        # 初始化各數組
        for i in range(n):
            dist[i] = G[v][i]
            if G[v][i] < inf:
                path[i] = v
        set[v] = 1
        path[v] = -1
        # 最短路徑求解
        for i in range(n):
            min = inf
            # 查找當前路徑最短的節點
            for j in range(n):
                if set[j] == 0 and dist[j] < min:
                    cur = j
                    min = dist[j]
            set[cur] = 1
            # 更新剩餘節點最短路徑
            for j in range(n):
                if set[j] == 0 and dist[cur] + G[cur][j] < dist[j]:
                    dist[j] = dist[cur] + G[cur][j]
                    path[j] = cur
        return dist

if __name__ == "__main__":
    test = Solution()
    inf = float('inf')
    G = [[0,4,6,6,inf,inf,inf],
         [inf,0,1,inf,7,inf,inf],
         [inf,inf,0,inf,6,4,inf],
         [inf,inf,2,0,inf,5,inf],
         [inf,inf,inf,inf,0,inf,6],
         [inf,inf,inf,inf,1,0,8],
         [inf,inf,inf,inf,inf,inf,0]]
    print(test.Dijkstra(G, 0))

時間複雜度O(n2)O(n^2),存在雙重循環

2. 弗洛伊德算法(Floyd)

Floyd 算法用於求解 圖中某一頂點到其餘各頂點的最短路徑,如果要求圖中任意一對頂點間的最短路徑,則通常用弗洛伊德算法。

實現 Floyd 算法需要 2 個輔助數組:A 和 Path

  • A 用來記錄當前已求得的任意兩個頂點最短路徑的長度
  • Path 用來記錄當前兩頂點最短路徑上要經過的中間節點,通過 Path 矩陣可以算出任意兩點間最短路徑上的頂點序列

具體實現:設置兩個輔助矩陣 A 和 Path,初始時將圖的鄰接矩陣賦值給 A,將矩陣 Path 中的元素全部置爲 -1。以頂點 k(0~n-1,n爲圖頂點個數) 爲中間頂點,對圖中所有頂點對 {i,j} 進行如下檢測與修改:如果 A[i][j] > A[i][k] + A[k][j],則將 A[i][j] 修改爲 A[i][k] + A[k][j] 的值,將 Path[i][j] 改爲 k,否則什麼都不做。

時間複雜度:O(n3)O(n^3),算法的核心部分是一個三重循環

要獲取任意兩個頂點間最短路徑長度很簡單,直接取 A[i][j] 即可。如果想獲取任意兩個頂點間最短路徑上的頂點序列,就需要從 Path 矩陣中遞歸地求解,當 Path[i][j] = -1 時,表示頂點 i 有直接指向頂點 j 的邊,求解結束。
Python 代碼實現

class Solution:

    def Floyd(self, G):
        n = len(G)
        # 初始化 A 和 Path
        self.A = G
        self.Path = [[-1]*n for _ in range(n)]
        # # 選擇每個頂點作爲中間點
        for k in range(n):
            # 檢測每個頂點對兒以k爲中間點的路徑
            for i in range(n):
                for j in range(n):
                    if self.A[i][j] > self.A[i][k] + self.A[k][j]:
                        self.A[i][j] = self.A[i][k] + self.A[k][j]
                        self.Path[i][j] = k
        return self.A, self.Path
	
	# 獲取最短路徑上的頂點序列
    def getSequence(self, u, v): 

        def get(Path, u, v):
            mid = Path[u][v]
            if mid == -1: return
            path.append(mid)
            get(Path, u, mid)
            get(Path, mid, v)

        if self.Path[u][v] == -1:
            return [u, v]
        path = [u]
        get(self.Path, u, v)
        path.append(v)
        return path
	
	# 獲取最短路徑大小
    def getMinPath(self, u, v):
        return self.A[u][v]

if __name__ == "__main__":
    inf = float('inf')
    G = [[0, 5, inf, 7],
         [inf, 0, 4, 2],
         [3, 3, 0, 2],
         [inf, inf, 1, 0]]
    test = Solution()
    print(test.Floyd(G))
    print(test.getMinPath(1,0))
    print(test.getSequence(1,0))

六、相關題目

1. 鄰接表

1042. 不鄰接植花

思路無向圖節點的鄰接性問題,考慮用圖的鄰接表來做。首先獲取每個花園的鄰接花園編號,構建鄰接表,然後對每個花園依次染色。染色的規則是剔除掉與自己鄰接花園已染的顏色,從剩下可選的顏色中任選一種。

創建一個二維數組 G,每行表示一個花園,共 N 行,列表示與該花園鄰接的花園。用一維數組 res 存儲每個花園最終要染的顏色,長度爲 N,初始化爲 0,表示未染色。

class Solution:
    def gardenNoAdj(self, N: int, paths: List[List[int]]) -> List[int]:
        res = [0]*N
        G = [[] for _ in range(N)]
        # 構建鄰接表
        for x, y in paths:
            G[x - 1].append(y - 1)
            G[y - 1].append(x - 1)
		# 染色
        for i in range(N):
            res[i] = ({1,2,3,4} - {res[j] for j in G[i]}).pop()
        return res
  • pop() 表示直接取可選列表的第一個

997. 找到小鎮的法官

思路 1有向圖節點的鄰接性問題,考慮用鄰接表來做。祕密法官實際上就滿足:1)除自己外,所有圖節點都有邊指向它;2)無出邊指向任何節點。知道這兩個條件就很好做了,首先求得每個圖節點的鄰接節點,然後找到鄰接節點爲空的節點(只可能有一個,如果有多個爲空說明不存在祕密法官),再依次判斷其他節點的鄰接表中是否都含有該節點,若是則找到了目標法官,否則無祕密法官。

特殊判斷:當 trust 爲空,若人數 N 爲 0,說明沒有法官;若人數 N 爲 1,說明只有 1 個人,且這個人就是法官。

class Solution:
    def findJudge(self, N: int, trust: List[List[int]]) -> int:	
        if not trust:
            if N == 0: return -1
            if N == 1: return 1
        G = [[] for _ in range(N)]
        # 構建鄰接表
        for a, b in trust:
            G[a-1].append(b-1)
        # 查找法官
        flag = 0
        for i in range(N):
            if not G[i]: 
                flag = 1
                break
        if flag:
            for j in range(0, i):
                if i not in G[j]: return -1
            for j in range(i+1, N):
                if i not in G[j]: return -1
            return i+1
        return -1

思路 2有向圖節點的鄰接性問題,祕密法官其實就是入度爲 0,出度爲 N-1 的節點,所以統計每個節點的出度和入度,最後查找有無符合 入讀=0,出度=N-1 條件的節點即可。

class Solution:
    def findJudge(self, N: int, trust: List[List[int]]) -> int:
        if not trust:
            if N == 0: return -1
            if N == 1: return 1
        G = [[0,0] for _ in range(N)]
        # 統計每個節點的出度入度
        for a, b in trust:
            G[a-1][0] += 1
            G[b-1][1] += 1
        # 查找出度=0,入度=N-1的節點
        id = 0
        for o, i in G:
            if o == 0 and i == N-1:
                return id+1
            id += 1
        return -1

2. 拓撲排序

802. 找到最終的安全狀態

題意:題目描述真不咋樣,給出的 case 是 graph = [[1,2],[2,3],[5],[0],[5],[],[]],輸出爲 [2,4,5,6]。意思是 graph 表示有向圖節點的鄰接表(節點從0開始),比如 0 號節點的鄰接表是 [1, 2],意思就是存在有向邊 0 -> 10 -> 2,以此類推,空表就表示該節點沒有出邊,也就是沒有後繼節點,比如上面的 5 號和 6 號節點。

要我們乾的事情就是找到 “安全節點”,“安全節點” 定義爲從該節點沿着有向邊走,一定能走到 “終點” 的節點,“終點” 就是沒有後繼節點的節點。所以如果一個節點在一個 “環” 裏,它就肯定不安全,有可能無限在這個環裏繞。比如上面 case 中的 0 號節點,它可以走 0 -> 1 也可以走 0 -> 2,如果選擇 0 -> 2,而 2 號節點有 2 -> 5,是可以達到終點 5 的,但 0 號節點並不安全,因爲如果選擇了 0 -> 1,再不巧又選了 1 -> 3,在 3 號節點又只能回到 0 號節點,形成了一個 “環”,所以 0 號節點不安全。

分析發現,要找的 “安全節點” 需要滿足條件:

  • 沒有後繼節點(出度爲0)
  • 後繼是安全節點

這樣看來,就和 AOE 網很相似,對圖進行 逆拓撲排序,每次將無後繼的節點納入 “安全節點”,然後將這些節點及進入節點的邊都刪除,再重複執行該步驟,直到找不到沒有後繼的節點爲止。由於要刪除 “進入” 節點的邊並不是很方便,考慮可以將整個有向圖的方向倒轉過來,即原前驅做後繼,後繼做前驅。創建一個倒轉的有向圖。這樣就可以做 拓撲排序 解決。

class Solution:
    def eventualSafeNodes(self, graph: List[List[int]]) -> List[int]:
        if not graph: return []
        N = len(graph)
        safe = [False] * N
        q = collections.deque()
        graph = list(map(set, graph))
        rgraph = [set() for _ in range(N)]
        # 倒轉有向圖
        for i, adj in enumerate(graph):
            if not adj:
                q.append(i)
            for j in adj:  
                rgraph[j].add(i)
        while q:
            cur = q.popleft()
            safe[cur] = True
            # 刪除原有向圖的前驅
            for k in rgraph[cur]:   
                graph[k].remove(cur) 
                if not graph[k]:
                    q.append(k)

        res = [i for i, j in enumerate(safe) if j]
        return res    
  • 第一個 for 循環將 graph 倒轉,將所有邊反向,同時找一下終止節點,若無後繼元素,則添加到隊列
  • 出隊元素,並將其 safe 狀態置爲 True,並循環刪除其在原 graph 中的前驅節點,刪除方法就是在反向圖 rgraph 中找,若刪除後前驅節點的後繼爲空,說明是安全的,添加到隊列
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章