DFS & BFSthầy Phúc

DFS — duyệt theo chiều sâu

Depth-First Search. Gặp đường là đi tới cùng; hết đường mới quay lại ngã rẽ gần nhất mà thử hướng khác.

1. Ý tưởng

Hãy tưởng tượng các em đi trong một mê cung, tay luôn bám vào tường bên phải. Các em không hề cân nhắc, không nhìn bản đồ: cứ đi thẳng tới khi cụt đường, rồi lùi lại ngã rẽ gần nhất và thử nhánh khác. Đó chính là DFS.

Từ khoá của DFS là “tới cùng”. Khi đứng ở đỉnh 1 và thấy hai đỉnh kề là 2 và 3, DFS chọn 2 rồi quên hẳn 3 đi — 3 sẽ được xử lý sau, khi nào toàn bộ những gì đi được từ 2 đã xong.

112234356748
DFS sau 4 đỉnh: nó đã lao thẳng xuống 1 → 2 → 4 → 7 mà chưa hề nhìn tới đỉnh 3, dù 3 kề ngay với đỉnh xuất phát. Đỉnh viền nét đứt (3 và 5) là những đỉnh đã biết nhưng còn nằm chờ trong ngăn xếp.

2. Tự bấm từng bước

Đừng tin, hãy xem. Bấm Bước tiếp để đi qua từng phép toán một. Chú ý cột ngăn xếp: mỗi lần lấy ra là lấy phần tử cuối cùng vừa được thêm vào.

DFS trên đồ thị mẫu — bấm từng bước

12345678

Ngăn xếp (stack)

1
đáyđỉnh (lấy ra & đưa vào) ←

Đã thăm (0)

chưa có đỉnh nào

Thứ tự thăm

chưa thăm đỉnh nào
khởi tạo

Bắt đầu: đưa đỉnh 1 vào ngăn xếp.

Thứ tự thăm của DFS trên đồ thị mẫu là 1 → 2 → 4 → 7 → 5 → 3 → 6 → 8.

Bảng mô phỏng đầy đủ — 27 bước
#Phép toánNgăn xếp (stack)Đã thămDiễn biến
1khởi tạo 11Bắt đầu: đưa đỉnh 1 vào ngăn xếp.
2lấy ra 11Lấy đỉnh 1 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 1 được thăm.
3đưa vào 331Xét đỉnh 3 kề với đỉnh 1: chưa thăm → đưa vào ngăn xếp.
4đưa vào 23 21Xét đỉnh 2 kề với đỉnh 1: chưa thăm → đưa vào ngăn xếp.
5lấy ra 231 2Lấy đỉnh 2 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 2 được thăm.
6đưa vào 53 51 2Xét đỉnh 5 kề với đỉnh 2: chưa thăm → đưa vào ngăn xếp.
7đưa vào 43 5 41 2Xét đỉnh 4 kề với đỉnh 2: chưa thăm → đưa vào ngăn xếp.
8bỏ qua 13 5 41 2Xét đỉnh 1 kề với đỉnh 2: đã thăm rồi, bỏ qua.
9lấy ra 43 51 2 4Lấy đỉnh 4 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 3 được thăm.
10đưa vào 73 5 71 2 4Xét đỉnh 7 kề với đỉnh 4: chưa thăm → đưa vào ngăn xếp.
11bỏ qua 23 5 71 2 4Xét đỉnh 2 kề với đỉnh 4: đã thăm rồi, bỏ qua.
12lấy ra 73 51 2 4 7Lấy đỉnh 7 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 4 được thăm.
13đưa vào 53 5 51 2 4 7Xét đỉnh 5 kề với đỉnh 7: chưa thăm → đưa vào ngăn xếp.
14bỏ qua 43 5 51 2 4 7Xét đỉnh 4 kề với đỉnh 7: đã thăm rồi, bỏ qua.
15lấy ra 53 51 2 4 7 5Lấy đỉnh 5 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 5 được thăm.
16bỏ qua 73 51 2 4 7 5Xét đỉnh 7 kề với đỉnh 5: đã thăm rồi, bỏ qua.
17bỏ qua 23 51 2 4 7 5Xét đỉnh 2 kề với đỉnh 5: đã thăm rồi, bỏ qua.
18trùng 531 2 4 7 5Lấy đỉnh 5 ra, nhưng đã thăm rồi — bỏ qua. (Một đỉnh có thể nằm nhiều lần trong ngăn xếp!)
19lấy ra 31 2 4 7 5 3Lấy đỉnh 3 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 6 được thăm.
20đưa vào 661 2 4 7 5 3Xét đỉnh 6 kề với đỉnh 3: chưa thăm → đưa vào ngăn xếp.
21bỏ qua 161 2 4 7 5 3Xét đỉnh 1 kề với đỉnh 3: đã thăm rồi, bỏ qua.
22lấy ra 61 2 4 7 5 3 6Lấy đỉnh 6 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 7 được thăm.
23đưa vào 881 2 4 7 5 3 6Xét đỉnh 8 kề với đỉnh 6: chưa thăm → đưa vào ngăn xếp.
24bỏ qua 381 2 4 7 5 3 6Xét đỉnh 3 kề với đỉnh 6: đã thăm rồi, bỏ qua.
25lấy ra 81 2 4 7 5 3 6 8Lấy đỉnh 8 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 8 được thăm.
26bỏ qua 61 2 4 7 5 3 6 8Xét đỉnh 6 kề với đỉnh 8: đã thăm rồi, bỏ qua.
27xong1 2 4 7 5 3 6 8Hàng chờ rỗng — DFS kết thúc. Thứ tự thăm: 1 → 2 → 4 → 7 → 5 → 3 → 6 → 8.

