Hàm trong Python

Phần 1: Khai báo và sử dụng hàm tự định nghĩa trong Python

1. Giới thiệu về hàm

Hàm (function) là một khối mã được đặt tên, thực hiện một nhiệm vụ cụ thể. Trong Python, chúng ta có hai loại hàm:

  • Hàm có sẵn (built-in): print(), len(), input(),...
  • Hàm tự định nghĩa (user-defined): Do người dùng tự tạo

2. Cấu trúc cơ bản của hàm

Python
def tên_hàm(tham_số1, tham_số2, ...):
    """
    Chuỗi mô tả hàm (docstring)
    """
    # Thân hàm
    # Các câu lệnh
    return giá_trị_trả_về  # Không bắt buộc

3. Ví dụ minh họa

Ví dụ 1: Hàm không có tham số và không trả về giá trị

Python
def chao_hoi():
    """Hàm in ra lời chào"""
    print("Xin chào! Chào mừng bạn đến với Python")
    print("Hôm nay là một ngày đẹp trời!")

# Gọi hàm
chao_hoi()

Ví dụ 2: Hàm có tham số và trả về giá trị

Python
def tinh_dien_tich_hinh_chu_nhat(chieu_dai, chieu_rong):
    """Tính diện tích hình chữ nhật"""
    dien_tich = chieu_dai * chieu_rong
    return dien_tich

# Sử dụng hàm
dai = 5
rong = 3
s = tinh_dien_tich_hinh_chu_nhat(dai, rong)
print(f"Diện tích hình chữ nhật {dai}x{rong} là: {s}")

Ví dụ 3: Hàm có giá trị mặc định

Python
def tinh_luong_thuong(luong_co_ban, he_so=1.5):
    """
    Tính lương thưởng cuối năm
    he_so mặc định là 1.5 nếu không cung cấp
    """
    return luong_co_ban * he_so

# Gọi hàm với đủ tham số
print(f"Lương thưởng: {tinh_luong_thuong(5000000, 2.0)}")

# Gọi hàm với tham số mặc định
print(f"Lương thưởng (mặc định): {tinh_luong_thuong(5000000)}")

4. Bảng so sánh các loại hàm

Loại hàm Có tham số Có trả về Ví dụ
Không tham số, không trả về Không Không def hello(): print("Hi")
Có tham số, không trả về Có Không def hello(name): print(f"Hi {name}")
Không tham số, có trả về Không Có def get_pi(): return 3.14
Có tham số, có trả về Có Có def add(a,b): return a+b

5. Tham số và đối số

  • Tham số (parameter): Biến được định nghĩa trong dấu ngoặc đơn khi khai báo hàm
  • Đối số (argument): Giá trị thực tế được truyền vào hàm khi gọi
Python
# Tham số: a, b
def cong(a, b):
    return a + b

# Đối số: 5, 3
ket_qua = cong(5, 3)

6. Phạm vi biến (Scope)

Python
# Biến toàn cục (global)
x = 10

def thay_doi_gia_tri():
    # Biến cục bộ (local)
    x = 5
    print(f"Giá trị trong hàm: {x}")

thay_doi_gia_tri()  # In ra: 5
print(f"Giá trị ngoài hàm: {x}")  # In ra: 10

7. Ví dụ thực tế: Chương trình máy tính đơn giản

Python
def menu():
    """Hiển thị menu lựa chọn"""
    print("\n" + "="*30)
    print("MÁY TÍNH ĐƠN GIẢN")
    print("="*30)
    print("1. Cộng hai số")
    print("2. Trừ hai số")
    print("3. Nhân hai số")
    print("4. Chia hai số")
    print("5. Thoát")
    print("="*30)

def nhap_so():
    """Nhập hai số từ bàn phím"""
    a = float(input("Nhập số thứ nhất: "))
    b = float(input("Nhập số thứ hai: "))
    return a, b

def cong(a, b):
    return a + b

def tru(a, b):
    return a - b

def nhan(a, b):
    return a * b

def chia(a, b):
    if b != 0:
        return a / b
    else:
        return "Lỗi: Không thể chia cho 0!"

# Chương trình chính
while True:
    menu()
    lua_chon = input("Nhập lựa chọn (1-5): ")

    if lua_chon == '5':
        print("Cảm ơn bạn đã sử dụng chương trình!")
        break

    if lua_chon in ['1', '2', '3', '4']:
        a, b = nhap_so()

        if lua_chon == '1':
            ket_qua = cong(a, b)
            phep_tinh = "+"
        elif lua_chon == '2':
            ket_qua = tru(a, b)
            phep_tinh = "-"
        elif lua_chon == '3':
            ket_qua = nhan(a, b)
            phep_tinh = "×"
        else:  # lua_chon == '4'
            ket_qua = chia(a, b)
            phep_tinh = "÷"

        print(f"{a} {phep_tinh} {b} = {ket_qua}")
    else:
        print("Lựa chọn không hợp lệ! Vui lòng chọn lại.")

8. Bài tập thực hành

Bài 1: Viết hàm kiem_tra_so_nguyen_to(n) kiểm tra xem một số có phải là số nguyên tố không.

