面試中圖論都考什麼?這篇文章告訴你!

lucifer發表於2021-11-09

圖論〔Graph Theory〕是數學的一個分支。它以圖為研究物件。圖論中的圖是由若干給定的點及連線兩點的線所構成的圖形,這種圖形通常用來描述某些事物之間的某種特定關係,用點代表事物,用連線兩點的線表示相應兩個事物間具有這種關係。

如下就是一種邏輯上的圖結構:

邏輯上的圖結構

圖是一種最複雜的資料結構,前面講的資料結構都可以看成是圖的特例。那為什麼不都用圖就好了,還要分那麼多種資料結構呢?

這是因為很多時候不需要用到那麼複雜的功能,圖的很多特性都不具備,如果籠統地都稱為圖那麼非常不利於溝通。你想你和別人溝通總不至於說這道題是考察一種特殊的圖,這種圖。。。。 這未免太囉嗦了,因此給其他圖的特殊的圖起了特殊的名字,這樣就方便溝通了。直到遇到了非常複雜的情況,我們才會用到 ”真正“的圖

前面章節提到了資料結構就是為了演算法服務的,資料結構就是儲存資料用的,目的是為了更高效。 那麼什麼時候需要用圖來儲存資料,在這種情況圖高效在哪裡呢?答案很簡單,那就是如果你用其他簡單的資料結構無法很好地進行儲存,就應該使用圖了。 比如我們需要儲存一種雙向的朋友關係,並且這種朋友關係是多對多的,那就一定要用到圖,因為其他資料結構無法模擬。

基本概念

無向圖 & 有向圖〔Undirected Graph & Deriected Graph〕

前面提到了二叉樹完全可以實現其他樹結構,類似地,有向圖也完全可以實現無向圖和混合圖,因此有向圖的研究一直是重點考察物件。

本文講的所有圖都是有向圖

前面提到了我們用連線兩點的線表示相應兩個事物間具有這種關係。因此如果兩個事物間的關係是有方向的,就是有向圖,否則就是無向圖。比如:A 認識 B,那麼 B 不一定認識 A。那麼關係就是單向的,我們需要用有向圖來表示。因為如果用無向圖表示,我們無法區分 A 和 B 的邊表示的是 A 認識 B 還是 B 認識 A。

習慣上,我們畫圖的時候用帶箭頭的表示有向圖,不帶箭頭的表示無向圖。

有權圖 & 無權圖〔Weighted Graph & Unweighted Graph〕

如果邊是有權重的是有權圖(或者帶權圖),否則是無權圖(或不帶權圖)。那麼什麼是有權重呢?比如匯率就是一種有權重的邏輯圖。1 貨幣 A 兌換 5 貨幣 B,那麼我們 A 和 B 的邊的權重就是 5。而像朋友這種關係,就可以看做一種不帶權的圖。

入度 & 出度〔Indegree & Outdegree〕

有多少邊指向節點 A,那麼節點 A 的入度就是多少。同樣地,有多少邊從 A 發出,那麼節點 A 的出度就是多少。

仍然以上面的圖為例,這幅圖的所有節點的入度和出度都為 1。

路徑 & 環〔路徑:Path〕

  • 有環圖〔Cyclic Graph〕 上面的圖就是一個有環圖,因為我們從圖中的某一個點觸發,能夠重新回到起點。這和現實中的環是一樣的。
  • 無環圖〔Acyclic Graph〕

我可以將上面的圖稍加改造就變成了無環圖,此時沒有任何一個環路。

連通圖 & 強連通圖

在無向圖中,若任意兩個頂點 i 與 j 都有路徑相通,則稱該無向圖為連通圖。

在有向圖中,若任意兩個頂點 i 與 j 都有路徑相通,則稱該有向圖為強連通圖。

生成樹

一個連通圖的生成樹是指一個連通子圖,它含有圖中全部 n 個頂點,但只有足以構成一棵樹的 n-1 條邊。一顆有 n 個頂點的生成樹有且僅有 n-1 條邊,如果生成樹中再新增一條邊,則必定成環。在連通網的所有生成樹中,所有邊的代價和最小的生成樹,稱為最小生成樹,其中代價和指的是所有邊的權重和。

圖的建立

一般圖的題目都不會給你一個現成的圖的資料結構。當你知道這是一個圖的題目的時候,解題的第一步通常就是建圖。

上面講的都是圖的邏輯結構,那麼計算機中的圖如何儲存呢?

我們知道圖是有點和邊組成的。理論上,我們只要儲存圖中的所有的邊關係即可,因為邊中已經包含了兩個點的關係。

這裡我簡單介紹兩種常見的建圖方式:鄰接矩陣(常用,重要)和鄰接表。

鄰接矩陣(常見)〔Adjacency Matrixs〕

第一種方式是使用陣列或者雜湊表來儲存圖,這裡我們用二維陣列來儲存。

使用一個 n * n 的矩陣來描述圖 graph,其就是一個二維的矩陣,其中 graphi 描述邊的關係。

一般而言,對於無權圖我都用 graphi = 1 來表示 頂點 i 和頂點 j 之間有一條邊,並且邊的指向是從 i 到 j。用 graphi = 0 來表示 頂點 i 和頂點 j 之間不存在一條邊。 對於有權圖來說,我們可以儲存其他數字,表示的是權重。

可以看出上圖是對角線對稱的,這樣我們只需看一半就好了,這就造成了一半的空間浪費。

