Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 8.1024 phút đọc

Đệ quy

Sau bài này bạn sẽ làm được

  • Viết hàm đệ quy có đủ điều kiện dừng và bước tiến về điều kiện dừng
  • Vẽ được ngăn xếp đệ quy và tính độ sâu tối đa
  • So sánh đệ quy với vòng lặp về thời gian và bộ nhớ
  • Nhận ra khi nào đệ quy gây tràn ngăn xếp hoặc bùng nổ số lời gọi

Một hàm được phép gọi chính nó. Nghe như một trò chơi chữ, nhưng đây là công cụ mạnh nhất trong Chương 8. Nó cho phép bạn diễn đạt những bài toán mà viết bằng vòng lặp sẽ dài gấp mấy lần. Đổi lại, nó có hai cách hỏng rất đặc trưng mà bài này sẽ chỉ rõ.

#Hai thành phần bắt buộc

giai-thua.c
int factorial(int n)
{
    if (n <= 1)                     /* 1. điều kiện dừng */
        return 1;

    return n * factorial(n - 1);    /* 2. bước tiến về điều kiện dừng */
}
Điều kiện dừng
Trường hợp đơn giản nhất mà hàm trả lời được ngay, không cần gọi lại chính nó. Thiếu nó thì chuỗi lời gọi không bao giờ kết thúc.
Bước tiến
Lời gọi lại với đối số đã tiến gần hơn về điều kiện dừng. Ở ví dụ trên, n - 1 luôn nhỏ hơn n, nên cuối cùng chắc chắn chạm n <= 1.
Thiếu thành phần nàoChuyện gì xảy ra
Thiếu điều kiện dừngGọi mãi không thôi, ngăn xếp đầy, chương trình bị hệ điều hành giết
Thiếu bước tiếnGọi lại với cùng đối số, cũng dẫn tới tràn ngăn xếp
Bước tiến sai hướngĐối số đi xa dần điều kiện dừng, kết quả cũng là tràn ngăn xếp
Ba cách viết sai
int a(int n) { return n * a(n - 1); }        /* không có điều kiện dừng */

int b(int n) {                               /* không có bước tiến */
    if (n <= 1) return 1;
    return n * b(n);
}

int c(int n) {                               /* bước tiến sai hướng */
    if (n <= 1) return 1;
    return n * c(n + 1);
}
Đủ hai thành phần
int factorial(int n)
{
    if (n <= 1)
        return 1;

    return n * factorial(n - 1);
}

#Chạy tay factorial(4)

factorial(4)
  = 4 * factorial(3)
        = 3 * factorial(2)
              = 2 * factorial(1)
                    = 1                <- điều kiện dừng
              = 2 * 1  = 2
        = 3 * 2  = 6
  = 4 * 6  = 24
Bốn khung ngăn xếp cùng tồn tại tại thời điểm sâu nhất. Chỉ khi chạm điều kiện dừng, chuỗi phép nhân mới được thực hiện theo chiều ngược lại.

Sơ đồ này giải thích một điều quan trọng: phép nhân n * factorial(n - 1) không thực hiện được ngay lúc gọi. Nó phải chờ lời gọi bên trong trả về. Nghĩa là mọi khung đều phải nằm nguyên trên ngăn xếp, giữ giá trị n của mình, cho tới lúc chuỗi đi xuống chạm đáy. Đó chính là lý do đệ quy tốn bộ nhớ tỷ lệ với độ sâu.

Lượt gọin nhận đượcChờ kết quả củaTrả về
14factorial(3)4 * 6 = 24
23factorial(2)3 * 2 = 6
32factorial(1)2 * 1 = 2
41không chờ ai1

#Đệ quy so với vòng lặp

Mọi hàm đệ quy đều viết lại được bằng vòng lặp, và ngược lại. Hai cách tương đương về sức mạnh, khác nhau về chi phí và về độ dễ đọc.

/* Đệ quy: đọc gần với định nghĩa toán học */
long long gt_de_quy(int n)
{
    return n <= 1 ? 1 : n * gt_de_quy(n - 1);
}

