Thuật toán Cốt lõi — Tìm kiếm, Sắp xếp & Đồ thị/Graph representation — Adjacency list vs matrix, trade-off bộ nhớ
25/36
Bài 25 / 36~20 phútĐường đi & quan hệ — Graph algorithmsMiễn phí lượt xem

Graph representation — Adjacency list vs matrix, trade-off bộ nhớ

Adjacency list hay matrix ảnh hưởng RAM và time complexity mỗi op. So sánh 3 representation, pseudocode, rule of thumb cho social network, road map, dependency graph.

TL;DR: Graph lưu trong bộ nhớ theo 3 cách chính: adjacency matrix (bảng V×V), adjacency list (mỗi đỉnh giữ danh sách hàng xóm), và edge list (danh sách tất cả cạnh). Với graph 1 triệu đỉnh, matrix cần ~1 TB RAM trong khi list chỉ cần vài trăm MB — vì hầu hết real-world graph là sparse (ít cạnh so với V²). Pitfall lớn nhất: mặc định chọn matrix "vì đơn giản" rồi gặp OutOfMemory ngay khi V vượt vài nghìn.

Module 3 dạy graph algorithms — BFS, DFS, Dijkstra, MST. Tất cả đều bắt đầu bằng cùng một câu hỏi: lưu graph thế nào trong bộ nhớ?

Nghe tưởng đơn giản, nhưng choice này ảnh hưởng mạnh đến cả RAM lẫn thời gian chạy. Một graph 1 triệu đỉnh: adjacency matrix cần 1.000.000² ô = 10¹² byte ≈ 1 TB RAM — bất khả thi dù graph có ít cạnh. Adjacency list chỉ lưu các cạnh thực tế: với sparse graph (social network, road map — mỗi đỉnh vài cạnh) chỉ dùng vài trăm MB. Lookup một cạnh: matrix O(1), list O(degree). Matrix chỉ đáng dùng khi graph dense (số cạnh gần V²) và V nhỏ.

Bài này so sánh 2 representation chính (adjacency list, adjacency matrix), variant edge list, pseudocode cho từng cái, và rule of thumb chọn đúng cho production graph.

1. Analogy — Sổ liên lạc lớp học

Hình dung một lớp 30 học sinh, mỗi học sinh có một số quan hệ bạn bè với nhau (tổng cộng 100 cặp bạn trong lớp).

Adjacency matrix = bảng 30×30, mỗi ô đánh dấu "X có quen Y không". Tốn đúng 900 ô dù chỉ có 100 quan hệ — 800 ô trống hoàn toàn lãng phí.

Adjacency list = mỗi học sinh giữ danh sách bạn riêng. Tổng kích thước tất cả các list = 2 × 100 = 200 entry (mỗi cặp đếm 2 lần vì undirected). Không tốn ô trống.

Edge list = 1 danh sách khổng lồ ghi "X-Y" cho từng cặp bạn. Tra "X có bạn nào?" phải scan toàn bộ danh sách.

Sổ liên lạcGraph representation
Bảng 30×30, ô tick X quen YAdjacency matrix: V×V boolean/int
Mỗi HS giữ danh sách bạn riêngAdjacency list: mỗi đỉnh giữ list hàng xóm
1 list "X-Y" cho từng cặp bạnEdge list: list of (u, v, weight)
900 ô dù chỉ 100 quan hệMatrix: O(V²) space dù ít cạnh
Total list = 2×số quan hệList: O(V + E) space
💡 Cách nhớ

Matrix = bảng vuông cố định V×V, nhanh lookup nhưng tốn RAM khi graph sparse. List = chỉ lưu cạnh thực sự có, tốt cho sparse graph — hầu hết real-world graph là sparse.

2. Định nghĩa graph

Graph G = (V, E): V là tập đỉnh (vertices/nodes), E là tập cạnh (edges).

  • Directed graph: cạnh (u, v) khác (v, u) — đường một chiều. Ví dụ: Twitter follow, dependency DAG.
  • Undirected graph: cạnh {u, v} = {v, u} — hai chiều. Ví dụ: Facebook friendship, đường hai chiều.
  • Weighted graph: mỗi cạnh có trọng số (số thực). Ví dụ: khoảng cách km, travel time, bandwidth.
  • Self-loop: cạnh từ đỉnh tới chính nó — (u, u).
  • Multi-edge: nhiều cạnh giữa cùng 2 đỉnh.

Real-world mapping:

