Bài 6: Đệ Quy Và Quay Lui¶
Tác giả: FPTOJ Team
Nội dung tham khảo từ: VNOI Wiki - Đệ quy và thuật toán quay lui
Bản chất vấn đề¶
Đệ quy là gì?¶
Đệ quy (Recursion) là kỹ thuật một hàm tự gọi chính nó để giải quyết bài toán. Bài toán lớn được chia thành các bài toán con nhỏ hơn có cùng cấu trúc, cho đến khi gặp trường hợp cơ sở (base case) — điểm dừng đệ quy.
Ví dụ kinh điển: tính giai thừa \(n!\)
Để tính \(5!\), ta cần \(4!\), để tính \(4!\) ta cần \(3!\), và cứ tiếp tục cho đến \(0! = 1\).
Quay lui là gì?¶
Quay lui (Backtracking) là kỹ thuật thử tất cả các khả năng tại mỗi bước, đánh dấu lựa chọn, đệ quy sang bước tiếp, sau đó khôi phục trạng thái (quay lui) để thử lựa chọn khác.
Quay lui thường được sử dụng để:
- Liệt kê tất cả hoán vị, tổ hợp, tập con
- Giải bài toán tìm kiếm mê cung
- Giải các bài toán ràng buộc (constraint satisfaction) như N-Queens, Sudoku
Khi nào sử dụng?¶
| Tình huống | Phương pháp |
|---|---|
| Bài toán có thể chia thành bài toán con cùng dạng | Đệ quy |
| Cần liệt kê tất cả nghiệm thỏa mãn | Quay lui |
| \(N \leq 15\) và cần duyệt toàn bộ | Quay lui hoặc bitmask |
| Cần tìm nghiệm tốt nhất với nhánh cận | Quay lui + cắt tỉa |
Tư duy cốt lõi¶
Nguyên tắc của đệ quy¶
Mọi hàm đệ quy phải tuân thủ hai nguyên tắc:
- Phải có base case — điều kiện dừng đệ quy, nếu không sẽ tràn stack
- Phần đệ quy phải gọi bài toán nhỏ hơn — đảm bảo tiến dần về base case
Cây đệ quy cho \(5!\) được minh họa như sau:
(base case)"] F --> G["return 1"] G --> H["return 2 × 1 = 2"] H --> I["return 3 × 2 = 6"] I --> J["return 4 × 6 = 24"] J --> K["return 5 × 24 = 120"]
Template quay lui¶
Mọi bài toán quay lui đều tuân theo một khuôn mẫu chung:
Ba bước cốt lõi lặp lại tại mỗi node trên cây tìm kiếm:
- Chọn (Choose) — thử một lựa chọn khả thi
- Khám phá (Explore) — đệ quy sang trạng thái tiếp
- Bỏ chọn (Unchoose) — khôi phục trạng thái cũ (đây chính là "quay lui")
Ví dụ minh họa: Sinh hoán vị \(\{1, 2, 3\}\)¶
Cây tìm kiếm khi sinh tất cả hoán vị của tập \(\{1, 2, 3\}\):
Tổng cộng \(3! = 6\) hoán vị. Mỗi lá trên cây là một nghiệm hoàn chỉnh.
Bài toán N-Queens¶
Xếp \(N\) quân hậu lên bàn cờ \(N \times N\) sao cho không hai quân hậu nào ăn nhau (không cùng hàng, cột, đường chéo).
Ý tưởng: Xếp hậu theo từng hàng. Hàng \(i\): thử từng cột \(j\), kiểm tra tính hợp lệ bằng 3 mảng đánh dấu:
col[j]— cột \(j\) đã có hậu chưadiag1[i + j]— đường chéo chính (tổng \(i+j\) cố định trên mỗi đường chéo)diag2[i - j + N]— đường chéo phụ (hiệu \(i-j\) cố định, cộng \(N\) để tránh chỉ số âm)
Phân tích tính đúng đắn¶
Tại sao đệ quy đúng?¶
Đệ quy đúng khi thỏa mãn hai điều kiện:
-
Base case đúng: Giá trị trả về ở trường hợp cơ sở là chính xác. Ví dụ: \(0! = 1\) là đúng theo định nghĩa.
-
Bước đệ quy đúng: Giả sử hàm đúng cho input nhỏ hơn (giả thuyết đệ quy), thì công thức đệ quy cho ra kết quả đúng cho input hiện tại.
Với giai thừa: Giả sử factorial(n-1) trả về đúng \((n-1)!\), thì `factorial(n) = n × factorial(n-1) = n × (n-1)! = n!$. Đúng.
Tại sao quay lui đúng?¶
Quay lui đúng vì nó duyệt toàn bộ không gian tìm kiếm mà không bỏ sót:
- Tại mỗi bước, tất cả lựa chọn khả thi đều được thử
- Sau mỗi lần thử, trạng thái được khôi phục hoàn toàn (bước quay lui)
- Do đó, lựa chọn tiếp theo không bị ảnh hưởng bởi lựa chọn trước
Ví dụ: Trong sinh hoán vị, nếu quên used[num] = false sau khi đệ quy, số num sẽ bị đánh dấu "đã dùng" vĩnh viễn, khiến các nhánh sau không thể chọn num lại — kết quả bị thiếu.
Kiểm tra correctness bằng ví dụ¶
Với bài toán tổ hợp có lặp (subset sum), sử dụng startIdx đảm bảo mỗi tổ hợp chỉ được sinh một lần (không sinh trùng hoán vị khác thứ tự). Nếu dùng i = 0 thay vì i = startIdx, ta sẽ sinh ra các tổ hợp trùng lặp.
Đánh giá độ phức tạp¶
Đệ quy đơn giản¶
| Bài toán | Độ phức tạp thời gian | Độ phức tạp không gian |
|---|---|---|
| Giai thừa \(n!\) | \(O(n)\) | \(O(n)\) (stack) |
| Fibonacci không nhớ | \(O(2^n)\) | \(O(n)\) (stack) |
| Fibonacci có nhớ (memoization) | \(O(n)\) | \(O(n)\) |
Fibonacci không nhớ có độ phức tạp \(O(2^n)\) vì mỗi hàm gọi đệ quy 2 lần, tạo thành cây nhị phân với số node tăng theo cấp số nhân.
Quay lui¶
Giả sử tại mỗi bước có \(k\) lựa chọn, độ sâu cây tìm kiếm là \(d\):
- Trường hợp tổng quát: \(O(k^d)\) — duyệt toàn bộ cây tìm kiếm
- Với nhánh cận (pruning): Có thể giảm đáng kể tùy bài toán
Độ phức tạp cụ thể cho các bài toán kinh điển:
| Bài toán | Thời gian | Không gian |
|---|---|---|
| Sinh hoán vị \(n\) phần tử | \(O(n!)\) | \(O(n)\) |
| Sinh tổ hợp \(C(n, k)\) | \(O\left(\binom{n}{k}\right)\) | \(O(k)\) |
| N-Queens | \(O(n!)\) (với nhánh cận) | \(O(n)\) |
Tại sao N-Queens là \(O(n!)\)?¶
Ở hàng đầu tiên có \(n\) lựa chọn, hàng thứ hai tối đa \(n-1\) (bị loại bỏ cột và đường chéo), hàng thứ ba tối đa \(n-2\), và cứ tiếp tục. Tổng: \(n \times (n-1) \times \ldots \times 1 = n!\). Nhánh cận (cắt bỏ các vị trí không hợp lệ) giúp thực tế chạy nhanh hơn nhiều so với cận trên này.
So sánh: Đệ quy vs. Quay lui vs. Quy hoạch động¶
| Tiêu chí | Đệ quy | Quay lui | Quy hoạch động |
|---|---|---|---|
| Mục đích | Chia bài toán con | Liệt kê/tìm nghiệm | Tối ưu hóa |
| Có nhớ (memo)? | Có thể | Không | Có |
| Không gian | \(O(\text{độ sâu})\) | \(O(\text{độ sâu})\) | \(O(\text{số trạng thái})\) |
| Thời gian | Tùy bài toán | \(O(k^d)\) | \(O(\text{số trạng thái} \times \text{transition})\) |
Code minh họa¶
Bài toán 1: Tính giai thừa¶
Đệ quy cơ bản nhất. Công thức: \(n! = n \times (n-1)!\) với \(0! = 1\).
Bài toán 2: Số Fibonacci¶
Công thức: \(F(n) = F(n-1) + F(n-2)\) với \(F(0) = 0\), \(F(1) = 1\).
Đệ quy trực tiếp có độ phức tạp \(O(2^n)\) vì tính lại cùng một giá trị nhiều lần. Dùng memoization để giảm xuống \(O(n)\).
Bài toán 3: Sinh tất cả hoán vị¶
Template quay lui cơ bản. Dùng mảng used đánh dấu số đã chọn, sau mỗi lần đệ quy phải bỏ đánh dấu (quay lui).
Bài toán 4: N-Queens¶
Xếp \(N\) hậu lên bàn cờ \(N \times N\). Dùng 3 mảng boolean kiểm tra cột, đường chéo chính, đường chéo phụ.
Bài toán 5: Tổ hợp có lặp (Subset Sum)¶
Tìm tất cả cách chọn số từ mảng sao cho tổng bằng target. Dùng startIdx để tránh sinh trùng tổ hợp.
Cạm bẫy thường gặp¶
Bẫy 1: Quên base case¶
Không có base case dẫn đến đệ quy vô hạn và tràn stack.
Bẫy 2: Quên bước quay lui¶
Sau khi đệ quy, phải khôi phục trạng thái. Nếu quên, các nhánh sau sẽ bị ảnh hưởng.
Bẫy 3: Tràn stack khi đệ quy sâu¶
Mỗi lần gọi hàm đệ quy, một frame mới được đẩy vào stack. Với \(N\) lớn (ví dụ \(N = 100000\)), stack sẽ tràn.
Giải pháp: Dùng memoization, chuyển sang đệ quy đuôi (tail recursion), hoặc viết lại bằng vòng lặp.
Bẫy 4: Đệ quy Fibonacci không nhớ — \(O(2^n)\)¶
Đây là lỗi phổ biến nhất. Hàm fibo(n) gọi cả fibo(n-1) và fibo(n-2), dẫn đến tính lại cùng một giá trị nhiều lần.
Bài tập luyện tập¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| CSES - Creating Strings | CSES | ⭐⭐ | Sinh hoán vị |
| CSES - Apple Division | CSES | ⭐⭐ | Quay lui chia tập |
| LeetCode - Permutations | LC | ⭐⭐ | Sinh hoán vị |
| LeetCode - N-Queens | LC | ⭐⭐⭐ | Xếp hậu |
| LeetCode - Sudoku Solver | LC | ⭐⭐⭐ | Quay lui giải Sudoku |
| VNOJ - Đi dạo (Backtrack A) | VNOJ | ⭐ | Backtracking cơ bản |
| VNOJ - Tháp Hà Nội 2 (Backtrack B) | VNOJ | ⭐ | Đệ quy |
| VNOJ - Bể chứa nước (Backtrack C) | VNOJ | ⭐⭐ | Backtracking |
Bài viết liên quan¶
Tài liệu tham khảo¶
- VNOI Wiki - Đệ quy và thuật toán quay lui
- GeeksforGeeks - Backtracking Algorithms
- USACO Guide - Backtracking
Bài tiếp theo: Mảng, Stack, Prefix Sum →