3. Bản đệ quy

Đây là bản các em nên viết trong 95% trường hợp. Nó ngắn, và nó không cần ngăn xếp tường minh — vì ngăn xếp lời gọi hàm của máy đã làm việc đó hộ.

DFS đệ quy

vector<vector<int>> ke;
vector<bool> daTham;

void dfs(int u) {
    daTham[u] = true;           // đánh dấu NGAY khi bước vào
    cout << u << ' ';           // xử lý đỉnh u
    for (int v : ke[u])
        if (!daTham[v])
            dfs(v);             // đi sâu vào v, chưa quan tâm các đỉnh kề còn lại
}

int main() {
    // ... đọc đồ thị vào ke ...
    daTham.assign(n + 1, false);
    dfs(1);                     // xuất phát từ đỉnh 1
}
import sys
sys.setrecursionlimit(300000)   # BẮT BUỘC với n lớn, xem phần dưới

ke = [[] for _ in range(n + 1)]
daTham = [False] * (n + 1)

def dfs(u: int) -> None:
    daTham[u] = True            # đánh dấu NGAY khi bước vào
    print(u, end=' ')           # xử lý đỉnh u
    for v in ke[u]:
        if not daTham[v]:
            dfs(v)              # đi sâu vào v, chưa quan tâm các đỉnh kề còn lại

dfs(1)                          # xuất phát từ đỉnh 1

Đọc lại ba dòng cốt lõi:

  1. daTham[u] = true — đánh dấu ngay khi bước vào, trước khi làm bất cứ việc gì khác.
  2. xử lý u — in ra, đếm, cộng dồn… tuỳ bài.
  3. for v : ke[u] nếu v chưa thăm thì dfs(v) — và lời gọi này chạy tới cùng trước khi vòng lặp sang v tiếp theo. Đó là chỗ “chiều sâu” nằm.

4. Bản ngăn xếp — và vì sao các em cần nó

Đệ quy có một giới hạn cứng: chiều sâu. Mỗi lời gọi dfs chiếm một khung trên ngăn xếp của chương trình. Với đồ thị 10⁵ đỉnh xếp thành một đường thẳng, đệ quy đi sâu 10⁵ tầng và chương trình tràn ngăn xếpSegmentation fault trong C++, RecursionError trong Python.

Bản ngăn xếp tường minh không có giới hạn đó, vì ngăn xếp nằm trên heap.

DFS bằng ngăn xếp tường minh

