圖的表示法 — 鄰接矩陣、鄰接串列與邊串列完整解析 | 資料結構與演算法

2026/07/08
圖的表示法 — 鄰接矩陣、鄰接串列與邊串列完整解析 | 資料結構與演算法

圖(Graph) 是由 頂點(Vertex)邊(Edge) 組成的資料結構,能描述現實世界中幾乎所有的關聯關係:社交網路的朋友關係、地圖的路網、網頁的超連結,都是圖的實例。選擇正確的 圖的表示法(Graph Representation)——鄰接矩陣、鄰接串列或邊串列——是決定圖演算法效能的第一步。

前言

想像你拿到一張捷運路線圖。每個站名是一個頂點,每條路線是連接兩站的邊。你現在面對的問題是:如何讓電腦「看懂」這張圖?

最直覺的方式是畫一個「站 vs 站」的表格,標記哪兩站有直達路線——這是鄰接矩陣。但台北捷運有幾百個站,這張表格會有幾萬格,大部分格子都是空的,浪費空間。更好的方式是為每個站記錄它的鄰接站名單——這是鄰接串列。而如果你只需要按票價排序所有路段,直接儲存每條邊的起站、終站、票價——這是邊串列

三種表示法各有優劣,沒有哪一種永遠最好。選擇表示法的決策,本質上是在時間與空間之間的取捨

