Bài 12 · Vận dụng · 22 phút· Cập nhật 11/06/2026
Đệ quy & call stack
Biên soạn bởi Nguyễn Anh Tuấn
Viết hàm đệ quy có base case và bước thu nhỏ; quan sát call stack, thứ tự in khi gọi trước/sau và nguy cơ stack overflow.
Bài 11 cho bạn hàm gọi hàm khác. C còn cho một nước đi táo bạo hơn: hàm tự gọi chính nó - gọi là đệ quy (recursion). Nghe vòng vo, nhưng có những bài toán mà định nghĩa của nó VỐN đã đệ quy. Giai thừa là ví dụ kinh điển: 4! = 4 × 3!, và 3! = 3 × 2!… cứ thế nhỏ dần về 1! = 1:
giai-thua.c - code gần như chép nguyên định nghĩa toán học
#include <stdio.h>
long giai_thua(int n) {
if (n <= 1) return 1; /* dieu kien dung (base case) */
return n * giai_thua(n - 1); /* buoc thu nho */
}
int main(void) {
printf("4! = %ld\n", giai_thua(4));
return 0;
} Kết quả khi chạy
4! = 24
Mọi hàm đệ quy đúng đắn đều có đủ hai mảnh:
- ▸ĐIỀU KIỆN DỪNG (base case): trường hợp nhỏ tới mức trả lời được ngay, không cần gọi tiếp - ở đây là n <= 1.
- ▸BƯỚC THU NHỎ: lời gọi tiếp theo phải TIẾN VỀ điều kiện dừng - giai_thua(n - 1) nhỏ hơn giai_thua(n).
- ▸Mẹo an toàn: viết điều kiện dừng kiểu "lưới rộng" (n <= 1, không phải n == 1) - lỡ ai gọi giai_thua(0) vẫn không lọt lưới.
Câu hỏi đúng lúc
Mỗi lời gọi hàm, C tạo một khung (frame) chứa tham số và biến cục bộ của riêng lời gọi đó, đặt lên đỉnh một chồng gọi là call stack. Hàm trả về thì khung bị gỡ xuống. Bấm từng bước mà xem - chọn cả 4 hàm:
long giai_thua(int n) {
if (n <= 1) return 1; /* dieu kien dung */
return n * giai_thua(n - 1); /* buoc thu nho */
}Trung thực: mô hình đã giản lược
Vị trí của câu lệnh so với lời gọi đệ quy quyết định thứ tự thực hiện. Hai hàm dưới đây chỉ khác nhau chỗ đặt printf - và in ra hai chiều ngược nhau:
dem.c - cùng bộ khung, đảo vị trí printf
#include <stdio.h>
void dem_nguoc(int n) {
if (n == 0) { printf("Bum!\n"); return; }
printf("%d\n", n); /* IN truoc... */
dem_nguoc(n - 1); /* ...GOI sau */
}
void dem_xuoi(int n) {
if (n == 0) return;
dem_xuoi(n - 1); /* GOI truoc... */
printf("%d\n", n); /* ...IN sau */
}
int main(void) {
dem_nguoc(3);
printf("---\n");
dem_xuoi(3);
return 0;
} Kết quả khi chạy
3 2 1 Bum! --- 1 2 3
- ▸In TRƯỚC khi gọi → làm việc trên đường "xuống dốc": 3, 2, 1.
- ▸In SAU khi gọi → mọi việc dồn về đường "lên dốc", lúc các khung lần lượt trả về: 1, 2, 3.
- ▸Xem lại hai preset này trong công cụ Bước 3 để thấy đúng khoảnh khắc mỗi số được in.
Stack hữu hạn. Quên điều kiện dừng là các khung chồng lên vô tận cho đến khi hết chỗ - stack overflow (tràn stack), chương trình sập:
tran.c - ĐỪNG mong output: chương trình sập vì segmentation fault (trên máy thử: exit code 139)
long sau(long n) {
return 1 + sau(n + 1); /* QUEN dieu kien dung! */
}
int main(void) {
return (int)sau(0);
} Cái giá thứ hai kín đáo hơn: đệ quy đôi như fib tính lại cùng một giá trị nhiều lần - công cụ Bước 3 đếm được fib(4) tốn 9 lời gọi dù chỉ sâu 4 khung. Quy tắc chọn:
- ▸Bài toán tự lồng nhau (cây thư mục, biểu thức lồng biểu thức) → đệ quy mô tả tự nhiên nhất.
- ▸Chuỗi việc thẳng hàng (đếm, cộng dồn, duyệt mảng) → vòng lặp (Bài 10): không tốn khung, không lo tràn.
- ▸Đệ quy đôi kiểu fib chỉ hợp với n nhỏ - n lớn cần kỹ thuật lưu kết quả (khoá DSA) hoặc vòng lặp.
Bài tiếp theo
Câu hỏi thường gặp
Không - tại mỗi thời điểm chỉ MỘT khung đang thực thi (khung trên đỉnh call stack); các khung dưới đứng yên chờ. Đệ quy là nhiều BẢN của cùng một hàm xếp chồng, không phải chạy song song.
Mỗi lời gọi một bản n riêng - đó chính là điều khung (frame) tồn tại để làm: mỗi khung chứa tham số và biến cục bộ của riêng lời gọi đó. Vì thế giai_thua(4) và giai_thua(3) không giẫm lên nhau dù cùng tên biến.
Lời gọi sẽ "nhảy qua" điều kiện dừng và chạy tiếp xuống các số âm - không bao giờ dừng, stack đầy, chương trình sập. Vì vậy người cẩn thận viết điều kiện dừng kiểu "lưới rộng": n <= 1 thay vì n == 1.
Tuỳ hệ điều hành: vùng stack thường được cấp vài MB (macOS ~8MB mặc định), mỗi khung chiếm vài chục byte trở lên - nên thường vài chục nghìn đến vài trăm nghìn khung là đầy. Con số chính xác không quan trọng; điều quan trọng là nó HỮU HẠN.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Một hàm đệ quy đúng đắn bắt buộc phải có hai thành phần nào?
- 1
Chạy chậm cả 4 hàm
Trong công cụ Bước 3: bấm từng bước hết cả 4 preset, để ý lúc nào call stack cao nhất.
Hoàn thành khi: Nói được:
giai_thua(4)sâu nhất 4 khung;fib(4)cũng sâu 4 nhưng tốn 9 lời gọi. - 2
Tổng 1..n bằng đệ quy
Viết
long tong(int n)đệ quy:tong(5)= 15. Xác định rõ điều kiện dừng trước khi viết.Hoàn thành khi:
tong(5)in 15; điều kiện dừngn <= 0trả về 0 (lưới rộng, không dùngn == 0). - 3
Đoán rồi mới chạy
Sửa
dem_xuoithành in cả lúc TRƯỚC khi gọi lẫn SAU khi gọi. Đoán output vớin = 3trước, rồi chạy kiểm.Hoàn thành khi: Đoán đúng: 3 2 1 1 2 3 (xuống dốc in lượt đi, lên dốc in lượt về).
- 4
Tự gây stack overflow
Chép hàm
sau(n)ở Bước 4 (quên điều kiện dừng), chạy và quan sát. Trên Linux thử thêmulimit -sđể xem giới hạn ngăn xếp.Hoàn thành khi: Chương trình sập (segmentation fault) - stack hữu hạn là có thật, không phải lý thuyết.
- 5
Đếm lời gọi của fib
Thêm một biến đếm toàn cục vào
fib, in số lời gọi vớin= 10, 20, 25.Hoàn thành khi: Thấy số lời gọi tăng vọt theo
n(cỡ gấp ~1.6 lần mỗi bậc) - hiểu vì saofibđệ quy đôi không dùng được chonlớn. - 6
Đệ quy quanh mèo con
Kể 2 thứ ngoài đời có cấu trúc tự lồng nhau mà đệ quy mô tả tự nhiên (gợi ý: thư mục, hộp trong hộp…).
Hoàn thành khi: Mỗi ví dụ chỉ ra được "bài toán con cùng dạng" và "điều kiện dừng" của nó.