Tư duy Tính toán

Đệ quy (Recursion)

Trường ĐH Công nghệ – Đại học Quốc gia Hà Nội

Nội dung

  1. Đệ quy: lặp lại không cần vòng lặp for
  2. Hàm số học lặp & đệ quy (giai thừa, tổng)
  3. Trường hợp cơ sở, trường hợp đệ quy & đệ quy vô hạn
  4. Đệ quy với nhiều trường hợp cơ sở: Fibonacci
  5. Đệ quy trên dữ liệu lồng nhau
  6. So sánh đệ quy & vòng lặp; mẫu lập trình đệ quy

Tài liệu đọc: Think Python 3rd ed., 5.{8–10}, 6.{6–8}

1.
Đệ quy: lặp lại
không cần vòng lặp

Lặp lại mà không cần vòng lặp

  • Chúng ta đã dùng for để lặp lại một khối lệnh.
  • Nhưng có thể lặp lại không dùng vòng lặp!
  • Ý tưởng: hàm có thể gọi chính nó như hàm trợ giúp.
  • Đó là đệ quy (recursion).

Đếm ngược bằng vòng lặp for


def blast_off_loop(n):
    """Print a countdown starting at n.
       n: a non-negative int"""
    for n in range(n, 0, -1):
        print(n)
    print('BLAST OFF!')
          
  • Dùng range(n, 0, -1) để đi từ n xuống 1.
  • Đây là cách làm lặp (iterative) quen thuộc.

Thuộc tính step của range


>>> r3 = range(3)
>>> r3.step
1
>>> r10 = range(0, 10, 2)
>>> r10.step
2
>>> list(r10)
[0, 2, 4, 6, 8]
>>> r_down = range(10, 0, -1)
>>> list(r_down)
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
          

step cho biết “bước nhảy” giữa hai giá trị liên tiếp trong range.

Đếm ngược mà không dùng vòng lặp


def blast_off_no_loop(n):
    """Print a countdown starting at n.
       n: a non-negative int"""
    if n == 0:
        print('BLAST OFF!')
    else:
        print(n)
        blast_off_no_loop(n-1)
          
  • Nếu n == 0 → in 'BLAST OFF!' (trường hợp cơ sở).
  • Ngược lại: in n rồi gọi lại hàm với n-1 (trường hợp đệ quy).

Hàm gọi chính nó

  • Một hàm có thể gọi các hàm trợ giúp khác.
  • Nó cũng có thể gọi chính nó → gọi đó là lời gọi đệ quy.
  • Hàm chứa ít nhất một lời gọi đệ quy là một hàm đệ quy.
  • Thực thi hàm đệ quy không hề “ma thuật”: ta hiểu được bằng call stack và sơ đồ gọi hàm.

Đệ quy vs. lặp

  • Đệ quy: dùng hàm đệ quy để lặp lại công việc.
  • Lặp (iteration): dùng vòng lặp for hoặc while.
  • Cả hai đều là mẫu lập trình cho sự lặp lại.
  • Đệ quy (với while) mạnh không kém gì vòng lặp, đôi khi còn thuận tiện hơn.

2.
Hàm số học lặp & đệ quy

Phép hoán vị & giai thừa

  • Có bao nhiêu cách sắp xếp n phần tử phân biệt?
  • n = 2:
    • A, B
    • B, A
    • → Có 2 = 2 * 1 cách.
  • n = 3: 6 cách sắp, = 3 * 2 * 1.
  • Nhìn chung: n! là số hoán vị của n phần tử.

Toán tử / hàm giai thừa

  • n! = n * (n - 1) * ... * 2 * 1
  • Hoặc: n! = 1 * 2 * ... * (n - 1) * n
  • Hoặc đệ quy:
    • n! = n * (n - 1)!
    • 0! = 1 (quy ước cơ sở).

Giai thừa dạng lặp (iterative)


def factorial_iter(n):
    """Returns n!.
    n: a non-negative integer"""
    product = 1
    for i in range(1, n+1):  # 1..n
        product = product * i
    return product
          

