← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202633 phút đọc29 min read

Cấu trúc dữ liệu đồ thịGraph Data Structures

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Thuật ngữ
  4. Directed và undirected
  5. Weighted và unweighted
  6. Dense và sparse
  7. Ba cách biểu diễn
  8. So sánh ba cách biểu diễn
  9. Khái niệm chính
  10. Breadth-first search
  11. Depth-first search
  12. BFS hay DFS?
  13. Connected component
  14. Phát hiện cycle — và vì sao directed khác undirected
  15. Topological sort
  16. Kiểm tra bipartite (tô 2 màu)
  17. Grid là graph trá hình
  18. Graph xuất hiện ở đâu ngoài phòng luyện thuật toán
  19. Best Practices
  20. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Terminology
  4. Directed vs undirected
  5. Weighted vs unweighted
  6. Dense vs sparse
  7. The three representations
  8. Representation comparison
  9. Key Concepts
  10. Breadth-first search
  11. Depth-first search
  12. BFS or DFS?
  13. Connected components
  14. Cycle detection — and why directed and undirected differ
  15. Topological sort
  16. Bipartite check (2-colouring)
  17. Grids are graphs in disguise
  18. Where graphs show up outside algorithm practice
  19. Best Practices
  20. 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àoduyệ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 uv 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|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).

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ỏiUndirectedDirected
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ôngconnected component qua BFS/DFSweakly và strongly connected (Tarjan/Kosaraju)
Topological sortkhông định nghĩađịnh nghĩa được khi và chỉ khi graph là DAG
Lưu trữlưu cả (u,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 đó.

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²)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⁶E = 5 × 10⁶ (một mạng lưới đường bộ hợp 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ácAdjacency listAdjacency matrixEdge list
Bộ nhớO(V + E)O(V²)O(E)
Thêm edgeO(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 uO(deg(u))O(V)O(E)
Duyệt toàn bộ edgeO(V + E)O(V²)O(E)
BFS / DFS toàn graphO(V + E)O(V²)phải chuyển đổi trước

Đọc bảng trên như một lời khuyên:

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

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:

  1. Đá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.
  2. Dùng deque thật. collections.deque cho popleft với chi phí O(1). Một list Python với pop(0)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 BFS O(V + E) thành O(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.

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?

BFSDFS
Cấu trúc frontierqueue (FIFO)stack (LIFO) / đệ quy
Thời gianO(V + E)O(V + E)
Bộ nhớ phụO(V) — trọn một lớpO(V) — path sâu nhất
Shortest path (unweighted)có, được đảm bảokhông
Tìm một path nhanh trên graph sâuchậm hơnthường nhanh hơn
Tự nhiên cho việc post-orderkhông
Rủi ro trên input khổng lồtốn bộ nhớ nếu lớp rộngstack 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 “uv 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
KahnDựa trên DFS
Phong cáchiterative, dùng queuerecursive (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 cyclemiễn phí — output ngắncần kiểm tra GRAY
Rủi ro độ sâu đệ quykhôngcó, trong Python
Biến thể dễ làmthứ 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:

Ý 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

Tài liệu tham khảo

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).

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:

QuestionUndirectedDirected
Cycle detection”visited a non-parent vertex""visited a vertex still on the recursion stack”
Connectivityconnected components via BFS/DFSweakly vs strongly connected (Tarjan/Kosaraju)
Topological sortnot defineddefined iff the graph is a DAG
Storagestore 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.

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), 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

OperationAdjacency listAdjacency matrixEdge list
SpaceO(V + E)O(V²)O(E)
Add edgeO(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 uO(deg(u))O(V)O(E)
Iterate all edgesO(V + E)O(V²)O(E)
BFS / DFS over whole graphO(V + E)O(V²)needs conversion first

Reading the table as advice:

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

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:

  1. 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.
  2. Use a real deque. collections.deque gives O(1) popleft. A Python list with pop(0) is O(n) per call because it shifts every remaining element — that alone turns an O(V + E) BFS into O(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.

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?

BFSDFS
Frontier structurequeue (FIFO)stack (LIFO) / recursion
TimeO(V + E)O(V + E)
Auxiliary spaceO(V) — a whole layerO(V) — the deepest path
Shortest path (unweighted)yes, guaranteedno
Finds a path fast on deep graphsslowerusually faster
Natural for post-order worknoyes
Risk on huge inputsmemory if layers are widestack 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’sDFS-based
Styleiterative, queuerecursive (or explicit stack)
Time / spaceO(V + E) / O(V)O(V + E) / O(V)
Cycle detectionfree — short outputneeds the GRAY check
Recursion depth risknoneyes, in Python
Easy variantslexicographic order, level/critical path, counting ordersnaturally 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:

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

References