/* Lặp: tốn bộ nhớ hằng số */
long long gt_lap(int n)
{
    long long kq = 1;

    for (int i = 2; i <= n; ++i)
        kq *= i;

    return kq;
}
Tiêu chíĐệ quyVòng lặp
Bộ nhớO của độ sâu, tính trên ngăn xếpO của một hằng số
Tốc độChậm hơn, mỗi tầng tốn một lời gọiNhanh hơn
Rủi ro riêngTràn ngăn xếp khi độ sâu lớnKhông có
Bài toán tự nhiên đệ quyNgắn và sát định nghĩaPhải tự quản lý ngăn xếp bằng tay, rất dài
Bài toán tuyến tínhDài dòng không cần thiếtNgắn gọn tự nhiên

Bài toán mà đệ quy thắng rõ ràng

hanoi.c
void hanoi(int n, char tu, char den, char tam)
{
    if (n == 0)
        return;

    hanoi(n - 1, tu, tam, den);
    printf("Chuyen dia %d: %c -> %c\n", n, tu, den);
    hanoi(n - 1, tam, den, tu);
}

Sáu dòng. Viết bài toán Tháp Hà Nội bằng vòng lặp thuần túy cần một ngăn xếp tự cài và khoảng ba mươi dòng, và gần như không ai đọc hiểu được ngay. Số bước hàm này in ra là 2ⁿ - 1. Với sáu tư đĩa và mỗi bước một giây, cần khoảng năm trăm tám tư tỷ năm.

terminal
./hanoi 3
Chuyen dia 1: A -> C
Chuyen dia 2: A -> B
Chuyen dia 1: C -> B
Chuyen dia 3: A -> C
Chuyen dia 1: B -> A
Chuyen dia 2: B -> C
Chuyen dia 1: A -> C

#Khi đệ quy bùng nổ: Fibonacci

fib.c
/* Cách 1: đệ quy ngây thơ. Mỗi tầng sinh hai nhánh. */
long long fib_de_quy(int n)
{
    return n <= 1 ? n : fib_de_quy(n - 1) + fib_de_quy(n - 2);
}

/* Cách 2: vòng lặp. */
long long fib_lap(int n)
{
    long long a = 0, b = 1;

    for (int i = 0; i < n; ++i) {
        long long t = a + b;

        a = b;
        b = t;
    }

    return a;
}

/* Cách 3: đệ quy có nhớ. Mỗi giá trị chỉ tính đúng một lần. */
long long fib_memo(int n, long long *memo)
{
    if (n <= 1)          return n;
    if (memo[n] != -1)   return memo[n];

    memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo);

    return memo[n];
}
CáchĐộ phức tạpSố lời gọi khi n bằng 40Thời gian
Đệ quy ngây thơO(2ⁿ)331 160 281khoảng 1 giây
Đệ quy có nhớO(n)79tức thì
Vòng lặpO(n)không có lời gọi nàotức thì

Con số 331 triệu không phải phóng đại. Công thức chính xác cho số lời gọi của phiên bản ngây thơ là 2 * F(n+1) - 1, và với n bằng 40 thì F(41) bằng 165 580 141. Mỗi lần tăng n lên một đơn vị, thời gian chạy nhân lên khoảng 1,618 lần. Với n bằng 50, bạn sẽ chờ khoảng hai phút. Với n bằng 90, bạn chờ khoảng chín trăm năm. Phải tới n khoảng 125 thì thời gian chạy mới vượt tuổi của vũ trụ.

#include <string.h>

long long fib(int n)
{
    long long memo[95];

    memset(memo, -1, sizeof memo);   /* mọi byte bằng 0xFF nên mọi phần tử bằng -1 */

    return fib_memo(n, memo);
}

Mẹo memset với giá trị âm một hoạt động vì biểu diễn bù hai của -1 là toàn bit 1. Nó chỉ đúng với -1 và với 0, đừng dùng để điền giá trị khác. Giới hạn 95 vì F(93) đã vượt quá long long.