Dùng biến tạm product để tích lũy kết quả trong vòng for.

Giai thừa dạng đệ quy


def factorial_rec(n):
    """Returns n!.
    n: a non-negative integer"""
    if n == 0:
        return 1
    else:
        n_minus_1_fact = factorial_rec(n-1)
        return n * n_minus_1_fact
          
  • Trường hợp cơ sở: n == 0 → trả về 1.
  • Trường hợp đệ quy: cần (n-1)! để nhân với n.

Tưởng tượng các khung gọi hàm (call frame)

  • Mỗi lần gọi hàm tạo một khung mới trên call stack.
  • Với factorial_rec(4), ta có các khung cho: n = 4, 3, 2, 1, 0.
  • Khi quay lui (return), kết quả được “trả ngược” lên các khung trước.
  • Hình dung call stack giúp hiểu rõ cách đệ quy thực thi.

Ví dụ & Quiz: hàm mystery


def mystery(n):
    if n == 0:
        return 0
    else:
        n_minus_1_result = mystery(n-1)
        return n + n_minus_1_result

print(mystery(4))
          

Câu hỏi: Hàm này in ra gì?

  • A: 0    B: 1    C: 4    D: 7    E: 10

Từ vựng cho đệ quy


def factorial_rec(n):
    """Returns n!.
    n: a non-negative integer"""
    if n == 0:
        return 1
    else:
        n_minus_1_fact = factorial_rec(n-1)
        return n * n_minus_1_fact
          
  • Base case: n == 0 → trả về 1.
  • Recursive case: n > 0 → cần factorial_rec(n-1).

Base case vs. Recursive case

  • Base case:
    • Không gọi đệ quy.
    • Tự tính được đáp án ngay lập tức.
  • Recursive case:
    • Có ít nhất một lời gọi đệ quy.
    • Cần “nhờ chính mình” giải bài toán nhỏ hơn rồi kết hợp lại.

3.
Đệ quy vô hạn &
lỗi RecursionError

Định nghĩa một thứ bằng chính nó?


def factorial_rec(n):
    """Returns n!.
    n: a non-negative integer"""
    if n == 0:
        return 1
    else:
        n_minus_1_fact = factorial_rec(n-1)
        return n * n_minus_1_fact
          

Về toán học:

  • n! = n * (n - 1)!   (trường hợp đệ quy)
  • 0! = 1   (trường hợp cơ sở)

Có vô lý không?

  • Câu hỏi: “Định nghĩa một thứ bằng chính nó” có phải là vòng tròn?
  • Trả lời:
    • Nếu có base case & mỗi bước tiến tới đó → OK.
    • Nếu không cẩn thận → dẫn đến đệ quy vô hạn.

Đệ quy vô hạn (ví dụ 1)


# recursionerrors.py
def infinite_recursion():
    infinite_recursion()
          

>>> import recursionerrors
>>> recursionerrors.infinite_recursion()
... RecursionError: maximum recursion depth exceeded
          

Không có base case → hàm tự gọi mãi mãi (cho tới khi Python chặn lại).

Đệ quy vô hạn (ví dụ 2)


# recursionerrors.py
def bad_blast_off_v1(n):
    if n == 0:
        print('BLAST OFF!')
    else:
        print(n)
        bad_blast_off_v1(n)  # BUG: need "n-1"


def bad_blast_off_v2(n):
    # BUG: no base case
    print(n)
    bad_blast_off_v2(n-1)
          
  • bad_blast_off_v1: quên giảm n.
  • bad_blast_off_v2: không có base case.
  • Cả hai đều dẫn tới RecursionError.

Python ngăn đệ quy vô hạn

  • Số lượng khung gọi hàm trên stack là hữu hạn (mặc định ~1000).
  • Nếu chương trình bị “kẹt” trong đệ quy, Python sẽ dừng với lỗi RecursionError.
  • Nhờ đó ta không thực sự chạy mã vô hạn, chỉ gặp lỗi báo sớm.
  • Thông điệp: hãy luôn kiểm tra:
    • base case hay không?
    • Có đảm bảo mỗi bước tiến tới base case không?

