cv.cm

Lập trình · Thuật toán

Đệ quy là gì: cách hoạt động, kèm ví dụ Python

6 phút đọc

Trả lời nhanh

Đệ quy là kỹ thuật trong đó một hàm giải bài toán bằng cách tự gọi chính nó trên phiên bản nhỏ hơn của cùng bài toán. Mỗi hàm đệ quy cần một trường hợp cơ sở (base case) trả về mà không đệ quy tiếp, và một trường hợp đệ quy tiến dần về trường hợp cơ sở. Mỗi lời gọi chờ trên ngăn xếp lời gọi (call stack) cho đến khi lời gọi nhỏ hơn trả về. Đệ quy phù hợp tự nhiên với cây, dữ liệu lồng nhau và các thuật toán chia để trị như sắp xếp trộn.

Đệ quy giải quyết vấn đề gì

Một số bài toán được định nghĩa theo chính nó. Một thư mục chứa các tệp và các thư mục khác. Một nút cây có các cây con. Giai thừa của n bằng n nhân giai thừa của n − 1. Viết những bài toán này bằng vòng lặp thông thường thường cần một ngăn xếp tường minh chứa công việc đang chờ; đệ quy để ngôn ngữ giữ ngăn xếp đó giúp bạn.

Một hàm đệ quy có hai phần:

  • Trường hợp cơ sở: đầu vào nhỏ nhất, trả lời trực tiếp. Với giai thừa, 0! = 1.
  • Trường hợp đệ quy: thu nhỏ bài toán, gọi hàm trên phần nhỏ hơn và kết hợp kết quả. Với giai thừa, n! = n × (n − 1)!.
def factorial(n):
    if n == 0:                  # base case
        return 1
    return n * factorial(n - 1) # recursive case
print(factorial(5))   # 120

Ngăn xếp lời gọi, theo dõi từng bước

Mỗi lời gọi có một khung (frame) riêng trên ngăn xếp lời gọi, chứa bản sao n của riêng nó. Các lời gọi đi xuống cho đến trường hợp cơ sở, rồi kết quả quay ngược lên theo thứ tự đảo lại.

BướcLời gọiTrạng thái
1factorial(3)chờ factorial(2)
2factorial(2)chờ factorial(1)
3factorial(1)chờ factorial(0)
4factorial(0)trường hợp cơ sở, trả về 1
5factorial(1)trả về 1 × 1 = 1
6factorial(2)trả về 2 × 1 = 2
7factorial(3)trả về 3 × 2 = 6

Độ sâu ngăn xếp ở đây là n + 1 khung, nên không gian phụ là O(n) dù không tạo list nào.

Đệ quy trên dữ liệu lồng nhau

Đệ quy hữu ích nhất khi hình dạng của dữ liệu tự nó mang tính đệ quy. Hàm này cộng tất cả các số trong một list có thể chứa các list khác ở độ sâu bất kỳ.

def deep_sum(items):
    total = 0
    for x in items:
        if isinstance(x, list):
            total += deep_sum(x)   # same problem, smaller piece
        else:
            total += x
    return total
print(deep_sum([1, [2, 3], [4, [5, [6]]]]))   # 21

Cùng mẫu này dùng để duyệt cây thư mục, tài liệu JSON, DOM của HTML hay cây nhị phân: xử lý nút hiện tại, rồi đệ quy vào từng nút con.

def power_set(items):
    if not items:
        return [[]]
    rest = power_set(items[1:])
    return rest + [[items[0]] + s for s in rest]
print(power_set([1, 2, 3]))
# [[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]

Độ phức tạp của hàm đệ quy

Ước lượng chi phí bằng (số lời gọi) × (công việc mỗi lời gọi). Không gian tối thiểu bằng độ sâu ngăn xếp lớn nhất.

HàmSố lời gọiThời gianKhông gian ngăn xếp
factorial(n)n + 1O(n)O(n)
tìm kiếm nhị phân, đệ quykhoảng log₂ nO(log n)O(log n)
sắp xếp trộnkhoảng 2nO(n log n)O(log n) ngăn xếp, O(n) bộ đệm
Fibonacci ngây thơkhoảng 1.6ⁿcận O(2ⁿ)O(n)
tập lũy thừa (power set)n + 1O(n · 2ⁿ)O(n) ngăn xếp, đầu ra là 2ⁿ list

Các hệ thức truy hồi chia để trị như T(n) = 2T(n/2) + O(n) của sắp xếp trộn cho kết quả O(n log n). Hàm thực hiện hai lời gọi trên n − 1 sẽ tăng theo hàm mũ.

Memoization: ngừng lặp lại công việc