讀完本文,你將能夠:

  • 掌握圖的核心術語:頂點、邊、度數、路徑、環、連通分量、稀疏/稠密圖
  • 理解 有向圖無向圖加權圖無權圖 的差異
  • 熟練三種圖表示法的原理、圖解與時空複雜度分析
  • TypeScript 實作完整的 Graph 類別(鄰接串列版)、AdjacencyMatrixEdgeList
  • 對照 C++ 的慣用寫法(vector<vector<pair<int,int>>>unordered_map
  • 根據題目情境,選擇最合適的圖表示法

核心概念

圖的基本術語

在進入實作之前,先建立紮實的術語基礎。下表整理了圖論中最重要的基本概念:

術語英文說明
頂點Vertex / Node圖的基本元素,代表實體(人、站、網頁)
Edge頂點間的連線,代表關係(朋友、路線、超連結)
有向圖Directed Graph (Digraph)邊有方向,A→B 不等於 B→A(如 Twitter 追蹤)
無向圖Undirected Graph邊無方向,A─B 等於 B─A(如 Facebook 好友)
加權圖Weighted Graph邊帶有數值(距離、費用、頻寬)
Degree頂點連接的邊數。有向圖分 出度(out-degree)入度(in-degree)
路徑Path頂點序列,相鄰頂點之間有邊;簡單路徑 不重複經過同一頂點
Cycle起點等於終點的路徑
連通分量Connected Component無向圖中互相可達的最大頂點集合
稀疏圖Sparse Graph邊數遠小於 V²,即 E ≪ V²
稠密圖Dense Graph邊數接近 V²,即 E ≈ V²
DAGDirected Acyclic Graph有向無環圖,可做拓撲排序

度的直覺: 在無向圖中,握手一次讓兩個人的度各增加 1,因此所有頂點的度數之和 = 邊數的兩倍。在有向圖中,一條邊貢獻出度給起點,貢獻入度給終點。

有向圖 vs 無向圖

無向圖(Undirected)          有向圖(Directed Digraph)
  0 ── 1                        0 ──→ 1
  │    │                        │     │
  2 ── 3                        ↓     ↓
                                2     3
A─B 等於 B─A                  A→B 不等於 B→A
遍歷鄰居需雙向                 有入度/出度之分

Facebook 的好友關係 是無向圖:你加我為好友,我也加你為好友,關係對等。Twitter 的追蹤關係 是有向圖:你可以追蹤一個名人,但名人不一定追蹤你。

稀疏圖 vs 稠密圖

選擇哪種圖的表示法,最關鍵的因素就是圖的密度

應用場景頂點 V邊 EE / V²密度建議
社交網路(Facebook)10 億1500 億≈ 0.00015%稀疏鄰接串列
城市路網(Google Maps)數百萬數千萬極小稀疏鄰接串列
完全圖(n 個城市兩兩相連)644032≈ 99%稠密鄰接矩陣
棋盤格(狀態轉移)64224≈ 55%稠密鄰接矩陣

關鍵判斷: E 是否遠小於 V²?若是,圖是稀疏的,鄰接串列通常是更好的選擇。


三種表示法

以同一個有向加權圖為例,展示三種表示法的差異:

範例圖(有向加權圖,V=4,E=5):
  0 ──(4)──→ 1
  0 ──(2)──→ 2
  1 ──(3)──→ 2
  1 ──(1)──→ 3
  2 ──(5)──→ 3

表示法 A — 鄰接矩陣(Adjacency Matrix)

用一個 V×V 的二維陣列儲存邊的資訊。matrix[u][v] 等於邊的權重,無邊則為 (加權圖)或 0(無權圖)。

      0     1     2     3
  0 [ ∞     4     2     ∞  ]
  1 [ ∞     ∞     3     1  ]
  2 [ ∞     ∞     ∞     5  ]
  3 [ ∞     ∞     ∞     ∞  ]

空間:V × V(不論實際邊數多少,一律配置 V² 格)

優點:

  • 查詢邊是否存在:O(1),直接讀 matrix[u][v]
  • 加入/刪除邊:O(1)
  • 適合 Floyd-Warshall 全對最短路 等需要頻繁查邊的演算法

缺點:

  • 空間:O(V²),稀疏圖浪費嚴重
  • 遍歷頂點的所有鄰居:O(V),需要掃描整行
  • 動態加入頂點需要重建矩陣:O(V²)

表示法 B — 鄰接串列(Adjacency List)

每個頂點維護一個串列,記錄所有鄰居(及權重)。

  0: [(1, 4), (2, 2)]
  1: [(2, 3), (3, 1)]
  2: [(3, 5)]
  3: []

空間:O(V + E)(每個頂點一個串列頭,加上所有邊)

優點:

  • 空間:O(V + E),稀疏圖高效
  • 遍歷鄰居:O(degree),只掃實際存在的邊
  • 動態加入頂點/邊:O(1)
  • 實務中最常用的圖表示法

缺點:

  • 查詢邊是否存在:O(degree),需線性掃描鄰居串列
  • 刪除邊:O(degree)

表示法 C — 邊串列(Edge List)

直接儲存所有邊的三元組 (src, dst, weight)

  [(0, 1, 4), (0, 2, 2), (1, 2, 3), (1, 3, 1), (2, 3, 5)]

空間:O(E)(只儲存邊,不儲存頂點的結構)

優點:

  • 空間:O(E),最省空間
  • 遍歷所有邊:O(E)
  • 按權重排序:直接 sort,Kruskal 最小生成樹的首選

缺點:

  • 查詢邊是否存在:O(E),需線性掃描
  • 遍歷特定頂點的鄰居:O(E)

三種表示法綜合比較

操作鄰接矩陣鄰接串列邊串列
空間O(V²)O(V + E)O(E)
查詢邊是否存在O(1)O(degree)O(E)
遍歷所有鄰居O(V)O(degree)O(E)
遍歷所有邊O(V²)O(V + E)O(E)
加入頂點O(V²) 重建O(1)O(1)
加入邊O(1)O(1)O(1)
刪除邊O(1)O(degree)O(E)
適合場景稠密圖、頻繁查邊、Floyd-Warshall稀疏圖、BFS/DFS、DijkstraKruskal MST、需排序邊

選擇原則: E ≈ V²(稠密圖)用鄰接矩陣;E ≪ V²(稀疏圖,即大部分實際問題)用鄰接串列;只需排序邊(如 Kruskal)用邊串列。


TypeScript / JavaScript 實作

核心 Graph 類別(鄰接串列,支援有向/無向/加權)

這是最通用的實作,支援有向圖與無向圖、加權與無權的各種組合:

// ─── 型別定義 ────────────────────────────────────────────
interface Edge {
  to: number;
  weight: number;
}

interface GraphOptions {
  directed: boolean;  // true = 有向圖,false = 無向圖
  weighted: boolean;  // true = 加權圖,false = 無加權(權重固定為 1)
}

// ─── 主類別:Graph(鄰接串列實作)───────────────────────
class Graph {
  private readonly adjList: Map<number, Edge[]>;
  private readonly options: GraphOptions;
  private _vertexCount: number;
  private _edgeCount: number;

  constructor(options: Partial<GraphOptions> = {}) {
    this.adjList = new Map();
    this.options = {
      directed: options.directed ?? true,
      weighted: options.weighted ?? false,
    };
    this._vertexCount = 0;
    this._edgeCount = 0;
  }

  // 新增頂點(若已存在則忽略)
  addVertex(v: number): void {
    if (!this.adjList.has(v)) {
      this.adjList.set(v, []);
      this._vertexCount++;
    }
  }

  // 新增邊:u → v(無向圖同時新增 v → u)
  addEdge(u: number, v: number, weight: number = 1): void {
    this.addVertex(u);
    this.addVertex(v);

    const w = this.options.weighted ? weight : 1;
    this.adjList.get(u)!.push({ to: v, weight: w });
    this._edgeCount++;

    if (!this.options.directed) {
      this.adjList.get(v)!.push({ to: u, weight: w });
      // 無向圖的 _edgeCount 不重複計算
    }
  }

  // 查詢邊是否存在:O(degree(u))
  hasEdge(u: number, v: number): boolean {
    const neighbors = this.adjList.get(u);
    if (!neighbors) return false;
    return neighbors.some(e => e.to === v);
  }

  // 取得頂點的所有鄰居
  getNeighbors(v: number): Edge[] {
    return this.adjList.get(v) ?? [];
  }

  // 取得所有頂點
  getVertices(): number[] {
    return [...this.adjList.keys()];
  }

  // 取得頂點的出度(有向圖)/ 度(無向圖)
  getDegree(v: number): number {
    return this.adjList.get(v)?.length ?? 0;
  }

  // 取得所有邊(三元組格式),無向圖只輸出一次
  getEdges(): Array<[number, number, number]> {
    const edges: Array<[number, number, number]> = [];
    for (const [u, neighbors] of this.adjList) {
      for (const { to, weight } of neighbors) {
        if (this.options.directed || u <= to) {
          edges.push([u, to, weight]);
        }
      }
    }
    return edges;
  }

  get vertexCount(): number { return this._vertexCount; }
  get edgeCount(): number   { return this._edgeCount; }
  get isDirected(): boolean { return this.options.directed; }

  // 列印鄰接串列(方便除錯)
  print(): void {
    for (const [v, neighbors] of this.adjList) {
      const edges = neighbors
        .map(e => this.options.weighted ? `(${e.to}, w=${e.weight})` : `${e.to}`)
        .join(', ');
      console.log(`${v}: [${edges}]`);
    }
  }
}

從邊串列建構圖(LeetCode 常見輸入格式)

LeetCode 的圖題通常給你 n(頂點數)和 edges(邊陣列),以下是兩種常見的建構方式:

// 從 LeetCode 常見的 number[][] 格式建構
// edges[i] = [u, v] 或 [u, v, w]
function buildGraphFromMatrix(
  n: number,
  edges: number[][],
  directed: boolean = false
): Graph {
  const g = new Graph({ directed, weighted: edges[0]?.length === 3 });
  // 確保所有頂點(0 到 n-1)都存在,即使是孤立頂點
  for (let i = 0; i < n; i++) g.addVertex(i);
  for (const edge of edges) {
    const [u, v, w = 1] = edge;
    g.addEdge(u, v, w);
  }
  return g;
}

// 使用範例:建構有向加權圖
const g = buildGraphFromMatrix(4, [
  [0, 1, 4],
  [0, 2, 2],
  [1, 2, 3],
  [1, 3, 1],
  [2, 3, 5],
], true /* directed */);

g.print();
// 輸出:
// 0: [(1, w=4), (2, w=2)]
// 1: [(2, w=3), (3, w=1)]
// 2: [(3, w=5)]
// 3: []

console.log(g.hasEdge(0, 1)); // 輸出:true
console.log(g.hasEdge(1, 0)); // 輸出:false(有向圖)
console.log(g.getDegree(1));  // 輸出:2(出度 = 2)

鄰接矩陣實作(適合稠密圖 / Floyd-Warshall)

// ─── 鄰接矩陣(稠密圖、Floyd-Warshall 使用)──────────────
const INF = Infinity;

class AdjacencyMatrix {
  private matrix: number[][];
  private readonly n: number;
  private readonly directed: boolean;

  constructor(n: number, directed: boolean = true) {
    this.n = n;
    this.directed = directed;
    // 初始化:無邊用 INF(加權圖);對角線為 0(自身距離)
    this.matrix = Array.from(
      { length: n },
      () => new Array(n).fill(INF)
    );
    for (let i = 0; i < n; i++) this.matrix[i][i] = 0;
  }

  addEdge(u: number, v: number, weight: number = 1): void {
    this.matrix[u][v] = weight;
    if (!this.directed) this.matrix[v][u] = weight;
  }

  hasEdge(u: number, v: number): boolean {
    return this.matrix[u][v] !== INF && u !== v;
  }

  getWeight(u: number, v: number): number {
    return this.matrix[u][v];
  }

  // 遍歷頂點 u 的所有鄰居:O(V)(需掃描整行)
  getNeighbors(u: number): Array<{ to: number; weight: number }> {
    const result: Array<{ to: number; weight: number }> = [];
    for (let v = 0; v < this.n; v++) {
      if (v !== u && this.matrix[u][v] !== INF) {
        result.push({ to: v, weight: this.matrix[u][v] });
      }
    }
    return result;
  }

  // 取得原始矩陣(Floyd-Warshall 需要直接操作)
  getMatrix(): number[][] {
    return this.matrix.map(row => [...row]); // 深拷貝
  }

  print(): void {
    const header = '     ' + [...Array(this.n).keys()]
      .map(i => String(i).padStart(5)).join('');
    console.log(header);
    for (let i = 0; i < this.n; i++) {
      const row = this.matrix[i]
        .map(v => (v === INF ? '  INF' : String(v).padStart(5)))
        .join('');
      console.log(`${String(i).padStart(3)}: ${row}`);
    }
  }
}

// 使用範例
const am = new AdjacencyMatrix(4, true);
am.addEdge(0, 1, 4);
am.addEdge(0, 2, 2);
am.addEdge(1, 2, 3);
am.addEdge(1, 3, 1);
am.addEdge(2, 3, 5);
am.print();
// 輸出:
//        0    1    2    3
//   0:   0    4    2  INF
//   1: INF    0    3    1
//   2: INF  INF    0    5
//   3: INF  INF  INF    0

邊串列實作(適合 Kruskal MST)

// ─── 邊串列(Kruskal 等需要排序邊的演算法)──────────────
interface WeightedEdge {
  u: number;
  v: number;
  weight: number;
}

class EdgeList {
  private edges: WeightedEdge[] = [];
  private readonly directed: boolean;

  constructor(directed: boolean = false) {
    this.directed = directed;
  }

  addEdge(u: number, v: number, weight: number = 1): void {
    this.edges.push({ u, v, weight });
  }

  // 按權重升序排列(Kruskal MST 的前置步驟)
  sortByWeight(): WeightedEdge[] {
    return [...this.edges].sort((a, b) => a.weight - b.weight);
  }

  getEdges(): WeightedEdge[] {
    return [...this.edges];
  }

  get edgeCount(): number {
    return this.edges.length;
  }
}

// 使用範例
const el = new EdgeList(false /* 無向 */);
el.addEdge(0, 1, 4);
el.addEdge(0, 2, 2);
el.addEdge(1, 2, 3);
el.addEdge(1, 3, 1);
el.addEdge(2, 3, 5);

const sorted = el.sortByWeight();
console.log(sorted.map(e => `(${e.u},${e.v},${e.weight})`));
// 輸出:["(1,3,1)", "(0,2,2)", "(1,2,3)", "(0,1,4)", "(2,3,5)"]
// 這正是 Kruskal 演算法的處理順序

字串頂點版本(社交網路、Web 爬蟲)

// ─── 字串頂點版本(頂點名稱為字串的場景)──────────────
class StringGraph {
  private adjList: Map<string, Map<string, number>>;
  private readonly directed: boolean;

  constructor(directed: boolean = true) {
    this.adjList = new Map();
    this.directed = directed;
  }

  addVertex(v: string): void {
    if (!this.adjList.has(v)) {
      this.adjList.set(v, new Map());
    }
  }

  addEdge(u: string, v: string, weight: number = 1): void {
    this.addVertex(u);
    this.addVertex(v);
    this.adjList.get(u)!.set(v, weight);
    if (!this.directed) {
      this.adjList.get(v)!.set(u, weight);
    }
  }

  // O(1) 查邊(Map 的 has 是 O(1) 攤銷)
  hasEdge(u: string, v: string): boolean {
    return this.adjList.get(u)?.has(v) ?? false;
  }

  getNeighbors(v: string): string[] {
    return [...(this.adjList.get(v)?.keys() ?? [])];
  }

  getVertices(): string[] {
    return [...this.adjList.keys()];
  }
}

// 使用範例:Web 爬蟲的有向圖
const webGraph = new StringGraph(true /* 有向 */);
webGraph.addEdge('home', 'products');
webGraph.addEdge('home', 'about');
webGraph.addEdge('products', 'cart');
webGraph.addEdge('products', 'home'); // 「返回首頁」連結

console.log(webGraph.hasEdge('home', 'products')); // 輸出:true
console.log(webGraph.hasEdge('products', 'home')); // 輸出:true(有「返回」連結)
console.log(webGraph.getNeighbors('home'));         // 輸出:["products", "about"]

// 使用範例:社交網路的無向圖
const social = new StringGraph(false /* 無向 */);
social.addEdge('Alice', 'Bob');
social.addEdge('Alice', 'Carol');
social.addEdge('Bob', 'Dave');
social.addEdge('Carol', 'Dave');

console.log(social.getNeighbors('Alice')); // 輸出:["Bob", "Carol"]
// BFS 即可求兩人之間的最短朋友鏈(六度分隔理論)

C++ 對照實作

C++ 中有三種常見的圖表示法對應,分別適用不同的場景:

#include <climits>
#include <iostream>
#include <utility>
#include <vector>
#include <unordered_map>
#include <algorithm>

// ─── 鄰接串列:vector<vector<pair<int,int>>> ─────────────
// LeetCode 最常見的圖表示形式
// adjList[u] = { (v1, w1), (v2, w2), ... }
using AdjList = std::vector<std::vector<std::pair<int, int>>>;

class GraphVec {
  AdjList adj;
  int n;
  bool directed;

 public:
  explicit GraphVec(int vertices, bool directed = true)
      : adj(vertices), n(vertices), directed(directed) {}

  void addEdge(int u, int v, int weight = 1) {
    adj[u].emplace_back(v, weight);
    if (!directed) adj[v].emplace_back(u, weight);
  }

  bool hasEdge(int u, int v) const {
    for (const auto& [to, w] : adj[u]) {
      if (to == v) return true;
    }
    return false;
  }

  const std::vector<std::pair<int, int>>& getNeighbors(int u) const {
    return adj[u];
  }

  void print() const {
    for (int u = 0; u < n; ++u) {
      std::cout << u << ": ";
      for (const auto& [v, w] : adj[u]) {
        std::cout << "(" << v << "," << w << ") ";
      }
      std::cout << "\n";
    }
  }
};

// ─── 鄰接矩陣版本(稠密圖、Floyd-Warshall)──────────────
constexpr int INF_W = INT_MAX / 2; // 避免加法溢位

class AdjacencyMatrixGraph {
  std::vector<std::vector<int>> mat;
  int n;
  bool directed;

 public:
  explicit AdjacencyMatrixGraph(int vertices, bool directed = true)
      : mat(vertices, std::vector<int>(vertices, INF_W)),
        n(vertices), directed(directed) {
    for (int i = 0; i < n; ++i) mat[i][i] = 0;
  }

  void addEdge(int u, int v, int weight = 1) {
    mat[u][v] = weight;
    if (!directed) mat[v][u] = weight;
  }

  bool hasEdge(int u, int v) const {
    return mat[u][v] != INF_W && u != v;
  }

  // Floyd-Warshall 全對最短路徑:O(V³)
  std::vector<std::vector<int>> floydWarshall() const {
    auto dist = mat;
    for (int k = 0; k < n; ++k) {       // 中繼點
      for (int i = 0; i < n; ++i) {     // 起點
        for (int j = 0; j < n; ++j) {   // 終點
          if (dist[i][k] != INF_W && dist[k][j] != INF_W) {
            dist[i][j] = std::min(dist[i][j], dist[i][k] + dist[k][j]);
          }
        }
      }
    }
    return dist;
  }
};

// ─── unordered_map 版本(動態頂點、字串頂點)───────────
template <typename V = int>
class GraphMap {
  using Neighbors = std::unordered_map<V, int>;
  std::unordered_map<V, Neighbors> adj;
  bool directed;

 public:
  explicit GraphMap(bool directed = true) : directed(directed) {}

  void addEdge(const V& u, const V& v, int weight = 1) {
    adj[u][v] = weight;
    if (!directed) adj[v][u] = weight;
  }

  // O(1) 平均查邊(unordered_map 雜湊查詢)
  bool hasEdge(const V& u, const V& v) const {
    auto it = adj.find(u);
    if (it == adj.end()) return false;
    return it->second.count(v) > 0;
  }

  std::vector<V> getNeighbors(const V& u) const {
    std::vector<V> result;
    auto it = adj.find(u);
    if (it != adj.end()) {
      for (const auto& [v, w] : it->second) {
        result.push_back(v);
      }
    }
    return result;
  }
};

// ─── 邊串列版本(Kruskal MST)────────────────────────────
struct WeightedEdge {
  int u, v, weight;
  bool operator<(const WeightedEdge& other) const {
    return weight < other.weight;
  }
};

class EdgeListGraph {
  std::vector<WeightedEdge> edges;
  int n;

 public:
  explicit EdgeListGraph(int vertices) : n(vertices) {}

  void addEdge(int u, int v, int weight = 1) {
    edges.push_back({u, v, weight});
  }

  // 按權重升序(Kruskal 演算法的前置步驟)
  std::vector<WeightedEdge> getSortedEdges() const {
    auto sorted = edges;
    std::sort(sorted.begin(), sorted.end());
    return sorted;
  }

  int vertexCount() const { return n; }
  int edgeCount()   const { return static_cast<int>(edges.size()); }
};

// ─── 使用範例 ────────────────────────────────────────────
int main() {
  // 有向加權圖(vector 版)
  GraphVec g(4, true);
  g.addEdge(0, 1, 4);
  g.addEdge(0, 2, 2);
  g.addEdge(1, 2, 3);
  g.addEdge(1, 3, 1);
  g.addEdge(2, 3, 5);
  g.print();
  // 0: (1,4) (2,2)
  // 1: (2,3) (3,1)
  // 2: (3,5)
  // 3:

  // Floyd-Warshall 最短路
  AdjacencyMatrixGraph mg(4, true);
  mg.addEdge(0, 1, 4); mg.addEdge(0, 2, 2);
  mg.addEdge(1, 2, 3); mg.addEdge(1, 3, 1); mg.addEdge(2, 3, 5);
  auto dist = mg.floydWarshall();
  std::cout << dist[0][3] << "\n"; // 輸出:5(0→1→3 = 4+1)

  // 字串頂點(unordered_map 版)
  GraphMap<std::string> sg(true);
  sg.addEdge("A", "B", 10);
  sg.addEdge("A", "C", 3);
  std::cout << sg.hasEdge("A", "B") << "\n"; // 輸出:1(true)

  return 0;
}

複雜度分析表

操作鄰接矩陣鄰接串列邊串列
空間O(V²)O(V + E)O(E)
初始化O(V²)O(V)O(1)
加入頂點O(V²) 重建O(1)O(1)
加入邊O(1)O(1)O(1)
刪除邊O(1)O(degree)O(E)
查詢邊 (u, v)O(1)O(degree)O(E)
遍歷頂點 u 的鄰居O(V)O(degree)O(E)
遍歷所有邊O(V²)O(V + E)O(E)
按權重排序邊O(V² log V²)O(E log E)O(E log E)

degree 指頂點的度(出度),在稀疏圖中遠小於 V,這正是鄰接串列遍歷鄰居比鄰接矩陣高效的原因。


變體與延伸

隱式圖(Implicit Graph)

並非所有圖的問題都需要顯式建構圖結構。隱式圖 指那些頂點和邊在程式執行時動態生成的圖——通常是棋盤格、狀態轉移、詞語轉換等問題。

// BFS 在棋盤格上(8 方向移動),不需要預先建圖
function bfsOnGrid(
  grid: number[][],
  start: [number, number],
  end: [number, number]
): number {
  const rows = grid.length;
  const cols = grid[0].length;
  const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]];
  const visited = Array.from({ length: rows }, () => new Array(cols).fill(false));
  const queue: [[number, number], number][] = [[start, 0]];
  visited[start[0]][start[1]] = true;

  while (queue.length > 0) {
    const [[r, c], dist] = queue.shift()!;
    if (r === end[0] && c === end[1]) return dist;

    for (const [dr, dc] of dirs) {
      const nr = r + dr;
      const nc = c + dc;
      if (nr >= 0 && nr < rows && nc >= 0 && nc < cols
          && !visited[nr][nc] && grid[nr][nc] === 0) {
        visited[nr][nc] = true;
        queue.push([[nr, nc], dist + 1]);
      }
    }
  }
  return -1;
}
// 頂點 = 格子座標,邊 = 相鄰且不是障礙的格子
// 不需要 Graph 物件,直接在 BFS 裡計算「鄰居」