Hệ thốngLoại graph
Twitter followDirected (follow khác nhau 2 chiều)
Facebook friendshipUndirected
Google Maps roadWeighted directed (one-way street)
Maven/Gradle dependencyDAG — directed acyclic graph
LinkedIn connectionsUndirected weighted

3. Adjacency Matrix

Ma trận V×V, ô adj[u][v] = 1 (hoặc true) nếu có cạnh từ u đến v.

-- Khởi tạo adjacency matrix cho graph V đỉnh
GraphMatrix(V):
    adj[0..V-1][0..V-1] <- 0      -- toàn bộ ô khởi tạo bằng 0

-- Thêm cạnh (undirected)
addEdge(u, v):
    adj[u][v] <- 1
    adj[v][u] <- 1                 -- bỏ dòng này nếu directed

-- Kiểm tra cạnh
hasEdge(u, v):
    return adj[u][v]               -- O(1)

-- Lấy danh sách hàng xóm
neighbors(u):
    result <- []
    for v <- 0 đến V-1:
        if adj[u][v] = 1:
            result.append(v)
    return result                  -- phải scan toàn hàng — O(V)

Time: O(1) cho hasEdge Space: O(V²)

Ví dụ matrix 5 đỉnh (0–4), 6 cạnh: 0-1, 0-2, 1-3, 2-3, 3-4, 1-4:

     0   1   2   3   4
0  [ .   X   X   .   . ]
1  [ X   .   .   X   X ]
2  [ X   .   .   X   . ]
3  [ .   X   X   .   X ]
4  [ .   X   .   X   . ]
graph LR
    subgraph Ma trận kề — 5 đỉnh
        A0((0)) --- A1((1))
        A0 --- A2((2))
        A1 --- A3((3))
        A1 --- A4((4))
        A2 --- A3
        A3 --- A4
    end

Complexity:

OperationMatrix
SpaceO(V²)
Edge lookupO(1)
Iterate neighborsO(V) — scan toàn hàng
Add edgeO(1)
Remove edgeO(1)

Dùng khi: graph dense (E gần V²), cần edge lookup O(1) liên tục, hoặc V đủ nhỏ để matrix vừa RAM.

4. Adjacency List

Mỗi đỉnh giữ một list các hàng xóm. Chỉ lưu cạnh thực sự tồn tại.

-- Khởi tạo adjacency list cho graph V đỉnh
GraphList(V):
    adj[0..V-1] <- [] cho mọi đỉnh   -- mỗi đỉnh có list rỗng

-- Thêm cạnh (undirected)
addEdge(u, v):
    adj[u].append(v)
    adj[v].append(u)                  -- bỏ dòng này nếu directed

-- Kiểm tra cạnh
hasEdge(u, v):
    return v trong adj[u]             -- O(degree(u))

-- Lấy danh sách hàng xóm
neighbors(u):
    return adj[u]                     -- O(1) trả về, duyệt O(degree)

Time: O(degree) cho hasEdge Space: O(V + E)

Với weighted graph, mỗi entry trong list là cặp (đỉnh đích, trọng số):

-- Thêm cạnh có trọng số (undirected)
addEdge(u, v, weight):
    adj[u].append((v, weight))
    adj[v].append((u, weight))

-- Duyệt cạnh có trọng số
for each (v, w) trong adj[u]:
    -- v là đỉnh đích, w là trọng số cạnh

Time: O(V + E) Space: O(V + E)

graph TD
    subgraph Adjacency List — 5 đỉnh
        V0["Đỉnh 0: [1, 2]"]
        V1["Đỉnh 1: [0, 3, 4]"]
        V2["Đỉnh 2: [0, 3]"]
        V3["Đỉnh 3: [1, 2, 4]"]
        V4["Đỉnh 4: [1, 3]"]
    end

Complexity:

OperationAdjacency list
SpaceO(V + E)
Edge lookupO(degree)
Iterate neighborsO(degree)
Add edgeO(1)
Remove edgeO(degree)

Dùng khi: graph sparse (E gần V), cần duyệt hàng xóm nhanh (BFS, DFS, Dijkstra). Đây là default choice cho hầu hết real-world graph.

5. Edge List

Chỉ một list các cạnh — không phân theo đỉnh nào. Space O(E), tra hàng xóm O(E).

-- Edge list: danh sách tất cả cạnh
edges <- []
edges.append((0, 1, 5))    -- cạnh từ 0 đến 1, trọng số 5
edges.append((0, 2, 3))
edges.append((1, 3, 2))