Hàm Fibonacci ngây thơ tính lại cùng các giá trị nhiều lần: fib(5) gọi fib(3) hai lần và fib(2) ba lần. Lưu kết quả vào bộ nhớ đệm ngay lần tính đầu tiên biến thời gian hàm mũ thành tuyến tính. functools.lru_cache của Python (hoặc functools.cache, Python 3.9+) làm việc này chỉ bằng một decorator.

from functools import lru_cache
def fib_slow(n):
    return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)
@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib_slow(20))   # 6765, about 22,000 calls
print(fib(90))        # 2880067194370816120, 91 distinct calls

Đệ quy có memoization là dạng từ trên xuống của quy hoạch động. Dạng từ dưới lên điền một bảng bằng vòng lặp.

Giới hạn đệ quy và chuyển sang vòng lặp

Bạn có thể nâng giới hạn bằng sys.setrecursionlimit(), nhưng đệ quy quá sâu vẫn có thể làm sập trình thông dịch. Với đầu vào có thể rất sâu, như danh sách liên kết một triệu nút hoặc cây suy biến, hãy viết lại hàm bằng vòng lặp và một ngăn xếp tường minh.

def deep_sum_iter(items):
    total, stack = 0, [items]
    while stack:
        current = stack.pop()
        for x in current:
            if isinstance(x, list):
                stack.append(x)
            else:
                total += x
    return total
print(deep_sum_iter([1, [2, 3], [4, [5, [6]]]]))   # 21

Lỗi thường gặp

  • Thiếu trường hợp cơ sở hoặc không bao giờ đạt tới nó, ví dụ factorial(-1) với đoạn mã trên không bao giờ về 0 và kết thúc bằng RecursionError.
  • Trường hợp đệ quy không thu nhỏ đầu vào, chẳng hạn gọi f(n) từ chính f(n).
  • Quên return kết quả đệ quy: factorial(n - 1) không có return sẽ cho None.
  • Sao chép dữ liệu ở mỗi lời gọi. items[1:] sao chép list, nên đệ quy trên các lát cắt của list dài tốn O(n²) thời gian; hãy truyền chỉ số thay vào đó.
  • Dùng đối số mặc định có thể thay đổi như def f(x, seen=[]) để mang trạng thái giữa các lời gọi; cùng một list được chia sẻ cho mọi lời gọi hàm.

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

Dùng đệ quy khi dữ liệu hoặc bài toán vốn mang tính đệ quy: cây, đồ thị (tìm kiếm theo chiều sâu), cấu trúc lồng nhau, phân tích biểu thức, quay lui (hoán vị, N-Queens, sudoku) và chia để trị (sắp xếp trộn, sắp xếp nhanh, tìm kiếm nhị phân). Dùng vòng lặp khi bài toán chỉ là một dãy đơn giản, hoặc khi độ sâu có thể tăng theo kích thước đầu vào trong Python.

Trong phỏng vấn, câu hỏi về đệ quy thường kiểm tra việc bạn nêu được trường hợp cơ sở, trường hợp đệ quy, chi phí thời gian và không gian kể cả ngăn xếp, và khi nào memoization có ích.

Câu hỏi thường gặp

Trường hợp cơ sở trong đệ quy là gì?

Trường hợp cơ sở là đầu vào mà hàm đệ quy trả lời trực tiếp, không tự gọi lại. Nó dừng đệ quy; nếu thiếu, hàm sẽ tự gọi cho đến khi tràn ngăn xếp.

Đệ quy có chậm hơn vòng lặp không?

Trong Python thường chậm hơn một chút, vì mỗi lời gọi hàm có chi phí phụ và dùng một khung ngăn xếp. Độ phức tạp thường như nhau; rủi ro thực tế chính là giới hạn độ sâu đệ quy.

Độ sâu đệ quy tối đa trong Python là bao nhiêu?

Giới hạn mặc định của CPython là 1000 khung, bạn có thể đọc bằng sys.getrecursionlimit() và đổi bằng sys.setrecursionlimit(). Vượt quá sẽ gây RecursionError.

Đệ quy đuôi là gì?

Một lời gọi là lời gọi đuôi khi nó là việc cuối cùng hàm làm trước khi trả về. Một số ngôn ngữ tái sử dụng khung ngăn xếp cho lời gọi đuôi, nhưng CPython thì không, nên đệ quy đuôi trong Python vẫn làm ngăn xếp tăng dần.

Hàm đệ quy nào cũng viết được thành vòng lặp không?

Có. Mọi đệ quy đều chuyển được thành vòng lặp quản lý một ngăn xếp tường minh. Với đệ quy tuyến tính đơn giản như giai thừa, một vòng lặp thường với biến tích lũy là đủ.

Tất cả hướng dẫn lập trình