這種儲存方式的空間複雜度為 O(n ^ 2),其中 n 為頂點個數。如果是稀疏圖(圖的邊的數目遠小於頂點的數目),那麼會很浪費空間。並且如果圖是無向圖,始終至少會有 50 % 的空間浪費。下面的圖也直觀地反應了這一點。

鄰接矩陣的優點主要有:

  1. 直觀,簡單。
  2. 判斷兩個頂點是否連線,獲取入度和出度以及更新度數,時間複雜度都是 O(1)

由於使用起來比較簡單, 因此我的所有的需要建圖的題目基本都用這種方式。

比如力扣 743. 網路延遲時間。 題目描述:

有 N 個網路節點,標記為 1 到 N。

給定一個列表 times,表示訊號經過有向邊的傳遞時間。 times[i] = (u, v, w),其中 u 是源節點,v 是目標節點, w 是一個訊號從源節點傳遞到目標節點的時間。

現在,我們從某個節點 K 發出一個訊號。需要多久才能使所有節點都收到訊號?如果不能使所有節點收到訊號,返回 -1。


示例:

輸入:times = [[2,1,1],[2,3,1],[3,4,1]], N = 4, K = 2
輸出:2
 

注意:

N 的範圍在 [1, 100] 之間。
K 的範圍在 [1, N] 之間。
times 的長度在 [1, 6000] 之間。
所有的邊 times[i] = (u, v, w) 都有 1 <= u, v <= N 且 0 <= w <= 100。

這是一個典型的圖的題目,對於這道題,我們如何用鄰接矩陣建圖呢?

一個典型的建圖程式碼:

使用雜湊表構建鄰接矩陣:

    graph = collections.defaultdict(list)
    for fr, to, w in times:
        graph[fr - 1].append((to - 1, w))

使用二維陣列構建鄰接矩陣:

graph = [[0]*n for _ in range(m)] # 新建一個 m * n 的二維矩陣

for fr, to, w in times:
    graph[fr-1][to-1] = w

這就構造了一個臨界矩陣,之後我們基於這個鄰接矩陣遍歷圖即可。

鄰接表〔Adjacency List〕

對於每個點,儲存著一個連結串列,用來指向所有與該點直接相連的點。對於有權圖來說,連結串列中元素值對應著權重。

例如在無向無權圖中:

graph-1
(圖片來自 https://zhuanlan.zhihu.com/p/...

可以看出在無向圖中,鄰接矩陣關於對角線對稱,而鄰接連結串列總有兩條對稱的邊。

而在有向無權圖中:

graph-2

(圖片來自 https://zhuanlan.zhihu.com/p/...

由於鄰接表使用起來稍微麻煩一點,另外也不常用。為了減少初學者的認知負擔,我就不貼程式碼了。

圖的遍歷

圖建立好了,接下來就是要遍歷了。

不管你是什麼演算法,肯定都要遍歷的,一般有這兩種方法:深度優先搜尋,廣度優先搜尋(其他奇葩的遍歷方式實際意義不大,沒有必要學習)。

不管是哪一種遍歷, 如果圖有環,就一定要記錄節點的訪問情況,防止死迴圈。當然你可能不需要真正地使用一個集合記錄節點的訪問情況,比如使用一個資料範圍外的資料原地標記,這樣的空間複雜度會是 $O(1)$。

這裡以有向圖為例, 有向圖也是類似,這裡不再贅述。

關於圖的搜尋,後面的搜尋專題也會做詳細的介紹,因此這裡就點到為止。

深度優先遍歷〔Depth First Search, DFS〕

深度優先遍歷圖的方法是,從圖中某頂點 v 出發, 不斷訪問鄰居, 鄰居的鄰居直到訪問完畢。

如上圖, 如果我們使用 DFS,並且從 A 節點開始的話, 一個可能的的訪問順序是: A -> C -> B -> D -> F -> G -> E,當然也可能是 A -> D -> C -> B -> F -> G -> E 等,具體取決於你的程式碼,但他們都是深度優先的。

廣度優先搜尋〔Breadth First Search, BFS〕

廣度優先搜尋,可以被形象地描述為 "淺嘗輒止",它也需要一個佇列以保持遍歷過的頂點順序,以便按出隊的順序再去訪問這些頂點的鄰接頂點。

如上圖, 如果我們使用 BFS,並且從 A 節點開始的話, 一個可能的的訪問順序是: A -> B -> C -> F -> E -> G -> D,當然也可能是 A -> B -> F -> E -> C -> G -> D 等,具體取決於你的程式碼,但他們都是廣度優先的。

需要注意的是 DFS 和 BFS 只是一種演算法思想,不是一種具體的演算法。 因此其有著很強的適應性,而不是侷限於特點的資料結構的,本文講的圖可以用,前面講的樹也可以用。實際上, 只要是非線性的資料結構都可以用

常見演算法

圖的題目的演算法比較適合套模板。

這裡介紹幾種常見的板子題。主要有:

  • Dijkstra
  • Floyd-Warshall
  • 最小生成樹(Kruskal & Prim) 目前此小節已經刪除,覺得自己寫的不夠詳細,之後補充完成會再次開放。
  • A 星尋路演算法
  • 二分圖(染色法)〔Bipartitie〕
  • 拓撲排序〔Topological Sort〕

下面列舉常見演算法的模板。

以下所有的模板都是基於鄰接矩陣建圖。

強烈建議大家學習完專題篇的搜尋之後再來學習下面經典演算法。大家可以拿幾道普通的搜尋題目測試下,如果能夠做出來再往下學習。推薦題目:最大化一張圖中的路徑價值

最短距離,最短路徑

Dijkstra 演算法

DIJKSTRA 基本思想是廣度優先遍歷。實際上搜尋的最短路演算法基本思想都是廣度優先,只不過具體的擴充套件策略不同而已。

DIJKSTRA 演算法主要解決的是圖中任意一點到圖中另外任意一個點的最短距離,即單源最短路徑。

Dijkstra 這個名字比較難記,大家可以簡單記為DJ 演算法,有沒有好記很多?

比如給你幾個城市,以及城市之間的距離。讓你規劃一條最短的從城市 a 到城市 b 的路線。

這個問題,我們就可以先將城市間的距離用圖建立出來,然後使用 dijkstra 來做。那麼 dijkstra 究竟如何計算最短路徑的呢?

dj 演算法的基本思想是貪心。從起點 start 開始,每次都遍歷所有鄰居,並從中找到距離最小的,本質上是一種廣度優先遍歷。這裡我們藉助堆這種資料結構,使得可以在 $logN$ 的時間內找到 cost 最小的點。

而如果使用普通的佇列的話,其實是圖中所有邊權值都相同的特殊情況。

比如我們要找從點 start 到點 end 的最短距離。我們期望 dj 演算法是這樣被使用的。

比如一個圖是這樣的:

E -- 1 --> B -- 1 --> C -- 1 --> D -- 1 --> F
 \                                         /\
  \                                        ||
    -------- 2 ---------> G ------- 1 ------

我們使用鄰接矩陣來構造:

G = {
    "B": [["C", 1]],
    "C": [["D", 1]],
    "D": [["F", 1]],
    "E": [["B", 1], ["G", 2]],
    "F": [],
    "G": [["F", 1]],
}

shortDistance = dijkstra(G, "E", "C")
print(shortDistance)  # E -- 3 --> F -- 3 --> C == 6

具體演算法:

  1. 初始化堆。堆裡的資料都是 (cost, v) 的二元祖,其含義是“從 start 走到 v 的距離是 cost”。因此初始情況,堆中存放元組 (0, start)
  2. 從堆中 pop 出來一個 (cost, v),第一次 pop 出來的一定是 (0, start)。 如果 v 被訪問過了,那麼跳過,防止環的產生。
  3. 如果 v 是 我們要找的終點,直接返回 cost,此時的 cost 就是從 start 到 該點的最短距離
  4. 否則,將 v 的鄰居入堆,即將 (neibor, cost + c) 加入堆。其中 neibor 為 v 的鄰居, c 為 v 到 neibor 的距離(也就是轉移的代價)。

重複執行 2 - 4 步

程式碼模板:

Python

import heapq


def dijkstra(graph, start, end):
    # 堆裡的資料都是 (cost, i) 的二元祖,其含義是“從 start 走到 i 的距離是 cost”。
    heap = [(0, start)]
    visited = set()
    while heap:
        (cost, u) = heapq.heappop(heap)
        if u in visited:
            continue
        visited.add(u)
        if u == end:
            return cost
        for v, c in graph[u]:
            if v in visited:
                continue
            next = cost + c
            heapq.heappush(heap, (next, v))
    return -1

JavaScript

const dijkstra = (graph, start, end) => {
  const visited = new Set()
  const minHeap = new MinPriorityQueue();
  //注:此處new MinPriorityQueue()用了LC的內建API,它的enqueue由兩個部分組成:
  //element 和 priority。
  //堆會按照priority排序,可以用element記錄一些內容。
  minHeap.enqueue(startPoint, 0)

  while(!minHeap.isEmpty()){
    const {element, priority} = minHeap.dequeue();
    //下面這兩個變數不是必須的,只是便於理解
    const curPoint = element;
    const curCost = priority;

    if(curPoint === end) return curCost;
    if(visited.has(curPoint)) continue;
    visited.add(curPoint);

    if(!graph[curPoint]) continue;
    for(const [nextPoint, nextCost] of graph[curPoint]){
      if(visited.has(nextPoint)) continue;
      //注意heap裡面的一定是從startPoint到某個點的距離;
      //curPoint到nextPoint的距離是nextCost;但curPoint不一定是startPoint。
      const accumulatedCost = nextCost + curCost;
      minHeap.enqueue(nextPoint, accumulatedCost);
    }
  }
  return -1
}

會了這個演算法模板, 你就可以去 AC 743. 網路延遲時間 了。

這裡提供完整程式碼供大家參考:

Python

class Solution:
    def dijkstra(self, graph, start, end):
        heap = [(0, start)]
        visited = set()
        while heap:
            (cost, u) = heapq.heappop(heap)
            if u in visited:
                continue
            visited.add(u)
            if u == end:
                return cost
            for v, c in graph[u]:
                if v in visited:
                    continue
                next = cost + c
                heapq.heappush(heap, (next, v))
        return -1
    def networkDelayTime(self, times: List[List[int]], N: int, K: int) -> int:
        graph = collections.defaultdict(list)
        for fr, to, w in times:
            graph[fr - 1].append((to - 1, w))
        ans = -1
        for to in range(N):
            dist = self.dijkstra(graph, K - 1, to)
            if dist == -1: return -1
            ans = max(ans, dist)
        return ans

JavaScript

const networkDelayTime = (times, n, k) => {
    //咳咳這個解法並不是Dijkstra在本題的最佳解法
    const graph = {};
    for(const [from, to, weight] of times){
        if(!graph[from]) graph[from] = [];
        graph[from].push([to, weight]);
    }

    let ans = -1;
    for(let to = 1; to <= n; to++){
        let dist = dikstra(graph, k, to)
        if(dist === -1) return -1;
        ans = Math.max(ans, dist);
    }
    return ans;
};

const dijkstra = (graph, startPoint, endPoint) => {
  const visited = new Set()
  const minHeap = new MinPriorityQueue();
  //注:此處new MinPriorityQueue()用了LC的內建API,它的enqueue由兩個部分組成:
  //element 和 priority。
  //堆會按照priority排序,可以用element記錄一些內容。
  minHeap.enqueue(startPoint, 0)

  while(!minHeap.isEmpty()){
    const {element, priority} = minHeap.dequeue();
    //下面這兩個變數不是必須的,只是便於理解
    const curPoint = element;
    const curCost = priority;
    if(visited.has(curPoint)) continue;
    visited.add(curPoint)
    if(curPoint === endPoint) return curCost;

    if(!graph[curPoint]) continue;
    for(const [nextPoint, nextCost] of graph[curPoint]){
      if(visited.has(nextPoint)) continue;
      //注意heap裡面的一定是從startPoint到某個點的距離;
      //curPoint到nextPoint的距離是nextCost;但curPoint不一定是startPoint。
      const accumulatedCost = nextCost + curCost;
      minHeap.enqueue(nextPoint, accumulatedCost);
    }
  }
  return -1
}

DJ 演算法的時間複雜度為 $vlogv+e$,其中 v 和 e 分別為圖中的點和邊的個數。

最後給大家留一個思考題:如果是計算一個點到圖中所有點的距離呢?我們的演算法會有什麼樣的調整?

提示:你可以使用一個 dist 雜湊表記錄開始點到每個點的最短距離來完成。想出來的話,可以用力扣 882 題去驗證一下哦~

值得注意的是, Dijkstra 無法處理邊權值為負的情況。即如果出現負權值的邊,那麼答案可能不正確。而基於動態規劃演算法的最短路(下文會講)則可以處理這種情況。

Floyd-Warshall 演算法

Floyd-Warshall 可以解決任意兩個點距離,即多源最短路徑,這點和 dj 演算法不一樣。

除此之外,貝爾曼-福特演算法也是解決最短路徑的經典動態規劃演算法,這點和 dj 也是不一樣的,dj 是基於貪心的。

相比上面的 dijkstra 演算法, 由於其計算過程會把中間運算結果儲存起來防止重複計算,因此其特別適合求圖中任意兩點的距離,比如力扣的 1462. 課程安排 IV。除了這個優點。下文要講的貝爾曼-福特演算法相比於此演算法最大的區別在於本演算法是多源最短路徑,而貝爾曼-福特則是單源最短路徑。不管是複雜度和寫法, 貝爾曼-福特演算法都更簡單,我們後面給大家介紹。

當然就不是說貝爾曼演算法以及上面的 dijkstra 就不支援多源最短路徑,你只需要加一個 for 迴圈列舉所有的起點罷了。

還有一個非常重要的點是 Floyd-Warshall 演算法由於使用了動態規劃的思想而不是貪心,因此其可以處理負權重的情況,這點需要大家尤為注意。 動態規劃的詳細內容請參考之後的動態規劃專題揹包問題

演算法也不難理解,簡單來說就是: i 到 j 的最短路徑 = i 到 k 的最短路徑 + k 到 j 的最短路徑的最小值。如下圖:

u 到 v 的最短距離是 u 到 x 的最短距離 + x 到 v 的最短距離。上圖 x 是 u 到 v 的必經之路,如果不是的話,我們需要多箇中間節點的值,並取最小的。

演算法的正確性不言而喻,因為從 i 到 j,要麼直接到,要麼經過圖中的另外一個點 k,中間節點 k 可能有多個,經過中間點的情況取出最小的,自然就是 i 到 j 的最短距離。

思考題: 最長無環路徑可以用動態規劃來解麼?

該演算法的時間複雜度是 $O(N^3)$,空間複雜度是 $O(N^2)$,其中 N 為頂點個數。

程式碼模板:

Python

# graph 是鄰接矩陣,n 是頂點個數
# graph 形如: graph[u][v] = w

def floyd_warshall(graph, n):
    dist = [[float("inf") for _ in range(n)] for _ in range(n)]

    for i in range(n):
        for j in range(n):
            dist[i][j] = graph[i][j]

    # check vertex k against all other vertices (i, j)
    for k in range(n):
        # looping through rows of graph array
        for i in range(n):
            # looping through columns of graph array
            for j in range(n):
                if (
                    dist[i][k] != float("inf")
                    and dist[k][j] != float("inf")
                    and dist[i][k] + dist[k][j] < dist[i][j]
                ):
                    dist[i][j] = dist[i][k] + dist[k][j]
    return dist

JavaScript

const floydWarshall = (graph, v)=>{
  const dist = new Array(v).fill(0).map(() => new Array(v).fill(Number.MAX_SAFE_INTEGER))

  for(let i = 0; i < v; i++){
    for(let j = 0; j < v; j++){
      //兩個點相同,距離為0
      if(i === j) dist[i][j] = 0;
      //i 和 j 的距離已知
      else if(graph[i][j]) dist[i][j] = graph[i][j];
      //i 和 j 的距離未知,預設是最大值
      else dist[i][j] = Number.MAX_SAFE_INTEGER;
    }
  }

  //檢查是否有一個點 k 使得 i 和 j 之間距離更短,如果有,則更新最短距離
  for(let k = 0; k < v; k++){
    for(let i = 0; i < v; i++){
      for(let j = 0; j < v; j++){
        dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j])
      }
    }
  }
  return 看需要
}

