National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 16

圖的表示法

鄰接矩陣、鄰接串列與邊列表:空間與操作成本的取捨
Interaction實作 25 minute(s)

Learning Objectives

  • 分辨有向圖、無向圖與加權圖,算出節點的入度與出度
  • 用邊列表、鄰接矩陣、鄰接串列存同一張圖,說出每一種在記憶體裡存了什麼
  • 比較三種表示法的空間(O(E)、O(V²)、O(V + E))與查一條邊、列出鄰居、算入度的成本
  • 依圖是稠密還是稀疏、程式最常做哪一種操作,選擇表示法
  • 把一種表示法轉換成另一種,處理無向邊在矩陣與串列裡出現兩次的問題

同一張圖,三種存法

捷運路線、社群網站的追蹤關係、課程的先修關係、網頁之間的連結,都可以畫成圖(graph): 一群節點(node,也叫頂點 vertex)加上連接它們的邊(edge)。

畫在紙上的圖一眼就看得懂,程式卻只能把它存成陣列、串列這些結構。常見的存法有三種:

表示法 存什麼
邊列表(edge list) 一筆一筆列出每條邊:「從哪裡到哪裡、權重多少」
鄰接矩陣(adjacency matrix) V × V 的表格,第 u 列第 v 行記錄 u 到 v 有沒有邊
鄰接串列(adjacency list) 每個節點一個串列,列出它連到哪些節點

三種存的是同一張圖,差別在佔多少記憶體、哪些操作快、哪些操作慢。這一課把三種都做出來,量一量各自的成本。

這一課只談「怎麼存」。在圖上走訪的演算法(廣度優先搜尋 BFS、深度優先搜尋 DFS)請看演算法軌第 16、17 課, 那兩課用的就是這裡的鄰接串列。

有向、無向、加權

先把幾個名詞說清楚。V 是節點數、E 是邊數,節點編號用 0 到 V - 1。

  • 無向圖(undirected graph):邊沒有方向,u — v 表示兩人互相是朋友。度數(degree)是連著幾條邊。
  • 有向圖(directed graph):邊有方向,u → v 表示 u 追蹤 v,不代表 v 追蹤 u。 從 u 出去的邊數叫出度(out-degree),進入 v 的邊數叫入度(in-degree)。
  • 加權圖(weighted graph):每條邊帶一個數字,例如距離、時間、票價。

邊數有上限。不算自己連到自己的邊(自環)時:

最多幾條邊
無向圖 V(V - 1) / 2(每兩個節點最多一條)
有向圖 V(V - 1)(每兩個節點兩個方向各一條)

E 接近這個上限的圖叫稠密圖(dense graph),E 遠小於上限(例如 E 和 V 差不多大)的叫稀疏圖(sparse graph)。 後面選表示法時,這個區別最重要。

接下來三個範例都用同一張有向加權圖:5 個節點、6 條邊,例如 0 → 1 的權重是 4。

邊列表:一筆一筆列出邊

邊列表就是一個 E × 3 的表格,每一列是一條邊 [u, v, w]。算出度與入度時,每一筆邊讓起點的出度、終點的入度各加 1。後半段查「2 → 3 有沒有邊」:邊列表沒有任何索引,只能從第 0 筆往下找;查不存在的 3 → 2 時,6 筆全部看完才能確定。

邊列表適合做什麼

邊列表的空間是 O(E):有幾條邊就存幾筆,沒有任何浪費。它也是最自然的輸入格式, 很多資料檔就是一行一條邊(例如 CSV 的「起點,終點,權重」)。

但它沒有依節點整理,所以任何「和某個節點有關」的問題都要掃過全部 E 筆:

操作 邊列表
u → v 有沒有邊 O(E)
列出 u 的鄰居 O(E)
算 v 的入度或出度 O(E)
加一條邊 O(1)(接到尾端)

適合邊列表的場合:只需要「把所有邊看過一遍」的演算法。例如把邊依權重排序、一條一條決定要不要選的 Kruskal 最小生成樹(演算法軌第 19 課)。 需要常常問「u 連到誰」時,就該換成下面兩種。

鄰接矩陣:V × V 的表格

有向圖的每條邊只填一格:m[u][v] 放 u → v 的權重,所以矩陣不對稱,m[2][3] 是 8、m[3][2] 是 0。查一條邊只要看一格;但列出 2 的鄰居要把第 2 列(橫的)5 格全部看完,算 1 的入度要把第 1 行(直的)5 格看完,不管 2 實際上有幾個鄰居。

