cover

視覺化概覽

flowchart TD
    A["初始化:起點距離=0\n其餘=∞"] --> B["從 Priority Queue\n取出距離最小的節點 u"]
    B --> C{"u 已訪問過?"}
    C -->|"是"| B
    C -->|"否"| D["標記 u 為已訪問"]
    D --> E["遍歷 u 的所有鄰居 v"]
    E --> F{"dist[u] + weight(u,v)\n< dist[v]?"}
    F -->|"是"| G["更新 dist[v]\n加入 Priority Queue"]
    F -->|"否"| H["跳過"]
    G --> I{"還有未訪問節點?"}
    H --> I
    I -->|"是"| B
    I -->|"否"| J["完成!dist[] 為最短路徑"]

你每天都在用圖演算法

你用 npm install 裝過套件嗎?npm 怎麼決定先安裝哪個套件?拓撲排序。你用過 Google Maps 導航嗎?它怎麼算出最短路線?Dijkstra / A*。你在 LinkedIn 看過「你可能認識的人」嗎?BFS(廣度優先搜尋)

圖演算法不只是學術題目,它藏在你每天用的工具裡:

工程場景對應演算法
npm install / pnpm install 套件安裝順序拓撲排序
Google Maps / Apple Maps 路線規劃Dijkstra + A*
社群網站「你可能認識的人」BFS / DFS
網路路由(OSPF 協定)Dijkstra
任務排程(CI/CD pipeline)拓撲排序 + 關鍵路徑法
git merge-base 找共同祖先BFS on DAG
資料庫 query optimizer 的 join 順序圖遍歷 + 動態規劃

最短路徑問題

從 A 到 B 最短要走多遠?

    1 ---4--- 2
    |         |
    2         1
    |         |
    3 ---3--- 4

Dijkstra 演算法

貪心策略:每次選擇目前最近的點。

從 1 出發:
1. dist = [0, ∞, ∞, ∞]  ← 1 確定
2. 更新 1 的鄰居:dist = [0, 4, 2, ∞]
3. 選最小的 3:dist[3] = 2 確定
4. 更新 3 的鄰居:dist = [0, 4, 2, 5]
5. 選最小的 2:dist[2] = 4 確定
6. 更新 2 的鄰居:dist = [0, 4, 2, 5]
7. 選最小的 4:dist[4] = 5 確定

實作(Priority Queue)

int[] dijkstra(Graph g, int source) {
    int[] dist = new int[V];
    Arrays.fill(dist, INF);
    dist[source] = 0;
 
    // (距離, 節點)
    PriorityQueue<int[]> pq = new PriorityQueue<>((a,b) -> a[0]-b[0]);
    pq.offer(new int[]{0, source});
 
    while (!pq.isEmpty()) {
        int[] curr = pq.poll();
        int d = curr[0], u = curr[1];
 
        if (d > dist[u]) continue;  // 已有更短路徑
 
        for (Edge e : neighbors(u)) {
            int newDist = dist[u] + e.weight;
            if (newDist < dist[e.to]) {
                dist[e.to] = newDist;
                pq.offer(new int[]{newDist, e.to});
            }
        }
    }
    return dist;
}

TypeScript 實作

// 用 adjacency list + Map 表示加權圖
type Graph = Map<string, Map<string, number>>;
 
function dijkstra(graph: Graph, source: string): Map<string, number> {
  const dist = new Map<string, number>();
  const visited = new Set<string>();
 
  // 初始化:所有距離設為 Infinity
  for (const node of graph.keys()) dist.set(node, Infinity);
  dist.set(source, 0);
 
  // 簡化版 priority queue(實務用 heap)
  while (visited.size < graph.size) {
    // 找未訪問中距離最小的節點
    let u = '';
    let minDist = Infinity;
    for (const [node, d] of dist) {
      if (!visited.has(node) && d < minDist) {
        u = node;
        minDist = d;
      }
    }
    if (u === '') break;
 
    visited.add(u);
 
    // 更新鄰居
    for (const [v, weight] of graph.get(u) ?? []) {
      const newDist = dist.get(u)! + weight;
      if (newDist < dist.get(v)!) {
        dist.set(v, newDist);
      }
    }
  }
  return dist;
}
 
// 使用:計算辦公室各站點之間的最短通勤距離
const metro: Graph = new Map([
  ['台北車站', new Map([['中山', 3], ['善導寺', 2]])],
  ['中山', new Map([['台北車站', 3], ['松江南京', 4]])],
  ['善導寺', new Map([['台北車站', 2], ['忠孝新生', 3]])],
  ['松江南京', new Map([['中山', 4], ['忠孝新生', 2]])],
  ['忠孝新生', new Map([['善導寺', 3], ['松江南京', 2]])],
]);
 
const distances = dijkstra(metro, '台北車站');
// 台北車站 → 忠孝新生: 5(經善導寺比經中山快)

特性