void dfsNganXep(int batDau) {
    vector<int> nganXep;
    nganXep.push_back(batDau);

    while (!nganXep.empty()) {
        int u = nganXep.back();      // lấy đỉnh TRÊN CÙNG
        nganXep.pop_back();

        if (daTham[u]) continue;     // ← BẮT BUỘC: u có thể nằm 2 lần trong ngăn xếp
        daTham[u] = true;            // đánh dấu KHI LẤY RA
        cout << u << ' ';

        // đưa vào theo thứ tự NGƯỢC để đỉnh nhỏ nhất được lấy ra trước,
        // khớp đúng thứ tự của bản đệ quy
        for (int i = ke[u].size() - 1; i >= 0; i--) {
            int v = ke[u][i];
            if (!daTham[v]) nganXep.push_back(v);
        }
    }
}
def dfs_ngan_xep(bat_dau: int) -> None:
    ngan_xep = [bat_dau]

    while ngan_xep:
        u = ngan_xep.pop()           # lấy đỉnh TRÊN CÙNG

        if daTham[u]:                # ← BẮT BUỘC: u có thể nằm 2 lần trong ngăn xếp
            continue
        daTham[u] = True             # đánh dấu KHI LẤY RA
        print(u, end=' ')

        # đưa vào theo thứ tự NGƯỢC để đỉnh nhỏ nhất được lấy ra trước,
        # khớp đúng thứ tự của bản đệ quy
        for v in reversed(ke[u]):
            if not daTham[v]:
                ngan_xep.append(v)

Hai bản có cùng thứ tự thăm — điều đó không tự nhiên mà có. Ta phải đưa các đỉnh kề vào ngăn xếp theo thứ tự ngược (dòng for i = size-1 down to 0). Vì ngăn xếp đảo thứ tự, đưa vào ngược thì lấy ra mới là thứ tự xuôi, khớp với bản đệ quy. Nếu đưa vào xuôi, thuật toán vẫn đúng, chỉ là thứ tự thăm khác đi.

5. Độ phức tạp

Chi phí Vì sao
Thời gian O(V + E) Mỗi đỉnh được đánh dấu và xử lý đúng một lần (V). Mỗi cạnh được xét đúng hai lần, một lần từ mỗi đầu (2E).
Bộ nhớ O(V) Mảng daTham cùng ngăn xếp; ngăn xếp sâu nhất bằng đường đi dài nhất, tối đa V.

Với đồ thị thưa (E ≈ V) đây là tuyến tính. Ghi nhớ: DFS và BFS có cùng độ phức tạp. Chọn cái nào không phải vì tốc độ.

6. Lỗi thường gặp

7. DFS trên lưới

Trên lưới, DFS đệ quy gọn tới mức không cần cả mảng daTham riêng: ghi đè lên chính ô vừa thăm.

DFS trên lưới bốn hướng

void dfsLuoi(vector<vector<char>>& a, int r, int c) {
    int n = a.size(), m = a[0].size();
    if (r < 0 || c < 0 || r >= n || c >= m) return;   // "đỉnh này không tồn tại"
    if (a[r][c] != '1') return;                      // nước, hoặc đã thăm
    a[r][c] = '0';                                   // đánh dấu, thay cho daTham
    dfsLuoi(a, r - 1, c);
    dfsLuoi(a, r + 1, c);
    dfsLuoi(a, r, c - 1);
    dfsLuoi(a, r, c + 1);
}
def dfs_luoi(a: List[List[str]], r: int, c: int) -> None:
    n, m = len(a), len(a[0])
    if r < 0 or c < 0 or r >= n or c >= m:    # "đỉnh này không tồn tại"
        return
    if a[r][c] != '1':                        # nước, hoặc đã thăm
        return
    a[r][c] = '0'                             # đánh dấu, thay cho daTham
    dfs_luoi(a, r - 1, c)
    dfs_luoi(a, r + 1, c)
    dfs_luoi(a, r, c - 1)
    dfs_luoi(a, r, c + 1)

DFS trên lưới — thử cả BFS để so sánh

0123401234

Ngăn xếp (stack)

(0,0)
đáyđỉnh (lấy ra & đưa vào) ←