-- Duyệt tất cả cạnh (dùng trong Bellman-Ford, Kruskal)
for each (u, v, w) trong edges:
    -- xử lý cạnh u->v với trọng số w

Time: O(E) để duyệt toàn bộ Space: O(E)

Complexity:

OperationEdge list
SpaceO(E)
Edge lookupO(E)
Iterate neighbors of uO(E) — scan toàn bộ
Add edgeO(1)
Remove edgeO(E)

Dùng khi: algorithm cần iterate qua tất cả cạnh theo thứ tự — Kruskal MST (Module 3 lesson 08), Bellman-Ford shortest path (lesson 06). Những algorithm này không cần tra hàng xóm của một đỉnh cụ thể, chỉ cần process từng cạnh một lần.

6. Bảng so sánh tổng hợp

AspectMatrixAdjacency listEdge list
SpaceO(V²)O(V + E)O(E)
Edge lookupO(1)O(degree)O(E)
Iterate neighborsO(V)O(degree)O(E)
Add edgeO(1)O(1)O(1)
Remove edgeO(1)O(degree)O(E)
Best forDense graphs, fast lookupSparse graphs, traversalEdge-centric algorithms

7. Rule of thumb — chọn representation nào

Adjacency list là default choice cho hầu hết real-world graph:

  • Social network: E ≈ 5×V (theo Friendster paper). Facebook 3B user, mỗi user avg 150-300 friends → sparse.
  • Road map: mỗi ngã tư có 2-4 outgoing road → sparse, weighted directed.
  • Dependency graph: mỗi package có vài chục dependency → sparse DAG.

Adjacency matrix khi:

  • Dense graph (E gần V²): complete graph, all-pairs shortest path baseline.
  • V đủ nhỏ (vài nghìn đỉnh) và cần edge lookup O(1) liên tục.
  • Thuật toán Floyd-Warshall (all-pairs shortest path) — đọc dist[u][v] rất nhiều lần.

Edge list khi algorithm là edge-centric:

  • Kruskal MST: sort tất cả cạnh theo trọng số, process từng cạnh một → edge list tự nhiên.
  • Bellman-Ford: relaxation qua tất cả cạnh V-1 lần → iterate edge list hiệu quả.

8. Pitfall tổng hợp

Pitfall 1 — Default dùng matrix "vì đơn giản"

-- BUG: matrix cho graph lớn, sparse
adj[1_000_000][1_000_000] <- 0
-- Cấp phát 10^12 ô = 1 TB RAM
-- Crash ngay lập tức với lỗi hết bộ nhớ

Với social network 1 triệu user, matrix cần 10¹² byte (1 TB RAM). Adjacency list với 5 triệu cạnh chỉ cần khoảng 40 MB. Luôn ước lượng density (E/V²) trước khi chọn — nếu density nhỏ hơn 0.01, dùng list.

Pitfall 2 — ArrayList vs HashSet cho neighbor list

-- Mặc định: List — cho phép multi-edge, hasEdge O(degree)
adj[u] = List of neighbors

-- Thay thế: Set — không trùng, hasEdge O(1) amortized
adj[u] = Set of neighbors

Nếu graph không có multi-edge và cần hasEdge thường xuyên (ví dụ cycle detection), dùng Set cho neighbor → hasEdge O(1) amortized thay vì O(degree). Trade-off: thêm overhead hash, không giữ insertion order.

Dùng List khi: multi-edge cần thiết, iterate neighbor theo thứ tự, hoặc BFS/DFS (chỉ iterate, không cần hasEdge).

Pitfall 3 — Quên double-add cho undirected graph

-- BUG: undirected nhưng chỉ thêm một chiều
addEdge(u, v):
    adj[u].append(v)
    -- Quên: adj[v].append(u)
-- Kết quả: graph bị directed — v không thấy u
-- BFS/DFS từ v bỏ sót mọi path đi qua cạnh này từ phía v
-- ĐÚNG: undirected — thêm cả hai chiều
addEdge(u, v):
    adj[u].append(v)
    adj[v].append(u)

Bug này rất khó debug vì graph "trông" đúng khi traverse từ u, nhưng fail khi traverse từ v. Viết test với path check cả 2 chiều.

9. Special graph types — preview

