Thuật toán Cốt lõi — Tìm kiếm, Sắp xếp & Đồ thị/DFS — Depth-First Search, preorder/postorder, cycle detection
27/36
Bài 27 / 36~22 phútĐường đi & quan hệ — Graph algorithmsMiễn phí lượt xem

DFS — Depth-First Search, preorder/postorder, cycle detection

DFS đi sâu từng nhánh — recursive và iterative, white/gray/black coloring phát hiện cycle, preorder vs postorder, connected components, pattern hay gặp trong production.

TL;DR: DFS (Depth-First Search) đi sâu vào một nhánh trước khi quay lại thử nhánh khác — cơ chế LIFO qua call stack hoặc explicit stack. Một lần chạy DFS cho ra hai thứ tự: preorder (khi vào đỉnh) và postorder (khi rời đỉnh sau khi xử lý xong toàn bộ con). Đảo ngược postorder trên DAG chính là topological sort. Pitfall lớn nhất: recursive DFS trên chain graph 1 triệu đỉnh gây StackOverflow — phải dùng iterative DFS với explicit stack trên heap thay thế.

BFS lan sóng theo từng level — nó biết "đỉnh nào cách nguồn 3 hop". Nhưng có những bài toán không cần khoảng cách theo level: bạn muốn đi sâu vào một nhánh trước, rồi mới quay lại thử nhánh khác.

Hãy nghĩ đến build dependency graph của Maven: khi kiểm tra xem có circular dependency không, bạn cần đi theo một chain (A phụ thuộc B, B phụ thuộc C, C phụ thuộc...) cho đến khi hoặc thoát hoặc quay lại đỉnh ban đầu — đó là DFS. Hoặc bài toán đếm connected component, topological sort, maze exploration, Tarjan SCC — tất cả đều tự nhiên dùng DFS.

Bài này dạy DFS recursive và iterative, white/gray/black coloring cho cycle detection, preorder vs postorder traversal, và các pattern hay gặp trong production.

1. Analogy — Khám phá mê cung

Hình dung bạn đang khám phá một mê cung lớn. Chiến lược: chọn một hướng và đi sâu nhất có thể theo hướng đó — đến khi gặp ngõ cụt (dead end) hoặc chỗ đã đến rồi. Lúc đó, quay lại điểm rẽ gần nhất và thử hướng khác. Cứ như vậy cho đến khi thăm hết mọi ngóc ngách.

Đây chính xác là LIFO mechanic — bạn luôn quay lại điểm rẽ gần nhất, không phải điểm rẽ đầu tiên. So sánh với BFS: BFS như drone khảo sát từ trên, thăm mọi phòng cách entrance 1 bước trước, rồi 2 bước, rồi 3 bước — không đi sâu vào nhánh nào.

Mê cungDFS
Bạn tại một ngã rẽĐỉnh hiện tại đang xử lý
Chọn 1 hướng, đi sâu hếtĐệ quy vào hàng xóm đầu tiên chưa thăm
Gặp ngõ cụt, quay lạiHết hàng xóm chưa thăm, return (pop frame)
Điểm rẽ gần nhấtTop của call stack (hoặc explicit stack)
Đánh dấu phòng đã thămvisited[v] <- true
Thứ tự vào phòngPreorder
Thứ tự ra khỏi phòngPostorder
💡 Cách nhớ

DFS = đi sâu trước. BFS = đi rộng trước. DFS dùng LIFO (call stack hoặc Stack), BFS dùng FIFO (Queue).

2. Thuật toán DFS — recursive

DFS(G, start):
    visited[v] <- false cho mọi v
    dfsHelper(G, start, visited)

dfsHelper(G, v, visited):
    if visited[v]: return
    visited[v] <- true

    -- xử lý preorder: ngay khi vào đỉnh
    process_preorder(v)

    for each đỉnh u kề v trong G:
        dfsHelper(G, u, visited)

    -- xử lý postorder: sau khi tất cả con xong
    process_postorder(v)

Time: O(V+E) Space: O(V) — call stack sâu tối đa V frame

Trace ví dụ — graph: 0-1, 0-2, 1-3, 1-4. DFS từ đỉnh 0:

Cây gọi đệ quy:
dfsHelper(0)
  -> dfsHelper(1)
       -> dfsHelper(3)  preorder:3, postorder:3
       -> dfsHelper(4)  preorder:4, postorder:4
     postorder:1
  -> dfsHelper(2)       preorder:2, postorder:2
  postorder:0

Thứ tự preorder:  0, 1, 3, 4, 2
Thứ tự postorder: 3, 4, 1, 2, 0
graph TD
    START(("Bắt đầu\ntừ 0")) --> N0["Vào 0\n(preorder: 0)"]
    N0 --> N1["Vào 1\n(preorder: 1)"]
    N1 --> N3["Vào 3\n(preorder: 3)\n(postorder: 3)"]
    N1 --> N4["Vào 4\n(preorder: 4)\n(postorder: 4)"]
    N1 --> OUT1["Rời 1\n(postorder: 1)"]
    N0 --> N2["Vào 2\n(preorder: 2)\n(postorder: 2)"]
    N0 --> OUT0["Rời 0\n(postorder: 0)"]

3. DFS iterative — explicit stack

Recursive DFS dùng JVM call stack. Với graph sâu O(V) frame, risk StackOverflowError là thực tế — chain graph 1 triệu đỉnh sẽ crash. Giải pháp: dùng Stack trên heap làm explicit stack.

dfsIterative(G, start):
    visited[v] <- false cho mọi v
    S <- Stack rỗng
    S.push(start)

    while S không rỗng:
        v <- S.pop()
        if visited[v]: continue        -- có thể vào stack nhiều lần
        visited[v] <- true
        process(v)

        -- push hàng xóm theo thứ tự ngược để giữ thứ tự duyệt
        for each u kề v theo thứ tự ngược:
            if not visited[u]:
                S.push(u)

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

Tại sao push ngược? Recursive DFS xử lý hàng xóm theo thứ tự tự nhiên (0, 1, 2...). Stack là LIFO — nếu push theo thứ tự tự nhiên, hàng xóm cuối sẽ được pop ra trước. Push ngược → hàng xóm đầu tiên được pop trước → cùng thứ tự duyệt với recursive.

Trade-off:

RecursiveIterative
CodeNgắn gọn, rõ ràngDài hơn
Stack overflowRisk với V lớnAn toàn — stack trên heap
PostorderTự nhiên (code sau vòng lặp)Phức tạp — cần state machine
Prod với V lớnCần tăng stack size hoặc convertPreferred
⚠️ Postorder trong iterative DFS

Iterative DFS không tự nhiên có postorder (vì bạn chưa biết khi nào xong hết children của một đỉnh). Cần dùng state machine — push đỉnh hai lần, lần đầu mark "cần xử lý children", lần sau (sau khi children xong) mới process postorder. Với recursive DFS, postorder chỉ là code ngay sau vòng lặp neighbors.

4. Preorder vs Postorder vs Reverse postorder

Một traversal DFS cho ra hai thứ tự khác nhau — preorder và postorder — từ cùng một lần chạy.

dfsHelper(G, v, visited):
    visited[v] <- true
    process_preorder(v)        -- preorder: ngay khi vào đỉnh

    for each đỉnh u kề v:
        if not visited[u]:
            dfsHelper(G, u, visited)

    process_postorder(v)       -- postorder: sau khi TẤT CẢ con xong

Khi nào dùng preorder:

  • File system traversal (find, ls -R): in thư mục cha trước khi liệt kê nội dung.
  • Copy tree: tạo node cha trước khi clone children.
  • Serialize tree structure: parent cần tồn tại trước children.

Khi nào dùng postorder:

  • Delete tree: xóa children trước khi xóa cha (tránh dangling reference).
  • Evaluate expression tree: cần giá trị của 2 sub-expression (children) trước khi tính operator (cha).
  • Dependency resolution: artifact phải build sau khi tất cả dependency của nó đã build xong — postorder DFS trên DAG cho thứ tự đúng.

Reverse postorder (đảo ngược thứ tự postorder) = topological sort của DAG. Đây là cơ sở của DFS-based topological sort — bài 04-topological-sort sẽ phân tích chi tiết.

5. Cycle detection — white/gray/black coloring

Cách phát hiện cycle trên directed graph là dùng 3 màu thay vì boolean visited:

  • WHITE: chưa thăm lần nào.
  • GRAY: đang trong recursion — đỉnh này đang nằm trên call stack.
  • BLACK: đã xong hoàn toàn — đã recurse qua tất cả descendants.

Logic detect cycle: nếu DFS từ đỉnh v gặp hàng xóm u màu GRAY, nghĩa là u đang nằm trên call stack — tức là có đường đi từ u đến v (qua path DFS đang đi) và từ v đến u (cạnh vừa xét). Đây là back-edge → cycle.

hasCycle(G):
    color[v] <- WHITE cho mọi v

    for each v trong G:
        if color[v] = WHITE:
            if dfsCheck(G, v, color): return true
    return false

dfsCheck(G, v, color):
    color[v] <- GRAY               -- đang vào: đỉnh lên stack

    for each đỉnh u kề v:
        if color[u] = GRAY: return true    -- back-edge -> cycle
        if color[u] = WHITE:
            if dfsCheck(G, u, color): return true

    color[v] <- BLACK              -- đang rời: đã xử lý xong
    return false

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

flowchart TD
    ENTER["Vào đỉnh v\n→ tô GRAY"]
    CHECK{"Hàng xóm u\nmàu gì?"}
    GRAY_FOUND["u màu GRAY\n→ back-edge\n→ CÓ CYCLE"]
    WHITE_RECURSE["u màu WHITE\n→ đệ quy DFS(u)"]
    BLACK_SKIP["u màu BLACK\n→ bỏ qua\n(đã xong, không phải cycle)"]
    EXIT["Rời đỉnh v\n→ tô BLACK"]

    ENTER --> CHECK
    CHECK --> GRAY_FOUND
    CHECK --> WHITE_RECURSE
    CHECK --> BLACK_SKIP
    WHITE_RECURSE --> CHECK
    BLACK_SKIP --> CHECK
    CHECK -- "hết hàng xóm" --> EXIT

Tại sao cần GRAY thay vì chỉ visited boolean? Với directed graph, boolean visited không phân biệt được "đang trên stack" vs "đã xử lý xong". GRAY có nghĩa đang trên call stack ngay lúc này. BLACK có nghĩa đã xong, không còn trên stack. Chỉ GRAY mới là back-edge và cycle.

Undirected graph: Cần exclude parent vì trong undirected graph, cùng một cạnh xuất hiện hai chiều trong adjacency list — trở về cha không phải cycle:

dfsCheckUndirected(G, v, parent, visited):
    visited[v] <- true
    for each đỉnh u kề v:
        if not visited[u]:
            if dfsCheckUndirected(G, u, v, visited): return true
        else if u != parent:
            return true           -- back-edge về non-parent -> cycle
    return false

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

Directed vs Undirected cycle detection

Directed graph: dùng white/gray/black coloring. GRAY = back-edge = cycle.
Undirected graph: boolean visited + track parent. Cạnh về đỉnh đã visited mà không phải parent = cycle.

6. Connected components — undirected graph

Đếm số connected component trong undirected graph: mỗi lần DFS từ một đỉnh chưa thăm sẽ visit đúng một component (tất cả đỉnh có thể đến được từ đó).

countComponents(G):
    visited[v] <- false cho mọi v
    count <- 0

    for each v trong G:
        if not visited[v]:
            dfsHelper(G, v, visited)   -- visit toàn bộ component chứa v
            count <- count + 1         -- một component mới được tìm thấy

    return count

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

Nếu graph có K component, vòng for sẽ trigger DFS đúng K lần — mỗi lần mark toàn bộ component đó rồi tăng counter. Tổng cộng mỗi đỉnh và cạnh vẫn chỉ được xử lý một lần dù DFS chạy nhiều lần.

7. Pitfall tổng hợp

Pitfall 1 — Stack overflow trên deep graph

-- BUG: recursive DFS trên chain graph 1 triệu đỉnh
-- 0 -> 1 -> 2 -> ... -> 999999
-- Độ sâu call stack = 1.000.000 frame -> crash StackOverflow

-- ĐÚNG: iterative DFS -- stack trên heap, không bị giới hạn
dfsIterative(G, start)    -- xử lý 1 triệu đỉnh thoải mái

Nếu buộc phải dùng recursive, có thể tạo thread riêng với stack size lớn hơn — nhưng đây là workaround, không phải giải pháp. Dùng iterative DFS là giải pháp đúng.

Pitfall 2 — Cycle detection sai trên undirected graph

-- BUG: không track parent -- false positive trên undirected graph
dfsCheckBug(G, v, visited):
    visited[v] <- true
    for each đỉnh u kề v:
        if visited[u]: return true   -- BUG: u có thể chỉ là parent!
    return false

-- Graph: 0 -- 1 -- 2 (không có cycle)
-- DFS từ 0: thăm 1, từ 1 thăm 0 (đã visited) -> báo có cycle -> SAI

Luôn track parent khi detect cycle trên undirected graph. Directed graph dùng white/gray/black (không cần parent vì mọi back-edge đều là cycle thực sự).

Pitfall 3 — Mark visited sai chỗ gây visit nhiều lần

-- BUG: không kiểm tra visited trước khi đệ quy
dfsHelperBug(G, v, visited):
    visited[v] <- true
    for each đỉnh u kề v:
        dfsHelperBug(G, u, visited)   -- không check -- có thể vào u nhiều lần

Với graph có nhiều đường tới cùng một đỉnh, code trên sẽ recurse vào u nhiều lần — exponential time. Kiểm tra visited[u] trước khi gọi đệ quy, hoặc check visited[v] ngay đầu hàm.

8. DFS variants — mở rộng

Iterative deepening DFS (IDDFS): kết hợp memory footprint của DFS với guarantee level-by-level của BFS. Chạy DFS với depth limit 1, rồi 2, rồi 3... Tìm được đỉnh đích ở độ sâu d với memory O(d) thay vì O(b^d) của BFS. Dùng trong AI game tree search (chess engine, puzzle solver).

Tarjan SCC (Strongly Connected Components): tìm tất cả SCC trong directed graph bằng một lần DFS, dùng low-link values để detect khi nào một SCC đã hoàn chỉnh. Complexity O(V + E). Bài nâng cao Module 3.

Articulation points và bridges: một đỉnh là articulation point nếu xóa nó làm graph bị ngắt kết nối. Một cạnh là bridge nếu xóa nó làm graph bị ngắt. Cả hai đều tìm được trong O(V + E) bằng DFS với disclow arrays. Ứng dụng: phân tích độ bền của network — tìm single point of failure.

9. Applied to tech

Maven / Gradle build dependency cycle detection: Gradle xây dựng DAG từ các implementation dependency, chạy DFS với white/gray/black để kiểm tra cycle. Nếu phát hiện back-edge (màu GRAY), Gradle throw error "Circular dependency". Không thể build project có circular dependency vì không xác định được thứ tự build đúng.

JSON / YAML parser tree walk: Sau khi parse xong, cây AST được duyệt bằng DFS. Preorder cho serialize (ghi parent rồi children), postorder cho evaluate (tính value của 2 node con trước khi kết hợp tại node cha). Ví dụ: expression (a + b) * c là cây với * ở root, + ở left child — postorder evaluate tính a + b trước.

File system find / ls -R: DFS preorder tự nhiên — thư mục cha xuất hiện trước các file và thư mục con. find . -name "*.java" duyệt toàn bộ cây thư mục theo DFS, in path khi mỗi node được enter (preorder).

Browser call stack: Mỗi lần JavaScript gọi một function, engine push frame lên call stack, pop khi return. Call stack inherently là DFS — đi sâu vào call chain, pop dần khi return. Stack trace bạn thấy trong DevTools là snapshot của DFS traversal đang diễn ra tại moment đó.

10. Deep Dive

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

Sách kinh điển:

  • Introduction to Algorithms (CLRS), Chapter 22.3 — DFS: formal proof về classification of edges (tree/back/forward/cross edge), white/gray/black theorem, parenthesis theorem.
  • Introduction to Algorithms (CLRS), Chapter 22.4 — Topological sort: chứng minh DFS-based topo sort correctness.
  • Tarjan, R.E. (1972) — "Depth-first search and linear graph algorithms": bài báo gốc giới thiệu DFS với low-link, SCC algorithm.

Cross-link trong khóa học:

  • Module 3, Lesson 01 — Graph representation: adjacency list dùng trong DFS code.
  • Module 3, Lesson 04 — Topological sort: DFS-based topo sort = reverse postorder của DFS.
  • Module 3, Lesson 02 — BFS: so sánh BFS vs DFS, khi nào dùng cái nào.
  • Module 3, Lesson 11 — Graph algorithms case study: Maven dependency cycle detection deep dive.

11. Tóm tắt

  • DFS đi sâu theo từng nhánh trước khi quay lại — LIFO mechanic qua call stack hoặc explicit Stack.
  • Recursive DFS ngắn gọn nhưng risk StackOverflow với graph sâu O(V); iterative DFS dùng Stack trên heap, an toàn hơn.
  • Preorder: process đỉnh khi enter — dùng cho serialize, copy tree, file system traversal.
  • Postorder: process đỉnh khi exit (sau tất cả children) — dùng cho delete tree, evaluate expression, dependency resolution.
  • Cycle detection directed: white/gray/black coloring. GRAY = đang trên call stack = back-edge = cycle.
  • Cycle detection undirected: boolean visited + track parent. Cạnh về đỉnh đã visited mà không phải parent = cycle.
  • Connected components: DFS từ mỗi unvisited đỉnh = một component. O(V + E) tổng cộng.
  • Reverse postorder của DFS trên DAG = topological sort (bài 04-topological-sort).

12. Tự kiểm tra

Tự kiểm tra
Q1
DFS recursive vs iterative — khi nào nên chọn iterative?

Chọn iterative khi depth của graph có thể đạt O(V) và V đủ lớn để gây StackOverflow. Cụ thể: chain graph, tree suy biến (skewed tree), hoặc bất kỳ graph nào mà input từ bên ngoài kiểm soát structure — attacker có thể gửi chain dài để crash.

JVM mặc định stack size nhỏ, tương đương khoảng 10.000–20.000 frame tùy kích thước method. Graph 1 triệu đỉnh sẽ vượt ngưỡng đó nếu dùng recursive DFS. Iterative DFS dùng Stack trên heap — heap thường vài GB, an toàn hơn nhiều.

Q2
Preorder vs postorder — cho một use case khác nhau cho mỗi loại.

Preorder (process khi enter): phù hợp khi parent cần được xử lý trước children. Ví dụ: serialize cây để lưu xuống file — node cha phải được ghi trước để khi đọc lại biết cấu trúc hierarchy. Hoặc file system traversal: in đường dẫn thư mục trước khi liệt kê nội dung.

Postorder (process khi exit): phù hợp khi cần kết quả của tất cả children trước khi xử lý cha. Ví dụ: tính size của thư mục — size của thư mục cha = tổng size các file và thư mục con, phải biết size children trước. Hoặc delete tree: xóa children trước để không có dangling reference, rồi mới xóa cha.

Q3
White/gray/black coloring cho cycle detection — gray nghĩa là gì về mặt semantics?

GRAY có nghĩa đúng là: đỉnh này đang nằm trên call stack ngay lúc này. Khi DFS enter đỉnh v, ta tô GRAY. Khi DFS exit (sau khi xử lý xong toàn bộ subtree của v), ta tô BLACK.

Tại bất kỳ thời điểm nào trong DFS, tập các GRAY đỉnh chính xác là tập đỉnh trên đường đi hiện tại từ start đến đỉnh đang được xử lý. Nếu DFS gặp cạnh tới một GRAY đỉnh, nghĩa là có đường từ đỉnh đó quay lại chính nó (qua path hiện tại) — đây là back-edge và là cycle. Đỉnh BLACK đã xong hoàn toàn — cạnh tới BLACK đỉnh không phải cycle.

Q4
Cycle detection trên undirected graph — vì sao cạnh về parent không phải cycle?

Trong undirected graph, cạnh {u, v} là hai chiều: xuất hiện trong adjacency list của cả uv. Khi DFS đang ở v (vừa đến từ u), và duyệt neighbor list của v, u là một trong số đó. u đã visited — nhưng đây chỉ là cùng một cạnh nhìn từ phía v, không phải một đường đi riêng biệt tạo ra cycle.

Cycle thực sự là khi DFS từ v tới hàng xóm ww đã visited và w không phải parent trực tiếp của v. Khi đó, tồn tại hai đường đi khác nhau từ w đến v — đó mới là cycle. Nếu không track parent, mọi undirected edge sẽ bị false-positive là cycle.

Q5
Connected components count — vì sao mỗi DFS call từ unvisited đỉnh = 1 component mới?

Định nghĩa connected component: tập đỉnh mà mọi cặp đều có đường đi đến nhau. DFS từ đỉnh i sẽ visit chính xác tất cả đỉnh có thể đến được từ i — đây là component chứa i.

Sau khi DFS từ i xong, mọi đỉnh trong component đó đã được mark visited. Vòng for tiếp tục tìm đỉnh chưa visited tiếp theo — đỉnh đó thuộc một component khác (vì nếu cùng component với i, nó đã được visit rồi). DFS từ đỉnh mới đó sẽ lại mark toàn bộ component của nó. Số lần DFS được trigger = số component.

Q6
Cho graph chain 1 triệu đỉnh (0→1→2→...→999999) — recursive DFS gây gì? Fix thế nào?

Recursive DFS từ đỉnh 0 sẽ gọi dfsHelper(1), trong đó gọi dfsHelper(2), ... cho đến dfsHelper(999999). Độ sâu call stack = 1.000.000 frame. JVM với stack size mặc định crash tại khoảng frame 10.000–20.000 với StackOverflow.

Hai cách fix: (1) Iterative DFS với Stack S = new Stack — stack trên heap, không bị giới hạn bởi thread stack size, xử lý 1 triệu đỉnh thoải mái. (2) Tăng stack size cho thread cụ thể — nhưng đây là workaround, lãng phí memory. Option 1 là giải pháp đúng.

Bài tiếp theo: Topological sort — Kahn vs DFS-based

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

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

Chưa có câu hỏi

Đặt câu hỏi

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

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