隱式圖的優點: 不需要 O(V + E) 的前置建圖,節省空間;缺點是邏輯分散在遍歷程式碼中,較難維護。

鄰接多重串列(Adjacency Multilist)

鄰接多重串列是專為無向圖設計的最佳化表示法。在標準鄰接串列中,無向邊 (u, v) 在 adjList[u]adjList[v] 各存一份,刪除邊需要同時找到兩份記錄。鄰接多重串列讓每條邊只存一份,透過雙向指針讓 u 和 v 都能找到它,使刪除邊的操作 O(1) 化。這在需要頻繁刪邊的場景(如某些網路流問題)中有顯著優勢,但實作複雜度較高,LeetCode 題目中較少直接要求。


面試考點

選擇表示法的決策框架

面試官最喜歡問的圖相關設計題,往往落在「如何建圖」的選擇上。以下是一個快速決策框架:

問題 1:圖有多少頂點和邊?
  → E ≈ V²(稠密) ─────→ 鄰接矩陣
  → E ≪ V²(稀疏) ─────→ 往下看

問題 2:需要頻繁查詢某條邊是否存在?
  → 是 ─────→ 考慮鄰接矩陣(O(1) 查詢)
               或 adjList 改用 Set/Map 儲存(O(1) 攤銷)
  → 否 ─────→ 鄰接串列