Bài 2: Viết hàm tinh_giai_thua(n) tính giai thừa của một số nguyên dương.

Bài 3: Viết hàm in_bang_cuu_chuong(n) in ra bảng cửu chương của một số.

9. Mẹo và lưu ý

  1. Đặt tên hàm rõ ràng, mô tả được chức năng
  2. Sử dụng docstring để giải thích hàm làm gì
  3. Một hàm nên chỉ làm một nhiệm vụ duy nhất
  4. Sử dụng return để trả về kết quả thay vì in trực tiếp trong hàm (trừ khi đó là chức năng chính của hàm)

Phần 2: Phương pháp đệ quy và bài toán sinh/duyệt cấu hình tổ hợp

1. Đệ quy là gì?

Đệ quy (recursion) là một kỹ thuật lập trình trong đó một hàm gọi chính nó. Nó thường được sử dụng để giải quyết các bài toán có thể chia thành các bài toán con tương tự nhưng nhỏ hơn.

Nguyên lý đệ quy:

  1. Phần cơ sở (base case): Trường hợp đơn giản nhất, có thể giải trực tiếp
  2. Phần đệ quy (recursive case): Chia bài toán thành các bài toán con nhỏ hơn và gọi đệ quy

2. Cấu trúc cơ bản của hàm đệ quy

Python
def ham_de_quy(tham_so):
    # 1. Kiểm tra điều kiện dừng (base case)
    if dieu_kien_dung:
        return ket_qua_co_so

    # 2. Xử lý đệ quy (recursive case)
    else:
        # Gọi lại chính hàm này với tham số nhỏ hơn
        return tinh_toan(tham_so, ham_de_quy(tham_so_nho_hon))

3. Ví dụ minh họa

Ví dụ 1: Tính giai thừa (n!)

Công thức toán học:

\[ n! = n \times (n-1)! \]
\[ 0! = 1 \]
Python
def giai_thua(n):
    """
    Tính giai thừa của n bằng đệ quy
    """
    # Phần cơ sở
    if n == 0 or n == 1:
        return 1
    # Phần đệ quy
    else:
        return n * giai_thua(n - 1)

# Kiểm tra
for i in range(6):
    print(f"{i}! = {giai_thua(i)}")

Quá trình tính 5!:

giai_thua(5)
= 5 * giai_thua(4)
= 5 * (4 * giai_thua(3))
= 5 * (4 * (3 * giai_thua(2)))
= 5 * (4 * (3 * (2 * giai_thua(1))))
= 5 * (4 * (3 * (2 * 1)))
= 5 * (4 * (3 * 2))
= 5 * (4 * 6)
= 5 * 24
= 120

Ví dụ 2: Dãy Fibonacci

Dãy Fibonacci: 0, 1, 1, 2, 3, 5, 8, 13, ...
Công thức:

\[ F(n) = F(n-1) + F(n-2) \]
\[ F(0) = 0, F(1) = 1 \]
Python
def fibonacci(n):
    """
    Tìm số Fibonacci thứ n bằng đệ quy
    """
    # Phần cơ sở
    if n <= 1:
        return n
    # Phần đệ quy
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

# In 10 số Fibonacci đầu tiên
print("10 số Fibonacci đầu tiên:")
for i in range(10):
    print(f"F({i}) = {fibonacci(i)}")

4. Mô hình cây đệ quy cho Fibonacci(4)

               fibonacci(4)
               /          \
       fibonacci(3)      fibonacci(2)
        /       \          /       \
fibonacci(2) fibonacci(1) fibonacci(1) fibonacci(0)
   /       \       1           1           0
fibonacci(1) fibonacci(0)
    1           0

5. Sinh và duyệt cấu hình tổ hợp

Bài toán 1: Sinh tất cả các dãy nhị phân độ dài n

Một dãy nhị phân độ dài n là một dãy gồm n ký tự, mỗi ký tự là 0 hoặc 1.

Python
def sinh_nhi_phan(n, chuoi=""):
    """
    Sinh tất cả các dãy nhị phân độ dài n
    """
    # Nếu độ dài chuỗi đã bằng n, in kết quả
    if len(chuoi) == n:
        print(chuoi)
    else:
        # Thử thêm '0' và đệ quy
        sinh_nhi_phan(n, chuoi + "0")
        # Thử thêm '1' và đệ quy
        sinh_nhi_phan(n, chuoi + "1")

print("Tất cả dãy nhị phân độ dài 3:")
sinh_nhi_phan(3)

Bài toán 2: Sinh tất cả các hoán vị của n phần tử

Python
def hoan_vi(arr, left, right):
    """
    Sinh tất cả hoán vị của mảng arr từ vị trí left đến right
    """
    if left == right:
        # In ra hoán vị khi đã đủ độ dài
        print(''.join(arr))
    else:
        for i in range(left, right + 1):
            # Đổi chỗ phần tử tại vị trí left và i
            arr[left], arr[i] = arr[i], arr[left]
            # Đệ quy cho phần còn lại
            hoan_vi(arr, left + 1, right)
            # Quay lui (backtrack): đổi lại vị trí ban đầu
            arr[left], arr[i] = arr[i], arr[left]