項目說明
時間O((V + E) log V)
限制不能有負權重
適用單源最短路徑

工程場景:網路路由協定 OSPF(Open Shortest Path First)就是用 Dijkstra 計算路由器之間的最短路徑。每台路由器會建立整個網路的拓撲圖,然後跑 Dijkstra 決定封包要怎麼走。

Bellman-Ford 演算法

可以處理負權重邊,也可以偵測負環。

原理

對所有邊做 V-1 次「鬆弛」:

鬆弛:如果經過 u 到 v 更短,就更新
if (dist[u] + weight < dist[v]) {
    dist[v] = dist[u] + weight;
}

偵測負環

第 V 次鬆弛還能更新 = 有負環(距離可以無限小)

// V-1 次鬆弛
for (int i = 0; i < V - 1; i++) {
    for (每條邊 (u, v, w)) {
        if (dist[u] + w < dist[v]) {
            dist[v] = dist[u] + w;
        }
    }
}
 
// 第 V 次:檢測負環
for (每條邊 (u, v, w)) {
    if (dist[u] + w < dist[v]) {
        return "有負環!";
    }
}

特性

項目說明
時間O(V × E)
優點可處理負權重
用途偵測負環

工程場景:金融系統中的套利偵測。如果匯率圖中存在「負環」(A→B→C→A 換完錢反而更多),就表示有套利機會。交易所會用 Bellman-Ford 偵測這種情況。

Floyd-Warshall 演算法

求所有點對之間的最短路徑。

原理

DP:dist[i][j] = 從 i 到 j,允許經過節點 0~k 的最短路徑

dist[i][j] = min(
    dist[i][j],           // 不經過 k
    dist[i][k] + dist[k][j]  // 經過 k
)

實作

for (int k = 0; k < V; k++) {
    for (int i = 0; i < V; i++) {
        for (int j = 0; j < V; j++) {
            dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
        }
    }
}

特性

項目說明
時間O(V³)
空間O(V²)
用途所有點對最短路徑

拓撲排序

線性排列 DAG(有向無環圖)的節點。

用途:課程依賴、編譯順序、任務排程

課程依賴:
  微積分 → 線性代數 → 機器學習
  程式設計 → 資料結構 → 演算法

拓撲排序:微積分, 程式設計, 線性代數, 資料結構, 機器學習, 演算法

TypeScript 實作(Kahn’s Algorithm)

// 通用拓撲排序 — 適用於任何依賴關係場景
function topologicalSort(graph: Map<string, string[]>): string[] {
  const inDegree = new Map<string, number>();
 
  // 初始化入度
  for (const node of graph.keys()) inDegree.set(node, 0);
  for (const [, deps] of graph) {
    for (const dep of deps) {
      inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1);
    }
  }
 
  // 入度為 0 的先加入
  const queue: string[] = [];
  for (const [node, degree] of inDegree) {
    if (degree === 0) queue.push(node);
  }
 
  const result: string[] = [];
  while (queue.length > 0) {
    const u = queue.shift()!;
    result.push(u);
 
    for (const v of graph.get(u) ?? []) {
      const newDegree = inDegree.get(v)! - 1;
      inDegree.set(v, newDegree);
      if (newDegree === 0) queue.push(v);
    }
  }
 
  // 如果結果數量 ≠ 節點數量 → 有環!
  if (result.length !== graph.size) {
    throw new Error('存在循環依賴!');
  }
  return result;
}

工程實戰:npm 的套件安裝順序

npm install 背後就是拓撲排序。每個套件有自己的 dependencies,npm 要決定先安裝哪個:

// 模擬 npm 的套件安裝順序決策
const packages = new Map<string, string[]>([
  // package → 它的依賴
  ['express', ['body-parser', 'cookie-parser']],
  ['body-parser', ['raw-body', 'content-type']],
  ['cookie-parser', ['cookie']],
  ['raw-body', []],
  ['content-type', []],
  ['cookie', []],
]);
 
const installOrder = topologicalSort(packages);
// ['raw-body', 'content-type', 'cookie', 'body-parser', 'cookie-parser', 'express']
// → 先裝沒有依賴的,再裝依賴它們的

其他拓撲排序場景:Webpack/Vite 的 module bundling(決定 import 順序)、CI/CD pipeline 的 job 排程(build → test → deploy)、資料庫 migration 的執行順序。

Java 實作(Kahn’s Algorithm)

List<Integer> topoSort(Graph g) {
    int[] inDegree = new int[V];
 
    // 計算入度
    for (每條邊 (u, v)) {
        inDegree[v]++;
    }
 
    // 入度為 0 的先加入
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < V; i++) {
        if (inDegree[i] == 0) queue.add(i);
    }
 
    List<Integer> result = new ArrayList<>();
    while (!queue.isEmpty()) {
        int u = queue.poll();
        result.add(u);
 
        for (v : neighbors(u)) {
            inDegree[v]--;
            if (inDegree[v] == 0) queue.add(v);
        }
    }
 
    return result;
}