我們回過頭來看下如何套模板解決 力扣的 1462. 課程安排 IV,題目描述:

你總共需要上 n 門課,課程編號依次為 0 到 n-1 。

有的課會有直接的先修課程,比如如果想上課程 0 ,你必須先上課程 1 ,那麼會以 [1,0] 數對的形式給出先修課程數對。

給你課程總數 n 和一個直接先修課程數對列表 prerequisite 和一個查詢對列表 queries 。

對於每個查詢對 queries[i] ,請判斷 queries[i][0] 是否是 queries[i][1] 的先修課程。

請返回一個布林值列表,列表中每個元素依次分別對應 queries 每個查詢對的判斷結果。

注意:如果課程 a 是課程 b 的先修課程且課程 b 是課程 c 的先修課程,那麼課程 a 也是課程 c 的先修課程。

 

示例 1:



輸入:n = 2, prerequisites = [[1,0]], queries = [[0,1],[1,0]]
輸出:[false,true]
解釋:課程 0 不是課程 1 的先修課程,但課程 1 是課程 0 的先修課程。
示例 2:

輸入:n = 2, prerequisites = [], queries = [[1,0],[0,1]]
輸出:[false,false]
解釋:沒有先修課程對,所以每門課程之間是獨立的。
示例 3:



輸入:n = 3, prerequisites = [[1,2],[1,0],[2,0]], queries = [[1,0],[1,2]]
輸出:[true,true]
示例 4:

輸入:n = 3, prerequisites = [[1,0],[2,0]], queries = [[0,1],[2,0]]
輸出:[false,true]
示例 5:

輸入:n = 5, prerequisites = [[0,1],[1,2],[2,3],[3,4]], queries = [[0,4],[4,0],[1,3],[3,0]]
輸出:[true,false,true,false]
 

提示:

2 <= n <= 100
0 <= prerequisite.length <= (n * (n - 1) / 2)
0 <= prerequisite[i][0], prerequisite[i][1] < n
prerequisite[i][0] != prerequisite[i][1]
先修課程圖中沒有環。
先修課程圖中沒有重複的邊。
1 <= queries.length <= 10^4
queries[i][0] != queries[i][1]

這道題也可以使用 Floyd-Warshall 來做。 你可以這麼想, 如果從 i 到 j 的距離大於 0,那不就是先修課麼。而這道題資料範圍 queries 大概是 10 ^ 4 , 用上面的 dijkstra 演算法肯定超時,,因此 Floyd-Warshall 演算法是明智的選擇。

我這裡直接套模板,稍微改下就過了。完整程式碼:
Python

class Solution:
    def Floyd-Warshall(self, dist, v):
        for k in range(v):
            for i in range(v):
                for j in range(v):
                    dist[i][j] = dist[i][j] or (dist[i][k] and dist[k][j])

        return dist

    def checkIfPrerequisite(self, n: int, prerequisites: List[List[int]], queries: List[List[int]]) -> List[bool]:
        graph = [[False] * n for _ in range(n)]
        ans = []

        for to, fr in prerequisites:
            graph[fr][to] = True
        dist = self.Floyd-Warshall(graph, n)
        for to, fr in queries:
            ans.append(bool(dist[fr][to]))
        return ans

JavaScript

//咳咳這個寫法不是本題最優
var checkIfPrerequisite = function(numCourses, prerequisites, queries) {
    const graph = {}
    for(const [course, pre] of prerequisites){
        if(!graph[pre]) graph[pre] = {}
        graph[pre][course] = true
    }

    const ans = []

    const dist = Floyd-Warshall(graph, numCourses)
    for(const [course, pre] of queries){
        ans.push(dist[pre][course])
    }

    return ans
};

var Floyd-Warshall = function(graph, n){
    dist = Array.from({length: n + 1}).map(() => Array.from({length: n + 1}).fill(false))
    for(let k = 0; k < n; k++){
        for(let i = 0; i < n; i++){
            for(let j = 0; j < n; j++){
                if(graph[i] && graph[i][j]) dist[i][j] = true
                if(graph[i] && graph[k]){
                    dist[i][j] = (dist[i][j])|| (dist[i][k] && dist[k][j])
                }else if(graph[i]){
                    dist[i][j] = dist[i][j]
                }
            }
        }
    }
    return dist
}

如果這道題你可以解決了,我再推薦一道題給你 1617. 統計子樹中城市之間最大距離,國際版有一個題解程式碼挺清晰,挺好理解的,只不過沒有使用狀態壓縮效能不是很好罷了,地址:https://leetcode.com/problems...

圖上的動態規劃演算法大家還可以拿這個題目來練習一下。

貝爾曼-福特演算法

和上面的演算法類似。這種解法主要解決單源最短路徑,即圖中某一點到其他點的最短距離。

其基本思想也是動態規劃。

核心演算法為:

  • 初始化起點距離為 0
  • 對圖中的所有邊進行若干次處理,直到穩定。處理的依據是:對於每一個有向邊 (u,v),如果 dist[u] + w 小於 dist[v],那麼意味著我們找到了一條到達 v 更近的路,更新之。
  • 上面的若干次的上限是頂點 V 的個數,因此不妨直接進行 n 次處理。
  • 最後檢查一下是否存在負邊引起的環。(注意)

舉個例子。對於如下的一個圖,存在一個 B -> C -> D -> B,這樣 B 到 C 和 D 的距離理論上可以無限小。我們需要檢測到這一種情況,並退出。

此演算法時間複雜度:$O(V*E)$, 空間複雜度:$O(V)$。

程式碼示例:
Python