Đã thăm (0)

chưa có đỉnh nào

Thứ tự thăm

chưa thăm đỉnh nào
khởi tạo

Bắt đầu: đưa ô (0,0) vào ngăn xếp.

0123401234
Một lần gọi DFS từ ô (0,0) sẽ thăm đúng ba ô: (0,0), (0,1) và (1,0). Đó là trọn một hòn đảo. Lưới này có 5 đảo, nên vòng quét ngoài sẽ gọi DFS 5 lần.

Hai điểm khác so với đồ thị:

8. Ba bài LeetCode

Ba bài dưới đây đi từ mẫu dễ nhất tới ứng dụng làm nhiều người ngạc nhiên. Hãy tự làm trước, rồi mới mở đáp án — đọc đáp án khi chưa vật lộn thì không học được gì.

#200Trung bình

Đếm số hòn đảoNumber of Islands ↗

Lưới các ô đất và nước. Đếm xem có bao nhiêu vùng đất rời nhau — đây chính là đếm thành phần liên thông, chỉ khác là đồ thị được giấu trong một cái bảng.

Xem đáp án

Nhìn bài toán thành đồ thị

Mỗi ô đất là một đỉnh. Hai ô đất kề nhau theo hàng hoặc cột thì có một cạnh. Một hòn đảo chính là một thành phần liên thông. Câu hỏi “có bao nhiêu đảo” trở thành câu hỏi “có bao nhiêu thành phần liên thông” — và đó là việc DFS làm giỏi nhất.

Thuật toán

  1. Quét cả lưới theo từng ô.
  2. Gặp một ô đất chưa thăm: tăng biến đếm lên 1, vì ta vừa tìm ra một đảo mới.
  3. Gọi DFS từ ô đó để thăm hết mọi ô cùng đảo, đánh dấu chúng lại.
  4. Tiếp tục quét. Những ô đã bị đánh dấu sẽ không sinh ra đảo nào nữa.

Điểm hay của bài này là ta không cần mảng daTham riêng: ghi '0' lên chính ô đất vừa thăm là đủ, vì một ô nước không bao giờ khởi động một đảo mới. Nếu đề không cho phép sửa lưới đầu vào thì phải dùng mảng daTham riêng.

Vì sao là DFS mà không phải BFS?

Bài này cả hai đều đúng và cùng độ phức tạp. Ta chỉ cần biết “đi hết được những đâu”, không cần biết khoảng cách. Khi đã không quan tâm khoảng cách, DFS thường ngắn hơn vì viết được bằng đệ quy, không cần khai báo hàng đợi.

Cài đặt

class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        int n = grid.size(), m = grid[0].size(), dao = 0;
        for (int i = 0; i < n; i++)
            for (int j = 0; j < m; j++)
                if (grid[i][j] == '1') {
                    dao++;          // tìm thấy một đảo mới
                    dfs(grid, i, j);  // xoá sạch cả đảo đó
                }
        return dao;
    }

private:
    void dfs(vector<vector<char>>& grid, int r, int c) {
        int n = grid.size(), m = grid[0].size();
        if (r < 0 || c < 0 || r >= n || c >= m) return;  // ra ngoài lưới
        if (grid[r][c] != '1') return;                   // nước, hoặc đã thăm
        grid[r][c] = '0';                                // đánh dấu đã thăm NGAY
        dfs(grid, r - 1, c);
        dfs(grid, r + 1, c);
        dfs(grid, r, c - 1);
        dfs(grid, r, c + 1);
    }
};
class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:
        n, m = len(grid), len(grid[0])

        def dfs(r: int, c: int) -> None:
            if r < 0 or c < 0 or r >= n or c >= m:   # ra ngoài lưới
                return
            if grid[r][c] != '1':                    # nước, hoặc đã thăm
                return
            grid[r][c] = '0'                         # đánh dấu đã thăm NGAY
            dfs(r - 1, c)
            dfs(r + 1, c)
            dfs(r, c - 1)
            dfs(r, c + 1)

        dao = 0
        for i in range(n):
            for j in range(m):
                if grid[i][j] == '1':
                    dao += 1        # tìm thấy một đảo mới
                    dfs(i, j)       # xoá sạch cả đảo đó
        return dao
Độ phức tạp
O(n·m) thời gian, O(n·m) bộ nhớ cho chiều sâu đệ quy trong trường hợp xấu nhất (cả lưới là đất).
Chỗ dễ sai
Đánh dấu ô đã thăm sau khi gọi đệ quy thay vì ngay khi bước vào — lập tức lặp vô hạn giữa hai ô kề nhau.
#547Trung bình

Đếm số tỉnhNumber of Provinces ↗

Cùng một câu hỏi với bài 200 — đếm thành phần liên thông — nhưng lần này đồ thị được cho tường minh bằng ma trận kề. Đây là bài để các em nhìn ra: đảo và tỉnh là một bài toán.

Xem đáp án

Ma trận kề thay cho danh sách kề

Đề cho keNhau[u][v] == 1 nghĩa là thành phố uv nối trực tiếp. Đây là ma trận kề. Ta không cần chuyển nó sang danh sách kề: vòng lặp for v in 0..n-1 đóng vai trò “duyệt các đỉnh kề của u”, chỉ là phải xem qua cả những v không kề.

Cái giá phải trả là O(n²): với ma trận kề, kể cả đồ thị chỉ có vài cạnh vẫn phải đọc n² ô.

Thuật toán

Giống hệt bài 200, đổi “ô đất” thành “thành phố”:

  1. Với mỗi thành phố u chưa thăm: tăng số tỉnh, rồi DFS từ u.
  2. DFS thăm hết mọi thành phố nối được tới u — đó là trọn một tỉnh.

Khi làm xong bài này, hãy tự nhìn lại bài 200 một lần nữa. Hai đoạn code gần như trùng nhau. Đó không phải trùng hợp: đếm thành phần liên thông là một mẫu (pattern), và nó chỉ cần DFS.

Mở rộng để tự làm

Bài 1971. Find if Path Exists in Graph là cùng khuôn nhưng chỉ hỏi hai đỉnh có cùng thành phần liên thông hay không.

Cài đặt

class Solution {
public:
    int findCircleNum(vector<vector<int>>& keNhau) {
        int n = keNhau.size(), soTinh = 0;
        vector<bool> daTham(n, false);
        for (int u = 0; u < n; u++)
            if (!daTham[u]) {
                soTinh++;                    // một tỉnh mới
                dfs(keNhau, daTham, u);
            }
        return soTinh;
    }

private:
    void dfs(vector<vector<int>>& keNhau, vector<bool>& daTham, int u) {
        daTham[u] = true;                    // đánh dấu NGAY khi bước vào
        int n = keNhau.size();
        for (int v = 0; v < n; v++)
            if (keNhau[u][v] == 1 && !daTham[v])
                dfs(keNhau, daTham, v);
    }
};
class Solution:
    def findCircleNum(self, keNhau: List[List[int]]) -> int:
        n = len(keNhau)
        daTham = [False] * n

        def dfs(u: int) -> None:
            daTham[u] = True                 # đánh dấu NGAY khi bước vào
            for v in range(n):
                if keNhau[u][v] == 1 and not daTham[v]:
                    dfs(v)

        soTinh = 0
        for u in range(n):
            if not daTham[u]:
                soTinh += 1                  # một tỉnh mới
                dfs(u)
        return soTinh
Độ phức tạp
O(n²) thời gian vì phải đọc hết ma trận kề, O(n) bộ nhớ.
Chỗ dễ sai
Quên daTham[u] = true ngay đầu hàm DFS, hoặc lặp v từ u + 1 thay vì từ 0 — bỏ sót cạnh đi ngược.
#207Trung bình

Xếp thời khoá biểu (phát hiện chu trình)Course Schedule ↗

Có thể học hết n môn với các ràng buộc "muốn học A phải học B trước" hay không? Câu trả lời là có, khi và chỉ khi đồ thị có hướng này không có chu trình. Đây là bài cho thấy DFS không chỉ để đi thăm.

Xem đáp án

Dịch đề bài sang đồ thị

