Menu
CoddyTech

Đệ 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àmThời gianKhông gianGhi chú
Đệ quy đơn giảnO(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ặpO(n)O(1)Hai biến luân phiên thay thế hoàn toàn ngăn xếp.
Đệ quy nói chungsố lời gọi × công việc mỗi lời gọiO(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
1Lời gọi đầu tiên fib(n) được đưa vào ngăn xếp lời gọi.
2Nó 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ờ.
3Cá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.
4Giá 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).
5Khi 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.
6Việ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ọiNgă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ợpfib(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ợpfib(4) > fib(3)1 + 1 = 2
fib(2) lần nữafib(4) > fib(2)1, tính lại từ đầu
fib(4) kết hợpfib(4)2 + 1 = 3

Khi nào nên dùng đệ quy

Nên dùng khiTrá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ụcCá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ơnBạ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

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)
Chạy code này trong Playground Python

Câu hỏi thường gặp về đệ quy

Điều kiện dừng trong đệ quy là gì?
Là đầu vào đủ nhỏ để trả lời mà không cần thêm lời gọi đệ quy nào. Với 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?
Môi trường chạy giữ một khung cho mỗi lời gọi đã bắt đầu nhưng chưa trả về, chứa các đối số và biến cục bộ của lời gọi đó. Độ sâu đệ quy bằng chiều cao ngăn xếp, nên một hàm đệ quy đi sâu 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ũ?
Vì cùng các bài toán con bị tính đi tính lại: trong ví dụ minh họa ở trên, 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?
Không cách nào tốt hơn trong mọi trường hợp. Mọi hàm đệ quy đều viết lại được thành vòng lặp với ngăn xếp tường minh, và mọi vòng lặp đều viết được thành đệ quy. Đệ quy dễ đọc hơn với các bài toán tự đồng dạng như duyệt cây và tìm kiếm theo chiều sâu; vòng lặp tiết kiệm bộ nhớ và chi phí gọi hàm hơn với các lượt duyệt tuyến tính.
Đ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.

Những thuật toán nào có bản chất đệ quy?
Các thuật toán sắp xếp chia để trị như merge sort và quicksort, việc duyệt cây nhị phân và đồ thị, tìm kiếm nhị phân, các bài toán quay lui như N quân hậu, và mọi thứ được định nghĩa trên cấu trúc lồng nhau như JSON hay hệ thống tệp.
Hình minh họa các ngôn ngữ lập trình của Coddy

Thành thạo thuật toán cùng Coddy

Bắt đầu