Quiz: trường hợp nào gây RecursionError?


# A
def recurse(n):
    if n == 0:
        return
    recurse(n-1)
          

# B
def recurse(n):
    if n == 0:
        return
    recurse(n+1)
          

Tiền điều kiện chung: n >= 0.

  • A: Chỉ A    B: Chỉ B    C: Cả hai

4.
Đệ quy với nhiều
base case: Fibonacci

Dãy Fibonacci

Chuỗi số:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, …

  • Mỗi số (từ vị trí thứ 2) bằng tổng của hai số trước.
  • Ứng dụng:
    • Thơ ca (mẫu thơ Sanskrit)
    • Sinh học: phân nhánh cây
    • Sinh thái: mô hình tăng trưởng dân số
    • … và rất nhiều lĩnh vực khác

Định nghĩa đệ quy của Fibonacci

  • F(0) = 0   (base case)
  • F(1) = 1   (base case)
  • F(n) = F(n - 1) + F(n - 2)   (recursive case)

hai base case — rất phổ biến trong đệ quy.

Fibonacci đệ quy


def fibo_rec(n):
    """Returns  F(n).
    n: a non-negative integer"""
    if n == 0 or n == 1:
        return n
    else:
        return fibo_rec(n-1) + fibo_rec(n-2)
          
  • Rất gần với định nghĩa toán học.
  • Mã ngắn gọn, dễ đọc → dễ cài đặt.

Fibonacci đệ quy

fibo_rec(n) tạo ra rất nhiều lời gọi lặp lại.

fib(5) fib(4) fib(3) fib(3) fib(2) fib(2) fib(1) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) fib(1) fib(0)

Fibonacci đệ quy

fibo_rec(n) tạo ra rất nhiều lời gọi lặp lại.

  • Ví dụ: fibo_rec(5) gọi:
    • fibo_rec(4)fibo_rec(3)
    • Trong đó fibo_rec(4) lại gọi fibo_rec(3)fibo_rec(2), v.v.
  • Nhiều tính toán bị lặp lại → tốn thời gian.

Fibonacci đệ quy: trực quan về số frame

  • Biểu đồ cây lời gọi của fibo_rec(n) phình to rất nhanh.
  • Số lượng khung gọi hàm (frame) tăng gần như theo cấp số nhân.
  • Vì vậy, dù dễ viết, hàm Fibonacci đệ quy này chạy không hiệu quả cho n lớn.

Fibonacci lặp (iterative)


def fibo_iter(n):
    if n == 0 or n == 1:
        return n
    else:
        a = 0
        b = 1
        for i in range(2, n+1):
            t = a
            a = b
            b = a + t
        return b
          
  • Cần suy nghĩ kỹ về:
    • giới hạn vòng lặp,
    • ý nghĩa của các biến a, b, t.
  • Đổi lại, giải pháp này không lặp lại tính toán.

Quiz: a, b là gì?


def fibo_iter(n):
    if n == 0 or n == 1:
        return n
    else:
        a = 0
        b = 1
        for i in range(2, n+1):    # a = F(???), b = F(???)
            t = a
            a = b
            b = a + t
        return b
          
  • A: a = F(i-2), b = F(i-1)
  • B: a = F(i-1), b = F(i-2)
  • C: a = F(i-1), b = F(i-1)
  • D: a = F(i-2), b = F(i-1)
  • E: a = F(i), b = F(i-1)

Đệ quy vs. lặp: Fibonacci & giai thừa

  • Giai thừa:
    • Dễ cài bằng cả đệ quy vòng lặp.
  • Fibonacci:
    • Dễ cài bằng đệ quy (giống định nghĩa).
    • Khó hơn một chút bằng vòng lặp, nhưng chạy hiệu quả hơn.
  • Câu hỏi mở: khi nào nhất thiết phải dùng đệ quy?

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