問題 3:需要按邊的權重排序或只遍歷所有邊?
  → 是(如 Kruskal MST) ─────→ 邊串列

問題 4:頂點數量動態增長?
  → 是 ─────→ Map / unordered_map 版本的鄰接串列
  → 否(頂點數已知)─→ vector<vector<>> 版本

常見陷阱

// 陷阱 1:孤立頂點(Isolated Vertex)遺漏
// 從邊串列建圖時,沒有邊的頂點容易被遺忘
// ❌ 錯誤:只有 [0,1],[1,2] 這兩條邊的圖,頂點 3 不存在
function badBuildGraph(n: number, edges: number[][]): Graph {
  const g = new Graph({ directed: false });
  for (const [u, v] of edges) g.addEdge(u, v);
  return g; // 若頂點 3 沒有邊,adjList 中不會有 key=3
}

// ✅ 正確:先初始化所有頂點
function goodBuildGraph(n: number, edges: number[][]): Graph {
  const g = new Graph({ directed: false });
  for (let i = 0; i < n; i++) g.addVertex(i); // 確保所有頂點存在
  for (const [u, v] of edges) g.addEdge(u, v);
  return g;
}

// 陷阱 2:無向圖重複計算邊數
// adjList[u] 和 adjList[v] 各存一份,若直接加總得到的是實際邊數的 2 倍
// ✅ 正確計法:只計 u < v 的邊,或用獨立的 _edgeCount 追蹤