Một số loại graph đặc biệt hay gặp trong Module 3:

  • DAG (Directed Acyclic Graph): không có cycle. Dùng cho dependency graph, task scheduling. Topological sort (lesson 04) chỉ work trên DAG.
  • Tree: acyclic connected undirected graph với đúng V - 1 cạnh. Spanning tree của graph là subgraph dạng tree gồm tất cả đỉnh.
  • Bipartite graph: chia đỉnh thành 2 partition, cạnh chỉ nối giữa 2 partition, không bao giờ trong cùng partition. Dùng cho matching problem (job-candidate assignment).
  • Planar graph: có thể vẽ trên mặt phẳng 2D không có 2 cạnh cắt nhau. Road map, circuit board routing.

10. Ứng dụng thực tế

Social network (Facebook/X): adjacency list. Graph 3B user nhưng sparse — avg 150-300 kết nối mỗi user. Với directed graph (Twitter follow), mỗi user giữ 2 list riêng: following và followers.

Google Maps road network: weighted directed adjacency list. Mỗi ngã tư là đỉnh, mỗi đoạn đường là directed edge với trọng số = travel time. Avg 2-4 outgoing edge mỗi ngã tư → rất sparse. Dijkstra (lesson 05) chạy trực tiếp trên adjacency list này.

Maven/Gradle build dependency: DAG dùng adjacency list. Mỗi artifact là đỉnh, mỗi dependency là directed edge. Topological sort trên DAG cho build order — artifact phải build sau tất cả dependency của nó. Gradle detect cycle dependency và throw error vì DAG không được phép có cycle.

Neo4j / TigerGraph (graph databases): native graph storage là pointer-based adjacency list. Mỗi node lưu pointer trực tiếp đến hàng xóm, traverse O(1) per hop thay vì lookup B-tree như relational DB. Đây là lý do graph DB nhanh hơn SQL JOIN nhiều bậc khi query deep relationship (friend of friend of friend).

11. Deep Dive

📚 Deep Dive — tài liệu tham khảo

Sách kinh điển:

  • Introduction to Algorithms (CLRS), Chapter 22 — Elementary Graph Algorithms: phân tích formal về adjacency list và matrix, complexity proof.
  • Graph Algorithms: Practical Examples in Apache Spark and Neo4j — Mark Needham: graph representation trong distributed và native graph storage.

Thư viện:

  • JGraphT documentation (jgrapht.org): interface Graph với các implementation SimpleGraph, DirectedGraph, WeightedGraph, và built-in algorithms.
  • Guava com.google.common.graph package: MutableGraph, MutableValueGraph — API functional và immutable-friendly.

Cross-link Module 3:

  • Lesson 02: BFS — unweighted shortest path
  • Lesson 03: DFS — cycle detection, connected components
  • Lesson 04: Topological sort — DAG ordering
  • Lesson 05: Dijkstra — weighted shortest path
  • Lesson 06: Bellman-Ford — negative weight edges

12. Tóm tắt

  • Graph G = (V, E): V đỉnh, E cạnh. Directed/undirected/weighted là 3 thuộc tính cơ bản.
  • Adjacency matrix: O(V²) space, edge lookup O(1), iterate neighbors O(V). Dùng cho dense graph hoặc khi cần lookup cực nhanh.
  • Adjacency list: O(V + E) space, edge lookup O(degree), iterate neighbors O(degree). Default choice cho sparse graph — hầu hết real-world graph là sparse.
  • Edge list: O(E) space, iterate all edges O(E). Dùng cho edge-centric algorithm: Kruskal, Bellman-Ford.
  • Sparse vs dense: nếu E ≪ V², chọn adjacency list. Nếu E gần V², matrix có thể hợp lý — nhưng ước lượng RAM trước.
  • Undirected graph: luôn add cả 2 chiều trong adjacency list/matrix. Quên một chiều → biến thành directed.
  • DAG, tree, bipartite, planar: các loại graph đặc biệt với thuật toán riêng — gặp lại trong các lesson 02-11 của Module 3.

13. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao adjacency list thắng adjacency matrix cho social network 1 tỉ user?

Social network là sparse graph: mỗi user có trung bình vài trăm kết nối trong tổng số 1 tỉ user. Adjacency matrix cần V² space — với 1 tỉ user là 10^18 ô, tức 1 triệu TB RAM. Không thể cấp phát được.

Adjacency list chỉ lưu cạnh thực sự tồn tại: O(V + E). Với V = 1 tỉ user, E = 300 tỉ cạnh (avg 300 friends), cần khoảng 2-3 TB RAM distributed — vẫn lớn nhưng khả thi. Matrix tốt khi E gần V², tức density cao — điều không xảy ra trong social network thực tế.

Q2
Edge lookup adjacency list là O(degree). Khi nào degree đủ nhỏ để không ảnh hưởng performance?