[a, b] nghĩa là muốn học a thì phải học b trước. Ta vẽ cạnh có hướng b → a: học b rồi mới tới a được. Học hết được mọi môn ⟺ đồ thị có hướng này không có chu trình.

Vì sao? Nếu có chu trình x → y → z → x thì x phải học trước y, y trước z, z trước x — không môn nào được học đầu tiên.

Ba màu, không phải hai

Đây là chỗ hầu hết học sinh sai lần đầu. Với đồ thị có hướng, ta cần phân biệt hai tình huống “gặp lại một đỉnh”:

  • Gặp đỉnh xám — đỉnh đang nằm trên đường đi từ gốc tới chỗ ta đứng. Ta vừa đi một vòng quay lại chính nó: đó là chu trình.
  • Gặp đỉnh đen — đỉnh đã duyệt xong hẳn ở một nhánh khác trước đó. Không có vòng nào cả, chỉ là hai đường khác nhau cùng dẫn tới nó. Bỏ qua là đúng.

Mảng daTham chỉ hai trạng thái gộp hai tình huống này lại, nên báo có chu trình ở cả trường hợp thứ hai — sai.

Dòng mau[u] = 2 nằm sau vòng lặp là quan trọng: nó có nghĩa “ta đã đi ra khỏi u”, và chỉ lúc ấy u mới thôi nằm trên đường đi hiện tại.

Liên hệ

Bài này chính là bước đầu của sắp xếp tô-pô (topological sort). Nếu đổi câu hỏi thành “cho tôi một thứ tự học hợp lệ” thì đó là bài 210. Course Schedule II: ghi lại các đỉnh theo thứ tự tô đen rồi đảo ngược.

Cài đặt

class Solution {
public:
    bool canFinish(int n, vector<vector<int>>& dieuKien) {
        vector<vector<int>> ke(n);
        for (auto& p : dieuKien)
            ke[p[1]].push_back(p[0]);        // học p[1] xong mới học được p[0]

        // 0 = trắng (chưa xét), 1 = xám (đang trong đường đi), 2 = đen (đã xong)
        vector<int> mau(n, 0);
        for (int u = 0; u < n; u++)
            if (mau[u] == 0 && coChuTrinh(ke, mau, u))
                return false;
        return true;
    }

private:
    bool coChuTrinh(vector<vector<int>>& ke, vector<int>& mau, int u) {
        mau[u] = 1;                          // bước vào: tô xám
        for (int v : ke[u]) {
            if (mau[v] == 1) return true;    // gặp lại đỉnh xám → CHU TRÌNH
            if (mau[v] == 0 && coChuTrinh(ke, mau, v)) return true;
            // mau[v] == 2: đã xét xong nhánh đó, bỏ qua
        }
        mau[u] = 2;                          // đi ra: tô đen
        return false;
    }
};
class Solution:
    def canFinish(self, n: int, dieuKien: List[List[int]]) -> bool:
        ke = [[] for _ in range(n)]
        for sau, truoc in dieuKien:
            ke[truoc].append(sau)            # học truoc xong mới học được sau

        # 0 = trắng (chưa xét), 1 = xám (đang trong đường đi), 2 = đen (đã xong)
        mau = [0] * n

        def coChuTrinh(u: int) -> bool:
            mau[u] = 1                       # bước vào: tô xám
            for v in ke[u]:
                if mau[v] == 1:              # gặp lại đỉnh xám → CHU TRÌNH
                    return True
                if mau[v] == 0 and coChuTrinh(v):
                    return True
                # mau[v] == 2: đã xét xong nhánh đó, bỏ qua
            mau[u] = 2                       # đi ra: tô đen
            return False

        for u in range(n):
            if mau[u] == 0 and coChuTrinh(u):
                return False
        return True
Độ phức tạp
O(n + E) thời gian, O(n + E) bộ nhớ.
Chỗ dễ sai
Dùng một mảng daTham hai trạng thái thay vì ba màu. Gặp lại một đỉnh đã xong không phải chu trình; chỉ gặp lại đỉnh đang trong đường đi hiện tại mới là chu trình.