#Tràn ngăn xếp

tran.c
#include <stdio.h>

void vo_han(int n)
{
    printf("%d\n", n);
    vo_han(n + 1);
}

int main(void)
{
    vo_han(0);
    return 0;
}
terminal
./tran | tail -3
130712
130713
130714
./tran > /dev/null; echo $?
139
# 139 nghĩa là 128 cộng 11, tức tín hiệu SIGSEGV
ulimit -s
8192

Ngăn xếp mặc định trên Linux là tám megabyte. Mỗi khung của hàm trên tốn khoảng sáu tư byte, nên độ sâu tối đa vào khoảng một trăm ba mươi nghìn tầng. Con số cụ thể phụ thuộc số biến cục bộ của hàm và mức tối ưu, nhưng bậc độ lớn thì luôn như vậy.

Nền tảngKích thước ngăn xếp mặc địnhCách xem
Linux, luồng chính8 MBulimit -s
Linux, luồng phụ8 MBpthread_attr_getstacksize
macOS, luồng chính8 MBulimit -s
macOS, luồng phụ512 KBmặc định của pthread
Windows1 MBghi trong tiêu đề file PE

Ba cách tránh

  • Chuyển sang vòng lặp khi độ sâu phụ thuộc dữ liệu đầu vào và có thể rất lớn. Duyệt một danh sách liên kết một triệu phần tử bằng đệ quy là chắc chắn sập.
  • Giới hạn độ sâu bằng một tham số đếm và trả về mã lỗi khi vượt ngưỡng. Rất quan trọng với trình phân tích cú pháp đọc dữ liệu từ bên ngoài, vì kẻ xấu có thể cố tình lồng sâu để làm sập chương trình.
  • Đừng khai báo mảng lớn làm biến cục bộ trong hàm đệ quy. Một mảng bốn kilobyte trong hàm đệ quy làm độ sâu tối đa tụt xuống còn khoảng hai nghìn tầng.
/* Giới hạn độ sâu, cách làm chuẩn của trình phân tích cú pháp */
#define DO_SAU_TOI_DA 200

int phan_tich(const char *s, int do_sau)
{
    if (do_sau > DO_SAU_TOI_DA)
        return -1;                        /* từ chối thay vì sập */

    /* ... phan_tich(s_con, do_sau + 1) ... */

    return 0;
}

#Đệ quy đuôi

Đệ quy đuôi
Dạng đệ quy mà lời gọi lại là việc cuối cùng hàm làm. Sau khi nó trả về, không còn phép tính nào cần thực hiện nữa.
/* Không phải đệ quy đuôi: sau khi factorial(n-1) trả về, còn phải nhân với n */
int gt_thuong(int n)
{
    if (n <= 1) return 1;

    return n * gt_thuong(n - 1);
}

/* Đệ quy đuôi: kết quả tích lũy được mang theo trong tham số acc */
int gt_duoi(int n, int acc)
{
    if (n <= 1) return acc;

    return gt_duoi(n - 1, n * acc);      /* không còn gì để làm sau lời gọi này */
}

Với dạng đuôi, khung hiện tại không còn giá trị gì cần giữ. Trình biên dịch có thể tái sử dụng luôn khung đó thay vì lập khung mới, và như vậy hàm đệ quy chạy với bộ nhớ ngăn xếp hằng số, đúng như một vòng lặp. Đây gọi là tối ưu lời gọi đuôi.

terminal
# Với -O2, gcc biến gt_duoi thành một vòng lặp thật sự
gcc -O2 -S -masm=intel gt.c -o - | head -12
gt_duoi:
        cmp     edi, 1
        jle     .L4
.L3:
        imul    esi, edi
        sub     edi, 1
        cmp     edi, 1
        jne     .L3       ; nhảy ngược, không có lệnh call nào
.L4:
        mov     eax, esi
        ret

#Khi nào nên và không nên dùng