# return -1 for not exsit
# else return dis map where dis[v] means for point s the least cost to point v
def bell_man(edges, s):
    dis = defaultdict(lambda: math.inf)
    dis[s] = 0
    for _ in range(n):
        for u, v, w in edges:
            if dis[u] + w < dis[v]:
                dis[v] = dis[u] + w

    for u, v, w in edges:
        if dis[u] + w < dis[v]:
            return -1

    return dis

JavaScript

const BellmanFord = (edges, startPoint)=>{
  const n = edges.length;
  const dist = new Array(n).fill(Number.MAX_SAFE_INTEGER);
  dist[startPoint] = 0;

  for(let i = 0; i < n; i++){
    for(const [u, v, w] of edges){
        if(dist[u] + w < dist[v]){
            dist[v] = dist[u] + w;
        }
    }
  }

  for(const [u, v, w] of edges){
    if(dist[u] + w < dist[v]) return -1;
  }

  return dist
}

推薦閱讀:

題目推薦:

拓撲排序

在電腦科學領域,有向圖的拓撲排序是對其頂點的一種線性排序,使得對於從頂點 u 到頂點 v 的每個有向邊 uv, u 在排序中都在之前。當且僅當圖中沒有定向環時(即有向無環圖),才有可能進行拓撲排序。

典型的題目就是給你一堆課程,課程之間有先修關係,讓你給出一種可行的學習路徑方式,要求先修的課程要先學。任何有向無環圖至少有一個拓撲排序。已知有演算法可以線上性時間內,構建任何有向無環圖的拓撲排序。

Kahn 演算法

簡單來說,假設 L 是存放結果的列表,先找到那些入度為零的節點,把這些節點放到 L 中,因為這些節點沒有任何的父節點。然後把與這些節點相連的邊從圖中去掉,再尋找圖中的入度為零的節點。對於新找到的這些入度為零的節點來說,他們的父節點已經都在 L 中了,所以也可以放入 L。重複上述操作,直到找不到入度為零的節點。如果此時 L 中的元素個數和節點總數相同,說明排序完成;如果 L 中的元素個數和節點總數不同,說明原圖中存在環,無法進行拓撲排序。

def topologicalSort(graph):
    """
    Kahn's Algorithm is used to find Topological ordering of Directed Acyclic Graph
    using BFS
    """
    indegree = [0] * len(graph)
    queue = collections.deque()
    topo = []
    cnt = 0

    for key, values in graph.items():
        for i in values:
            indegree[i] += 1

    for i in range(len(indegree)):
        if indegree[i] == 0:
            queue.append(i)

    while queue:
        vertex = queue.popleft()
        cnt += 1
        topo.append(vertex)
        for x in graph[vertex]:
            indegree[x] -= 1
            if indegree[x] == 0:
                queue.append(x)

    if cnt != len(graph):
        print("Cycle exists")
    else:
        print(topo)


# Adjacency List of Graph
graph = {0: [1, 2], 1: [3], 2: [3], 3: [4, 5], 4: [], 5: []}
topologicalSort(graph)

最小生成樹

首先我們來看下什麼是生成樹。

首先生成樹是原圖的一個子圖,它本質是一棵樹,這也是為什麼叫做生成樹,而不是生成圖的原因。其次生成樹應該包括圖中所有的頂點。 如下圖由於沒有包含所有頂點,換句話說所有頂點沒有在同一個聯通域,因此不是一個生成樹。

黃色頂點沒有包括在內

你可以將生成樹看成是根節點不確定的多叉樹,由於是一棵樹,那麼一定不包含環。如下圖就不是生成樹。

因此不難得出,最小生成樹的邊的個數是 n - 1,其中 n 為頂點個數。

接下來我們看下什麼是最小生成樹。

最小生成樹是在生成樹的基礎上加了最小關鍵字,是最小權重生成樹的簡稱。從這句話也可以看出,最小生成樹處理正是有權圖。生成樹的權重是其所有邊的權重和,那麼最小生成樹就是權重和最小的生成樹,由此可看出,不管是生成樹還是最小生成樹都可能不唯一。

最小生成樹在實際生活中有很強的價值。比如我要修建一個地鐵,並覆蓋 n 個站,這 n 個站要互相都可以到達(同一個聯通域),如果建造才能使得花費最小?由於每個站之間的路線不同,因此造價也不一樣,因此這就是一個最小生成樹的實際使用場景,類似的例子還有很多。

(圖來自維基百科)

不難看出,計算最小生成樹就是從邊集合中挑選 n - 1 個邊,使得其滿足生成樹,並且權值和最小。

Kruskal 和 Prim 是兩個經典的求最小生成樹的演算法,這兩個演算法又是如何計算最小生成樹的呢?本節我們就來了解一下它們。

Kruskal

Kruskal 相對比較容易理解,推薦掌握。

Kruskal 演算法也被形象地稱為加邊法,每前進一次都選擇權重最小的邊,加入到結果集。為了防止環的產生(增加環是無意義的,只要權重是正數,一定會使結果更差),我們需要檢查下當前選擇的邊是否和已經選擇的邊聯通了。如果聯通了,是沒有必要選取的,因為這會使得環產生。因此演算法上,我們可使用並查集輔助完成。關於並查集,我們會在之後的進階篇進行講解。

下面程式碼中的 find_parent 部分,實際上就是並查集的核心程式碼,只是我們沒有將其封裝並使用罷了。