// 陷阱 3:鄰接矩陣初始化為 0 導致加權圖錯誤
// ❌ 錯誤:matrix 初始化為 0,但 0 是有效權重!
const badMatrix = Array.from({ length: 4 }, () => new Array(4).fill(0));
// 查詢 hasEdge(0,1) 時無法分辨「無邊」與「邊的權重=0」

// ✅ 正確:初始化為 Infinity(TypeScript)或 INT_MAX/2(C++)
const goodMatrix = Array.from({ length: 4 }, () => new Array(4).fill(Infinity));

// 陷阱 4:有向圖混淆入度/出度
// adjList[u].length 是頂點 u 的「出度」,不是「入度」
// 計算入度需要額外遍歷所有邊,或建圖時維護 inDegree[] 陣列
function computeInDegrees(n: number, edges: number[][]): number[] {
  const inDegree = new Array(n).fill(0);
  for (const [, v] of edges) {
    inDegree[v]++;  // 每條邊 u→v 讓 v 的入度加 1
  }
  return inDegree;
}

LeetCode 練習

#題目難度圖的核心操作
133Clone GraphMediumBFS/DFS 遍歷 + 建構鄰接串列
207Course ScheduleMedium有向圖偵測環(拓撲排序基礎)
997Find the Town JudgeEasy計算入度/出度
1971Find if Path Exists in GraphEasyBFS/DFS 判斷連通性
785Is Graph Bipartite?Medium圖的二分性驗證(染色)