Bài toánNên chọnVì sao
Duyệt cây, ví dụ cây nhị phânĐệ quyCấu trúc tự nó là đệ quy, độ sâu bằng chiều cao cây nên nhỏ
Tháp Hà Nội, sinh hoán vị, quay luiĐệ quyViết bằng vòng lặp cần tự cài ngăn xếp, dài gấp nhiều lần
Sắp xếp trộn, sắp xếp nhanhĐệ quyĐộ sâu chỉ khoảng log của n
Tính giai thừa, tính tổng dãyVòng lặpBài toán tuyến tính, đệ quy không mang lại gì
Duyệt danh sách liên kết dàiVòng lặpĐộ sâu bằng số phần tử, rất dễ tràn ngăn xếp
Fibonacci và các bài chồng lấnVòng lặp hoặc đệ quy có nhớĐệ quy ngây thơ tính lại cùng một giá trị vô số lần

Đệ quy gián tiếp

int la_le(int n);          /* bắt buộc có nguyên mẫu vì hai hàm gọi vòng nhau */

int la_chan(int n)
{
    return n == 0 ? 1 : la_le(n - 1);
}

int la_le(int n)
{
    return n == 0 ? 0 : la_chan(n - 1);
}

Hai hàm gọi lẫn nhau cũng là đệ quy, chỉ là vòng dài hơn một bậc. Đây là chỗ nguyên mẫu ở Bài 8.2 trở thành bắt buộc chứ không còn là lựa chọn. Ví dụ trên chỉ để minh họa, trong thực tế bạn dùng n % 2.

Tự làm thử

  1. Cài int la_nguyen_to(long n) chạy trong O của căn bậc hai của n, rồi viết hàm liệt kê mọi số nguyên tố tới n.
  2. Cài ước chung lớn nhất theo ba cách: trừ liên tiếp, Euclid lặp và Euclid đệ quy. Đếm số bước của mỗi cách với cặp 1 071 và 462.
  3. Cài đủ ba phiên bản Fibonacci trong bài, đo thời gian bằng clock() với n từ 30 tới 45, lập bảng so sánh.
  4. Tìm giá trị n lớn nhất mà factorial(n) chưa tràn với kiểu int, với long long, và với double.
  5. Cài Tháp Hà Nội, đếm số bước với n từ 1 tới 20 và chứng minh kết quả luôn bằng 2ⁿ - 1.
  6. Viết hàm đệ quy đảo ngược một chuỗi tại chỗ, và hàm đệ quy tính tổng các chữ số của một số nguyên.
  7. Cài tìm kiếm nhị phân bằng đệ quy, sau đó viết lại bằng vòng lặp. So sánh độ dài và độ sâu ngăn xếp của hai bản.
  8. Viết hàm sinh mọi hoán vị của một chuỗi bằng kỹ thuật quay lui, và giải thích vì sao bài này gần như bắt buộc phải dùng đệ quy.
  9. Cài hàm Ackermann, thử A(3, 3), rồi giải thích vì sao A(4, 2) không chạy nổi.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Hàm đệ quy bắt buộc có hai thành phần: điều kiện dừng và bước tiến về điều kiện dừng.
  • Mỗi lời gọi chiếm một khung ngăn xếp riêng, và tất cả cùng tồn tại cho tới khi chạm điều kiện dừng.
  • Đệ quy ngây thơ cho Fibonacci là O(2ⁿ) vì tính lại cùng một giá trị vô số lần. Memo hóa đưa nó về O(n) mà vẫn giữ dạng đệ quy.
  • Ngăn xếp mặc định tám megabyte cho khoảng một trăm ba mươi nghìn tầng. Đừng để độ sâu tỷ lệ với dữ liệu do người dùng nhập.
  • Đệ quy đuôi có thể được tối ưu thành vòng lặp, nhưng chuẩn C không đảm bảo nên đừng phụ thuộc.
  • Chọn đệ quy khi độ sâu là log hoặc bị chặn, chọn vòng lặp khi độ sâu tỷ lệ thuận với kích thước dữ liệu.