文章目錄
一、圖的存儲結構
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)
思想:從圖中任意取出一個頂點,把它當作一棵樹(此時樹只有一個根節點),然後從與這棵樹相接的邊中選取一條權值最小的邊,並將這條邊及其所連接的頂點也納入這棵樹中,此時得到了一顆有兩個頂點的樹。然後繼續從這棵樹相接的邊中選取一條權值最小的邊,並將該邊和所連頂點納入樹中,得到一顆有三個頂點的樹。以此類推,直到圖中所有頂點都被納入樹中爲止,此時得到的生成樹就是最小生成樹。
使用普里姆算法構建最小生成樹,需要額外建立兩個數組: 和 , 表示頂點 已被納入生成樹中, 表示頂點 未被納入生成樹中。 數組存放當前生成樹到剩餘各頂點最短邊的權值。
普里姆算法執行過程:從樹中某一個頂點 開始
1)將 到其他頂點的所有邊當作侯選邊
2)重複一下步驟 n-1 次,使得剩餘 n-1 個頂點被納入到生成樹中:
- 從侯選邊中選一個權值最小的邊輸出,並將與該邊另一端相連的頂點 也納入到生成樹中
- 考察所有剩餘頂點 ,如果 的權值比 小,則用 的權值更新
時間複雜度:,普里姆算法時間複雜度僅與圖頂點個數有關,與邊數無關,故普里姆算法適合用於稠密圖。
2. 克魯斯卡爾算法(kruskal)
思想:每次找出侯選邊中權值最小的邊,將該邊納入生成樹中。重複此過程直到所有邊都被檢測完爲止。
克魯斯卡爾算法執行過程:將圖中邊按照權值從小到大排序,然後從最小的邊開始掃描,檢查當前邊併入是否會產生迴路,如果不會構成迴路,則將當前邊併入生成樹中,直到所有邊都被掃描完爲止。其中判斷是否會產生迴路需要用到並查集。
時間複雜度:主要由選取的排序算法決定,排序算法所處理的數據規模由圖的邊數決定,與頂點數無關,因此克魯斯卡爾算法適用於稀疏圖。
五、圖的最短路徑
1. 迪傑斯特拉算法(Dijkstra)
Dijkstra 用於求解 圖中某一頂點到其餘各頂點的最短路徑。Dijkstra 基於 “貪心思想”,其思路就是維護一個集合 S,S 內的點是已經確定的最短路徑的點,可以視爲一個大整體,每次從剩餘節點中找出一個距離該大集合最近的點加入集合中,並確定它的最短路徑是前驅節點的最短路徑 + 該邊權值,存放在 dist
數組中。
注意:Dijkstra 只適用於正權圖,不適用於存在負權值的圖!
實現 Dijkstra 算法需要 3 個輔助數組:dist、path 和 set
dist[vi]
表示當前已找到的從v0
到每個終點vi
的最短路徑的長度,其初始化爲:若從v0
到vi
有邊,則dist[vi]
爲邊上的權值,否則置爲正無窮,表示不可達path[vi]
表示從v0
到vi
最短路徑上vi
的前一個頂點,其初始化爲,若v0
到vi
有邊,則path[vi] = v0
,否則path[vi] = -1
set[]
爲標記數組,set[vi] = 0
表示vi
沒有被併入最短路徑,set[vi] = 1
表示vi
已經被併入最短路徑中,其初始化爲set[v0] = 1
,其餘元素全爲 0
具體實現:用鄰接矩陣存儲有向圖,使用雙重循環,外層循環用於保證將圖的所有節點添加進最短路徑中,內層循環包含兩個,一是查找當前路徑最短的節點,二是更新剩餘節點的最短路徑。在循環開始前,要記得先初始化 dist
、path
、set
數組。
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))
時間複雜度:,存在雙重循環
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
,否則什麼都不做。
時間複雜度:,算法的核心部分是一個三重循環
要獲取任意兩個頂點間最短路徑長度很簡單,直接取 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. 鄰接表
思路:無向圖節點的鄰接性問題,考慮用圖的鄰接表來做。首先獲取每個花園的鄰接花園編號,構建鄰接表,然後對每個花園依次染色。染色的規則是剔除掉與自己鄰接花園已染的顏色,從剩下可選的顏色中任選一種。
創建一個二維數組 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()
表示直接取可選列表的第一個
思路 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. 拓撲排序
題意:題目描述真不咋樣,給出的 case 是 graph = [[1,2],[2,3],[5],[0],[5],[],[]]
,輸出爲 [2,4,5,6]
。意思是 graph
表示有向圖節點的鄰接表(節點從0開始),比如 0 號節點的鄰接表是 [1, 2]
,意思就是存在有向邊 0 -> 1
和 0 -> 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 中找,若刪除後前驅節點的後繼爲空,說明是安全的,添加到隊列