Lưu một mục lục như thế nào?

  • Preface
  • 1 Fundamentals
    • 1.1 Basic Programming Model
    • 1.2 Data Abstraction
    • 1.3 Bags, Queues, and Stacks
      • Bags
      • Queues
      • Stacks
    • 1.4 Analysis of Algorithms
    • 1.5 Case Study: Union-Find
  • 2 Sorting
  • 3 Searching

Book = [
    'Preface',
    '1 Fundamentals',
    ['1.1 Basic Programming Model',
     '1.2 Data Abstraction',
     '1.3 Bags, Queues, and Stacks',
     ['Bags',
      'Queues',
      'Stacks'],
     '1.4 Analysis of Algorithms',
     '1.5 Case Study: Union-Find'],
    '2 Sorting',
    '3 Searching',
    ...
]
          
  • Dùng list lồng nhau để biểu diễn cấu trúc nhiều mức.
  • Hỏi: in đề cương này ra màn hình như thế nào cho đẹp?

Cách 1: in từng phần tử


# outline.py
def print_outline_v1(outline):
    for item in outline:
        print(item)
          

Preface
1 Fundamentals
['1.1 Basic Programming Model', '1.2 Data Abstraction',
 '1.3 Bags, Queues, and Stacks', ['Bags', 'Queues', 'Stacks'],
 '1.4 Analysis of Algorithms', '1.5 Case Study: Union-Find']
2 Sorting
3 Searching
          

Danh sách lồng bên trong bị in ra nguyên cả list → khó đọc.

Cách 2: xử lý một mức lồng nhau


def print_outline_v2(outline):
    for item in outline:
        # handle nested lists
        if isinstance(item, list):
            for subitem in item:
                print(' ' * 4 + str(subitem))
        else:
            print(item)
          

Preface
1 Fundamentals
    1.1 Basic Programming Model
    1.2 Data Abstraction
    1.3 Bags, Queues, and Stacks
    ['Bags', 'Queues', 'Stacks']
    1.4 Analysis of Algorithms
    1.5 Case Study: Union-Find
2 Sorting
3 Searching
          

Cách 3: xử lý hai mức lồng nhau


def print_outline_v3(outline):
    for item in outline:
        # handle nested lists
        if isinstance(item, list):
            for subitem in item:
                # handle nested nested lists
                if isinstance(subitem, list):
                    for subsubitem in subitem:
                        print(' ' * 8 + str(subsubitem))
                else:
                    print(' ' * 4 + str(subitem))
        else:
            print(item)
          

Preface
1 Fundamentals
    1.1 Basic Programming Model
    1.2 Data Abstraction
    1.3 Bags, Queues, and Stacks
        Bags
        Queues
        Stacks
    1.4 Analysis of Algorithms
    1.5 Case Study: Union-Find
2 Sorting
3 Searching
          

Dùng for-loop là không đủ

  • Nếu outline có độ sâu lồng nhau là N:
    • Cần N vòng lặp lồng nhau để xử lý.
  • Không thể viết sẵn chương trình cho “độ sâu bất kỳ”.
  • Mã trở nên dài & khó bảo trì ngay cả với độ sâu nhỏ (ví dụ 3).
  • Giải pháp: dùng đệ quy để tự xử lý mọi mức lồng nhau.

In outline bằng đệ quy


def indent(level):
    return ' ' * level


def print_outline_rec(outline, level):
    for item in outline:
        if isinstance(item, list):
            print_outline_rec(item, level+1)
        else:
            print(indent(level) + item)
          
  • Base case: phần tử là chuỗi → in ra với thụt lề.
  • Recursive case: phần tử là list → gọi lại chính mình với level+1.

Đệ quy giúp gì ở đây?

  • Cùng một đoạn mã xử lý được outline lồng sâu “vừa đủ” bất kỳ.
  • Không cần thêm vòng lặp khi có thêm một mức lồng nhau.
  • Ý tưởng: để chính hàm xử lý phần sub-outline.
  • Kết quả: mã gọn gàng, dễ hiểu, dễ mở rộng.