Kruskal 具體演算法:

  1. 對邊按照權值從小到大進行排序。
  2. 將 n 個頂點初始化為 n 個聯通域
  3. 按照權值從小到大選擇邊加入到結果集,每次貪心地選擇最小邊。如果當前選擇的邊是否和已經選擇的邊聯通了(如果強行加就有環了),則放棄選擇,否則進行選擇,加入到結果集。
  4. 重複 3 直到我們找到了一個聯通域大小為 n 的子圖

程式碼模板:

其中 edge 是一個陣列,陣列每一項都形如: (cost, fr, to),含義是 從 fr 到 to 有一條權值為 cost的邊。

class DisjointSetUnion:
    def __init__(self, n):
        self.n = n
        self.rank = [1] * n
        self.f = list(range(n))
    
    def find(self, x: int) -> int:
        if self.f[x] == x:
            return x
        self.f[x] = self.find(self.f[x])
        return self.f[x]
    
    def unionSet(self, x: int, y: int) -> bool:
        fx, fy = self.find(x), self.find(y)
        if fx == fy:
            return False

        if self.rank[fx] < self.rank[fy]:
            fx, fy = fy, fx
        
        self.rank[fx] += self.rank[fy]
        self.f[fy] = fx
        return True

class Solution:
    def Kruskal(self, edges) -> int:
        n = len(points)
        dsu = DisjointSetUnion(n)
        
        edges.sort()
        
        ret, num = 0, 1
        for length, x, y in edges:
            if dsu.unionSet(x, y):
                ret += length
                num += 1
                if num == n:
                    break
        
        return ret

Prim

Prim 演算法也被形象地稱為加點法,每前進一次都選擇權重最小的點,加入到結果集。形象地看就像一個不斷生長的真實世界的樹。

Prim 具體演算法:

  1. 初始化最小生成樹點集 MV 為圖中任意一個頂點,最小生成樹邊集 ME 為空。我們的目標是將 MV 填充到 和 V 一樣,而邊集則根據 MV 的產生自動計算。
  2. 在集合 E 中 (集合 E 為原始圖的邊集)選取最小的邊 <u, v> 其中 u 為 MV 中已有的元素,而 v 為 MV 中不存在的元素(像不像上面說的不斷生長的真實世界的樹),將 v 加入到 MV,將 <u, v> 加到 ME。
  3. 重複 2 直到我們找到了一個聯通域大小為 n 的子圖

程式碼模板:

其中 dist 是二維陣列,disti = x 表示頂點 i 到頂點 j 有一條權值為 x 的邊。

class Solution:
    def Prim(self, dist) -> int:
        n = len(dist)
        d = [float("inf")] * n # 表示各個頂點與加入最小生成樹的頂點之間的最小距離.
        vis = [False] * n # 表示是否已經加入到了最小生成樹裡面
        d[0] = 0
        ans = 0
        for _ in range(n):
            # 尋找目前這輪的最小d
            M = float("inf") 
            for i in range(n):
                if not vis[i] and d[i] < M:
                    node = i
                    M = d[i]
            vis[node] = True
            ans += M
            for i in range(n):
                if not vis[i]:
                    d[i] = min(d[i], dist[i][node])
        return ans

兩種演算法比較

為了後面描述方便,我們令 V 為圖中的頂點數, E 為圖中的邊數。那麼 KruKal 的演算法複雜度是 $O(ElogE)$,Prim 的演算法時間複雜度為 $E + VlogV$。因此 Prim 適合適用於稠密圖,而 KruKal 則適合稀疏圖。

大家也可以參考一下 維基百科 - 最小生成樹 的資料作為補充。

另外這裡有一份視訊學習資料,其中的動畫做的不錯,大家可以作為參考,地址:https://www.bilibili.com/vide...

大家可以使用 LeetCode 的 1584. 連線所有點的最小費用 來練習該演算法。

其他演算法

A 星尋路演算法

A 星尋路解決的問題是在一個二維的表格中找出任意兩點的最短距離或者最短路徑。常用於遊戲中的 NPC 的移動計算,是一種常用啟發式演算法。一般這種題目都會有障礙物。除了障礙物,力扣的題目還會增加一些限制,使得題目難度增加。

這種題目一般都是力扣的困難難度。理解起來不難, 但是要完整地沒有 bug 地寫出來卻不那麼容易。

在該演算法中,我們從起點開始,檢查其相鄰的四個方格並嘗試擴充套件,直至找到目標。A 星尋路演算法的尋路方式不止一種,感興趣的可以自行了解一下。

公式表示為: f(n)=g(n)+h(n)。

其中:

  • f(n) 是從初始狀態經由狀態 n 到目標狀態的估計代價,
  • g(n) 是在狀態空間中從初始狀態到狀態 n 的實際代價,
  • h(n) 是從狀態 n 到目標狀態的最佳路徑的估計代價。

如果 g(n)為 0,即只計算任意頂點 n 到目標的評估函式 h(n),而不計算起點到頂點 n 的距離,則演算法轉化為使用貪心策略的最良優先搜尋,速度最快,但可能得不出最優解;
如果 h(n)不大於頂點 n 到目標頂點的實際距離,則一定可以求出最優解,而且 h(n)越小,需要計算的節點越多,演算法效率越低,常見的評估函式有——歐幾里得距離、曼哈頓距離、切比雪夫距離;
如果 h(n)為 0,即只需求出起點到任意頂點 n 的最短路徑 g(n),而不計算任何評估函式 h(n),則轉化為單源最短路徑問題,即 Dijkstra 演算法,此時需要計算最多的頂點;

這裡有一個重要的概念是估價演算法,一般我們使用 曼哈頓距離來進行估價,即 H(n) = D * (abs ( n.x – goal.x ) + abs ( n.y – goal.y ) )

