🌳 Đệ quy và cây tìm kiếm
Đệ quy là cách giải bài toán bằng cách gọi lại chính nó trên bài toán nhỏ hơn. Khi mỗi bước có nhiều lựa chọn, các lời gọi tạo thành một cây tìm kiếm. Đây là nền tảng cho quay lui, liệt kê tổ hợp, sinh hoán vị, DFS và nhiều thuật toán HSG.
🎯 Mục tiêu bài học
Cần hiểu
- Đệ quy gồm
base casevàrecursive case. - Call stack lưu các lời gọi đang chờ kết quả.
- Cây đệ quy mô tả các lời gọi phát sinh.
- Cây tìm kiếm mô tả các trạng thái và lựa chọn.
- Độ sâu, hệ số phân nhánh và số trạng thái.
Cần làm được
- Viết hàm đệ quy đơn giản.
- Mô phỏng call stack bằng tay.
- Liệt kê tập con bằng chọn/không chọn.
- Viết quay lui có chọn và hoàn tác.
- Chuyển một DFS đệ quy sang dùng stack.
1. Đặt vấn đề
Cho n đồ vật nhỏ. Hãy liệt kê mọi cách chọn một số đồ vật trong đó.
Với mỗi đồ vật, ta có hai lựa chọn: không chọn hoặc chọn. Sau khi quyết định cho đồ vật hiện tại, phần còn lại vẫn là cùng một dạng bài toán nhưng nhỏ hơn.
1.1. Vì sao vòng lặp thường khó viết?
Nếu n = 3, ta có thể viết 3 vòng hoặc dùng bitmask. Nhưng khi n thay đổi, số vòng lặp cũng thay đổi. Đệ quy cho phép mô tả thống nhất: xử lý vị trí hiện tại rồi giao phần còn lại cho lời gọi sau.
1.2. Cây tìm kiếm xuất hiện như thế nào?
Ở vị trí i, ta rẽ thành hai nhánh. Mỗi nhánh lại tiếp tục rẽ ở vị trí i+1. Các nhánh này tạo thành một cây trạng thái.
2. Đệ quy cơ bản
Base case
Trường hợp đủ nhỏ để trả lời ngay, không gọi tiếp.
if (n == 0) return 1;Recursive case
Gọi lại chính hàm với dữ liệu nhỏ hơn hoặc trạng thái tiến gần điểm dừng.
return n * factorial(n - 1);2.1. Mẫu suy nghĩ
- Định nghĩa hàm đang giải bài toán gì.
- Xác định trường hợp dừng.
- Giả sử lời gọi nhỏ hơn đã đúng.
- Ghép kết quả lời gọi nhỏ hơn để giải bài toán hiện tại.
3. Mô phỏng Call Stack với factorial
Call stack
4. Cây đệ quy và cây tìm kiếm
Cây đệ quy
Mô tả các lời gọi hàm phát sinh. Ví dụ Fibonacci có hai lời gọi con: f(n-1) và f(n-2).
Cây tìm kiếm
Mỗi node là một trạng thái; mỗi cạnh là một lựa chọn. Ví dụ liệt kê tập con: chọn hoặc không chọn phần tử i.
| Khái niệm | Ý nghĩa | Ảnh hưởng |
|---|---|---|
| Độ sâu | Số quyết định liên tiếp tối đa | Ảnh hưởng bộ nhớ stack. |
| Hệ số phân nhánh | Số lựa chọn tại mỗi trạng thái | Ảnh hưởng số trạng thái. |
| Lá | Trạng thái hoàn chỉnh hoặc điểm dừng | Thường là nơi ghi nhận đáp án. |
| Cắt nhánh | Bỏ qua nhánh chắc chắn không cần xét | Giảm thời gian chạy. |
5. Mô phỏng cây tìm kiếm: liệt kê tập con
Phần tử đang xét
Đường đi hiện tại
6. Quay lui: chọn → gọi → bỏ chọn
Quay lui là mẫu đệ quy dùng để thử các lựa chọn. Sau khi thử một lựa chọn, ta phải hoàn tác để thử lựa chọn khác.
Mẫu chọn / không chọn
void backtrack(int i) {
if (i == n) {
print(current);
return;
}
backtrack(i + 1);
current.push_back(a[i]);
backtrack(i + 1);
current.pop_back();
}Mẫu hoán vị
for (int value = 1; value <= n; value++) {
if (!used[value]) {
used[value] = true;
permutation.push_back(value);
backtrack(position + 1);
permutation.pop_back();
used[value] = false;
}
}7. Phân tích thuật toán
| Bài toán | Số nhánh | Độ sâu | Số trạng thái gần đúng | Ghi chú |
|---|---|---|---|---|
| Factorial | 1 | n | O(n) | Mỗi mức gọi một lần. |
| Fibonacci đệ quy thô | 2 | n | O(2ⁿ) | Nhiều lời gọi trùng lặp. |
| Liệt kê tập con | 2 | n | O(2ⁿ) | Mỗi phần tử chọn hoặc không chọn. |
| Liệt kê hoán vị | Giảm dần | n | O(n!) | Mỗi vị trí chọn một phần tử chưa dùng. |
| DFS cây/đồ thị | Bậc node | Tùy cấu trúc | O(V+E) | Mỗi cạnh và đỉnh xét hữu hạn lần. |
8. Code mẫu
#include <bits/stdc++.h>
using namespace std;
long long factorial(int n) {
if (n == 0) return 1;
return 1LL * n * factorial(n - 1);
}
int main() {
int n;
cin >> n;
cout << factorial(n);
}#include <bits/stdc++.h>
using namespace std;
vector<int> a;
vector<int> current;
void generateSubsets(int index) {
if (index == (int)a.size()) {
for (int value : current) cout << value << ' ';
cout << '\n';
return;
}
generateSubsets(index + 1);
current.push_back(a[index]);
generateSubsets(index + 1);
current.pop_back();
}
int main() {
a = {1, 2, 3};
generateSubsets(0);
}int n;
vector<int> permutation;
vector<bool> used;
void backtrack(int position) {
if (position == n) {
for (int value : permutation) cout << value << ' ';
cout << '\n';
return;
}
for (int value = 1; value <= n; value++) {
if (!used[value]) {
used[value] = true;
permutation.push_back(value);
backtrack(position + 1);
permutation.pop_back();
used[value] = false;
}
}
}vector<vector<int>> adj;
vector<bool> visited;
void dfs(int u) {
visited[u] = true;
cout << u << ' ';
for (int v : adj[u]) {
if (!visited[v]) dfs(v);
}
}def generate_subsets(a):
current = []
def backtrack(index):
if index == len(a):
print(current)
return
backtrack(index + 1)
current.append(a[index])
backtrack(index + 1)
current.pop()
backtrack(0)9. Giải thích code
9.1. Vì sao factorial phải có n == 0?
Đó là điểm dừng. Nếu không có điểm dừng, hàm tiếp tục gọi factorial(n-1) mãi và làm tràn call stack.
9.2. Vì sao tập con có hai lời gọi?
Với mỗi phần tử, ta có đúng hai lựa chọn: không chọn hoặc chọn. Hai lựa chọn này tạo hai nhánh trong cây tìm kiếm.
9.3. Vì sao phải current.pop_back()?
Sau khi thử nhánh chọn phần tử, ta phải hoàn tác để trạng thái hiện tại trở về như trước khi thử nhánh đó.
9.4. Khi nào chuyển sang stack thủ công?
Khi độ sâu lớn, môi trường giới hạn stack, hoặc cần kiểm soát thứ tự xử lý trạng thái rõ hơn.
10. Lỗi thường gặp
- Quên base case.
- Recursive case không làm bài toán nhỏ hơn.
- Không return kết quả của lời gọi đệ quy.
- Quên hoàn tác trạng thái khi quay lui.
- Dùng đệ quy Fibonacci thô cho n lớn.
- Không ước lượng số trạng thái của cây tìm kiếm.
- Gọi đệ quy quá sâu gây stack overflow.
- DFS đồ thị nhưng không đánh dấu visited đúng thời điểm.
11. Quiz và bài tập
📘 Cơ bản
- Tính n!.
- Tính tổng mảng bằng đệ quy.
- In từ 1 đến n và từ n về 1.
📗 Trung bình
- Liệt kê tập con.
- Liệt kê xâu nhị phân độ dài n.
- DFS trên cây.
📙 Nâng cao
- Liệt kê hoán vị.
- N-Queens bản nhỏ.
- Chọn k phần tử có tổng bằng S.
🐉 HSG
- Cắt nhánh bài tối ưu.
- Meet-in-the-middle từ cây tìm kiếm.
- DFS trạng thái có memoization.
💳 Quét mã ủng hộ tuỳ tâm nhé!