- Mọi hàm đệ quy bắt buộc phải có trường hợp cơ sở (base case) để dừng lại; thiếu nó chương trình sẽ gọi mãi tới khi tràn bộ nhớ ngăn xếp (Stack Overflow).
- Mỗi lời gọi đệ quy chưa xong đều được lưu vào một khung bộ nhớ trên ngăn xếp lời gọi (call stack), tốn bộ nhớ
O(độ sâu đệ quy). - Khi các nhánh đệ quy tính đi tính lại cùng một giá trị (như dãy Fibonacci), hãy lưu kết quả vào mảng nhớ (đệ quy có nhớ — memoization) để giảm thời gian từ
O(2ⁿ)xuốngO(n).
Đệ quy là gì? Hình dung qua việc xếp hàng đếm số thứ tự
Giả sử bạn đang đứng trong một hàng dọc rất dài và muốn biết mình đang đứng ở vị trí thứ mấy tính từ đầu hàng, nhưng bạn không được rời khỏi chỗ mà chỉ được hỏi người đứng ngay trước mặt mình:
- Bạn hỏi người phía trước: "Bạn đứng thứ mấy?" rồi lấy câu trả lời cộng thêm
1. - Người đó cũng chưa biết, lại hỏi tiếp người đứng trước họ câu y hệt.
- Cứ thế câu hỏi truyền lên đến người đầu hàng. Người đầu hàng nhìn phía trước không còn ai, trả lời ngay: "Tôi đứng thứ 1!" (không cần hỏi ai nữa).
- Từ con số
1đó, người thứ hai cộng1trả lời2, người thứ ba trả lời3,..., cho đến khi kết quả truyền ngược về tới bạn.
Quá trình trên chính là đệ quy (recursion): giải một bài toán kích thước n bằng cách nhờ lời giải của bài toán nhỏ hơn n - 1 có cùng bản chất, cho tới khi chạm tới trường hợp nhỏ nhất biết sẵn đáp án.
Hai thành phần bắt buộc của mọi hàm đệ quy
Khi viết bất kỳ hàm đệ quy nào bằng C++ hay Python, bạn luôn phải kiểm tra đủ 2 phần:
- Trường hợp cơ sở (Base case — điều kiện dừng): Khi dữ liệu đầu vào đã đủ nhỏ (ví dụ
n == 0hoặcn == 1), hàm trả về kết quả ngay lập tức mà không gọi đệ quy thêm nữa. - Bước đệ quy (Recursive step): Biểu diễn lời giải của bài toán cỡ
nthông qua lời gọi hàm với đầu vào nhỏ hơn (ví dụn - 1hoặcn / 2), đảm bảo sau hữu hạn bước chắc chắn sẽ chạm tới trường hợp cơ sở.
Hãy xem hai ví dụ kinh điển: tính giai thừa n! = n × (n - 1)! và tính tổng các chữ số của một số nguyên dương n:
#include <iostream>
using namespace std;
// Tính n! = 1 * 2 * ... * n bằng đệ quy
long long giaiThua(int n) {
if (n <= 1) return 1; // trường hợp cơ sở (điểm dừng)
return n * giaiThua(n - 1); // bước đệ quy thu nhỏ bài toán
}
// Tính tổng các chữ số của số nguyên không âm n
int tongChuSo(long long n) {
if (n < 10) return (int)n; // số có 1 chữ số
return (int)(n % 10) + tongChuSo(n / 10);
}
int main() {
cout << giaiThua(4) << "\n"; // 24
cout << tongChuSo(2026) << "\n"; // 10 (2 + 0 + 2 + 6)
return 0;
}
def giai_thua(n):
"""Tính n! = 1 * 2 * ... * n bằng đệ quy."""
if n <= 1:
return 1 # trường hợp cơ sở (điểm dừng)
return n * giai_thua(n - 1)
def tong_chu_so(n):
"""Tính tổng các chữ số của số nguyên không âm n."""
if n < 10:
return n
return (n % 10) + tong_chu_so(n // 10)
print(giai_thua(4)) # 24
print(tong_chu_so(2026)) # 10
Điều gì thực sự diễn ra trong bộ nhớ ngăn xếp (Call Stack)?
Nhiều bạn mới học thường thắc mắc: "Khi giaiThua(4) gọi giaiThua(3), biến n = 4 có bị ghi đè thành 3 không?". Câu trả lời là không, vì mỗi lần hàm được gọi, máy tính tạo ra một khung hàm (stack frame) hoàn toàn riêng biệt để giữ biến cục bộ n của lần gọi đó.
Hãy theo dõi từng bước khi gọi giaiThua(4):
| Thứ tự thời gian | Hành động của chương trình | Các khung đang chờ trong Call Stack |
|---|---|---|
| Bước 1 | Gọi giaiThua(4), chưa biết giaiThua(3) nên tạm dừng chờ |
[f(4)] |
| Bước 2 | Gọi giaiThua(3), tạm dừng chờ giaiThua(2) |
[f(4), f(3)] |
| Bước 3 | Gọi giaiThua(2), tạm dừng chờ giaiThua(1) |
[f(4), f(3), f(2)] |
| Bước 4 | Gọi giaiThua(1), chạm điều kiện n <= 1, trả về 1 ngay |
[f(4), f(3), f(2), f(1)] |
| Bước 5 | giaiThua(2) nhận 1, tính 2 * 1 = 2 rồi trả về 2 |
[f(4), f(3)] |
| Bước 6 | giaiThua(3) nhận 2, tính 3 * 2 = 6 rồi trả về 6 |
[f(4)] |
| Bước 7 | giaiThua(4) nhận 6, tính 4 * 6 = 24 và kết thúc |
[] (ngăn xếp rỗng) |
Vì ngăn xếp phải giữ cùng lúc n khung hàm đang chờ trước khi chạm điểm dừng, nên dù hàm giaiThua(n) không khai báo mảng nào, độ phức tạp bộ nhớ của nó vẫn là O(n).
Chủ đề này nằm trong Lecture 09: Recursion (Đệ quy) của Khóa Green. Xem đề cương
Bẫy tính lặp trong đệ quy phân nhánh và cách khắc phục bằng Đệ quy có nhớ
Với bài toán giai thừa, mỗi lần hàm chỉ gọi chính nó một lần, nên tổng số lần gọi là O(n). Nhưng với dãy số Fibonacci (F(0) = 0, F(1) = 1, F(n) = F(n - 1) + F(n - 2)), mỗi lần hàm gọi đệ quy hai lần:
- Để tính
F(5), máy gọiF(4)vàF(3). - Trong
F(4)lại gọiF(3)vàF(2). Như vậyF(3)bị tính lại từ đầu hai lần, cònF(2)bị tính lại ba lần! - Khi
ntăng thêm 1, số lời gọi gần như nhân đôi, khiến độ phức tạp thời gian bùng nổ thành O(2n). Chỉ vớin = 45, hàm đệ quy thường đã phải gọi hơn 3 tỷ lần.
Cách sửa rất đơn giản và đẹp mắt: ta tạo một mảng memo khởi tạo bằng -1. Trước khi gọi đệ quy tính fib(n), ta kiểm tra xem memo[n] đã có kết quả chưa; nếu có rồi thì trả về ngay lập tức, nếu chưa thì tính một lần rồi cất vào memo[n]. Kỹ thuật này gọi là đệ quy có nhớ (memoization):
#include <iostream>
#include <vector>
using namespace std;
int goiThuong = 0;
long long fibThuong(int n) {
goiThuong++;
if (n <= 1) return n;
return fibThuong(n - 1) + fibThuong(n - 2);
}
int goiNho = 0;
long long fibNho(int n, vector<long long>& memo) {
goiNho++;
if (n <= 1) return n;
if (memo[n] != -1) return memo[n]; // đã tính rồi thì lấy ra dùng ngay
memo[n] = fibNho(n - 1, memo) + fibNho(n - 2, memo);
return memo[n];
}
int main() {
vector<long long> memo10(11, -1);
cout << fibThuong(10) << " " << goiThuong << "\n"; // 55 177
cout << fibNho(10, memo10) << " " << goiNho << "\n"; // 55 19
vector<long long> memo40(41, -1);
cout << fibNho(40, memo40) << "\n"; // 102334155
return 0;
}
goi_thuong = 0
def fib_thuong(n):
global goi_thuong
goi_thuong += 1
if n <= 1:
return n
return fib_thuong(n - 1) + fib_thuong(n - 2)
goi_nho = 0
def fib_nho(n, memo):
global goi_nho
goi_nho += 1
if n <= 1:
return n
if memo[n] != -1:
return memo[n]
memo[n] = fib_nho(n - 1, memo) + fib_nho(n - 2, memo)
return memo[n]
memo10 = [-1] * 11
print(fib_thuong(10), goi_thuong) # 55 177
print(fib_nho(10, memo10), goi_nho) # 55 19
memo40 = [-1] * 41
print(fib_nho(40, memo40)) # 102334155
Hãy nhìn kết quả in ra từ chương trình trên:
- Với
n = 10: hàmfibThuong(10)phải gọi đệ quy 177 lần, trong khifibNho(10)chỉ gọi đúng 19 lần. - Với
n = 40: hàmfibNho(40)trả về ngay102334155chỉ sau 79 lần gọi hàm (độ phức tạp giảm từO(2ⁿ)xuống đúng O(n)). Đây cũng chính là ý tưởng cốt lõi của quy hoạch động từ trên xuống (Top-down Dynamic Programming).
Khi nào nên dùng đệ quy, khi nào nên dùng vòng lặp?
| Tình huống bài toán | Nên chọn cách nào? | Lý do |
|---|---|---|
| Tính giai thừa, tổng dãy, duyệt mảng 1 chiều, tìm kiếm nhị phân | Vòng lặp (for / while) |
Đệ quy chỉ gọi 1 nhánh tuyến tính thì viết vòng lặp gọn hơn, tốn O(1) bộ nhớ và không sợ tràn ngăn xếp |
| Sắp xếp trộn (Merge Sort), Chia để trị, Tháp Hà Nội | Đệ quy | Bài toán tự nhiên tách làm 2 nửa độc lập có độ sâu chỉ khoảng log₂ n (rất nông, không bao giờ tràn bộ nhớ) |
| Quay lui (Backtracking) sinh hoán vị, tổ hợp, N quân hậu, duyệt cây / đồ thị DFS | Đệ quy | Ngăn xếp đệ quy tự động lưu trạng thái trước đó để quay lui thử nhánh khác, viết bằng vòng lặp sẽ phải tự dựng ngăn xếp rất dài |
Lỗi hay gặp khi viết hàm đệ quy
- Thiếu hoặc viết sai điều kiện dừng. Ví dụ viết
if (n == 1) return 1;nhưng người dùng truyền vàon = 0hoặc số âm, làm hàm vượt qua điểm dừng và gọin = -1, -2, -3...cho tới khi chương trình bị sập vì tràn ngăn xếp (Segmentation fault/RecursionError). Hãy dùng điều kiện bao quát nhưif (n <= 1). - Đầu vào của lời gọi đệ quy không nhỏ đi. Ví dụ khi chia đôi đoạn
[lo, hi]mà gọi lại với chính đoạn[lo, hi]cũ thì hàm sẽ lặp vô hạn tại một chỗ. - Vượt giới hạn độ sâu đệ quy mặc định của Python. Python giới hạn mặc định khoảng
1.000tầng đệ quy để bảo vệ bộ nhớ. Nếu bài toán cần đệ quy sâu tới10^5tầng (như duyệt đồ thị DFS), trong Python bạn cần thêmimport sys; sys.setrecursionlimit(200000)ở đầu chương trình hoặc chuyển sang viết bằng vòng lặp. - Tính lặp lại cùng một bài toán con mà không dùng mảng nhớ. Nếu thấy hàm đệ quy gọi từ 2 nhánh trở lên với các tham số trùng nhau như
f(n - 1) + f(n - 2), luôn nhớ thêm mảngmemo.
Luyện ngay trên Big-O Coding
Thực hành ngay tư duy chia nhỏ bài toán và đệ quy qua các bài tập dưới đây trên hệ thống Big-O Coding:
Câu hỏi thường gặp
Mọi hàm đệ quy đều có thể viết lại bằng vòng lặp đúng không?
Đúng về mặt lý thuyết. Vì máy tính thực thi đệ quy bằng cách đẩy các khung hàm vào ngăn xếp bộ nhớ (Call Stack), nên bạn luôn có thể thay thế bất kỳ hàm đệ quy nào bằng một vòng lặp while kết hợp với một cấu trúc dữ liệu ngăn xếp (std::stack trong C++ hoặc list trong Python) do bạn tự quản lý.
Lỗi Stack Overflow trong đệ quy là gì?
Bộ nhớ dành cho ngăn xếp lời gọi hàm của mỗi chương trình thường chỉ có vài megabyte (MB). Khi hàm đệ quy gọi quá sâu (thường từ vài chục nghìn đến vài trăm nghìn tầng lồng nhau) hoặc không có điều kiện dừng, vùng nhớ ngăn xếp bị đầy và hệ điều hành buộc phải dừng chương trình ngay lập tức.
Đệ quy đuôi (tail recursion) là gì?
Đệ quy đuôi là trường hợp lời gọi đệ quy nằm ở ngay câu lệnh cuối cùng của hàm và kết quả trả về của nó không phải chờ nhân hay cộng thêm gì nữa. Khi bật tối ưu hóa (-O2), trình biên dịch C++ có thể tự động biến đệ quy đuôi thành vòng lặp để chỉ tốn O(1) bộ nhớ; tuy nhiên Python không tự động tối ưu đệ quy đuôi.
Nên học gì ngay sau khi hiểu đệ quy?
Sau khi nắm chắc đệ quy, hướng đi tự nhiên nhất là học Thuật toán quay lui (Backtracking) để giải các bài toán liệt kê cấu hình và tìm đường đi, đồng thời xem cách đệ quy chia đôi mảng hoạt động trong Các thuật toán sắp xếp (Merge Sort, Quick Sort).
Khóa Green cho người mới, sinh viên năm 1–2: xây nền tảng lập trình và thuật toán vững chắc, biết dùng AI hỗ trợ. 16 buổi online, học thử buổi 1 miễn phí.