Quiz: số lần gọi đệ quy


outline = ['Intro',
           ['Part 1',
            ['Detail 1.1', 'Detail 1.2'],
            'Part 2'],
           'Conclusion']

print_outline_rec(outline, 0)
          

def print_outline_rec(outline, level):
    for item in outline:
        if isinstance(item, list):
            print_outline_rec(item, level+1)
        else:
            print(indent(level) + item)
          

Hỏi: tổng cộng bao nhiêu lần hàm print_outline_rec được gọi (kể cả lần đầu)?

  • A: 1    B: 2    C: 3    D: 6    E: 7

Đệ quy vs. lặp: dữ liệu lồng nhau

  • Với giai thừa, Fibonacci:
    • Đệ quy và vòng lặp đều làm được.
  • Với dữ liệu lồng nhau độ sâu bất kỳ:
    • Không thể biết trước cần bao nhiêu vòng lặp.
    • Đệ quy là cần thiết để xử lý mọi độ sâu.

6.
Mẫu lập trình
đệ quy

Ôn lại: hình ảnh call stack

  • Mỗi lần hàm gọi chính nó → thêm một khung lên stack.
  • Base case: khung cuối cùng return kết quả.
  • Các khung phía trên lần lượt nhận kết quả và kết hợp lại.
  • Hình dung này giúp kiểm tra:
    • Ta có tiến về base case không?
    • Ta có kết hợp đúng kết quả con không?

Mẫu lập trình đệ quy

Mục tiêu: giải bài toán P trên một dữ liệu đầu vào.

  • Bước 1 – Phân rã dữ liệu:
    • Chia dữ liệu thành một hoặc nhiều phần nhỏ hơn.
  • Bước 2 – Base case:
    • Nếu phần đủ nhỏ → trả lời trực tiếp.
  • Bước 3 – Recursive case:
    • Nếu phần còn lớn → gọi đệ quy để giải P trên phần nhỏ hơn.
    • Sau đó kết hợp (recombine) kết quả để ra lời giải cuối.

Kỹ thuật này thường được gọi là “chia để trị” (divide and conquer).

Ví dụ mẫu đệ quy: Outline

Mục tiêu: In mục lục từ list lồng nhau.

  • Phân rã: tách thành từng item trong list.
  • Base case:
    • Nếu string → in ra với mức thụt lề hiện tại.
  • Recursive case:
    • Nếu list → gọi print_outline_rec với level+1.
  • Kết hợp: mọi dòng in ra trên màn hình ghép lại thành một outline hoàn chỉnh.

Ví dụ mẫu đệ quy: Fibonacci

Mục tiêu: Tính F(n).

  • Phân rã: tách thành hai bài toán nhỏ hơn: n-1n-2.
  • Base case:
    • Nếu n là 0 hoặc 1 → trả về n.
  • Recursive case:
    • Nếu n >= 2: F(n) = F(n-1) + F(n-2).
  • Kết hợp: cộng hai kết quả con.

Ví dụ mẫu đệ quy: Giai thừa

Mục tiêu: Tính n!.

  • Phân rã: biến dữ liệu n thành bài toán n-1.
  • Base case:
    • Nếu n == 0 → trả về 1.
  • Recursive case:
    • Nếu n > 0: n! = n * (n-1)!.
  • Kết hợp: nhân n với kết quả đệ quy.

Giải bài toán bằng đệ quy

  • 1. Quyết định cách phân rã dữ liệu:
    • Dữ liệu lớn thành các phần nhỏ hơn ra sao?
  • 2. Xác định base case:
    • Khi nào bài toán đủ đơn giản để trả lời trực tiếp?
  • 3. Thiết kế recursive case:
    • Gọi lại hàm trên phần dữ liệu nhỏ hơn.
    • Kết hợp kết quả để ra đáp án tổng thể.

Mỗi bước đều phải đảm bảo: tiến tới base case, không được quay vòng vô hạn.