print("Tất cả hoán vị của ABC:")
chuoi = list("ABC")
hoan_vi(chuoi, 0, len(chuoi) - 1)

Bài toán 3: Sinh tất cả các tổ hợp chập k của n

Tổ hợp chập k của n là cách chọn k phần tử từ n phần tử (không quan tâm thứ tự).

Python
def to_hop_chap_k(n, k, bat_dau=1, day=[], do_sau=0):
    """
    Sinh tất cả tổ hợp chập k của n số từ 1 đến n
    """
    # Nếu đã chọn đủ k phần tử
    if do_sau == k:
        print(day)
    else:
        # Duyệt các phần tử từ vị trí bắt đầu đến n
        for i in range(bat_dau, n + 1):
            # Thêm phần tử i vào dãy
            day.append(i)
            # Đệ quy chọn phần tử tiếp theo
            to_hop_chap_k(n, k, i + 1, day, do_sau + 1)
            # Quay lui: bỏ phần tử i ra khỏi dãy
            day.pop()

print("Tất cả tổ hợp chập 2 của 4:")
to_hop_chap_k(4, 2)

6. Bảng so sánh các thuật toán sinh

Thuật toán Mục đích Số lượng kết quả Ví dụ với n=3
Sinh nhị phân Sinh dãy 0/1 độ dài n \(2^n\) 000, 001, 010, 011, 100, 101, 110, 111
Sinh hoán vị Sinh thứ tự sắp xếp n phần tử \(n!\) ABC, ACB, BAC, BCA, CAB, CBA
Sinh tổ hợp Sinh cách chọn k phần tử từ n \(C_n^k\) Chọn 2 từ 3: (1,2), (1,3), (2,3)

7. Bài toán thực tế: Tháp Hà Nội

Bài toán Tháp Hà Nội là một ví dụ kinh điển về đệ quy:

  • Có 3 cọc A, B, C
  • Có n đĩa với kích thước khác nhau ở cọc A (đĩa nhỏ ở trên, đĩa lớn ở dưới)
  • Mục tiêu: Chuyển tất cả đĩa sang cọc C
  • Luật: Mỗi lần chỉ di chuyển 1 đĩa, không được đặt đĩa lớn lên đĩa nhỏ
Python
def thap_ha_noi(n, coc_nguon, coc_dich, coc_trung_gian):
    """
    Giải bài toán Tháp Hà Nội
    n: số đĩa
    coc_nguon: cọc nguồn
    coc_dich: cọc đích
    coc_trung_gian: cọc trung gian
    """
    if n == 1:
        print(f"Di chuyển đĩa 1 từ {coc_nguon} sang {coc_dich}")
    else:
        # 1. Di chuyển n-1 đĩa từ nguồn sang trung gian
        thap_ha_noi(n-1, coc_nguon, coc_trung_gian, coc_dich)

        # 2. Di chuyển đĩa lớn nhất từ nguồn sang đích
        print(f"Di chuyển đĩa {n} từ {coc_nguon} sang {coc_dich}")

        # 3. Di chuyển n-1 đĩa từ trung gian sang đích
        thap_ha_noi(n-1, coc_trung_gian, coc_dich, coc_nguon)

print("Giải bài toán Tháp Hà Nội với 3 đĩa:")
thap_ha_noi(3, 'A', 'C', 'B')

8. Lưu ý khi sử dụng đệ quy

Ưu điểm:

  • Code ngắn gọn, dễ hiểu với các bài toán có cấu trúc đệ quy tự nhiên
  • Dễ triển khai cho các bài toán chia để trị

Nhược điểm:

  • Tốn bộ nhớ (stack) do lưu trữ nhiều lần gọi hàm
  • Có thể chậm do tính toán lặp lại (ví dụ Fibonacci)
  • Dễ gây tràn stack nếu không có điều kiện dừng

Cải thiện hiệu suất:

  1. Thêm điều kiện dừng hợp lý
  2. Sử dụng kỹ thuật "đệ quy có nhớ" (sẽ học ở phần sau)
  3. Chuyển đổi sang vòng lặp khi có thể

9. Bài tập thực hành

Bài 1: Viết hàm đệ quy tính tổng các số từ 1 đến n.

Bài 2: Viết hàm đệ quy tính \(a^n\) (a mũ n).

Bài 3: Viết hàm đệ quy tìm ước chung lớn nhất (UCLN) của hai số.

Bài 4: Viết chương trình sinh tất cả các xâu có độ dài n chỉ chứa ký tự 'A', 'B', 'C'.

Bài 5: Giải bài toán 8 hậu: Đặt 8 quân hậu trên bàn cờ 8x8 sao cho không có hai quân nào ăn nhau.

10. Tổng kết

Đệ quy là một công cụ mạnh mẽ để giải quyết các bài toán có cấu trúc tự lặp lại. Khi kết hợp với kỹ thuật quay lui (backtracking), chúng ta có thể sinh ra tất cả các cấu hình tổ hợp cần thiết cho nhiều bài toán.

Phần 3: Phương pháp chia để trị và các thuật toán sắp xếp QuickSort, MergeSort

1. Giới thiệu về phương pháp chia để trị

