Cấu trúc dữ liệu đồ thịGraph Data Structures
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Thuật ngữ
- Directed và undirected
- Weighted và unweighted
- Dense và sparse
- Ba cách biểu diễn
- So sánh ba cách biểu diễn
- Khái niệm chính
- Breadth-first search
- Depth-first search
- BFS hay DFS?
- Connected component
- Phát hiện cycle — và vì sao directed khác undirected
- Topological sort
- Kiểm tra bipartite (tô 2 màu)
- Grid là graph trá hình
- Graph xuất hiện ở đâu ngoài phòng luyện thuật toán
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- Terminology
- Directed vs undirected
- Weighted vs unweighted
- Dense vs sparse
- The three representations
- Representation comparison
- Key Concepts
- Breadth-first search
- Depth-first search
- BFS or DFS?
- Connected components
- Cycle detection — and why directed and undirected differ
- Topological sort
- Bipartite check (2-colouring)
- Grids are graphs in disguise
- Where graphs show up outside algorithm practice
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Graph là một tập các vertex (node) cùng với một tập các edge, mỗi edge nối một cặp vertex. Định nghĩa này đơn giản đến mức gần như tầm thường, và đó chính là lý do graph quan trọng: hầu như mọi mối quan hệ bạn có thể kể tên đều là một graph. Thành phố và đường sá. Trang web và hyperlink. Con người và quan hệ bạn bè. Package và các dependency của nó. Task và các điều kiện tiên quyết. Tài khoản ngân hàng và các giao dịch chuyển tiền. Pixel và các pixel lân cận. Commit Git và commit cha của nó. Một khi bạn nhìn ra được graph ẩn bên trong một bài toán, cả một kho thuật toán chuẩn mực lập tức sẵn sàng phục vụ bạn.
Graph cũng là cấu trúc tổng quát nhất trong roadmap này. Một linked list là graph mà mọi vertex có out-degree tối đa bằng 1. Một tree là graph vô hướng liên thông và không có cycle. Một heap là tree kèm ràng buộc thứ tự. Tất cả những gì bạn đã học cho tới giờ đều là trường hợp đặc biệt của những gì sắp trình bày — và đó cũng là lý do thuật toán trên graph không cần thêm bất kỳ primitive mới nào: queue cho bạn BFS, stack cho bạn DFS, và priority queue cho bạn Dijkstra.
Hai thứ đáng phải cân nhắc kỹ là lưu graph như thế nào và duyệt graph như thế nào. Chọn adjacency matrix cho một social network mười triệu user là một sai lầm trị giá hàng trăm terabyte; chọn adjacency list khi vòng lặp nóng của bạn là “giữa u và v có edge không?” biến một phép kiểm tra hằng số thành một lần quét tuyến tính. Và gần như mọi thuật toán graph trong note này lẫn hai note kế tiếp đều là BFS hoặc DFS gắn thêm một thứ gì đó. Nắm chắc hai phép duyệt thì phần còn lại tự khắc theo sau.
Note này bao gồm thuật ngữ, ba cách biểu diễn chuẩn cùng trade-off của chúng, BFS và DFS ở cả dạng iterative lẫn recursive, và các ứng dụng kinh điển: connected component, phát hiện cycle, topological sort, kiểm tra bipartite, và duyệt grid. Shortest path có trọng số nằm ở ./14-shortest-path-algorithms.md; minimum spanning tree ở ./15-minimum-spanning-trees.md.
Kiến thức nền tảng
Thuật ngữ
Về mặt hình thức, graph là G = (V, E) với V là tập vertex và E ⊆ V × V là tập edge. Xuyên suốt note này, V được hiểu là |V| — số vertex, và E là |E| — số edge, theo cách viết tắt tiêu chuẩn trong các phát biểu độ phức tạp kiểu O(V + E).
- Adjacent / neighbour —
uvàvlà adjacent nếu tồn tại edge(u, v). Neighbour củaulà tất cả vertex adjacent với nó. - Incident — một edge là incident với hai vertex mà nó nối.
- Degree — số edge incident với một vertex. Trong directed graph, khái niệm này tách thành in-degree (edge trỏ vào) và out-degree (edge đi ra).
- Path — một dãy vertex mà hai vertex liên tiếp được nối bởi một edge. Simple path không lặp lại vertex nào.
- Cycle — một path bắt đầu và kết thúc tại cùng một vertex, có ít nhất một edge và không lặp lại edge.
- Connected — graph vô hướng là connected nếu giữa mọi cặp vertex đều có path. Một mảnh liên thông tối đại gọi là connected component.
- Strongly connected — directed graph mà mọi vertex đều đi tới được mọi vertex khác. Weakly connected nghĩa là connected khi bỏ qua hướng của edge.
- Acyclic — không chứa cycle. Directed graph không có cycle gọi là DAG, trường hợp đặc biệt quan trọng nhất trong thực tế (build system, task scheduler, công thức spreadsheet, lịch sử Git).
- Self-loop — edge nối một vertex với chính nó. Parallel edge (multigraph) là hai edge cùng nối một cặp vertex. Hầu hết thuật toán giả định không có cả hai; hãy nói rõ khi input của bạn có chúng.
- Subgraph — graph tạo từ một tập con vertex và edge. Spanning subgraph dùng toàn bộ vertex.
Handshake lemma rất đáng ghi nhớ vì nó khiến các cận độ phức tạp trở nên hiển nhiên: trong graph vô hướng, Σ deg(v) = 2E, bởi mỗi edge đóng góp 1 vào degree của mỗi đầu mút. Đó là lý do “duyệt mọi vertex, và với mỗi vertex duyệt neighbour của nó” tốn O(V + E) chứ không phải O(V · E) — tổng các vòng lặp trong cộng lại là 2E, chứ không phải E cho mỗi vertex.
Directed và undirected
Trong undirected graph, edge {u, v} là đối xứng: nếu đi được từ u sang v thì cũng đi ngược lại được. Quan hệ bạn bè, dây cáp vật lý, “giáp ranh với”.
Trong directed graph (digraph), mỗi edge (u, v) có một đuôi u và một đầu v, và chỉ đi được một chiều. Đường một chiều, quan hệ follow trên Twitter, “phụ thuộc vào”, “là commit cha của”.
Undirected Directed
0 --- 1 0 ---> 1
| /| ^ /|
| / | | / |
| / | | v v
2 --- 3 2 <--- 3
Sự phân biệt này không hề mang tính hình thức — nhiều thuật toán khác nhau về bản chất:
| Câu hỏi | Undirected | Directed |
|---|---|---|
| Phát hiện cycle | ”gặp vertex đã visited khác parent" | "gặp vertex vẫn còn trên recursion stack” |
| Tính liên thông | connected component qua BFS/DFS | weakly và strongly connected (Tarjan/Kosaraju) |
| Topological sort | không định nghĩa | định nghĩa được khi và chỉ khi graph là DAG |
| Lưu trữ | lưu cả (u,v) và (v,u) | chỉ lưu (u,v) |
Lưu undirected graph nghĩa là lưu mỗi edge hai lần trong adjacency list — một lần trong adj[u], một lần trong adj[v]. Quên lần thứ hai là bug graph phổ biến nhất, và nó tạo ra một graph trông vẫn ổn cho tới khi một phép duyệt bỗng dưng không tới được một nửa số vertex.
Weighted và unweighted
Trong weighted graph, mỗi edge mang một con số: khoảng cách, chi phí, latency, capacity, xác suất. Trong graph unweighted, mọi edge như nhau — tương đương với việc mọi trọng số đều bằng 1.
Sự phân biệt này quyết định bạn cần thuật toán shortest path nào. Trên graph unweighted, BFS cho shortest path trong O(V + E), và dùng Dijkstra là phí công. Trên graph weighted, BFS đơn giản là sai — path ít edge nhất không phải là path rẻ nhất:
1
A ------- B
\ |
\ 10 | 1
\ |
------ C
BFS từ A nói rằng dist(A→C) = 1 edge (chính là edge trọng số 10).
Đường rẻ nhất là A→B→C với tổng trọng số 2.
Trọng số có thể âm (một khoản hoàn tiền, một lượng năng lượng thu được, một cơ hội arbitrage tiền tệ), và điều đó phá vỡ hoàn toàn lập luận greedy của Dijkstra — xem ./14-shortest-path-algorithms.md.
Dense và sparse
Một simple graph vô hướng trên V vertex có tối đa V(V−1)/2 edge; graph có hướng thì tối đa V(V−1). Mật độ được định nghĩa là E so với con số tối đa đó.
- Sparse —
E = O(V)hoặc xấp xỉ vậy. Mạng lưới đường bộ (mỗi ngã tư có khoảng 4 con đường), social graph (số bạn bè bị chặn trên), dependency graph, và hầu hết graph trong thực tế. - Dense —
E = Θ(V²). Complete graph, ma trận khoảng cách giữa mọi cặp thành phố, correlation graph, hoặc những graph nhỏ mà mọi thứ nối với mọi thứ.
Mật độ là yếu tố quyết định gần như mọi lựa chọn biểu diễn và thuật toán trong note này. O(V²) và O(E) là cùng bậc trên graph dense, nhưng khác nhau một trời một vực trên graph sparse: với V = 10⁶ và E = 5 × 10⁶ (một mạng lưới đường bộ hợp lý), V² là 10¹² — gấp một triệu lần khối lượng công việc.
Ba cách biểu diễn
Lấy graph vô hướng nhỏ sau:
0 --- 1
| / |
| / |
2 3
Adjacency list — với mỗi vertex, lưu danh sách neighbour của nó. Đây là lựa chọn mặc định; hãy dùng nó trừ khi có lý do cụ thể để không dùng.
adj[0] = [1, 2]
adj[1] = [0, 2, 3]
adj[2] = [0, 1]
adj[3] = [1]
Adjacency matrix — một lưới V × V với m[u][v] bằng 1 (hoặc bằng trọng số) nếu edge tồn tại.
0 1 2 3
0 [ 0 1 1 0 ]
1 [ 1 0 1 1 ]
2 [ 1 1 0 0 ]
3 [ 0 1 0 0 ]
Edge list — chỉ gồm các edge, không theo thứ tự nào. Gọn, vô dụng cho câu hỏi “neighbour của u là ai”, và lại đúng là định dạng input mà Kruskal và Bellman-Ford cần.
[(0, 1), (0, 2), (1, 2), (1, 3)]
class AdjListGraph:
"""Biểu diễn mặc định: adj[u] là danh sách neighbour của u."""
def __init__(self, n, directed=False):
self.n = n
self.directed = directed
self.adj = [[] for _ in range(n)]
def add_edge(self, u, v):
self.adj[u].append(v)
if not self.directed:
self.adj[v].append(u) # undirected = lưu edge từ cả hai đầu
def neighbours(self, u):
return self.adj[u] # O(1) để lấy list, O(deg(u)) để duyệt hết
def has_edge(self, u, v):
return v in self.adj[u] # O(deg(u)) — điểm yếu của cách bố trí này
class AdjMatrixGraph:
"""Lưới V x V. Tra edge O(1), tốn O(V^2) bộ nhớ dù graph có ít edge tới đâu."""
def __init__(self, n, directed=False):
self.n = n
self.directed = directed
self.m = [[0] * n for _ in range(n)]
def add_edge(self, u, v, w=1):
self.m[u][v] = w
if not self.directed:
self.m[v][u] = w
def neighbours(self, u):
return [v for v in range(self.n) if self.m[u][v]] # O(V) dù u chỉ có 2 edge
def has_edge(self, u, v):
return self.m[u][v] != 0 # O(1) — lý do tồn tại của cách bố trí này
Với weighted graph, adjacency list lưu cặp thay vì chỉ id vertex: adj[u] = [(v, w), ...]. Đó chính là dạng được giả định xuyên suốt ./14-shortest-path-algorithms.md.
So sánh ba cách biểu diễn
| Thao tác | Adjacency list | Adjacency matrix | Edge list |
|---|---|---|---|
| Bộ nhớ | O(V + E) | O(V²) | O(E) |
| Thêm edge | O(1) | O(1) | O(1) |
Xoá edge (u,v) | O(deg(u)) | O(1) | O(E) |
has_edge(u, v) | O(deg(u)) | O(1) | O(E) |
Duyệt neighbour của u | O(deg(u)) | O(V) | O(E) |
| Duyệt toàn bộ edge | O(V + E) | O(V²) | O(E) |
| BFS / DFS toàn graph | O(V + E) | O(V²) | phải chuyển đổi trước |
Đọc bảng trên như một lời khuyên:
- Mặc định dùng adjacency list. Graph thực tế thường sparse, phép duyệt chiếm phần lớn thời gian, và
O(V + E)thắngO(V²). - Dùng adjacency matrix khi graph dense,
Vnhỏ (chẳng hạn ≤ 2000), hoặchas_edgelà thao tác nóng. Floyd-Warshall cần matrix. Mọi thuật toán được phát biểu dưới dạng số học ma trận cũng vậy (đếm số path độ dàikbằng luỹ thừa ma trận, PageRank như một toán tử tuyến tính). - Dùng edge list khi thuật toán tiêu thụ edge một cách toàn cục thay vì theo từng vertex. Kruskal sort toàn bộ edge; Bellman-Ford relax toàn bộ edge
V−1lần. Cả hai đều không bao giờ hỏi “neighbour củaulà ai”.
Một ví dụ định lượng: V = 100.000, E = 300.000 (một mạng đường bộ cỡ vừa). Adjacency list bằng int Python tốn cỡ vài triệu machine word. Adjacency matrix sẽ cần 10¹⁰ ô — 10 GB ngay cả khi mỗi ô 1 byte, trong đó 99,997% là số 0. Ở đây matrix không phải là lựa chọn chậm hơn; nó là lựa chọn bất khả thi.
Nếu bạn muốn một adjacency list dạng hash map với label tuỳ ý thay vì số nguyên 0..n−1, collections.defaultdict(list) là thứ bạn thực sự sẽ viết trong production. Nó tốn một phép hash mỗi lần tra thay vì một phép index mảng — vẫn là O(1) kỳ vọng, với hệ số hằng có thể gấp 3-5 lần. Với competitive programming hoặc vòng lặp nóng, hãy đánh số lại vertex thành số nguyên trước.
Khái niệm chính
Breadth-first search
BFS duyệt graph theo từng lớp: trước hết là source, rồi tới mọi thứ cách 1 edge, rồi mọi thứ cách 2 edge, và cứ thế. Nó dùng queue — first in, first out — và đó chính xác là thứ tạo ra cấu trúc theo lớp.
Source 0, các lớp của một graph vô hướng:
lớp 0: 0
/ \
lớp 1: 1 2
/ / \
lớp 2: 3 4 5
from collections import deque
def bfs(adj, source):
"""Breadth-first search từ `source`.
Trả về (dist, parent) với dist[v] là số EDGE trên shortest path
source -> v (-1 nếu không tới được), và parent[v] là vertex liền trước
v trên path đó. Thời gian O(V + E), bộ nhớ O(V).
"""
n = len(adj)
dist = [-1] * n
parent = [-1] * n
dist[source] = 0
q = deque([source])
while q:
u = q.popleft()
for v in adj[u]:
if dist[v] == -1: # chưa được phát hiện
dist[v] = dist[u] + 1
parent[v] = u
q.append(v) # đánh dấu lúc ENQUEUE, không phải lúc dequeue
return dist, parent
def reconstruct_path(parent, source, target):
"""Lần ngược theo parent pointer rồi đảo lại. O(độ dài path)."""
if target != source and parent[target] == -1:
return [] # không tới được
path = []
cur = target
while cur != -1:
path.append(cur)
cur = parent[cur]
path.reverse()
return path
Hai chi tiết tạo nên khác biệt giữa một BFS đúng và một BFS bậc hai:
- Đánh dấu vertex là đã phát hiện lúc push, không phải lúc pop. Nếu đánh dấu lúc pop, một vertex tới được từ ba neighbour sẽ vào queue ba lần. Trên graph dense điều này xuống cấp nghiêm trọng, và “khoảng cách” bạn ghi lại không còn được đảm bảo là nhỏ nhất ngay lần thăm đầu tiên.
- Dùng deque thật.
collections.dequechopopleftvới chi phíO(1). MộtlistPython vớipop(0)làO(n)mỗi lần gọi vì nó phải dịch mọi phần tử còn lại — chỉ riêng điều đó đã biến BFSO(V + E)thànhO(V² + E).
Độ phức tạp: thời gian O(V + E), bộ nhớ O(V). Mỗi vertex được enqueue tối đa một lần (V lần push và pop), và với mỗi vertex được dequeue ta quét adjacency list của nó đúng một lần; theo handshake lemma, tổng các lần quét là 2E trên graph vô hướng và E trên graph có hướng. Bộ nhớ gồm mảng dist/parent cộng với queue, mà queue chứa tối đa một lớp đầy đủ — trên graph rộng (grid, complete bipartite graph) lớp đó có thể là Θ(V).
BFS dùng để làm gì: shortest path trên graph unweighted (tính chất nổi bật nhất của nó), duyệt tree theo level-order, tìm số nước đi tối thiểu trong một puzzle, crawl web theo độ sâu link, và tính eccentricity/diameter bằng cách chạy nó từ mọi vertex.
Depth-first search
DFS đi sâu hết mức có thể theo một nhánh, rồi quay lui và thử nhánh kế tiếp. Nó dùng stack — last in, first out — hoặc là stack tường minh, hoặc ở dạng recursive là chính call stack.
import sys
def dfs_recursive(adj, source):
"""Cách viết tự nhiên nhất. Thời gian O(V + E), bộ nhớ O(V) cho `visited`
cộng O(độ sâu) cho call stack — và đó chính là cái bẫy, xem bên dưới."""
visited = [False] * len(adj)
order = []
def visit(u):
visited[u] = True
order.append(u) # pre-order: ghi nhận lúc đi vào
for v in adj[u]:
if not visited[v]:
visit(v)
# phần việc post-order sẽ đặt ở đây, sau khi mọi hậu duệ đã xong
visit(source)
return order
def dfs_iterative(adj, source):
"""Cùng phép duyệt với stack tường minh — không vướng giới hạn đệ quy."""
visited = [False] * len(adj)
order = []
stack = [source]
while stack:
u = stack.pop()
if visited[u]: # ở đây phải kiểm tra lúc POP, không phải lúc
continue # push, vì một vertex có thể nằm 2 lần trên stack
visited[u] = True
order.append(u)
for v in reversed(adj[u]): # reversed() để khớp thứ tự của bản recursive
if not visited[v]:
stack.append(v)
return order
Bản recursive và bản iterative thăm cùng tập vertex trong O(V + E), nhưng bản iterative khác ở một điểm: một vertex có thể được push nhiều lần trước khi lần đầu được pop, nên stack có thể chứa O(E) phần tử thay vì O(V). Điều đó thường chấp nhận được, và đánh đổi này xứng đáng bởi vì giới hạn đệ quy mặc định của Python là 1000. Một DFS trên path graph 100.000 vertex sẽ ném RecursionError từ rất lâu trước khi chạy xong. Bạn có hai lối thoát: sys.setrecursionlimit(300000) (rủi ro làm interpreter crash cứng, vì C stack có giới hạn riêng và nhỏ hơn), hoặc viết dạng iterative. Với bất kỳ input nào mà bạn không kiểm soát được kích thước, hãy viết iterative.
Nếu bạn cần phần việc post-order (finishing time, topological sort, tổng hợp trên subtree) trong DFS iterative, hãy giữ một iterator cho mỗi stack frame để phân biệt “tôi còn con để thử” với “tôi đã xong”:
def dfs_pre_and_post(adj, source):
"""DFS iterative sinh ra cả thứ tự vào (pre) lẫn thứ tự ra (post).
Mỗi stack frame giữ một iterator sống trên adjacency list của vertex."""
visited = [False] * len(adj)
pre, post = [], []
visited[source] = True
pre.append(source)
stack = [(source, iter(adj[source]))]
while stack:
u, it = stack[-1]
advanced = False
for v in it: # tiếp tục từ chỗ iterator này đang dừng
if not visited[v]:
visited[v] = True
pre.append(v)
stack.append((v, iter(adj[v])))
advanced = True
break
if not advanced: # đã duyệt hết adjacency list: u kết thúc
stack.pop()
post.append(u)
return pre, post
Độ phức tạp: thời gian O(V + E), bộ nhớ O(V) (cộng độ sâu đệ quy, xấu nhất là V trên path graph).
DFS dùng để làm gì: connected component, phát hiện cycle, topological sort, strongly connected component (Tarjan, Kosaraju), articulation point và bridge, tìm kiếm backtracking trên một state graph ngầm định (xem ./21-backtracking.md), và mọi bài toán mà bạn muốn xử lý sau khi toàn bộ subtree đã được xử lý.
BFS hay DFS?
| BFS | DFS | |
|---|---|---|
| Cấu trúc frontier | queue (FIFO) | stack (LIFO) / đệ quy |
| Thời gian | O(V + E) | O(V + E) |
| Bộ nhớ phụ | O(V) — trọn một lớp | O(V) — path sâu nhất |
| Shortest path (unweighted) | có, được đảm bảo | không |
| Tìm một path nhanh trên graph sâu | chậm hơn | thường nhanh hơn |
| Tự nhiên cho việc post-order | không | có |
| Rủi ro trên input khổng lồ | tốn bộ nhớ nếu lớp rộng | stack overflow nếu quá sâu |
Với câu hỏi “từ s có tới được t không” thì cả hai đều được. Với “t cách bao xa” thì bắt buộc là BFS. Với “sắp xếp các task này” hoặc “có cycle không” thì thường là DFS.
Connected component
Liên tục khởi động một phép duyệt từ một vertex chưa thăm bất kỳ; mọi thứ nó tới được là một component.
def connected_components(adj):
"""Gán cho mỗi vertex của graph vô hướng một component id.
Trả về (count, comp). Thời gian O(V + E)."""
n = len(adj)
comp = [-1] * n
count = 0
for s in range(n):
if comp[s] != -1:
continue # đã thuộc về một component trước đó
comp[s] = count
stack = [s]
while stack:
u = stack.pop()
for v in adj[u]:
if comp[v] == -1:
comp[v] = count
stack.append(v)
count += 1
return count, comp
Vòng lặp ngoài trên mọi s chính là thứ khiến tổng chi phí là O(V + E) chứ không phải O(V(V + E)): mỗi vertex chỉ được đi vào đúng một lần trên tất cả các phép duyệt, nên tổng công việc vẫn là một lượt quét graph. Có comp trong tay rồi thì câu hỏi “u và v có liên thông không?” trở thành comp[u] == comp[v] với chi phí O(1).
Nếu edge đến dần dần và bạn cần xen kẽ truy vấn liên thông với thao tác chèn, phép duyệt là công cụ sai — hãy dùng cấu trúc disjoint-set union, xem ./16-disjoint-set-union-find.md.
Phát hiện cycle — và vì sao directed khác undirected
Đây là chỗ kinh điển mà copy nhầm đoạn code sẽ cho ra một chương trình sai một cách tinh vi. Hai trường hợp thực sự cần logic khác nhau.
Undirected: một edge dẫn tới vertex đã visited mà không phải parent nghĩa là có cycle. Việc loại trừ “không phải parent” là bắt buộc, bởi trong graph vô hướng mỗi edge u—v xuất hiện trong cả hai adjacency list, nên từ v bạn luôn nhìn thấy lại u. Đó không phải cycle; đó là chính edge đó.
def has_cycle_undirected(adj):
"""True khi và chỉ khi graph vô hướng chứa cycle. Thời gian O(V + E)."""
n = len(adj)
visited = [False] * n
def visit(u, parent):
visited[u] = True
for v in adj[u]:
if not visited[v]:
if visit(v, u):
return True
elif v != parent: # back edge tới một vertex visited KHÁC
return True
return False
return any(visit(s, -1) for s in range(n) if not visited[s])
Một mẹo đáng biết: graph vô hướng liên thông là acyclic (là một tree) khi và chỉ khi E = V − 1. Vậy với graph liên thông, bạn có thể trả lời câu hỏi này bằng cách đếm edge. Tổng quát hơn, graph vô hướng là acyclic khi và chỉ khi E = V − c với c là số connected component.
Directed: cycle tồn tại khi và chỉ khi DFS tìm thấy một back edge tới vertex vẫn còn trên recursion stack. Chỉ “đã visited” là chưa đủ — trong một DAG như A→B, A→C, B→D, C→D, DFS từ A tới D hai lần, và lần thứ hai D đã visited, nhưng không hề có cycle. Sơ đồ ba màu phân biệt “đã xong” với “đang xử lý”:
WHITE, GRAY, BLACK = 0, 1, 2 # chưa thăm / đang trên stack / đã duyệt xong
def has_cycle_directed(adj):
"""True khi và chỉ khi graph có hướng chứa cycle. Thời gian O(V + E)."""
n = len(adj)
color = [WHITE] * n
def visit(u):
color[u] = GRAY # u giờ nằm trên đường DFS hiện tại
for v in adj[u]:
if color[v] == GRAY: # back edge -> có cycle
return True
if color[v] == WHITE and visit(v):
return True
color[u] = BLACK # u và mọi hậu duệ của nó đã xong
return False
return any(visit(s) for s in range(n) if color[s] == WHITE)
Cách nhớ: GRAY nghĩa là “tổ tiên của chỗ tôi đang đứng”. Gặp một vertex GRAY nghĩa là bạn đã đi thành một vòng tròn. Gặp một vertex BLACK chỉ nghĩa là bạn tới một thứ đã được duyệt xong theo con đường khác — một cross edge hoặc forward edge, cả hai đều vô hại.
Hai lưu ý cho bản undirected. Thứ nhất, nó coi parallel edge (hai edge phân biệt giữa cùng một cặp) là không phải cycle vì cả hai đều trông như “parent”; nếu input của bạn là multigraph, hãy theo dõi edge id thay vì vertex parent. Thứ hai, self-loop u—u được báo là cycle vì u != parent chỉ khi parent != u; nên thay vì dựa vào trường hợp biên đó, tốt hơn là assert rằng input không có self-loop.
Topological sort
Topological order của một DAG là một cách sắp xếp tuyến tính các vertex sao cho mọi edge u → v đều đặt u trước v. Nó trả lời câu hỏi “làm các task này theo thứ tự nào để mọi điều kiện tiên quyết đều xong trước?” — sắp lịch môn học, build system, tính lại spreadsheet, cài đặt package, sắp thứ tự database migration.
Nó tồn tại khi và chỉ khi graph là DAG. Một cycle nghĩa là một nhóm task phụ thuộc lẫn nhau, và không thứ tự nào thoả mãn được tất cả.
Thuật toán Kahn (kiểu BFS) liên tục lấy ra một vertex có in-degree bằng 0:
from collections import deque
def topo_sort_kahn(adj):
"""Topological order của DAG bằng cách liên tục gỡ vertex có in-degree 0.
Ném ValueError nếu graph có cycle. Thời gian O(V + E), bộ nhớ O(V)."""
n = len(adj)
indeg = [0] * n
for u in range(n):
for v in adj[u]:
indeg[v] += 1
q = deque(u for u in range(n) if indeg[u] == 0)
order = []
while q:
u = q.popleft()
order.append(u)
for v in adj[u]:
indeg[v] -= 1 # coi như ta vừa xoá edge u -> v
if indeg[v] == 0: # điều kiện tiên quyết cuối cùng của v vừa xong
q.append(v)
if len(order) != n: # còn vertex không bao giờ đạt in-degree 0
raise ValueError("graph has a cycle - no topological order exists")
return order
Kahn cho bạn khả năng phát hiện cycle miễn phí — nếu output ngắn hơn V, những vertex còn lại chính là những vertex kẹt trong cycle hoặc nằm ở hạ lưu của cycle. Thay deque bằng heap thì bạn có topological order nhỏ nhất theo thứ tự từ điển, với chi phí O((V + E) log V). Theo dõi chỉ số lớp thay vì chỉ thứ tự thì bạn có độ dài critical path của DAG, đây chính là cách các build system ước lượng makespan khi chạy song song.
Topological sort dựa trên DFS dựa vào một sự thật đáng nhớ: trong DAG, reverse post-order chính là topological order. Một vertex chỉ kết thúc sau khi mọi hậu duệ của nó đã kết thúc, nên đẩy vertex vào list lúc đi ra rồi đảo ngược sẽ cho đúng thứ tự cần tìm.
def topo_sort_dfs(adj):
"""Topological order bằng reverse DFS post-order. Thời gian O(V + E)."""
n = len(adj)
color = [WHITE] * n
order = []
def visit(u):
color[u] = GRAY
for v in adj[u]:
if color[v] == GRAY:
raise ValueError("graph has a cycle - no topological order exists")
if color[v] == WHITE:
visit(v)
color[u] = BLACK
order.append(u) # append lúc ĐI RA -> đây là post-order
for s in range(n):
if color[s] == WHITE:
visit(s)
order.reverse() # reverse post-order == topological order
return order
| Kahn | Dựa trên DFS | |
|---|---|---|
| Phong cách | iterative, dùng queue | recursive (hoặc stack tường minh) |
| Thời gian / bộ nhớ | O(V + E) / O(V) | O(V + E) / O(V) |
| Phát hiện cycle | miễn phí — output ngắn | cần kiểm tra GRAY |
| Rủi ro độ sâu đệ quy | không | có, trong Python |
| Biến thể dễ làm | thứ tự từ điển, level/critical path, đếm số thứ tự | tận dụng luôn DFS bạn đã viết |
Trong Python, hãy ưu tiên Kahn cho mọi thứ có kích thước không biết trước, đơn giản vì nó không bao giờ đụng tới giới hạn đệ quy. Thư viện chuẩn cũng có sẵn graphlib.TopologicalSorter (Python 3.9+), hiện thực thuật toán Kahn và bổ sung API “cho tôi mọi node đã sẵn sàng ngay bây giờ” mà các scheduler song song cần.
Kiểm tra bipartite (tô 2 màu)
Graph là bipartite nếu tập vertex của nó chia được thành hai tập sao cho mọi edge đều nối giữa hai tập — tương đương với việc tô được bằng 2 màu, tương đương với việc không có cycle độ dài lẻ. Cấu trúc bipartite xuất hiện trong các bài toán matching (việc và người, sinh viên và đề tài), conflict graph (“hai cái này không được cùng một nhóm”), và là điều kiện tiên quyết của thuật toán maximum matching Hopcroft-Karp.
def is_bipartite(adj):
"""Tô 2 màu graph bằng BFS. Trả về (True, colors) hoặc (False, None).
Thời gian O(V + E)."""
n = len(adj)
color = [-1] * n
for s in range(n):
if color[s] != -1:
continue # component này đã được tô rồi
color[s] = 0
q = deque([s])
while q:
u = q.popleft()
for v in adj[u]:
if color[v] == -1:
color[v] = 1 - color[u] # neighbour nhận màu ngược lại
q.append(v)
elif color[v] == color[u]: # edge nằm trong cùng một phía -> cycle lẻ
return False, None
return True, color
Vòng lặp ngoài rất quan trọng: bipartite là tính chất của mọi component, nên graph chỉ bipartite khi tất cả component của nó đều bipartite. Lưu ý graph không có edge nào thì hiển nhiên là bipartite, và mọi tree đều bipartite (tô màu theo tính chẵn lẻ của độ sâu).
Grid là graph trá hình
Một phần rất lớn các bài “grid” — flood fill, đếm đảo, tìm đường ngắn nhất qua mê cung, cam thối lan ra, đếm vùng đất bị bao vây — thực chất là bài toán graph thông thường mà người ta không buồn dựng graph lên. Mẹo là nhìn ra graph ngầm định: mỗi ô (r, c) là một vertex, và neighbour của nó là tối đa bốn (hoặc tám, nếu tính đường chéo) ô kề nằm trong biên. Bạn không bao giờ tạo adjacency list; bạn tính neighbour ngay tại chỗ.
DIRS4 = ((-1, 0), (1, 0), (0, -1), (0, 1)) # lên, xuống, trái, phải
def count_islands(grid):
"""Số nhóm liên thông của các ô '1' trong lưới rows x cols.
Mỗi ô là một vertex; edge nối các ô '1' kề nhau theo bốn hướng.
Thời gian O(rows * cols), bộ nhớ O(rows * cols)."""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
seen = [[False] * cols for _ in range(rows)]
islands = 0
for r0 in range(rows):
for c0 in range(cols):
if grid[r0][c0] != "1" or seen[r0][c0]:
continue
islands += 1 # tìm thấy một component mới
seen[r0][c0] = True
stack = [(r0, c0)] # iterative: lưới 1000x1000 toàn
while stack: # đất sẽ làm vỡ call stack
r, c = stack.pop()
for dr, dc in DIRS4:
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] == "1" and not seen[nr][nc]):
seen[nr][nc] = True
stack.append((nr, nc))
return islands
def grid_shortest_path(grid, start, goal):
"""Số bước ít nhất từ start tới goal, tránh các ô '#', hoặc -1.
BFS thuần trên graph grid ngầm định. Thời gian O(rows * cols)."""
rows, cols = len(grid), len(grid[0])
dist = {start: 0}
q = deque([start])
while q:
r, c = q.popleft()
if (r, c) == goal:
return dist[(r, c)]
for dr, dc in DIRS4:
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] != "#" and (nr, nc) not in dist):
dist[(nr, nc)] = dist[(r, c)] + 1
q.append((nr, nc))
return -1
Ba thứ hay hỏng trong việc duyệt grid hơn bất cứ chỗ nào khác:
- Kiểm tra biên trước khi truy cập mảng. Trong Python,
grid[-1][c]lặng lẽ vòng về hàng cuối thay vì báo lỗi, nên thiếu điều kiện0 <= nrsẽ cho kết quả sai chứ không phải crash. - Độ sâu đệ quy. Lưới 1000×1000 toàn đất là một khối/đường sâu 10⁶ vertex. Flood fill bắt buộc phải iterative ở quy mô đó.
- Thời điểm đánh dấu. Đánh dấu ô là đã thấy ngay khi push, đúng vì lý do BFS làm vậy — nếu không, một ô có bốn neighbour hợp lệ sẽ vào queue bốn lần.
Ý tưởng “graph ngầm định” này còn tổng quát hơn nhiều so với grid: vertex của một state graph puzzle (một cấu hình Rubik, một trạng thái rót nước giữa các bình, một word ladder) được sinh ra bởi một hàm successor thay vì được lưu sẵn. BFS trên graph ngầm định là lời giải chuẩn cho các bài “số nước đi tối thiểu”, và thứ duy nhất thay đổi là cách bạn liệt kê neighbour.
Graph xuất hiện ở đâu ngoài phòng luyện thuật toán
Tư duy graph không chỉ giới hạn trong bài phỏng vấn. Query planner của một relational database biểu diễn việc tìm thứ tự join như một DAG các plan node (xem ../../postgresql-dba/vi/10-query-planning-and-performance-tuning.md). Các graph database như Neo4j biến adjacency thành primitive lưu trữ chính chứ không phải thứ dẫn xuất — xem ../../backend/vi/09-nosql-databases.md. Các framework xử lý dữ liệu phân tán lập lịch công việc dưới dạng DAG các stage, bàn tới trong ../../data-engineer/vi/10-big-data-and-distributed-computing.md. Trong mọi trường hợp, thuật toán vẫn là những thuật toán trong note này, chỉ áp lên một graph đặc thù của lĩnh vực đó.
Best Practices
- Mặc định dùng adjacency list. Chỉ chuyển sang matrix khi graph dense,
Vnhỏ, hoặchas_edgethực sự là đường nóng. Chỉ chuyển sang edge list khi thuật toán tiêu thụ edge một cách toàn cục (Kruskal, Bellman-Ford). - Đánh số lại vertex thành
0..n−1ngay ở biên. Lấy bất kỳ id nào bài toán cho — string, UUID, toạ độ — và map một lần thành số nguyên liên tục. Mọi cấu trúc dựa trên mảng ở phía sau sẽ đơn giản hơn và nhanh hơn vài lần. - Khi dựng graph vô hướng, hãy append theo cả hai chiều. Viết một hàm
add_edgeduy nhất và dùng nó ở mọi nơi thay vì tự append tại chỗ gọi; cái bug “một nửa số edge trở thành một chiều” rất khó nhìn ra và rất dễ phòng. - Đánh dấu vertex lúc push trong BFS, lúc pop trong DFS iterative. Hai cái này khác nhau có lý do: BFS push mỗi vertex đúng một lần và cần khoảng cách tại lần phát hiện đầu tiên là chính xác; DFS iterative cố tình cho phép trùng lặp trên stack.
- Viết DFS dạng iterative cho bất cứ thứ gì bạn không kiểm soát được kích thước. Giới hạn 1000 frame mặc định của Python sẽ tìm ra path graph 10.000 vertex của bạn. Nâng giới hạn là đổi một
RecursionErrorsạch sẽ lấy một segfault. - Dùng đúng hàm phát hiện cycle theo tính có hướng của graph. Undirected cần loại trừ parent; directed cần kiểm tra “đang trên stack” (GRAY). Dùng bản undirected cho graph có hướng sẽ báo cycle ở mọi hình thoi; dùng bản directed cho graph vô hướng sẽ báo cycle ở mọi edge đơn lẻ.
- Ưu tiên
collections.dequethay vìlistkhi làm queue.list.pop(0)làO(n). Đây là cách phổ biến nhất để vô tình viết ra một BFS bậc hai. - Xử lý graph không liên thông một cách tường minh. Một lần BFS/DFS từ vertex 0 chỉ phủ component của vertex 0. Mọi tính chất của toàn graph — bipartite, acyclic, số component — đều cần vòng lặp trên mọi vertex khởi đầu.
- Kiểm tra
V,Evà sự tồn tại của self-loop/parallel edge trước khi tin một đoạn code. Phần lớn code graph được publish đều ngầm giả định là simple graph. - Lưu
parent[]song song vớidist[]ngay từ đầu. Dựng lại path thực sự sau đó gần như miễn phí nếu bạn đã giữ parent, và bất khả thi nếu không. - Kiểm tra nhanh bằng handshake lemma. Sau khi dựng xong graph vô hướng,
sum(len(adj[u]) for u in range(n))phải bằng2 * E. Một dòng assert bắt được cả một lớp bug khi dựng graph.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Graph (abstract data type) — Wikipedia
- Graph theory — Wikipedia
- Breadth-first search — Wikipedia
- Depth-first search — Wikipedia
- Topological sorting — Wikipedia
- Bipartite graph — Wikipedia
- cp-algorithms — Breadth-first search
- cp-algorithms — Depth-first search
- cp-algorithms — Finding connected components
- cp-algorithms — Checking a graph for acyclicity and finding a cycle
- cp-algorithms — Topological sorting
- cp-algorithms — Check whether a graph is bipartite
- CLRS — Introduction to Algorithms, Part VI: Graph Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- VisuAlgo — Graph traversal (DFS/BFS)
- Python Documentation —
collections.deque - Python Documentation —
graphlib.TopologicalSorter - Big-O Cheat Sheet
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
A graph is a set of vertices (nodes) together with a set of edges, where each edge connects a pair of vertices. That definition is almost insultingly simple, and that is exactly why graphs matter: almost every relationship you can name is a graph. Cities and roads. Web pages and hyperlinks. People and friendships. Packages and their dependencies. Tasks and their prerequisites. Bank accounts and transfers. Pixels and their neighbours. Git commits and their parents. Once you can see the graph inside a problem, a large catalogue of standard algorithms becomes available to you.
Graphs are also the most general of the structures in this roadmap. A linked list is a graph where every vertex has out-degree at most one. A tree is a connected acyclic undirected graph. A heap is a tree with an ordering constraint. Everything you have learned so far is a special case of what follows, which is why graph algorithms need no new primitives — a queue gives you BFS, a stack gives you DFS, and a priority queue gives you Dijkstra.
The two things worth being deliberate about are how you store the graph and how you walk it. Choosing an adjacency matrix for a social network with ten million users is a hundred-terabyte mistake; choosing an adjacency list when your inner loop is “is there an edge between u and v?” turns a constant-time check into a linear scan. And nearly every graph algorithm in this note and the next two is BFS or DFS with something extra bolted on. Get the traversals right and the rest follows.
This note covers terminology, the three standard representations and their trade-offs, BFS and DFS in both iterative and recursive form, and the standard applications: connected components, cycle detection, topological sort, bipartite checking, and grid traversal. Weighted shortest paths are in ./14-shortest-path-algorithms.md; minimum spanning trees in ./15-minimum-spanning-trees.md.
Fundamentals
Terminology
Formally a graph is G = (V, E) where V is the vertex set and E ⊆ V × V is the edge set. Throughout this note V means |V|, the number of vertices, and E means |E|, the number of edges — the standard abuse of notation in complexity statements like O(V + E).
- Adjacent / neighbour —
uandvare adjacent if the edge(u, v)exists. The neighbours ofuare all vertices adjacent to it. - Incident — an edge is incident to the two vertices it connects.
- Degree — the number of edges incident to a vertex. In a directed graph this splits into in-degree (edges pointing at it) and out-degree (edges leaving it).
- Path — a sequence of vertices where consecutive vertices are connected by an edge. A simple path repeats no vertex.
- Cycle — a path that starts and ends at the same vertex, with at least one edge and no repeated edge.
- Connected — an undirected graph is connected if there is a path between every pair of vertices. A maximal connected piece is a connected component.
- Strongly connected — a directed graph in which every vertex can reach every other vertex. Weakly connected means connected when you ignore edge direction.
- Acyclic — contains no cycle. A directed acyclic graph is a DAG, the single most important special case in practice (build systems, task schedulers, spreadsheet formulas, Git history).
- Self-loop — an edge from a vertex to itself. Parallel edges (a multigraph) are two edges between the same pair. Most algorithms assume neither; be explicit when your input has them.
- Subgraph — a graph formed from a subset of the vertices and edges. A spanning subgraph uses every vertex.
The handshake lemma is worth internalizing because it makes complexity bounds obvious: in an undirected graph, Σ deg(v) = 2E, because every edge contributes one to the degree of each endpoint. This is why “visit every vertex, and for each vertex iterate its neighbours” costs O(V + E) and not O(V · E) — the inner loops sum to 2E in total, not E each.
Directed vs undirected
In an undirected graph the edge {u, v} is symmetric: if you can walk from u to v, you can walk back. Friendship, physical wires, “shares a border with.”
In a directed graph (digraph) each edge (u, v) has a tail u and a head v and points one way only. One-way streets, Twitter follows, “depends on,” “is a parent commit of.”
Undirected Directed
0 --- 1 0 ---> 1
| /| ^ /|
| / | | / |
| / | | v v
2 --- 3 2 <--- 3
The distinction is not cosmetic — several algorithms differ fundamentally:
| Question | Undirected | Directed |
|---|---|---|
| Cycle detection | ”visited a non-parent vertex" | "visited a vertex still on the recursion stack” |
| Connectivity | connected components via BFS/DFS | weakly vs strongly connected (Tarjan/Kosaraju) |
| Topological sort | not defined | defined iff the graph is a DAG |
| Storage | store both (u,v) and (v,u) | store (u,v) only |
Storing an undirected graph means storing each edge twice in the adjacency list — once in adj[u] and once in adj[v]. Forgetting the second insertion is the single most common graph bug, and it produces a graph that looks fine until a traversal mysteriously fails to reach half the vertices.
Weighted vs unweighted
In a weighted graph each edge carries a number: distance, cost, latency, capacity, probability. In an unweighted graph edges are all equal, which is the same as all weights being 1.
This distinction decides which shortest-path algorithm you need. On an unweighted graph, BFS gives you shortest paths in O(V + E), and reaching for Dijkstra would be a waste. On a weighted graph BFS is simply wrong — the fewest-edges path is not the lowest-weight path:
1
A ------- B
\ |
\ 10 | 1
\ |
------ C
BFS from A says dist(A→C) = 1 edge (the weight-10 edge).
The cheapest route is A→B→C with total weight 2.
Weights can be negative (a refund, an energy gain, a currency arbitrage), which breaks Dijkstra’s greedy argument entirely — see ./14-shortest-path-algorithms.md.
Dense vs sparse
A simple undirected graph on V vertices has at most V(V−1)/2 edges; a directed one at most V(V−1). Define density as E relative to that maximum.
- Sparse —
E = O(V)or thereabouts. Road networks (each intersection has ~4 roads), social graphs (bounded friend counts), dependency graphs, most real-world graphs. - Dense —
E = Θ(V²). Complete graphs, distance matrices between all pairs of cities, correlation graphs, small graphs where everything touches everything.
Density is the deciding input to almost every representation and algorithm choice in this note. O(V²) and O(E) are the same order on a dense graph and wildly different on a sparse one: for V = 10⁶ and E = 5 × 10⁶ (a plausible road network), V² is 10¹² — a million times more work.
The three representations
Take this small undirected graph:
0 --- 1
| / |
| / |
2 3
Adjacency list — for each vertex, the list of its neighbours. This is the default; use it unless you have a specific reason not to.
adj[0] = [1, 2]
adj[1] = [0, 2, 3]
adj[2] = [0, 1]
adj[3] = [1]
Adjacency matrix — a V × V grid where m[u][v] is 1 (or the weight) if the edge exists.
0 1 2 3
0 [ 0 1 1 0 ]
1 [ 1 0 1 1 ]
2 [ 1 1 0 0 ]
3 [ 0 1 0 0 ]
Edge list — just the edges, in no particular order. Compact, useless for “who are u’s neighbours”, and exactly the input format that Kruskal’s algorithm and Bellman-Ford want.
[(0, 1), (0, 2), (1, 2), (1, 3)]
class AdjListGraph:
"""The default representation: adj[u] is the list of u's neighbours."""
def __init__(self, n, directed=False):
self.n = n
self.directed = directed
self.adj = [[] for _ in range(n)]
def add_edge(self, u, v):
self.adj[u].append(v)
if not self.directed:
self.adj[v].append(u) # undirected = store the edge from both ends
def neighbours(self, u):
return self.adj[u] # O(1) to get the list, O(deg(u)) to walk it
def has_edge(self, u, v):
return v in self.adj[u] # O(deg(u)) — the weak spot of this layout
class AdjMatrixGraph:
"""V x V grid. O(1) edge lookup, O(V^2) space regardless of how few edges exist."""
def __init__(self, n, directed=False):
self.n = n
self.directed = directed
self.m = [[0] * n for _ in range(n)]
def add_edge(self, u, v, w=1):
self.m[u][v] = w
if not self.directed:
self.m[v][u] = w
def neighbours(self, u):
return [v for v in range(self.n) if self.m[u][v]] # O(V) even if u has 2 edges
def has_edge(self, u, v):
return self.m[u][v] != 0 # O(1) — the reason this layout exists
For weighted graphs the adjacency list stores pairs instead of bare vertex ids: adj[u] = [(v, w), ...]. That is the shape assumed throughout ./14-shortest-path-algorithms.md.
Representation comparison
| Operation | Adjacency list | Adjacency matrix | Edge list |
|---|---|---|---|
| Space | O(V + E) | O(V²) | O(E) |
| Add edge | O(1) | O(1) | O(1) |
Remove edge (u,v) | O(deg(u)) | O(1) | O(E) |
has_edge(u, v) | O(deg(u)) | O(1) | O(E) |
Iterate neighbours of u | O(deg(u)) | O(V) | O(E) |
| Iterate all edges | O(V + E) | O(V²) | O(E) |
| BFS / DFS over whole graph | O(V + E) | O(V²) | needs conversion first |
Reading the table as advice:
- Use an adjacency list by default. Real graphs are sparse, traversals dominate, and
O(V + E)beatsO(V²). - Use an adjacency matrix when the graph is dense,
Vis small (say ≤ 2000), orhas_edgeis the hot operation. Floyd-Warshall wants a matrix. So does any algorithm phrased as matrix arithmetic (counting paths of lengthkvia matrix powers, PageRank as a linear operator). - Use an edge list when the algorithm consumes edges globally rather than per-vertex. Kruskal sorts all edges; Bellman-Ford relaxes all edges
V−1times. Neither ever asks “who areu’s neighbours.”
A concrete sizing example: V = 100,000, E = 300,000 (a modest road network). An adjacency list of Python ints costs on the order of a few million machine words. An adjacency matrix would need 10¹⁰ cells — 10 GB even at one byte each, of which 99.997% are zero. The matrix is not a slower choice here; it is an impossible one.
If you want a hash-map adjacency list keyed by arbitrary labels rather than integers 0..n−1, collections.defaultdict(list) is what you would actually write in production. It costs a hash per lookup instead of an array index — still O(1) expected, with a constant factor of maybe 3-5x. For competitive programming or hot loops, relabel vertices to integers first.
Key Concepts
Breadth-first search
BFS explores the graph in layers: first the source, then everything one edge away, then everything two edges away, and so on. It uses a queue — first in, first out — which is exactly what produces the layering.
Source 0, layers of an undirected graph:
layer 0: 0
/ \
layer 1: 1 2
/ / \
layer 2: 3 4 5
from collections import deque
def bfs(adj, source):
"""Breadth-first search from `source`.
Returns (dist, parent) where dist[v] is the number of EDGES on a shortest
path source -> v (-1 if unreachable), and parent[v] is v's predecessor on
that path. Time O(V + E), space O(V).
"""
n = len(adj)
dist = [-1] * n
parent = [-1] * n
dist[source] = 0
q = deque([source])
while q:
u = q.popleft()
for v in adj[u]:
if dist[v] == -1: # not discovered yet
dist[v] = dist[u] + 1
parent[v] = u
q.append(v) # mark on ENQUEUE, never on dequeue
return dist, parent
def reconstruct_path(parent, source, target):
"""Walk the parent pointers backwards, then reverse. O(path length)."""
if target != source and parent[target] == -1:
return [] # unreachable
path = []
cur = target
while cur != -1:
path.append(cur)
cur = parent[cur]
path.reverse()
return path
Two details that are the difference between a correct BFS and a quadratic one:
- Mark vertices as discovered when you push them, not when you pop them. If you mark on pop, a vertex reachable from three neighbours enters the queue three times. On a dense graph this degrades badly and the “distance” you record is no longer guaranteed minimal on the first visit.
- Use a real deque.
collections.dequegivesO(1)popleft. A Pythonlistwithpop(0)isO(n)per call because it shifts every remaining element — that alone turns anO(V + E)BFS intoO(V² + E).
Complexity: O(V + E) time, O(V) space. Each vertex is enqueued at most once (V pushes and pops), and for each dequeued vertex we scan its adjacency list once; by the handshake lemma those scans total 2E on an undirected graph and E on a directed one. Space is the dist/parent arrays plus the queue, which holds at most one full layer — on a wide graph (a grid, a complete bipartite graph) that layer can be Θ(V).
What BFS is for: shortest paths in unweighted graphs (its headline property), level-order traversal of a tree, finding the minimum number of moves in a puzzle, web crawling by link depth, and computing eccentricity/diameter by running it from every vertex.
Depth-first search
DFS goes as deep as it can along one branch, then backtracks and tries the next. It uses a stack — last in, first out — which is either explicit or, in the recursive form, the call stack itself.
import sys
def dfs_recursive(adj, source):
"""The natural formulation. Time O(V + E), space O(V) for `visited` plus
O(depth) for the call stack — which is the catch, see below."""
visited = [False] * len(adj)
order = []
def visit(u):
visited[u] = True
order.append(u) # pre-order: record on entry
for v in adj[u]:
if not visited[v]:
visit(v)
# post-order work would go here, after all descendants are done
visit(source)
return order
def dfs_iterative(adj, source):
"""Same traversal with an explicit stack — no recursion-depth limit."""
visited = [False] * len(adj)
order = []
stack = [source]
while stack:
u = stack.pop()
if visited[u]: # here we must check on POP, not on push,
continue # because a vertex can sit on the stack twice
visited[u] = True
order.append(u)
for v in reversed(adj[u]): # reversed() to match the recursive visit order
if not visited[v]:
stack.append(v)
return order
The recursive and iterative versions visit the same vertices in O(V + E) but the iterative one differs in one respect: a vertex may be pushed several times before it is first popped, so the stack can hold O(E) entries rather than O(V). That is usually fine, and the trade is worth it because Python’s default recursion limit is 1000. A DFS on a path graph of 100,000 vertices will raise RecursionError long before it finishes. You have two outs: sys.setrecursionlimit(300000) (which risks a hard interpreter crash because the C stack is a separate, smaller limit), or write it iteratively. On any input you do not control the size of, write it iteratively.
If you need post-order work (finishing times, topological sort, subtree aggregation) in an iterative DFS, keep an iterator per stack frame so you can tell “I have more children to try” from “I am done”:
def dfs_pre_and_post(adj, source):
"""Iterative DFS that produces both entry (pre) and exit (post) orders.
Each stack frame holds a live iterator over the vertex's adjacency list."""
visited = [False] * len(adj)
pre, post = [], []
visited[source] = True
pre.append(source)
stack = [(source, iter(adj[source]))]
while stack:
u, it = stack[-1]
advanced = False
for v in it: # resumes where this iterator left off
if not visited[v]:
visited[v] = True
pre.append(v)
stack.append((v, iter(adj[v])))
advanced = True
break
if not advanced: # adjacency list exhausted: u is finished
stack.pop()
post.append(u)
return pre, post
Complexity: O(V + E) time, O(V) space (plus recursion depth, worst case V on a path graph).
What DFS is for: connected components, cycle detection, topological sort, strongly connected components (Tarjan, Kosaraju), articulation points and bridges, backtracking search over an implicit state graph (see ./21-backtracking.md), and any problem where you want to do work after the whole subtree is processed.
BFS or DFS?
| BFS | DFS | |
|---|---|---|
| Frontier structure | queue (FIFO) | stack (LIFO) / recursion |
| Time | O(V + E) | O(V + E) |
| Auxiliary space | O(V) — a whole layer | O(V) — the deepest path |
| Shortest path (unweighted) | yes, guaranteed | no |
| Finds a path fast on deep graphs | slower | usually faster |
| Natural for post-order work | no | yes |
| Risk on huge inputs | memory if layers are wide | stack overflow if depth is large |
For “is t reachable from s” either works. For “how far is t”, it must be BFS. For “order these tasks” or “is there a cycle”, it is usually DFS.
Connected components
Repeatedly start a traversal from any unvisited vertex; everything it reaches is one component.
def connected_components(adj):
"""Label every vertex of an undirected graph with its component id.
Returns (count, comp). Time O(V + E)."""
n = len(adj)
comp = [-1] * n
count = 0
for s in range(n):
if comp[s] != -1:
continue # already assigned to an earlier component
comp[s] = count
stack = [s]
while stack:
u = stack.pop()
for v in adj[u]:
if comp[v] == -1:
comp[v] = count
stack.append(v)
count += 1
return count, comp
The outer loop over all s is what makes this O(V + E) overall rather than O(V(V + E)): each vertex is entered exactly once across all traversals, so the total work is still one pass over the graph. With comp in hand, “are u and v connected?” becomes comp[u] == comp[v] in O(1).
If the edges arrive incrementally and you need connectivity queries interleaved with insertions, a traversal is the wrong tool — use a disjoint-set union structure instead, see ./16-disjoint-set-union-find.md.
Cycle detection — and why directed and undirected differ
This is the classic place where copying the wrong snippet gives a subtly wrong program. The two cases genuinely need different logic.
Undirected: an edge to any visited vertex that is not your parent means a cycle. The “not your parent” exclusion is necessary because in an undirected graph every edge u—v appears in both adjacency lists, so from v you always see u again. That is not a cycle; it is the same edge.
def has_cycle_undirected(adj):
"""True iff the undirected graph contains a cycle. Time O(V + E)."""
n = len(adj)
visited = [False] * n
def visit(u, parent):
visited[u] = True
for v in adj[u]:
if not visited[v]:
if visit(v, u):
return True
elif v != parent: # a back edge to some OTHER visited vertex
return True
return False
return any(visit(s, -1) for s in range(n) if not visited[s])
A shortcut worth knowing: a connected undirected graph is acyclic (a tree) if and only if E = V − 1. So for a connected graph you can answer the question by counting edges. In general, an undirected graph is acyclic iff E = V − c where c is the number of connected components.
Directed: a cycle exists iff DFS finds a back edge to a vertex still on the recursion stack. “Already visited” is not enough — in a DAG like A→B, A→C, B→D, C→D, the DFS from A reaches D twice, and D is visited the second time, yet there is no cycle. The three-colour scheme distinguishes “finished” from “in progress”:
WHITE, GRAY, BLACK = 0, 1, 2 # unvisited / on the stack / fully explored
def has_cycle_directed(adj):
"""True iff the directed graph contains a cycle. Time O(V + E)."""
n = len(adj)
color = [WHITE] * n
def visit(u):
color[u] = GRAY # u is now on the current DFS path
for v in adj[u]:
if color[v] == GRAY: # back edge -> cycle
return True
if color[v] == WHITE and visit(v):
return True
color[u] = BLACK # u and all its descendants are finished
return False
return any(visit(s) for s in range(n) if color[s] == WHITE)
The mnemonic: GRAY means “an ancestor of where I am standing.” Hitting a GRAY vertex means you have walked in a circle. Hitting a BLACK vertex means you have merely arrived at something already fully explored by a different route — a cross edge or forward edge, both harmless.
Two caveats for the undirected version. First, it treats parallel edges (two distinct edges between the same pair) as non-cycles because both look like “the parent”; if your input is a multigraph, track edge ids rather than the parent vertex. Second, a self-loop u—u is correctly reported as a cycle since u != parent in that check only when parent != u; if u is its own parent-free root the check fires as intended, but it is worth asserting your input has no self-loops rather than relying on the edge case.
Topological sort
A topological order of a DAG is a linear ordering of its vertices such that every edge u → v places u before v. It answers “in what order can I do these tasks so that every prerequisite is done first?” — course scheduling, build systems, spreadsheet recalculation, package installation, database migration ordering.
It exists if and only if the graph is a DAG. A cycle means a set of tasks that mutually depend on each other, and no order can satisfy them all.
Kahn’s algorithm (BFS-style) repeatedly removes a vertex with in-degree zero:
from collections import deque
def topo_sort_kahn(adj):
"""Topological order of a DAG via repeated removal of in-degree-0 vertices.
Raises ValueError if the graph has a cycle. Time O(V + E), space O(V)."""
n = len(adj)
indeg = [0] * n
for u in range(n):
for v in adj[u]:
indeg[v] += 1
q = deque(u for u in range(n) if indeg[u] == 0)
order = []
while q:
u = q.popleft()
order.append(u)
for v in adj[u]:
indeg[v] -= 1 # pretend we deleted the edge u -> v
if indeg[v] == 0: # v's last prerequisite just finished
q.append(v)
if len(order) != n: # some vertices never reached in-degree 0
raise ValueError("graph has a cycle - no topological order exists")
return order
Kahn’s gives you cycle detection for free — if the output is shorter than V, the leftover vertices are exactly those trapped in or downstream of a cycle. Swap the deque for a heap and you get the lexicographically smallest topological order, at O((V + E) log V). Track the layer index instead of just the order and you get the critical path length of the DAG, which is how build systems estimate parallel makespan.
DFS-based topological sort relies on a fact worth remembering: in a DAG, reverse post-order is a topological order. A vertex finishes only after all of its descendants have finished, so pushing vertices onto a list on exit and reversing gives you exactly what you want.
def topo_sort_dfs(adj):
"""Topological order via reverse DFS post-order. Time O(V + E)."""
n = len(adj)
color = [WHITE] * n
order = []
def visit(u):
color[u] = GRAY
for v in adj[u]:
if color[v] == GRAY:
raise ValueError("graph has a cycle - no topological order exists")
if color[v] == WHITE:
visit(v)
color[u] = BLACK
order.append(u) # append on EXIT -> this is post-order
for s in range(n):
if color[s] == WHITE:
visit(s)
order.reverse() # reverse post-order == topological order
return order
| Kahn’s | DFS-based | |
|---|---|---|
| Style | iterative, queue | recursive (or explicit stack) |
| Time / space | O(V + E) / O(V) | O(V + E) / O(V) |
| Cycle detection | free — short output | needs the GRAY check |
| Recursion depth risk | none | yes, in Python |
| Easy variants | lexicographic order, level/critical path, counting orders | naturally reuses the DFS you already wrote |
In Python, prefer Kahn’s for anything of unknown size, purely because it never touches the recursion limit. The standard library also ships graphlib.TopologicalSorter (Python 3.9+), which implements Kahn’s and additionally supports the “give me every node that is ready right now” API that parallel schedulers want.
Bipartite check (2-colouring)
A graph is bipartite if its vertices can be split into two sets with every edge crossing between them — equivalently, if it can be 2-coloured, equivalently if it has no odd-length cycle. Bipartite structure shows up in matching problems (jobs to workers, students to projects), conflict graphs (“these two cannot be in the same group”), and as a precondition for Hopcroft-Karp maximum matching.
def is_bipartite(adj):
"""Two-colour the graph with BFS. Returns (True, colors) or (False, None).
Time O(V + E)."""
n = len(adj)
color = [-1] * n
for s in range(n):
if color[s] != -1:
continue # this component is already coloured
color[s] = 0
q = deque([s])
while q:
u = q.popleft()
for v in adj[u]:
if color[v] == -1:
color[v] = 1 - color[u] # neighbours get the opposite colour
q.append(v)
elif color[v] == color[u]: # an edge inside one side -> odd cycle
return False, None
return True, color
The outer loop matters: bipartiteness is a property of every component, so a graph is bipartite only if all of its components are. Note that a graph with no edges is trivially bipartite, and every tree is bipartite (colour by depth parity).
Grids are graphs in disguise
A huge fraction of “grid” problems — flood fill, counting islands, shortest path through a maze, rotting oranges, number of enclaves — are ordinary graph problems where nobody bothered to build the graph. The trick is to see the implicit graph: each cell (r, c) is a vertex, and its neighbours are the up to four (or eight, if diagonals count) in-bounds cells adjacent to it. You never materialize an adjacency list; you compute neighbours on the fly.
DIRS4 = ((-1, 0), (1, 0), (0, -1), (0, 1)) # up, down, left, right
def count_islands(grid):
"""Number of connected groups of '1' cells in a rows x cols grid.
Each cell is a vertex; edges connect orthogonally adjacent '1' cells.
Time O(rows * cols), space O(rows * cols)."""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
seen = [[False] * cols for _ in range(rows)]
islands = 0
for r0 in range(rows):
for c0 in range(cols):
if grid[r0][c0] != "1" or seen[r0][c0]:
continue
islands += 1 # found a new component
seen[r0][c0] = True
stack = [(r0, c0)] # iterative: a 1000x1000 grid of
while stack: # land would blow the call stack
r, c = stack.pop()
for dr, dc in DIRS4:
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] == "1" and not seen[nr][nc]):
seen[nr][nc] = True
stack.append((nr, nc))
return islands
def grid_shortest_path(grid, start, goal):
"""Fewest steps from start to goal avoiding '#' cells, or -1.
Plain BFS on the implicit grid graph. Time O(rows * cols)."""
rows, cols = len(grid), len(grid[0])
dist = {start: 0}
q = deque([start])
while q:
r, c = q.popleft()
if (r, c) == goal:
return dist[(r, c)]
for dr, dc in DIRS4:
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] != "#" and (nr, nc) not in dist):
dist[(nr, nc)] = dist[(r, c)] + 1
q.append((nr, nc))
return -1
Three things go wrong in grid traversals more than anywhere else:
- Bounds checks before the array access. In Python,
grid[-1][c]silently wraps to the last row instead of raising, so a missing0 <= nrcheck produces a wrong answer rather than a crash. - Recursion depth. A 1000×1000 grid of all-land is a path/blob 10⁶ vertices deep. Flood fill must be iterative at that size.
- Marking timing. Mark cells as seen when you push them, for exactly the reason BFS does — otherwise a cell with four qualifying neighbours enters the queue four times.
The same “implicit graph” idea generalizes far beyond grids: the vertices of a puzzle state graph (a Rubik’s cube configuration, a jug-pouring state, a word ladder) are generated by a successor function rather than stored. BFS over an implicit graph is the standard solution to “minimum number of moves” problems, and the only thing that changes is how you enumerate neighbours.
Where graphs show up outside algorithm practice
Graph thinking is not confined to interview problems. Query planners in a relational database represent a join order search as a DAG of plan nodes (see ../../postgresql-dba/en/10-query-planning-and-performance-tuning.md). Graph databases such as Neo4j make adjacency the primary storage primitive rather than a derived one — see ../../backend/en/09-nosql-databases.md. Distributed data processing frameworks schedule work as a DAG of stages, discussed in ../../data-engineer/en/10-big-data-and-distributed-computing.md. In every case the algorithms are the ones in this note, applied to a domain-specific graph.
Best Practices
- Default to an adjacency list. Switch to a matrix only when the graph is dense,
Vis small, orhas_edgeis genuinely the hot path. Switch to an edge list only when the algorithm consumes edges globally (Kruskal, Bellman-Ford). - Relabel vertices to
0..n−1at the boundary. Take whatever ids the problem gives you — strings, UUIDs, coordinates — and map them once to dense integers. Every array-based structure downstream becomes simpler and several times faster. - When building an undirected graph, append in both directions. Write a single
add_edgehelper and use it everywhere rather than hand-appending at call sites; the bug where half the edges are one-way is very hard to see and very easy to prevent. - Mark vertices at push time in BFS, at pop time in the iterative DFS. These are different for a reason: BFS pushes each vertex once and needs the first-discovery distance to be final; iterative DFS deliberately allows duplicates on the stack.
- Write DFS iteratively for anything you do not control the size of. Python’s 1000-frame default recursion limit will find your 10,000-vertex path graph. Raising the limit trades a clean
RecursionErrorfor a segfault. - Use the right cycle-detection routine for the graph’s directedness. Undirected needs the parent exclusion; directed needs the on-stack (GRAY) check. Using the undirected version on a directed graph reports cycles in every diamond; using the directed version on an undirected graph reports a cycle for every single edge.
- Prefer
collections.dequeoverlistfor queues.list.pop(0)isO(n). This is the single most common way to accidentally write a quadratic BFS. - Handle disconnected graphs explicitly. A single BFS/DFS from vertex 0 only covers vertex 0’s component. Any property of the whole graph — bipartiteness, acyclicity, component count — needs the loop over all start vertices.
- Check
V,Eand the presence of self-loops/parallel edges before trusting a snippet. Most published graph code silently assumes a simple graph. - Store
parent[]alongsidedist[]from the start. Reconstructing the actual path afterwards is nearly free if you kept parents and impossible if you did not. - Sanity-check with the handshake lemma. After building an undirected graph,
sum(len(adj[u]) for u in range(n))must equal2 * E. A one-line assertion catches a whole class of construction bugs.
References
- roadmap.sh — Data Structures & Algorithms
- Graph (abstract data type) — Wikipedia
- Graph theory — Wikipedia
- Breadth-first search — Wikipedia
- Depth-first search — Wikipedia
- Topological sorting — Wikipedia
- Bipartite graph — Wikipedia
- cp-algorithms — Breadth-first search
- cp-algorithms — Depth-first search
- cp-algorithms — Finding connected components
- cp-algorithms — Checking a graph for acyclicity and finding a cycle
- cp-algorithms — Topological sorting
- cp-algorithms — Check whether a graph is bipartite
- CLRS — Introduction to Algorithms, Part VI: Graph Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- VisuAlgo — Graph traversal (DFS/BFS)
- Python Documentation —
collections.deque - Python Documentation —
graphlib.TopologicalSorter - Big-O Cheat Sheet