← Lập trình C cơ bản

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

Khoan đã - giai_thua(4) gọi giai_thua(3), cùng MỘT hàm, cùng tên biến n. Hai cái n đó có giẫm lên nhau không? Câu trả lời nằm ở cơ chế bên dưới - bước tiếp theo.

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   */
}
0/12 bước
Call stack (đỉnh ở trên)
bấm "Bước ▶" để chạy
Hàm này sẽ tạo tối đa 4 khung chồng nhau, tổng 4 lời gọi.
Màn hình

Trung thực: mô hình đã giản lược

Khung thật trong máy còn chứa địa chỉ quay về, thanh ghi cần khôi phục… và nằm trong một vùng bộ nhớ tên là stack - khoá C nâng cao (bài Bố cục bộ nhớ) sẽ mổ xẻ tận nơi. Mô phỏng này chỉ vẽ điều bạn cần hôm nay: mỗi lời gọi một khung riêng, xếp chồng và gỡ đúng thứ tự ngượ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

Bạn đã đủ vũ khí về hàm - kể cả hàm tự gọi mình. Bài kế chuyển sang chỗ chứa dữ liệu nhiều phần tử đầu tiên của C: mảng & chuỗi.

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

fib gọi chính nó HAI lần, và hai nhánh tính lại cùng những giá trị: fib(4) tính fib(2) hai lần, fib(1) ba lần… Số lời gọi tăng theo cấp số nhân - fib(40) kiểu này là hàng tỷ lời gọi. Cách chữa (lưu kết quả đã tính, hoặc chuyển sang vòng lặp) thuộc khoá Cấu trúc dữ liệu & giải thuật.

Bài toán có cấu trúc TỰ LỒNG NHAU: duyệt thư mục chứa thư mục, cây gia phả, biểu thức trong biểu thức (chính trình biên dịch C phân tích code bằng đệ quy). Với chuỗi việc thẳng hàng như đếm 1..n thì vòng lặp (Bài 10) tự nhiên và rẻ 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.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Một hàm đệ quy đúng đắn bắt buộc phải có hai thành phần nào?

  1. 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. 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ừng n <= 0 trả về 0 (lưới rộng, không dùng n == 0).

  3. 3

    Đoán rồi mới chạy

    Sửa dem_xuoi thành in cả lúc TRƯỚC khi gọi lẫn SAU khi gọi. Đoán output với n = 3 trướ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. 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êm ulimit -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. 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ới n = 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ì sao fib đệ quy đôi không dùng được cho n lớn.

  6. 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ó.