Đệ 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
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 */
}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ào | Chuyện gì xảy ra |
|---|---|
| Thiếu điều kiện dừng | Gọ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ến | Gọ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 |
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);
}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 = 24Sơ đồ 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ọi | n nhận được | Chờ kết quả của | Trả về |
|---|---|---|---|
| 1 | 4 | factorial(3) | 4 * 6 = 24 |
| 2 | 3 | factorial(2) | 3 * 2 = 6 |
| 3 | 2 | factorial(1) | 2 * 1 = 2 |
| 4 | 1 | không chờ ai | 1 |
#Đệ 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í | Đệ quy | Vòng lặp |
|---|---|---|
| Bộ nhớ | O của độ sâu, tính trên ngăn xếp | O của một hằng số |
| Tốc độ | Chậm hơn, mỗi tầng tốn một lời gọi | Nhanh hơn |
| Rủi ro riêng | Tràn ngăn xếp khi độ sâu lớn | Không có |
| Bài toán tự nhiên đệ quy | Ngắn và sát định nghĩa | Phải tự quản lý ngăn xếp bằng tay, rất dài |
| Bài toán tuyến tính | Dài dòng không cần thiết | Ngắn gọn tự nhiên |
Bài toán mà đệ quy thắng rõ ràng
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.
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
/* 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ạp | Số lời gọi khi n bằng 40 | Thời gian |
|---|---|---|---|
| Đệ quy ngây thơ | O(2ⁿ) | 331 160 281 | khoảng 1 giây |
| Đệ quy có nhớ | O(n) | 79 | tức thì |
| Vòng lặp | O(n) | không có lời gọi nào | tứ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
#include <stdio.h>
void vo_han(int n)
{
printf("%d\n", n);
vo_han(n + 1);
}
int main(void)
{
vo_han(0);
return 0;
}130712 130713 130714
139
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ảng | Kích thước ngăn xếp mặc định | Cách xem |
|---|---|---|
| Linux, luồng chính | 8 MB | ulimit -s |
| Linux, luồng phụ | 8 MB | pthread_attr_getstacksize |
| macOS, luồng chính | 8 MB | ulimit -s |
| macOS, luồng phụ | 512 KB | mặc định của pthread |
| Windows | 1 MB | ghi 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
/* 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.
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án | Nên chọn | Vì sao |
|---|---|---|
| Duyệt cây, ví dụ cây nhị phân | Đệ quy | Cấ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 | Đệ quy | Viế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ãy | Vòng lặp | Bài toán tuyến tính, đệ quy không mang lại gì |
| Duyệt danh sách liên kết dài | Vò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ấn | Vò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ử
- 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. - 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.
- 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. - Tìm giá trị n lớn nhất mà
factorial(n)chưa tràn với kiểuint, vớilong long, và vớidouble. - 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. - 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.
- 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.
- 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.
- Cài hàm Ackermann, thử
A(3, 3), rồi giải thích vì saoA(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.