LeetCode 997 — Find the Town Judge(詳解):

// 題目:找出「鎮長」:被其他所有人信任(入度 n-1),但不信任任何人(出度 0)
function findJudge(n: number, trust: number[][]): number {
  const inDegree  = new Array(n + 1).fill(0); // 被信任次數
  const outDegree = new Array(n + 1).fill(0); // 信任他人次數

  for (const [a, b] of trust) {
    outDegree[a]++; // a 信任 b → a 的出度加 1
    inDegree[b]++;  // a 信任 b → b 的入度加 1
  }

  for (let i = 1; i <= n; i++) {
    // 鎮長:出度為 0(不信任任何人)且入度為 n-1(被所有人信任)
    if (outDegree[i] === 0 && inDegree[i] === n - 1) return i;
  }

  return -1;
}

console.log(findJudge(2, [[1, 2]]));                     // 輸出:2
console.log(findJudge(3, [[1,3],[2,3]]));                // 輸出:3
console.log(findJudge(3, [[1,3],[2,3],[3,1]]));          // 輸出:-1

LeetCode 1971 — Find if Path Exists in Graph(詳解):

// 題目:無向圖中判斷 source 與 destination 是否連通(BFS 版本)
function validPath(
  n: number,
  edges: number[][],
  source: number,
  destination: number
): boolean {
  if (source === destination) return true;

  // 建構鄰接串列
  const adj: number[][] = Array.from({ length: n }, () => []);
  for (const [u, v] of edges) {
    adj[u].push(v);
    adj[v].push(u); // 無向圖
  }

  // BFS 從 source 出發,看能否到達 destination
  const visited = new Array(n).fill(false);
  const queue = [source];
  visited[source] = true;

  while (queue.length > 0) {
    const curr = queue.shift()!;
    for (const next of adj[curr]) {
      if (next === destination) return true;
      if (!visited[next]) {
        visited[next] = true;
        queue.push(next);
      }
    }
  }

  return false;
}