Chia để trị (Divide and Conquer) là một phương pháp thiết kế thuật toán quan trọng, dựa trên việc chia bài toán lớn thành các bài toán con nhỏ hơn, giải quyết các bài toán con đó, rồi kết hợp lời giải để có lời giải cho bài toán ban đầu.

Ba bước chính của chia để trị:

  1. Chia (Divide): Chia bài toán thành các bài toán con nhỏ hơn, độc lập
  2. Trị (Conquer): Giải các bài toán con (thường bằng đệ quy)
  3. Kết hợp (Combine): Tổng hợp lời giải của các bài toán con để có lời giải của bài toán ban đầu

Tìm kiếm nhị phân là ví dụ đơn giản nhất và dễ hiểu nhất về phương pháp chia để trị.

2.1. Bài toán

Tìm một phần tử x trong một mảng đã được sắp xếp tăng dần.

2.2. Ý tưởng

Thay vì tìm kiếm tuần tự (xét từ đầu đến cuối), chúng ta:

  1. So sánh x với phần tử ở giữa mảng
  2. Nếu bằng nhau → tìm thấy
  3. Nếu x nhỏ hơn → tìm trong nửa đầu của mảng
  4. Nếu x lớn hơn → tìm trong nửa sau của mảng
  5. Lặp lại quá trình trên nửa mảng được chọn

2.3. Minh họa quá trình

Mảng đã sắp xếp: [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
Tìm x = 23

Bước 1: [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
        ↑               ↑                    ↑
       left            mid                  right
        mid = (0+9)//2 = 4, arr[4] = 16
        23 > 16 → tìm ở nửa bên phải

Bước 2: [23, 38, 56, 72, 91]
        ↑       ↑       ↑
       left    mid    right
        mid = (5+9)//2 = 7, arr[7] = 56
        23 < 56 → tìm ở nửa bên trái

Bước 3: [23, 38]
        ↑  ↑  ↑
        l mid r
        mid = (5+6)//2 = 5, arr[5] = 23
        Tìm thấy tại vị trí 5!

2.4. Cài đặt đệ quy

Python
def binary_search_recursive(arr, target, left=0, right=None):
    """
    Tìm kiếm nhị phân bằng phương pháp chia để trị (đệ quy)
    """
    if right is None:
        right = len(arr) - 1

    # Bước cơ sở: không tìm thấy
    if left > right:
        return -1

    # Bước CHIA: tìm phần tử giữa
    mid = (left + right) // 2

    # Bước TRỊ: so sánh với target
    if arr[mid] == target:
        return mid  # Tìm thấy
    elif arr[mid] > target:
        # Tìm trong nửa trái: CHIA nhỏ hơn nữa
        return binary_search_recursive(arr, target, left, mid - 1)
    else:
        # Tìm trong nửa phải: CHIA nhỏ hơn nữa
        return binary_search_recursive(arr, target, mid + 1, right)

# Ví dụ
arr = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
target = 23
result = binary_search_recursive(arr, target)
print(f"Tìm kiếm nhị phân đệ quy: {target} ở vị trí {result}")

2.5. Cài đặt vòng lặp

Python
def binary_search_iterative(arr, target):
    """
    Tìm kiếm nhị phân bằng phương pháp chia để trị (vòng lặp)
    """
    left, right = 0, len(arr) - 1

    while left <= right:
        # CHIA: Tìm phần tử giữa
        mid = (left + right) // 2

        # TRỊ: So sánh và quyết định
        if arr[mid] == target:
            return mid  # Tìm thấy
        elif arr[mid] < target:
            # Tìm nửa bên phải
            left = mid + 1
        else:
            # Tìm nửa bên trái
            right = mid - 1

    return -1  # Không tìm thấy

# Ví dụ
target = 38
result = binary_search_iterative(arr, target)
print(f"Tìm kiếm nhị phân vòng lặp: {target} ở vị trí {result}")

2.6. Phân tích chia để trị trong tìm kiếm nhị phân

Bước Tìm kiếm nhị phân Mô tả
CHIA Chia mảng thành 2 nửa Tìm vị trí giữa (mid)
TRỊ Giải bài toán con So sánh target với arr[mid]
KẾT HỢP Trả về kết quả Không cần kết hợp phức tạp, chỉ trả về kết quả

2.7. Độ phức tạp

  • Không gian: O(1) - không cần thêm bộ nhớ đáng kể
  • Thời gian: O(log n) - mỗi bước giảm kích thước bài toán đi một nửa

Công thức đệ quy:

\[ T(n) = T(n/2) + O(1) \]
\[ T(1) = O(1) \]

Giải phương trình:

\[ T(n) = O(\log n) \]

2.8. Biểu đồ minh họa chia để trị trong tìm kiếm nhị phân

Bài toán ban đầu: Tìm x trong mảng n phần tử
         │
         ▼
    CHIA: Tìm phần tử giữa
         │
    ┌────┴────┐
    ▼         ▼
x < arr[mid] x > arr[mid]
    │           │
    ▼           ▼
Tìm nửa trái  Tìm nửa phải
(n/2 phần tử) (n/2 phần tử)
    │           │
    ▼           ▼
  TRỊ: đệ quy TRỊ: đệ quy
    │           │
    └─────┬─────┘
          ▼
   KẾT HỢP: trả về kết quả

3. Thuật toán MergeSort (Sắp xếp trộn)

3.1. Ý tưởng

MergeSort là một ví dụ kinh điển khác của chia để trị:

  1. Chia: Chia mảng thành hai nửa bằng nhau
  2. Trị: Sắp xếp đệ quy từng nửa
  3. Kết hợp: Trộn hai nửa đã sắp xếp thành một mảng đã sắp xếp hoàn chỉnh

3.2. Cài đặt

Python
def merge_sort(arr):
    """
    Sắp xếp mảng bằng thuật toán MergeSort
    """
    # Trường hợp cơ sở
    if len(arr) <= 1:
        return arr

    # CHIA: Chia đôi mảng
    mid = len(arr) // 2
    left = arr[:mid]
    right = arr[mid:]

    # TRỊ: Sắp xếp đệ quy
    left_sorted = merge_sort(left)
    right_sorted = merge_sort(right)

    # KẾT HỢP: Trộn hai mảng đã sắp xếp
    return merge(left_sorted, right_sorted)

def merge(left, right):
    """
    Trộn hai mảng đã sắp xếp
    """
    result = []
    i = j = 0

    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1

    # Thêm phần còn lại
    result.extend(left[i:])
    result.extend(right[j:])

    return result

# Ví dụ
arr = [38, 27, 43, 3, 9, 82, 10]
print(f"\nMergeSort - Mảng ban đầu: {arr}")
sorted_arr = merge_sort(arr.copy())
print(f"Mảng sau sắp xếp: {sorted_arr}")

3.3. Phân tích chia để trị trong MergeSort

Bước MergeSort Mô tả
CHIA Chia mảng thành 2 nửa Chia tại vị trí giữa
TRỊ Sắp xếp mỗi nửa Gọi đệ quy MergeSort
KẾT HỢP Trộn hai nửa Dùng hàm merge()

3.4. Độ phức tạp

Công thức đệ quy:

\[ T(n) = 2T(n/2) + O(n) \]
\[ T(1) = O(1) \]

Giải phương trình:

\[ T(n) = O(n \log n) \]

4. Thuật toán QuickSort (Sắp xếp nhanh)

4.1. Ý tưởng

QuickSort cũng là thuật toán chia để trị nhưng với cách chia khác:

  1. Chọn pivot: Chọn một phần tử làm mốc
  2. Phân hoạch: Chia mảng thành hai phần: nhỏ hơn pivot và lớn hơn pivot
  3. Đệ quy: Sắp xếp hai phần

4.2. Cài đặt

Python
def quick_sort(arr):
    """
    Sắp xếp mảng bằng QuickSort
    """
    if len(arr) <= 1:
        return arr

    # CHIA: Chọn pivot và phân hoạch
    pivot = arr[len(arr) // 2]

    # Phân hoạch thành 3 phần
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]

    # TRỊ: Sắp xếp đệ quy
    # KẾT HỢP: Ghép các phần
    return quick_sort(left) + middle + quick_sort(right)

# Ví dụ
arr = [38, 27, 43, 3, 9, 82, 10]
print(f"\nQuickSort - Mảng ban đầu: {arr}")
sorted_arr = quick_sort(arr.copy())
print(f"Mảng sau sắp xếp: {sorted_arr}")

4.3. Phân tích chia để trị trong QuickSort

Bước QuickSort Mô tả
CHIA Phân hoạch theo pivot Chia thành 3 phần: <, =, > pivot
TRỊ Sắp xếp các phần Gọi đệ quy QuickSort
KẾT HỢP Ghép các phần Nối left + middle + right

4.4. Độ phức tạp

  • Trung bình: O(n log n)
  • Xấu nhất (khi pivot chọn không tốt): O(n²)

5. So sánh các thuật toán chia để trị

Thuật toán Cách chia Cách kết hợp Độ phức tạp Ứng dụng
Tìm kiếm nhị phân Chia đôi khoảng tìm kiếm Chọn nửa phù hợp O(log n) Tìm phần tử trong mảng đã sắp xếp
MergeSort Chia đôi mảng Trộn hai mảng đã sắp xếp O(n log n) Sắp xếp ổn định, dữ liệu lớn
QuickSort Phân hoạch theo pivot Ghép các phần O(n log n) trung bình Sắp xếp nhanh trong thực tế

6. Mô hình tổng quát của chia để trị

Bài toán kích thước n
         │
         ▼
    CHIA thành a bài toán con
         │
    ┌────┴────┐
    ▼         ▼
Bài toán    Bài toán
 con 1      con a
(kích thước   (kích thước
   n/b)          n/b)
    │             │
    ▼             ▼
  TRỊ: giải     TRỊ: giải
   đệ quy        đệ quy
    │             │
    └──────┬──────┘
           ▼
     KẾT HỢP kết quả
           │
           ▼
   Lời giải bài toán

Công thức tổng quát:

\[ T(n) = aT(n/b) + f(n) \]

Trong đó:

  • \(a\): số bài toán con
  • \(n/b\): kích thước mỗi bài toán con
  • \(f(n)\): thời gian để chia và kết hợp

7. Bài tập thực hành

Bài 1: Viết hàm tìm phần tử lớn nhất trong mảng bằng phương pháp chia để trị.

Bài 2: Cài đặt thuật toán tính dãy Fibonacci sử dụng chia để trị.

Bài 3: Viết chương trình tìm cặp điểm gần nhất trong mặt phẳng sử dụng chia để trị.

Bài 4: So sánh hiệu suất của tìm kiếm tuần tự và tìm kiếm nhị phân trên các mảng có kích thước khác nhau.

Bài 5: Phân tích cách MergeSort và QuickSort áp dụng chia để trị khác nhau như thế nào.

8. Tổng kết

Phương pháp chia để trị là một kỹ thuật mạnh mẽ với ba bước cơ bản: CHIA - TRỊ - KẾT HỢP. Chúng ta đã thấy qua các ví dụ:

  1. Tìm kiếm nhị phân: Ví dụ đơn giản nhất, chia bài toán thành 1 bài toán con (không phải 2 như thường nghĩ)
  2. MergeSort: Chia đều, giải đệ quy, kết hợp bằng trộn
  3. QuickSort: Chia không đều theo pivot, giải đệ quy, kết hợp đơn giản

Hiểu rõ phương pháp này giúp bạn thiết kế các thuật toán hiệu quả cho nhiều bài toán khác nhau.

Phần 4: Phương pháp đệ quy có nhớ (Memoization)

1. Đệ quy có nhớ là gì?

Đệ quy có nhớ (Memoization) là một kỹ thuật tối ưu hóa để tăng tốc độ cho các chương trình đệ quy bằng cách lưu trữ kết quả của các lời gọi hàm đã tính toán trước đó, tránh tính toán lại nhiều lần.

Kỹ thuật này đặc biệt hữu ích cho các bài toán có tính chất chồng chéo (overlapping subproblems), tức là trong quá trình đệ quy, cùng một bài toán con được gọi nhiều lần.

2. Vấn đề của đệ quy thông thường

Hãy xem xét lại ví dụ Fibonacci mà chúng ta đã học ở phần trước:

Python
def fibonacci_simple(n):
    """Fibonacci đệ quy thông thường (không tối ưu)"""
    if n <= 1:
        return n
    return fibonacci_simple(n-1) + fibonacci_simple(n-2)

# Tính F(6)
print("Fibonacci thông thường F(6) =", fibonacci_simple(6))

2.1. Vấn đề: Tính toán trùng lặp

Để tính F(6), hàm gọi:

  • F(5) và F(4)
  • F(5) gọi F(4) và F(3)
  • F(4) gọi F(3) và F(2)
  • ...

Cây đệ quy cho F(6):

                F(6)
               /     \
            F(5)     F(4)
           /   \     /   \
        F(4)  F(3) F(3)  F(2)
        / \   / \  / \   / \
     F(3)F(2)... (tiếp tục)

Nhận xét: F(3) được tính 3 lần, F(2) được tính 5 lần, F(1) được tính 8 lần!

2.2. Bảng thống kê số lần gọi hàm

n Số lần tính F(n) trong F(6)
6 1
5 1
4 2
3 3
2 5
1 8
0 5

Tổng số lần gọi hàm: 1+1+2+3+5+8+5 = 25 lần!

3. Giải pháp: Đệ quy có nhớ

3.1. Ý tưởng

Thay vì tính toán lại các giá trị đã tính, chúng ta lưu trữ chúng vào một từ điển (dictionary) hoặc mảng (list). Mỗi khi cần tính F(k), trước tiên kiểm tra xem đã tính chưa:

  • Nếu đã tính → lấy kết quả từ bộ nhớ
  • Nếu chưa tính → tính toán và lưu vào bộ nhớ

3.2. Cài đặt cơ bản

Python
def fibonacci_memo(n, memo={}):
    """
    Fibonacci với đệ quy có nhớ
    memo: từ điển lưu trữ các giá trị đã tính
    """
    # Kiểm tra xem đã tính F(n) chưa
    if n in memo:
        return memo[n]

    # Trường hợp cơ sở
    if n <= 1:
        memo[n] = n
        return n

    # Tính toán và lưu vào memo
    memo[n] = fibonacci_memo(n-1, memo) + fibonacci_memo(n-2, memo)
    return memo[n]

# Tính F(6)
print("Fibonacci có nhớ F(6) =", fibonacci_memo(6))
print("Memo:", fibonacci_memo.__defaults__[0])

3.3. Cách hoạt động

Quá trình tính F(6) với đệ quy có nhớ:

F(6) → tính F(5) và F(4)
F(5) → tính F(4) và F(3)  # nhưng F(4) đã được F(6) tính trước?

Thực tế, với đệ quy có nhớ, mỗi F(k) chỉ được tính một lần duy nhất:

  1. F(6) gọi F(5)
  2. F(5) gọi F(4)
  3. F(4) gọi F(3)
  4. F(3) gọi F(2)
  5. F(2) gọi F(1) và F(0)
  6. F(1) và F(0) trả về ngay (base case)
  7. Từ dưới lên: F(2) được tính và lưu, F(3) được tính và lưu, ...

Số lần gọi hàm: Chỉ 7 lần (F(0) đến F(6)) thay vì 25 lần!

4. Biểu đồ so sánh

4.1. Đệ quy thông thường vs Đệ quy có nhớ

ĐỆ QUY THÔNG THƯỜNG          ĐỆ QUY CÓ NHỚ
      F(6)                         F(6)
     /    \                         |
    F(5)  F(4)                     F(5)
   /    \   |                       |
  F(4) F(3) F(3)                   F(4)
  ...    ...                       ... (mỗi F(k) chỉ tính 1 lần)

25 lần gọi hàm                7 lần gọi hàm

4.2. Bảng so sánh hiệu suất

n Đệ quy thông thường Đệ quy có nhớ Tốc độ cải thiện
10 177 lần gọi 11 lần gọi 16 lần
20 21,891 lần gọi 21 lần gọi 1,042 lần
30 ~2.7 triệu lần gọi 31 lần gọi ~87,000 lần
40 ~331 triệu lần gọi 41 lần gọi ~8 triệu lần

5. Cài đặt chi tiết với các cách tiếp cận khác nhau

5.1. Sử dụng decorator (trang trí hàm)

Python
def memoize(func):
    """Decorator để thêm tính năng memoization cho bất kỳ hàm nào"""
    cache = {}

    def wrapper(n):
        if n not in cache:
            cache[n] = func(n)
        return cache[n]

    return wrapper

@memoize
def fibonacci_decorated(n):
    """Fibonacci với decorator memoize"""
    if n <= 1:
        return n
    return fibonacci_decorated(n-1) + fibonacci_decorated(n-2)

# Sử dụng
print("Fibonacci với decorator F(10) =", fibonacci_decorated(10))

5.2. Sử dụng danh sách (list) thay vì từ điển

Python
def fibonacci_list(n, memo=None):
    """Fibonacci với memoization dùng list"""
    if memo is None:
        # Khởi tạo memo với None cho tất cả các vị trí
        memo = [None] * (n + 1)

    # Đã tính
    if memo[n] is not None:
        return memo[n]

    # Base case
    if n <= 1:
        memo[n] = n
        return n

    # Tính toán và lưu
    memo[n] = fibonacci_list(n-1, memo) + fibonacci_list(n-2, memo)
    return memo[n]

print("Fibonacci với list F(10) =", fibonacci_list(10))

5.3. Cài đặt không đệ quy (bottom-up)

Python
def fibonacci_bottom_up(n):
    """Fibonacci không đệ quy, tính từ dưới lên"""
    if n <= 1:
        return n

    # Tạo mảng lưu trữ
    fib = [0] * (n + 1)
    fib[0] = 0
    fib[1] = 1

    # Tính từ nhỏ đến lớn
    for i in range(2, n + 1):
        fib[i] = fib[i-1] + fib[i-2]

    return fib[n]

print("Fibonacci bottom-up F(10) =", fibonacci_bottom_up(10))

6. Các ví dụ khác về đệ quy có nhớ

6.1. Bài toán tổ hợp C(n, k)

Công thức tổ hợp:

\[ C(n, k) = C(n-1, k-1) + C(n-1, k) \]
\[ C(n, 0) = C(n, n) = 1 \]
Python
def combination_memo(n, k, memo={}):
    """Tính tổ hợp C(n,k) với đệ quy có nhớ"""
    # Tạo key duy nhất cho cặp (n,k)
    key = (n, k)

    # Kiểm tra đã tính chưa
    if key in memo:
        return memo[key]

    # Base cases
    if k == 0 or k == n:
        memo[key] = 1
        return 1

    # Tính toán và lưu
    memo[key] = combination_memo(n-1, k-1, memo) + combination_memo(n-1, k, memo)
    return memo[key]

# Ví dụ
print("C(10, 3) =", combination_memo(10, 3))
print("C(20, 10) =", combination_memo(20, 10))

6.2. Bài toán leo cầu thang

Bài toán: Có n bậc thang. Mỗi lần có thể bước 1 hoặc 2 bậc. Hỏi có bao nhiêu cách để leo lên n bậc thang?

Python
def climb_stairs(n, memo={}):
    """Số cách leo cầu thang với đệ quy có nhớ"""
    if n in memo:
        return memo[n]

    # Base cases
    if n == 0:  # Đứng tại chỗ
        return 1
    if n == 1:  # Chỉ có 1 cách: bước 1 bậc
        return 1

    # Công thức: ways(n) = ways(n-1) + ways(n-2)
    memo[n] = climb_stairs(n-1, memo) + climb_stairs(n-2, memo)
    return memo[n]

# Ví dụ
print("Số cách leo 5 bậc thang:", climb_stairs(5))
print("Số cách leo 10 bậc thang:", climb_stairs(10))

6.3. Bài toán cái túi (Knapsack) đơn giản

Python
def knapsack(values, weights, capacity, n, memo={}):
    """Bài toán cái túi với đệ quy có nhớ"""
    # Tạo key duy nhất
    key = (capacity, n)

    if key in memo:
        return memo[key]

    # Base case: không còn đồ vật hoặc không còn dung lượng
    if n == 0 or capacity == 0:
        return 0

    # Nếu đồ vật thứ n quá nặng, bỏ qua
    if weights[n-1] > capacity:
        memo[key] = knapsack(values, weights, capacity, n-1, memo)
        return memo[key]

    # Lựa chọn: lấy hoặc không lấy đồ vật thứ n
    take = values[n-1] + knapsack(values, weights, capacity - weights[n-1], n-1, memo)
    skip = knapsack(values, weights, capacity, n-1, memo)

    memo[key] = max(take, skip)
    return memo[key]

# Ví dụ
values = [60, 100, 120]
weights = [10, 20, 30]
capacity = 50
n = len(values)
print("Giá trị tối đa có thể lấy:", knapsack(values, weights, capacity, n))

7. Bảng so sánh các phương pháp

Phương pháp Tên gọi Hướng tiếp cận Ưu điểm Nhược điểm
Đệ quy thông thường Top-down Từ lớn đến nhỏ Code đơn giản, dễ hiểu Chậm, tính toán trùng lặp
Đệ quy có nhớ Top-down với memo Từ lớn đến nhỏ + lưu kết quả Nhanh, tận dụng kết quả đã tính Tốn bộ nhớ, vẫn có overhead đệ quy
Quy hoạch động bottom-up Bottom-up Từ nhỏ đến lớn Nhanh nhất, không overhead đệ quy Code phức tạp hơn, phải xác định thứ tự tính

8. Khi nào nên sử dụng đệ quy có nhớ?

8.1. Các bài toán phù hợp:

  1. Có tính chất chồng chéo: Cùng bài toán con xuất hiện nhiều lần
  2. Có cấu trúc đệ quy tự nhiên: Dễ viết đệ quy hơn vòng lặp
  3. Số trạng thái không quá lớn: Để bộ nhớ đủ lưu trữ

8.2. Các bài toán không phù hợp:

  1. Không có tính chồng chéo: Mỗi bài toán con chỉ xuất hiện 1 lần
  2. Số trạng thái quá lớn: Không đủ bộ nhớ
  3. Cần tối ưu bộ nhớ: Nên dùng quy hoạch động bottom-up

9. Bài tập thực hành

Bài 1: Viết hàm tính số Catalan thứ n với đệ quy có nhớ. Công thức:

\[ C_0 = 1, \quad C_{n} = \sum_{i=0}^{n-1} C_i \cdot C_{n-1-i} \]

Bài 2: Bài toán đổi tiền: Cho mệnh giá các đồng xu và một số tiền S. Tính số cách ít nhất để đổi S bằng các đồng xu đã cho. Sử dụng đệ quy có nhớ.

Bài 3: Tính đường đi ngắn nhất trong ma trận từ góc trên bên trái đến góc dưới bên phải (chỉ được đi xuống hoặc sang phải). Mỗi ô có chi phí. Sử dụng đệ quy có nhớ.

Bài 4: Cài đặt hàm tính tổng các số từ 1 đến n với đệ quy có nhớ và so sánh với vòng lặp thông thường.

Bài 5: Tối ưu hóa hàm Fibonacci có nhớ bằng cách:

  • Chỉ lưu 2 giá trị gần nhất thay vì tất cả
  • Sử dụng cache LRU (Least Recently Used) cho n rất lớn

10. Mẹo và lưu ý

  1. Chọn cấu trúc lưu trữ phù hợp:

    • Dictionary: linh hoạt, dùng khi không biết trước n
    • List/Array: nhanh hơn, dùng khi biết giới hạn trên của n
  2. Quản lý bộ nhớ:

    • Xóa cache khi không cần thiết
    • Sử dụng LRU cache cho bài toán lớn
    • Giới hạn kích thước cache
  3. Tránh side-effect:

    • Sử dụng default argument cẩn thận (vì nó chỉ khởi tạo 1 lần)
    • Tốt hơn nên khởi tạo memo bên trong hàm
  4. Kiểm tra hiệu suất:

    • So sánh với các phương pháp khác
    • Đo thời gian thực thi
    • Kiểm tra sử dụng bộ nhớ

11. Tổng kết

Đệ quy có nhớ là một kỹ thuật mạnh mẽ để tối ưu hóa các thuật toán đệ quy. Bằng cách lưu trữ và tái sử dụng kết quả của các bài toán con, chúng ta có thể:

  1. Giảm đáng kể thời gian thực thi: Từ cấp số mũ xuống tuyến tính
  2. Giữ được sự đơn giản của đệ quy: Code vẫn dễ đọc, dễ hiểu
  3. Áp dụng cho nhiều bài toán: Fibonacci, tổ hợp, quy hoạch động, ...

Tuy nhiên, cần lưu ý về việc quản lý bộ nhớ và lựa chọn cấu trúc dữ liệu phù hợp. Trong nhiều trường hợp, quy hoạch động bottom-up có thể là lựa chọn tốt hơn.

Bình luận

Mới nhất
Tải bình luận...

Không có bình luận nào.