(圖來自維基百科 https://zh.wikipedia.org/wiki/A*%E6%90%9C%E5%B0%8B%E6%BC%94%E7%AE%97%E6%B3%95 )

一個完整的程式碼模板:

grid = [
    [0, 1, 0, 0, 0, 0],
    [0, 1, 0, 0, 0, 0],  # 0 are free path whereas 1's are obstacles
    [0, 1, 0, 0, 0, 0],
    [0, 1, 0, 0, 1, 0],
    [0, 0, 0, 0, 1, 0],
]

"""
heuristic = [[9, 8, 7, 6, 5, 4],
             [8, 7, 6, 5, 4, 3],
             [7, 6, 5, 4, 3, 2],
             [6, 5, 4, 3, 2, 1],
             [5, 4, 3, 2, 1, 0]]"""

init = [0, 0]
goal = [len(grid) - 1, len(grid[0]) - 1]  # all coordinates are given in format [y,x]
cost = 1

# the cost map which pushes the path closer to the goal
heuristic = [[0 for row in range(len(grid[0]))] for col in range(len(grid))]
for i in range(len(grid)):
    for j in range(len(grid[0])):
        heuristic[i][j] = abs(i - goal[0]) + abs(j - goal[1])
        if grid[i][j] == 1:
            heuristic[i][j] = 99  # added extra penalty in the heuristic map


# the actions we can take
delta = [[-1, 0], [0, -1], [1, 0], [0, 1]]  # go up  # go left  # go down  # go right


# function to search the path
def search(grid, init, goal, cost, heuristic):

    closed = [
        [0 for col in range(len(grid[0]))] for row in range(len(grid))
    ]  # the reference grid
    closed[init[0]][init[1]] = 1
    action = [
        [0 for col in range(len(grid[0]))] for row in range(len(grid))
    ]  # the action grid

    x = init[0]
    y = init[1]
    g = 0
    f = g + heuristic[init[0]][init[0]]
    cell = [[f, g, x, y]]

    found = False  # flag that is set when search is complete
    resign = False  # flag set if we can't find expand

    while not found and not resign:
        if len(cell) == 0:
            return "FAIL"
        else:  # to choose the least costliest action so as to move closer to the goal
            cell.sort()
            cell.reverse()
            next = cell.pop()
            x = next[2]
            y = next[3]
            g = next[1]

            if x == goal[0] and y == goal[1]:
                found = True
            else:
                for i in range(len(delta)):  # to try out different valid actions
                    x2 = x + delta[i][0]
                    y2 = y + delta[i][1]
                    if x2 >= 0 and x2 < len(grid) and y2 >= 0 and y2 < len(grid[0]):
                        if closed[x2][y2] == 0 and grid[x2][y2] == 0:
                            g2 = g + cost
                            f2 = g2 + heuristic[x2][y2]
                            cell.append([f2, g2, x2, y2])
                            closed[x2][y2] = 1
                            action[x2][y2] = i
    invpath = []
    x = goal[0]
    y = goal[1]
    invpath.append([x, y])  # we get the reverse path from here
    while x != init[0] or y != init[1]:
        x2 = x - delta[action[x][y]][0]
        y2 = y - delta[action[x][y]][1]
        x = x2
        y = y2
        invpath.append([x, y])

    path = []
    for i in range(len(invpath)):
        path.append(invpath[len(invpath) - 1 - i])
    print("ACTION MAP")
    for i in range(len(action)):
        print(action[i])

    return path


a = search(grid, init, goal, cost, heuristic)
for i in range(len(a)):
    print(a[i])

典型題目1263. 推箱子

二分圖

二分圖我在這兩道題中講過了,大家看一下之後把這兩道題做一下就行了。其實這兩道題和一道題沒啥區別。

推薦順序為: 先看 886 再看 785。

總結

理解圖的常見概念,我們就算入門了。接下來,我們就可以做題了。

一般的圖題目有兩種,一種是搜尋題目,一種是動態規劃題目。

對於搜尋類題目,我們可以:

  • 第一步都是建圖
  • 第二步都是基於第一步的圖進行遍歷以尋找可行解
如果題目說明了是無環圖,我們可以不使用 visited 陣列,否則大多數都需要 visited 陣列。當然也可以選擇原地演算法減少空間複雜度,具體的搜尋技巧會在專題篇的搜尋篇進行討論。

圖的題目相對而言比較難,尤其是程式碼書寫層面。但是就面試題目而言, 圖的題目型別卻不多。

  • 就搜尋題目來說,很多題目都是套模板就可以解決。因此建議大家多練習模板,並自己多手敲,確保可以自己敲出來。
  • 而對於動態規劃題目,一個經典的例子就是Floyd-Warshall 演算法,理解好了之後大家不妨拿 787. K 站中轉內最便宜的航班 練習一下。當然這要求大家應該先學習動態規劃,關於動態規劃,我們會在後面的《動態規劃》以及《揹包問題》中進行深度講解。

常見的圖的板子題有以下幾種:

  1. 最短路。演算法有 DJ 演算法, floyd 演算法 和 bellman 演算法。這其中有的是單源演算法,有的是多源演算法,有的是貪心演算法,有的是動態規劃。
  2. 拓撲排序。拓撲排序可以使用 bfs ,也可以使用 dfs。相比於最短路,這種題目屬於知道了就簡單的型別。
  3. 最小生成樹。最小生成樹是這三種題型中出現頻率最低的,可以最後突破。
  4. A 星尋路和二分圖題目比例非常低,大家可以根據自己的情況選擇性掌握。

相關文章