console.log(validPath(3, [[0,1],[1,2],[2,0]], 0, 2)); // 輸出:true
console.log(validPath(6, [[0,1],[0,2],[3,5],[5,4],[4,3]], 0, 5)); // 輸出:false

總結

圖的表示法是所有圖演算法的起點。三種表示法各有最適合的場景:鄰接矩陣 用於稠密圖和頻繁查邊;鄰接串列 是稀疏圖的首選,覆蓋 BFS、DFS、Dijkstra 等大部分演算法;邊串列 在需要排序邊時(如 Kruskal MST)最為直接。

選擇表示法的核心問題只有一個:E 是否遠小於 V²? 現實世界的圖大多是稀疏的,所以鄰接串列是你的預設答案,除非有明確的理由選其他表示法。

建圖時有四個常見陷阱值得銘記:孤立頂點的初始化、無向圖邊數的重複計算、鄰接矩陣初始值不應為 0、有向圖的入度與出度之別。

下一篇將深入探討 BFS(廣度優先搜尋)與 DFS(深度優先搜尋)——這是圖演算法中最基礎的兩種遍歷策略,幾乎所有圖的問題都從這兩個方向之一出發。有了這篇打下的圖結構基礎,學習 BFS 和 DFS 將更加得心應手。

希望這篇文章能幫助你徹底理解圖的三種表示法,以及在面試中做出正確的設計選擇。如有任何問題或疑惑,歡迎至 Contact 頁面 留言討論!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。