A* 演算法

啟發式搜尋:比 Dijkstra 更聰明。

原理

f(n) = g(n) + h(n)

g(n) = 從起點到 n 的實際距離
h(n) = 從 n 到終點的估計距離(啟發函數)

啟發函數

函數公式適用
曼哈頓|x1-x2| + |y1-y2|網格(只能上下左右)
歐幾里得√((x1-x2)² + (y1-y2)²)任意方向
切比雪夫max(|x1-x2|, |y1-y2|)網格(含對角)

A* vs Dijkstra

        終點
          ↑
    □ □ □ □ □
    □ ■ ■ ■ □    ■ = 障礙物
    □ □ □ ■ □
    □ ■ □ □ □
    起點

Dijkstra:往所有方向擴展(藍色區域大)
A*:往終點方向擴展(藍色區域小)
比較DijkstraA*
擴展方向所有方向往目標方向
速度較慢較快
最佳解保證保證(若 h 可接受)

工程場景:地圖導航

Google Maps 不是單純跑 Dijkstra,它用的是 A* 的變體。地圖資料本身就是一張巨大的加權圖(路口 = 節點、道路 = 邊、距離/時間 = 權重)。A* 利用「直線距離到終點」當啟發函數,避免向反方向搜尋,大幅減少搜尋範圍。

// 簡化版 A* — 網格地圖上的路徑搜尋
interface Point { x: number; y: number }
 
function manhattanDistance(a: Point, b: Point): number {
  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
 
function aStar(grid: number[][], start: Point, end: Point): Point[] {
  const rows = grid.length, cols = grid[0].length;
  const openSet = new Map<string, { point: Point; f: number; g: number; parent?: string }>();
  const closedSet = new Set<string>();
  const key = (p: Point) => `${p.x},${p.y}`;
 
  openSet.set(key(start), {
    point: start,
    g: 0,
    f: manhattanDistance(start, end),
  });
 
  while (openSet.size > 0) {
    // 找 f 值最小的節點
    let currentKey = '';
    let minF = Infinity;
    for (const [k, v] of openSet) {
      if (v.f < minF) { minF = v.f; currentKey = k; }
    }
 
    const current = openSet.get(currentKey)!;
    if (current.point.x === end.x && current.point.y === end.y) {
      // 回溯路徑
      return reconstructPath(currentKey, openSet);
    }
 
    openSet.delete(currentKey);
    closedSet.add(currentKey);
 
    // 探索四個方向的鄰居
    for (const [dx, dy] of [[0,1],[0,-1],[1,0],[-1,0]]) {
      const nx = current.point.x + dx, ny = current.point.y + dy;
      const nk = `${nx},${ny}`;
 
      if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) continue;
      if (grid[nx][ny] === 1 || closedSet.has(nk)) continue; // 1 = 障礙物
 
      const g = current.g + 1;
      const f = g + manhattanDistance({ x: nx, y: ny }, end);
 
      const existing = openSet.get(nk);
      if (!existing || g < existing.g) {
        openSet.set(nk, { point: { x: nx, y: ny }, g, f, parent: currentKey });
      }
    }
  }
  return []; // 無路可走
}

延伸:實際導航系統會用更高階的演算法如 Contraction Hierarchies 或 ALT (A* + Landmarks + Triangle inequality) 做預處理,讓查詢從數秒降到毫秒等級。

演算法比較

演算法時間負權重用途
DijkstraO((V+E) log V)單源最短路徑
Bellman-FordO(V × E)單源 + 負環偵測
Floyd-WarshallO(V³)所有點對最短路徑
A*O(E)點對點(有啟發函數)
拓撲排序O(V + E)-DAG 排序

如何選擇?

需要最短路徑?
├─ 單源到所有點
│   ├─ 有負權重?→ Bellman-Ford
│   └─ 無負權重?→ Dijkstra
├─ 所有點對?→ Floyd-Warshall
└─ 點到點(地圖)?→ A*

工程實務:選擇正確的圖演算法

你想做什麼用什麼實際案例
計算兩點最短路徑A*地圖導航
計算一點到所有點的最短路徑Dijkstra網路路由(OSPF)
有負權重的最短路徑Bellman-Ford匯率套利偵測
所有點對之間的距離Floyd-Warshall小型網路的全對最短路徑
決定依賴/安裝順序拓撲排序npm install、CI/CD pipeline
偵測循環依賴拓撲排序(檢查結果大小)ESLint circular dependency
找社群/連通元件BFS / DFS社群網站推薦

什麼時候不需要圖演算法

  • 資料沒有「關係」概念:純粹的列表、集合 → 用 Hash Table 或 Array
  • 樹狀結構:有明確的父子關係、不需要處理環 → 用 Tree 的遍歷就好
  • 只需排序:資料之間沒有依賴關係 → 用 排序演算法

延伸閱讀

相關檔案