鄰接矩陣在記憶體裡

鄰接矩陣是一個二維陣列,C++ 的 int m[V][V] 或 Java 的 new int[V][V] 依列優先一列接一列存放(第 02 課的位址公式)。 不管有幾條邊,都要 V × V 格,空間 O(V²)。

操作 鄰接矩陣
u → v 有沒有邊 O(1),看 m[u][v] 一格
列出 u 的鄰居 O(V),看第 u 列整列
算 v 的入度 O(V),看第 v 行整行
加邊、刪邊 O(1)

幾個實作細節:

  • 無向圖的矩陣左右對稱,加一條邊要改 m[u][v] 和 m[v][u] 兩格。只存一半(上三角)可以省下將近一半空間, 但每次讀取都要先比較 u、v 的大小,程式比較囉唆。
  • 權重 0 的問題:範例用 0 表示「沒有邊」,前提是權重不會是 0。權重可能是 0 或負數時, 沒有邊要改用一個不可能出現的值(例如很大的數當作無限大),或另外存一個布林矩陣。
  • 不需要權重時可以一格一個位元:用位元陣列(bitset)存,V = 10,000 時是 10⁸ 個位元,約 12.5 MB; 用 int 存要 400 MB。

鄰接串列:每個節點一個串列

adj[u] 只存從 u 出去的邊,每一筆是 [終點, 權重]。列出 2 的鄰居只要走過 adj[2] 的 2 筆,和節點總數無關。代價在入度:鄰接串列只記「從哪裡出去」,要知道「誰指向我」只能把每個串列都掃一遍。

鄰接串列在記憶體裡

鄰接串列是「V 個串列」:一個長度 V 的陣列放每個串列的開頭,加上所有鄰居的資料。 有向圖一共 E 筆鄰居,無向圖每條邊在兩端各記一次,一共 2E 筆。空間是 O(V + E)。

操作 鄰接串列
u → v 有沒有邊 O(deg(u)),掃過 adj[u]
列出 u 的鄰居 O(deg(u))
算 u 的出度 O(1),就是 adj[u] 的長度
算 v 的入度 O(V + E),掃過全部串列
加一條邊 O(1),接到尾端
刪一條邊 O(deg(u)),先找到它

每個串列可以用動態陣列(Python 的 list、C++ 的 vector、Java 的 ArrayList,第 03 課)或鏈結串列(第 06 課)。 動態陣列比較常用:同一個節點的鄰居連續存放,走訪時快取命中率高。

常常要算入度、或要沿著邊「倒著走」的程式,會另外存一份反向圖:radj[v] 列出所有指向 v 的節點。 空間多一倍,換來兩個方向都是 O(deg)。這一課最後的實作就是建反向圖。

互相轉換:無向圖的矩陣變成串列與邊列表

這一次的圖是無向圖,矩陣左右對稱:0 — 1 這條邊同時出現在 m[0][1] 和 m[1][0]。轉成鄰接串列時,矩陣的每個 1 都變成一筆鄰居,所以每條邊在兩端各出現一次;轉成邊列表時只收 u < v 的那一格,每條邊只記一次。轉換要看過整個矩陣,O(V²)。

三種表示法放在一起比

V 是節點數、E 是邊數、deg(u) 是 u 的鄰居數(有向圖指出度)。

邊列表 鄰接矩陣 鄰接串列
空間 O(E) O(V²) O(V + E)
u → v 有沒有邊 O(E) O(1) O(deg(u))
列出 u 的鄰居 O(E) O(V) O(deg(u))
算 v 的入度(有向圖) O(E) O(V) O(V + E)
加一條邊 O(1) O(1) O(1)
走過所有的邊 O(E) O(V²) O(V + E)

互相轉換的成本:從邊列表建另外兩種是 O(E)(矩陣還要先花 O(V²) 填 0);從矩陣轉出去要看過全部 V² 格; 從鄰接串列轉成邊列表是 O(V + E)。

怎麼選看兩件事:圖是稀疏還是稠密,以及程式最常做哪一種操作。

  • 稀疏圖用鄰接串列。實際的圖大多是稀疏的:道路網的路口平均只連著三、四條路,社群網站的人也只追蹤一小部分的人。 V = 1,000,000、平均每個節點 10 個鄰居時,鄰接串列約存一千萬筆鄰居;鄰接矩陣要 10¹² 格,用 int 存要 4 TB,根本放不進記憶體。
  • 稠密圖、或節點很少又常常要問「u 和 v 有沒有直接相連」,用鄰接矩陣。E 接近 V² 時,鄰接串列也要存約 V² 筆, 而且每筆還要額外的空間(串列的結構、指標),矩陣反而比較省,查邊又是 O(1)。
  • 只需要把邊整批處理一遍,用邊列表:讀檔、依權重排序、Kruskal。

