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.
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
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án
Ngăn xếp (stack)
Đã thăm
Diễn biến
1
khởi tạo 1
1
—
Bắt đầu: đưa đỉnh 1 vào ngăn xếp.
2
lấy ra 1
—
1
Lấ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 3
3
1
Xét đỉnh 3 kề với đỉnh 1: chưa thăm → đưa vào ngăn xếp.
4
đưa vào 2
3 2
1
Xét đỉnh 2 kề với đỉnh 1: chưa thăm → đưa vào ngăn xếp.
5
lấy ra 2
3
1 2
Lấ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 5
3 5
1 2
Xét đỉnh 5 kề với đỉnh 2: chưa thăm → đưa vào ngăn xếp.
7
đưa vào 4
3 5 4
1 2
Xét đỉnh 4 kề với đỉnh 2: chưa thăm → đưa vào ngăn xếp.
8
bỏ qua 1
3 5 4
1 2
Xét đỉnh 1 kề với đỉnh 2: đã thăm rồi, bỏ qua.
9
lấy ra 4
3 5
1 2 4
Lấ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 7
3 5 7
1 2 4
Xét đỉnh 7 kề với đỉnh 4: chưa thăm → đưa vào ngăn xếp.
11
bỏ qua 2
3 5 7
1 2 4
Xét đỉnh 2 kề với đỉnh 4: đã thăm rồi, bỏ qua.
12
lấy ra 7
3 5
1 2 4 7
Lấ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 5
3 5 5
1 2 4 7
Xét đỉnh 5 kề với đỉnh 7: chưa thăm → đưa vào ngăn xếp.
14
bỏ qua 4
3 5 5
1 2 4 7
Xét đỉnh 4 kề với đỉnh 7: đã thăm rồi, bỏ qua.
15
lấy ra 5
3 5
1 2 4 7 5
Lấy đỉnh 5 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 5 được thăm.
16
bỏ qua 7
3 5
1 2 4 7 5
Xét đỉnh 7 kề với đỉnh 5: đã thăm rồi, bỏ qua.
17
bỏ qua 2
3 5
1 2 4 7 5
Xét đỉnh 2 kề với đỉnh 5: đã thăm rồi, bỏ qua.
18
trùng 5
3
1 2 4 7 5
Lấ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!)
19
lấy ra 3
—
1 2 4 7 5 3
Lấ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 6
6
1 2 4 7 5 3
Xét đỉnh 6 kề với đỉnh 3: chưa thăm → đưa vào ngăn xếp.
21
bỏ qua 1
6
1 2 4 7 5 3
Xét đỉnh 1 kề với đỉnh 3: đã thăm rồi, bỏ qua.
22
lấy ra 6
—
1 2 4 7 5 3 6
Lấ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 8
8
1 2 4 7 5 3 6
Xét đỉnh 8 kề với đỉnh 6: chưa thăm → đưa vào ngăn xếp.
24
bỏ qua 3
8
1 2 4 7 5 3 6
Xét đỉnh 3 kề với đỉnh 6: đã thăm rồi, bỏ qua.
25
lấy ra 8
—
1 2 4 7 5 3 6 8
Lấy đỉnh 8 ra khỏi đỉnh ngăn xếp, đánh dấu đã thăm. Đây là đỉnh thứ 8 được thăm.
26
bỏ qua 6
—
1 2 4 7 5 3 6 8
Xét đỉnh 6 kề với đỉnh 8: đã thăm rồi, bỏ qua.
27
xong
—
1 2 4 7 5 3 6 8
Hà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 syssys.setrecursionlimit(300000) # BẮT BUỘC với n lớn, xem phần dướike = [[] 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ạidfs(1) # xuất phát từ đỉnh 1
Đọc lại ba dòng cốt lõi:
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.
xử lý u — in ra, đếm, cộng dồn… tuỳ bài.
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ếp — Segmentation 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
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.
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ị:
Không có ke[u]. Các đỉnh kề được tính tại chỗ từ bốn hướng dr, dc.
Phải kiểm tra biên lưới trước khi đọc ô, và đó chính là phép “đỉnh này có tồn tại không”.
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ì.
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
Quét cả lưới theo từng ô.
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.
Gọi DFS từ ô đó để thăm hết mọi ô cùng đảo, đánh dấu chúng lại.
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.
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ố u và v 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ố”:
Với mỗi thành phố uchưa thăm: tăng số tỉnh, rồi DFS từ u.
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.
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.
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ướngb → 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.