Đệ quy
Cập nhật lần cuối
Đệ quy (recursion) là khi một hàm tự gọi chính nó trên một phiên bản nhỏ hơn của cùng bài toán, cho đến khi gặp một trường hợp đủ nhỏ để trả lời trực tiếp. Trường hợp trả lời trực tiếp đó gọi là điều kiện dừng (base case), và mọi hàm đệ quy đều cần một điều kiện dừng: fib(n) liên tục tách thành fib(n - 1) và fib(n - 2) cho đến khi gặp fib(1) hoặc fib(0), những lời gọi chỉ đơn giản trả về chính giá trị của chúng. Mô phỏng ở trên chạy đúng như vậy: nhấn Phát và xem các lời gọi phân nhánh thành một cây, chạm tới điều kiện dừng ở các lá, rồi trả giá trị ngược lên, kết hợp lại ở mỗi tầng.
Điều thứ hai hình động cho thấy là ngăn xếp lời gọi (call stack): mọi lời gọi đã bắt đầu nhưng chưa trả về. Ngăn xếp lớn dần khi các lời gọi đi sâu hơn, đạt đỉnh ở độ sâu đệ quy, rồi thu lại khi kết quả trả về, đó là lý do đệ quy quá sâu có thể gây tràn ngăn xếp (stack overflow) trong khi vòng lặp không bao giờ làm ngăn xếp lớn lên. Cùng hình dạng lời gọi này điều khiển tìm kiếm theo chiều sâu, merge sort và hầu hết các thao tác trên cây nhị phân.
Độ phức tạp thời gian và không gian
Với hàm Fibonacci đệ quy đơn giản ở trên, và hai cách khắc phục tiêu chuẩn:
| Cách làm | Thời gian | Không gian | Ghi chú |
|---|---|---|---|
| Đệ quy đơn giản | O(2^n) | O(n) | Cây lời gọi gấp đôi ở mỗi tầng; không gian là ngăn xếp sâu nhất, không phải toàn bộ cây. |
| Có ghi nhớ (memoization) | O(n) | O(n) | Mỗi fib(k) chỉ được tính một lần và lưu lại; các cây con lặp lại thu gọn thành phép tra cứu. |
| Vòng lặp | O(n) | O(1) | Hai biến luân phiên thay thế hoàn toàn ngăn xếp. |
| Đệ quy nói chung | số lời gọi × công việc mỗi lời gọi | O(max depth) | Ngăn xếp giữ một khung cho mỗi lời gọi đã bắt đầu nhưng chưa trả về. |
Từng bước
| Bước | Điều gì xảy ra |
|---|---|
| 1 | Lời gọi đầu tiên fib(n) được đưa vào ngăn xếp lời gọi. |
| 2 | Nó cần fib(n - 1), nên lời gọi đó cũng được đưa vào ngăn xếp; lời gọi cha phải chờ. |
| 3 | Các lời gọi tiếp tục lồng nhau cho đến khi một lời gọi hỏi về n <= 1: điều kiện dừng trả lời ngay, không gọi sâu hơn. |
| 4 | Giá trị của điều kiện dừng trả về cho lời gọi cha, lúc này lời gọi cha có thể bắt đầu lời gọi thứ hai, fib(n - 2). |
| 5 | Khi cả hai lời gọi con đã trả về, lời gọi cha cộng chúng lại và cũng trả về; khung của nó rời khỏi ngăn xếp. |
| 6 | Việc trả về lặp lại ngược lên cây cho đến khi khung của lời gọi đầu tiên được lấy ra cùng đáp án cuối cùng và ngăn xếp rỗng. |
Ví dụ minh họa
Tính fib(4) theo đúng thứ tự lời gọi, như hình động thể hiện:
| Lời gọi | Ngăn xếp tại thời điểm đó | Trả về |
|---|---|---|
fib(4) | fib(4) | chờ các lời gọi con |
fib(3) | fib(4) > fib(3) | chờ các lời gọi con |
fib(2) | fib(4) > fib(3) > fib(2) | chờ các lời gọi con |
fib(1) | fib(4) > fib(3) > fib(2) > fib(1) | 1 (điều kiện dừng) |
fib(0) | fib(4) > fib(3) > fib(2) > fib(0) | 0 (điều kiện dừng) |
fib(2) kết hợp | fib(4) > fib(3) > fib(2) | 1 + 0 = 1 |
fib(1) | fib(4) > fib(3) > fib(1) | 1 (điều kiện dừng) |
fib(3) kết hợp | fib(4) > fib(3) | 1 + 1 = 2 |
fib(2) lần nữa | fib(4) > fib(2) | 1, tính lại từ đầu |
fib(4) kết hợp | fib(4) | 2 + 1 = 3 |
Khi nào nên dùng đệ quy
| Nên dùng khi | Tránh dùng khi |
|---|---|
| Bài toán có tính tự đồng dạng: cây, cấu trúc lồng nhau, chia để trị | Một vòng lặp đơn giản diễn đạt được điều tương tự mà không cần khung ngăn xếp |
Độ sâu có giới hạn và vừa phải, như O(log n) trong merge sort | Độ sâu có thể bằng kích thước đầu vào với dữ liệu rất lớn, có nguy cơ tràn ngăn xếp |
| Quay lui (backtracking) cần ngăn xếp để nhớ chỗ tiếp tục | Các bài toán con lặp lại mà bạn không lưu kết quả |
| Phiên bản đệ quy rõ ràng dễ đọc và dễ kiểm chứng hơn | Bạn đang ở trong một vòng lặp nóng, nơi chi phí gọi hàm ảnh hưởng đo được |
Code Recursion
Một cách cài đặt Recursion gọn gàng, chạy được ngay bằng Python, JavaScript, Java, C++, C. Chọn một ngôn ngữ, sao chép code hoặc mở sẵn trong Coddy Playground.
Code Recursion bằng Python
1calls = 02
3def fib(n, depth=0):4 global calls5 calls += 16 # Print the call with its depth so the recursion is visible7 print(" " * depth + f"fib({n})")8 if n <= 1:9 return n10 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1)11
12
13print("fib(5) =", fib(5))14print("calls made:", calls)Code Recursion bằng JavaScript
1let calls = 0;2
3function fib(n, depth = 0) {4 calls += 1;5 // Print the call with its depth so the recursion is visible6 console.log(' '.repeat(depth) + `fib(${n})`);7 if (n <= 1) return n;8 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);9}10
11console.log('fib(5) =', fib(5));12console.log('calls made:', calls);Code Recursion bằng Java
1public class Main {2 static int calls = 0;3
4 static int fib(int n, int depth) {5 calls++;6 // Print the call with its depth so the recursion is visible7 System.out.println(" ".repeat(depth) + "fib(" + n + ")");8 if (n <= 1) return n;9 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);10 }11
12 public static void main(String[] args) {13 System.out.println("fib(5) = " + fib(5, 0));14 System.out.println("calls made: " + calls);15 }16}Code Recursion bằng C++
1#include <iostream>2#include <string>3
4int calls = 0;5
6int fib(int n, int depth) {7 calls++;8 // Print the call with its depth so the recursion is visible9 std::cout << std::string(depth * 2, ' ') << "fib(" << n << ")\n";10 if (n <= 1) return n;11 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);12}13
14int main() {15 int result = fib(5, 0);16 std::cout << "fib(5) = " << result << "\n";17 std::cout << "calls made: " << calls << "\n";18 return 0;19}Code Recursion bằng C
1#include <stdio.h>2
3int calls = 0;4
5int fib(int n, int depth) {6 calls++;7 /* Print the call with its depth so the recursion is visible */8 printf("%*sfib(%d)\n", depth * 2, "", n);9 if (n <= 1) return n;10 return fib(n - 1, depth + 1) + fib(n - 2, depth + 1);11}12
13int main(void) {14 printf("fib(5) = %d\n", fib(5, 0));15 printf("calls made: %d\n", calls);16 return 0;17}Câu hỏi thường gặp về đệ quy
Điều kiện dừng trong đệ quy là gì?
fib(n), đó là n <= 1, trả về n trực tiếp. Nếu không có điều kiện dừng có thể đạt tới, các lời gọi không bao giờ dừng, ngăn xếp cứ lớn dần và chương trình bị lỗi tràn ngăn xếp.Ngăn xếp lời gọi là gì và vì sao nó quan trọng?
n tầng sẽ dùng O(n) bộ nhớ dù mỗi lời gọi gần như không làm gì. Hàng thẻ bên dưới hình động cho thấy chính ngăn xếp này lớn dần rồi thu lại.Tại sao Fibonacci đệ quy tốn thời gian hàm mũ?
fib(2) được tính hai lần bên trong fib(4), và sự trùng lặp này gần như gấp đôi qua mỗi tầng, tạo ra O(2^n) lời gọi. Lưu lại mỗi kết quả ngay lần đầu tính, gọi là ghi nhớ (memoization), thu gọn cây xuống còn O(n).Đệ quy có tốt hơn vòng lặp không?
Điều gì gây ra tràn ngăn xếp trong hàm đệ quy?
Hoặc thiếu điều kiện dừng hay điều kiện dừng không thể đạt tới, khiến các lời gọi không bao giờ dừng, hoặc một hàm đệ quy đúng nhưng độ sâu quá lớn so với giới hạn ngăn xếp của môi trường chạy, như đệ quy một lần cho mỗi phần tử với đầu vào hàng triệu phần tử. Cách khắc phục là bảo đảm điều kiện dừng, giới hạn độ sâu, hoặc chuyển sang vòng lặp.