很多程式會同時存兩種:讀進來是邊列表,建一份鄰接串列給走訪用,必要時再加一份反向圖。多花空間,換取每一種操作都快。

動手做:三種表示法同步

上方的圖和下面三種表示法是同一份資料。選好 u、v 之後加邊、刪邊(也可以直接點鄰接矩陣的格子),三種表示法會一起變;按「查詢」的按鈕,每一種表示法實際看過的格子會標出來,表格最下面比較各看了幾格、各佔幾格記憶體。

想一想:空間與操作成本

1.

一張無向圖有 V = 1,000 個節點、E = 3,000 條邊。鄰接矩陣與鄰接串列各存多少?

2.

有向圖用鄰接串列存(adj[u] 只列出從 u 出去的邊),下面哪一個操作最慢?

3.

5 個節點的有向圖(不含自環)最多可以有幾條邊?

第 1 次作答

更緊湊的鄰接串列:兩個陣列

圖建好之後不再改變時,常把鄰接串列壓成兩個一維陣列,叫做 CSR(compressed sparse row):

  • to:把 adj[0]、adj[1]、…、adj[V - 1] 的鄰居依序接成一個長度 E 的陣列
  • start:長度 V + 1,start[u] 是 u 的鄰居在 to 裡從哪裡開始;u 的鄰居就是 to[start[u]] 到 to[start[u + 1] - 1]

以前面的有向圖為例:

adj[0] = [1, 2]   adj[1] = [3]   adj[2] = [1, 3]   adj[3] = [4]   adj[4] = []
start = [0, 2, 3, 5, 6, 6]
to    = [1, 2, 3, 1, 3, 4]
for k in range(start[u], start[u + 1]):    # 列出 u 的鄰居
    print(to[k])

空間一樣是 O(V + E),但沒有 V 個獨立的串列物件,鄰居全部連續存放,走訪時記憶體存取很規律。 代價是不能隨意加邊:中間插入一筆,後面全部要往後搬。大型的圖運算(例如稀疏矩陣計算、處理數百萬節點的圖)常用這種格式。

想一想:讀程式、選表示法

1.

下面的程式印出什麼?

m = [[0, 1, 0],
     [0, 0, 1],
     [1, 1, 0]]
cnt = 0
for u in range(3):
    cnt += m[u][1]
print(cnt)
2.

下面的程式把無向圖的邊列表轉成鄰接串列,印出什麼?

edges = [[0, 2], [1, 2], [0, 1]]
adj = [[] for _ in range(3)]
for u, v in edges:
    adj[u].append(v)
    adj[v].append(u)
print(adj[2], adj[0])
3.

一張 50 個節點、幾乎每兩點之間都有邊的圖,程式要反覆查「u 和 v 之間有沒有直接相連」。用哪一種表示法最合適?

第 1 次作答

實作:建出反向圖

adj 是一張有向圖的鄰接串列。請完成 reverse_graph:對每一條 u → v,把 u 加進 radj[v],建好之後 radj[v] 就是「所有指向 v 的節點」,入度就是 radj[v] 的長度。依 u 由小到大處理,印出來的順序才會和預期輸出相同。

你的輸出
預期輸出

重點整理

  • 邊列表:一筆一筆列出邊,空間 O(E);和某個節點有關的問題都要掃全部的邊。適合讀檔、排序邊、Kruskal
  • 鄰接矩陣:V × V 表格,空間 O(V²);查一條邊 O(1),列出鄰居 O(V)。無向圖左右對稱;權重可能是 0 時要另選「沒有邊」的記號
  • 鄰接串列:每個節點一個串列,空間 O(V + E);列出鄰居 O(deg(u))。無向邊在兩端各記一次;有向圖算入度要掃全部,常另存反向圖
  • 稀疏圖用鄰接串列,稠密圖或常查邊的小圖用鄰接矩陣;必要時同時存兩種
  • 圖不再改變時,鄰接串列可以壓成 start、to 兩個陣列(CSR)

下一課:區間查詢。同樣是「同一份資料、不同的存法讓不同操作變快」,主角換成線段樹與 Fenwick 樹。

這一課完成了!
Next lesson:區間查詢:線段樹與 Fenwick 樹
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。