Trong hầu hết real-world sparse graph, degree là hằng số nhỏ hoặc O(log V) — không phụ thuộc V. Ví dụ: road map avg 3-4 outgoing edge mỗi ngã tư, dependency graph avg 5-10 dependency mỗi package. Với degree nhỏ như vậy, O(degree) là O(1) thực tế.

Degree trở thành vấn đề với hub vertex trong scale-free network: celebrity Twitter account có hàng triệu follower — degree của đỉnh đó là O(V). Giải pháp thực tế: dùng Set thay List cho neighbor list → hasEdge O(1) amortized dù degree lớn. Hoặc dùng cấu trúc dữ liệu chuyên biệt (adjacency array + offset) cho cache efficiency.

Q3
Undirected graph trong adjacency list — vì sao mỗi cạnh xuất hiện 2 lần?

Trong undirected graph, cạnh {u, v} là hai chiều: u thấy v trong neighbor list, và v cũng thấy u trong neighbor list. Vì mỗi đỉnh giữ list hàng xóm của riêng nó, một cạnh cần được ghi vào 2 list khác nhau.

Khi gọi addEdge(u, v), cần thực hiện cả adj[u].append(v)adj[v].append(u). Vì vậy tổng số entry trong toàn bộ adjacency list là 2E, dẫn đến space O(V + 2E) = O(V + E). Đây không phải lỗi — đây là cơ chế đúng. Với directed graph, mỗi cạnh chỉ ghi 1 lần (chiều được chỉ định), nên tổng entry = E.

Q4
Cho graph 100 đỉnh, 500 cạnh. So sánh RAM dùng bởi adjacency matrix vs adjacency list.

Density = E / V² = 500 / 10000 = 0.05 = 5%. Graph tương đối sparse.

Adjacency matrix: V² ô = 10.000 ô. Nếu mỗi ô 1 byte → 10 KB. Cộng overhead header → khoảng 10-11 KB.

Adjacency list: V list + 2E entry (undirected). 100 list + 1000 entry. Mỗi list có overhead, mỗi entry cũng có overhead cho object. Ước lượng: khoảng 20 KB tùy ngôn ngữ. List overhead nhiều hơn matrix trong trường hợp này.

Với graph nhỏ (V = 100), matrix thực ra compact hơn list do object overhead. List thắng khi V lớn và graph sparse (ít cạnh hơn 1% density). Ngưỡng thực tế: với V vượt vài nghìn và density dưới 1%, list tiết kiệm RAM rõ rệt.

Q5
Edge list dùng cho Kruskal MST — vì sao algorithm cần list of edges thay vì neighbor map?

Kruskal MST hoạt động bằng cách: sort tất cả cạnh theo trọng số tăng dần, lần lượt thêm cạnh nhỏ nhất không tạo cycle. Algorithm không cần biết "hàng xóm của đỉnh u là gì" — nó cần process từng cạnh theo thứ tự trọng số.

Adjacency list lưu cạnh phân tán theo đỉnh: để lấy tất cả cạnh phải iterate qua mọi đỉnh rồi mọi hàng xóm. Với edge list, tất cả cạnh đã ở một chỗ → sort trực tiếp O(E log E), iterate O(E). Không có overhead traverse theo đỉnh.

Bellman-Ford cũng tương tự: relaxation "với mỗi cạnh (u,v,w): nếu dist[u] + w nhỏ hơn dist[v] thì update" — cần iterate qua tất cả cạnh V-1 lần. Edge list làm điều này tự nhiên và cache-friendly.

Q6
DAG là gì? Cho 2 ví dụ thực tế production dùng DAG.

DAG — Directed Acyclic Graph — là graph có hướng và không có cycle. "Directed" nghĩa là mỗi cạnh có chiều, "acyclic" nghĩa là không có đường đi nào từ một đỉnh quay lại chính nó.

Ví dụ thực tế production:

  • Build dependency (Maven/Gradle): mỗi artifact phụ thuộc vào các artifact khác, nhưng không được có circular dependency. Gradle enforce DAG — nếu cycle tồn tại, build fail. Topological sort trên DAG cho thứ tự build đúng.
  • Apache Airflow / Prefect workflow: mỗi task là đỉnh, cạnh biểu diễn "task A phải hoàn thành trước task B". DAG đảm bảo không có deadlock — không có tình huống A chờ B mà B lại chờ A. Scheduler dùng topological sort để tìm thứ tự chạy task hợp lệ.

Bài tiếp theo: BFS — unweighted shortest path

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên