Tìm kiếm theo chiều sâu (DFS)
Cập nhật lần cuối
Tìm kiếm theo chiều sâu (depth-first search) duyệt đồ thị bằng cách đi sâu hết mức có thể theo từng nhánh trước khi quay lui. Bắt đầu từ một đỉnh nguồn, thuật toán thăm một đỉnh kề, rồi đỉnh kề của đỉnh đó, cứ thế cho đến khi gặp ngõ cụt (một đỉnh không còn đỉnh kề nào chưa thăm), lúc đó nó quay lui và thử nhánh tiếp theo. Nhấn Phát ở trên để xem thuật toán đi xuống một đường, chạm đáy rồi lùi ra.
DFS được diễn đạt tự nhiên bằng ngăn xếp (stack): hoặc một ngăn xếp tường minh (như trong hoạt ảnh này), hoặc ngăn xếp lời gọi hàm thông qua đệ quy. Thuật toán thăm mỗi đỉnh và mỗi cạnh một lần, nên chạy trong thời gian O(V + E) với đồ thị có V đỉnh và E cạnh.
Độ phức tạp thời gian và không gian
| Tiêu chí | Độ phức tạp | Ghi chú |
|---|---|---|
| Thời gian | O(V + E) | Mỗi đỉnh và mỗi cạnh được thăm một lần |
| Không gian | O(V) | Ngăn xếp + tập đã thăm, xấu nhất chứa mọi đỉnh |
| Cách duyệt | Sâu nhất trước | Đi hết một nhánh rồi mới sang nhánh khác |
| Cấu trúc dữ liệu | Ngăn xếp (hoặc đệ quy) | Thứ tự LIFO tạo ra cách duyệt theo chiều sâu |
Từng bước thực hiện
| Bước | Điều gì xảy ra |
|---|---|
| 1 | Đẩy đỉnh nguồn vào ngăn xếp. |
| 2 | Lấy một đỉnh ra; nếu đã thăm thì bỏ qua. |
| 3 | Đánh dấu đã thăm và ghi lại cạnh dẫn đến nó. |
| 4 | Đẩy tất cả các đỉnh kề chưa thăm của nó vào ngăn xếp. |
| 5 | Lặp lại cho đến khi ngăn xếp rỗng. |
Ví dụ minh họa
DFS dạng vòng lặp từ A trên đồ thị A → [B, C], B → [D], C → [E], đẩy các đỉnh kề theo thứ tự liệt kê (ngăn xếp LIFO lấy ra phần tử được đẩy vào sau cùng trước):
| Bước | Ngăn xếp (đỉnh ở bên phải) | Đã thăm | Thao tác |
|---|---|---|---|
| 1 | [A] | {} | Đẩy đỉnh nguồn A. |
| 2 | [B, C] | {A} | Lấy A ra, đánh dấu đã thăm, đẩy các đỉnh kề B rồi C. |
| 3 | [B, E] | {A, C} | Lấy C ra, đánh dấu đã thăm, đẩy đỉnh kề E. |
| 4 | [B] | {A, C, E} | Lấy E ra, đánh dấu đã thăm, không có đỉnh kề. |
| 5 | [D] | {A, C, E, B} | Lấy B ra, đánh dấu đã thăm, đẩy đỉnh kề D. |
| 6 | [] | {A, C, E, B, D} | Lấy D ra, đánh dấu đã thăm, ngăn xếp rỗng: xong. |
Khi nào nên dùng DFS
| Nên dùng khi | Tránh dùng khi |
|---|---|
| Bạn cần phát hiện chu trình, sắp xếp topo hoặc tìm các thành phần liên thông. | Bạn cần đường đi ngắn nhất trên đồ thị không trọng số: BFS làm được, DFS thì không. |
| Đồ thị rộng và nông, nên ngăn xếp tốn ít bộ nhớ hơn nhiều so với một hàng đợi chứa nhiều đỉnh biên. | Đồ thị rất sâu và bạn dùng đệ quy: ngăn xếp lời gọi hàm có thể bị tràn. |
| Bạn muốn duyệt cạn mọi đường đi, như khi giải mê cung hoặc tìm kiếm quay lui. | Bạn cần phát hiện các đỉnh theo thứ tự khoảng cách từ đỉnh nguồn. |
| Bạn đang kiểm tra khả năng đến được hoặc có tồn tại đường đi giữa hai đỉnh hay không. | Bạn phải bảo đảm đường đi tìm được có số cạnh ít nhất. |
Code Depth-First Search
Một cách cài đặt Depth-First Search gọn gàng, chạy được ngay bằng Python, JavaScript, Java, C++, C. Chọn một ngôn ngữ, sao chép code hoặc mở sẵn trong Coddy Playground.
Code Depth-First Search bằng Python
1def dfs(graph, node, visited):2 visited.add(node)3 order = [node]4 for neighbor in graph[node]:5 if neighbor not in visited:6 order.extend(dfs(graph, neighbor, visited))7 return order8
9
10graph = {11 "A": ["B", "C"],12 "B": ["D", "E"],13 "C": ["F"],14 "D": [],15 "E": ["F"],16 "F": [],17}18
19order = dfs(graph, "A", set())20print("DFS order:", " -> ".join(order))Code Depth-First Search bằng JavaScript
1const graph = {2 A: ["B", "C"],3 B: ["D", "E"],4 C: ["F"],5 D: [],6 E: ["F"],7 F: [],8};9
10function dfs(node, visited = new Set(), order = []) {11 if (visited.has(node)) return order;12 visited.add(node);13 order.push(node);14 for (const next of graph[node]) {15 dfs(next, visited, order);16 }17 return order;18}19
20console.log("DFS from A:", dfs("A").join(" -> "));Code Depth-First Search bằng Java
1import java.util.List;2
3public class Main {4 static List<List<Integer>> adj = List.of(5 List.of(1, 2), // neighbors of 06 List.of(0, 3, 4), // neighbors of 17 List.of(0, 5), // neighbors of 28 List.of(1),9 List.of(1, 5),10 List.of(2, 4)11 );12 static boolean[] visited = new boolean[6];13
14 static void dfs(int node) {15 visited[node] = true;16 System.out.print(" " + node);17 for (int next : adj.get(node)) {18 if (!visited[next]) dfs(next);19 }20 }21
22 public static void main(String[] args) {23 System.out.print("DFS from 0:");24 dfs(0);25 System.out.println();26 }27}Code Depth-First Search bằng C++
1#include <iostream>2#include <vector>3
4void dfs(int node, const std::vector<std::vector<int>>& adj,5 std::vector<bool>& visited) {6 visited[node] = true;7 std::cout << node << " ";8 // Recurse into every unvisited neighbor9 for (int next : adj[node]) {10 if (!visited[next]) dfs(next, adj, visited);11 }12}13
14int main() {15 // 6-node undirected graph as an adjacency list16 std::vector<std::vector<int>> adj = {17 {1, 2}, // 018 {0, 3, 4}, // 119 {0, 5}, // 220 {1}, // 321 {1, 5}, // 422 {2, 4}, // 523 };24 std::vector<bool> visited(adj.size(), false);25 std::cout << "DFS from node 0: ";26 dfs(0, adj, visited);27 std::cout << "\n";28 return 0;29}Code Depth-First Search bằng C
1#include <stdbool.h>2#include <stdio.h>3
4#define N 65
6// 6-node undirected graph as an adjacency matrix7int adj[N][N] = {8 {0, 1, 1, 0, 0, 0},9 {1, 0, 0, 1, 1, 0},10 {1, 0, 0, 0, 0, 1},11 {0, 1, 0, 0, 0, 0},12 {0, 1, 0, 0, 0, 1},13 {0, 0, 1, 0, 1, 0},14};15bool visited[N];16
17void dfs(int node) {18 visited[node] = true;19 printf("%d ", node);20 // Recurse into every unvisited neighbor21 for (int next = 0; next < N; next++) {22 if (adj[node][next] && !visited[next]) dfs(next);23 }24}25
26int main(void) {27 printf("DFS from node 0: ");28 dfs(0);29 printf("\n");30 return 0;31}Câu hỏi thường gặp về tìm kiếm theo chiều sâu
Độ phức tạp thời gian của DFS là bao nhiêu?
O(V + E), với V là số đỉnh và E là số cạnh, vì thuật toán thăm mỗi đỉnh một lần và xét mỗi cạnh một lần. DFS dùng O(V) bộ nhớ cho ngăn xếp và tập đã thăm.DFS và BFS khác nhau như thế nào?
DFS dùng đệ quy hay vòng lặp?
Cả hai đều được. Phiên bản đệ quy dùng ngầm ngăn xếp lời gọi hàm của chương trình và rất ngắn gọn; phiên bản vòng lặp dùng một ngăn xếp tường minh (như hoạt ảnh ở đây) và tránh tràn ngăn xếp do đệ quy quá sâu trên đồ thị lớn.
Khi nào nên dùng DFS thay cho BFS?
Dùng DFS khi bạn cần duyệt trọn các đường đi: phát hiện chu trình, sắp xếp topo, tìm thành phần liên thông hoặc tìm kiếm quay lui như giải mê cung. DFS cũng tốn ít bộ nhớ hơn trên đồ thị rộng, vì ngăn xếp mỗi lúc chỉ giữ biên của một nhánh. Hãy chọn BFS khi bạn cần đường đi ngắn nhất trên đồ thị không trọng số hoặc cần các đỉnh theo thứ tự khoảng cách từ đỉnh nguồn.
DFS có tìm được đường đi ngắn nhất không?
Vì sao DFS cần tập đã thăm?
O(V + E). Một sai lầm phổ biến là chỉ đánh dấu khi lấy ra nhưng vẫn đẩy các bản trùng vào, cách này vẫn đúng nhưng có thể để lại các mục cũ trên ngăn xếp cần phải bỏ qua.