Nhập môn

Đệ quy là gì? Giải thích dễ hiểu, sơ đồ ngăn xếp và code C++, Python

Đệ quy (recursion) trong lập trình là kỹ thuật một hàm tự gọi lại chính nó để giải quyết bài toán bằng cách chia nhỏ thành các bài toán con có cùng cấu trúc. Một hàm đệ quy đúng luôn phải có hai phần: trường hợp cơ sở (điểm dừng) trả về kết quả trực tiếp, và bước đệ quy thu nhỏ đầu vào tiến dần về điểm dừng đó.

Ý chính
  • 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ống O(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ộng 1 trả lời 2, người thứ ba trả lời 3,..., 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:

  1. 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 == 0 hoặc n == 1), hàm trả về kết quả ngay lập tức mà không gọi đệ quy thêm nữa.
  2. Bước đệ quy (Recursive step): Biểu diễn lời giải của bài toán cỡ n thông qua lời gọi hàm với đầu vào nhỏ hơn (ví dụ n - 1 hoặc n / 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;
}

Đ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):

1. Pha gọi xuống (đẩy vào Stack) 2. Pha quay lui (trả kết quả lên) giaiThua(4) chờ 4 * giaiThua(3) giaiThua(3) chờ 3 * giaiThua(2) giaiThua(2) chờ 2 * giaiThua(1) giaiThua(1) → điểm dừng = 1 trả về 1 cho giaiThua(2) 2 * 1 = 2 → trả về 2 3 * 2 = 6 → trả về 6 4 * 6 = 24 (Kết quả cuối)
1. Gọi xuống 2. Trả kết quả lên f(4) chờ 4 * f(3) f(3) chờ 3 * f(2) f(2) chờ 2 * f(1) f(1): điểm dừng = 1 f(1) trả về 1 f(2) = 2 * 1 = 2 f(3) = 3 * 2 = 6 f(4) = 4 * 6 = 24
Hình 1. Hai pha của lời gọi giaiThua(4): pha gọi xuống đẩy từng khung hàm vào ngăn xếp, pha quay lui nhân ngược kết quả từ đáy lên.
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).

Học cùng giảng viê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ọi F(4) và F(3).
  • Trong F(4) lại gọi F(3) và F(2). Như vậy F(3) bị tính lại từ đầu hai lần, còn F(2) bị tính lại ba lần!
  • Khi n tă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ới n = 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;
}

Hãy nhìn kết quả in ra từ chương trình trên:

  • Với n = 10: hàm fibThuong(10) phải gọi đệ quy 177 lần, trong khi fibNho(10) chỉ gọi đúng 19 lần.
  • Với n = 40: hàm fibNho(40) trả về ngay 102334155 chỉ 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

Cẩn thận
  • 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ào n = 0 hoặc số âm, làm hàm vượt qua điểm dừng và gọi n = -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.000 tầng đệ quy để bảo vệ bộ nhớ. Nếu bài toán cần đệ quy sâu tới 10^5 tầng (như duyệt đồ thị DFS), trong Python bạn cần thêm import 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ảng memo.

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).

Muốn học bài bản cùng giảng viên?

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í.

Xem Khóa Green

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483