← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202632 phút đọc27 min read

Đệ quy & Chia để trịRecursion & Divide and Conquer

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Hai thành phần: base case và recursive case
  4. Call stack
  5. Giới hạn độ sâu stack và sys.setrecursionlimit
  6. Đệ quy trực tiếp và gián tiếp
  7. Chuyển đệ quy thành lặp
  8. Tail recursion, và vì sao Python không tối ưu nó
  9. Khái niệm chính
  10. Recursion tree như một công cụ phân tích
  11. Chia để trị: divide, conquer, combine
  12. Master Theorem
  13. Memoization — cầu nối sang quy hoạch động
  14. Đệ quy xuất hiện ở đâu khác trong roadmap này
  15. Best Practices
  16. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. The two components: base case and recursive case
  4. The call stack
  5. Stack depth limits and sys.setrecursionlimit
  6. Direct vs indirect recursion
  7. Converting recursion to iteration
  8. Tail recursion, and why Python does not optimize it
  9. Key Concepts
  10. The recursion tree as an analysis tool
  11. Divide and conquer: divide, conquer, combine
  12. The Master Theorem
  13. Memoization — the bridge to dynamic programming
  14. Where recursion shows up elsewhere in this roadmap
  15. Best Practices
  16. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

Đệ quy (recursion) là cách giải một bài toán bằng cách diễn đạt nó qua một phiên bản nhỏ hơn của chính bài toán đó. Một hàm đệ quy gọi lại chính nó trên input đã được thu nhỏ, và cứ tiếp tục như vậy cho tới khi input đủ nhỏ để trả lời trực tiếp. Trường hợp “đủ nhỏ” đó là base case; bước tự gọi lại chính mình là recursive case. Mọi hàm đệ quy đúng đắn đều có cả hai, và phải tiến gần base case hơn sau mỗi lần gọi — một đệ quy không thu nhỏ input là một vòng lặp vô hạn với hành vi lỗi còn tệ hơn, vì thay vì quay mãi, nó ăn hết bộ nhớ rồi crash.

Lý do đệ quy quan trọng không phải vì nó đẹp — nó thường đẹp thật, nhưng vẻ đẹp không đủ để biện minh cho một kỹ thuật. Nó quan trọng vì rất nhiều cấu trúc dữ liệu bản thân chúng được định nghĩa đệ quy, và code phản chiếu đúng hình dạng của dữ liệu là code dễ viết đúng nhất. Một binary tree là một node với một left subtree và một right subtree, mỗi cái lại là một binary tree. Một thư mục chứa file và thư mục. Một giá trị JSON là một scalar, hoặc một list các giá trị JSON, hoặc một map từ string tới giá trị JSON. Viết traversal cho bất kỳ thứ nào ở trên theo kiểu lặp nghĩa là bạn phải tự dựng một stack; viết theo kiểu đệ quy nghĩa là bạn chỉ việc chép lại định nghĩa và để call stack của ngôn ngữ lo phần ghi sổ.

Chia để trị (divide and conquer) là kỹ thuật thiết kế thuật toán xây trên nền đệ quy. Nó có ba bước được đặt tên rõ ràng: divide — chia bài toán thành các subproblem độc lập cùng loại, conquer — giải chúng bằng cách đệ quy (cho tới khi đủ nhỏ để giải trực tiếp), và combine — ghép các kết quả con thành đáp án cho bài toán gốc. Merge sort và binary search là hai ví dụ kinh điển, và lý do chia để trị cho ra độ phức tạp tốt như vậy chỉ là số học: cắt đôi input mỗi lần cho ra độ sâu đệ quy log n thay vì n, và n log n thắng một cách dứt khoát khi n vượt vài nghìn.

Bài này bao quát phần cơ chế của đệ quy (call stack, giới hạn độ sâu, tail call), các công cụ phân tích (recursion tree, Master Theorem), mẫu chia để trị cùng các implementation cụ thể, và memoization — chính là điểm mà đệ quy biến thành quy hoạch động.

Kiến thức nền tảng

Hai thành phần: base case và recursive case

def factorial(n):
    """n! tính bằng đệ quy. Điều kiện tiên quyết: n >= 0."""
    if n <= 1:              # base case — biết đáp án mà không cần đệ quy
        return 1
    return n * factorial(n - 1)   # recursive case — cùng bài toán, input nhỏ hơn

Ba điều kiện phải đúng để nó hoạt động, và mọi bug đệ quy đều là vi phạm một trong ba:

  1. Base case phải tới được. factorial(-1) đệ quy vô hạn vì n - 1 đi xa dần khỏi n <= 1. Kiểm tra điều kiện tiên quyết, hoặc viết base case là if n <= 1 thay vì if n == 1, là thứ làm cho nó bền vững.
  2. Lời gọi đệ quy phải nhận input nhỏ hơn thực sự. “Nhỏ hơn” cần một đại lượng đo được — list ngắn hơn, số nguyên nhỏ hơn, subtree thay vì cả tree. f(n) gọi f(n) thì không nhỏ hơn; f(n) gọi f(n // 2) thì có, miễn là n // 2 < n, điều này sai tại n = 0n = 1 — nên hai giá trị đó phải là base case.
  3. Bạn phải tin tưởng lời gọi đệ quy. Đây là rào cản tâm lý. Khi viết factorial(n), đừng trace cả stack trong đầu. Hãy giả định factorial(n - 1) đã trả về đúng đáp án cho n - 1 (“cú nhảy niềm tin đệ quy”) rồi chỉ hỏi: với giả định đó, n * factorial(n - 1) có đúng không? Đây chính là quy nạp — base case cộng bước quy nạp — và cùng một lối lập luận với loop invariant.

Call stack

Đệ quy không phải phép màu; nó chỉ là cơ chế gọi hàm thông thường được dùng lặp đi lặp lại. Mỗi lời gọi push một stack frame chứa tham số, biến cục bộ và địa chỉ trở về của lời gọi đó. Khi lời gọi return, frame của nó được pop và quyền điều khiển quay về hàm gọi. Vì các frame được push và pop theo thứ tự LIFO, runtime dùng một stack để chứa chúng — chính là call stack.

Với factorial(4), stack lớn dần tới độ sâu 4 rồi tháo ngược:

push factorial(4)   ->  cần 4 * factorial(3)
push factorial(3)   ->  cần 3 * factorial(2)
push factorial(2)   ->  cần 2 * factorial(1)
push factorial(1)   ->  base case, trả về 1

           các frame trên stack tại điểm sâu nhất
           +------------------+
   top ->  | factorial(1) n=1 |  trả về 1
           | factorial(2) n=2 |  đang chờ: 2 * ?
           | factorial(3) n=3 |  đang chờ: 3 * ?
   base -> | factorial(4) n=4 |  đang chờ: 4 * ?
           +------------------+

tháo ngược: 1 -> 2*1=2 -> 3*2=6 -> 4*6=24

Hai hệ quả xuất hiện ngay lập tức và là nguồn gốc của hầu hết rắc rối thực tế:

Giới hạn độ sâu stack và sys.setrecursionlimit

CPython giới hạn độ sâu đệ quy để ngăn một đệ quy chạy lồng tràn stack của hệ điều hành và gây segfault. Mặc định là 1000, và chạm ngưỡng sẽ ném RecursionError.

import sys

print(sys.getrecursionlimit())   # mặc định là 1000

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

# depth(2000)  ->  RecursionError: maximum recursion depth exceeded

sys.setrecursionlimit(20000)     # nâng lên — nhưng phải hiểu bạn đang nâng cái gì
print(depth(15000))              # 15001

Giới hạn này là một lớp bảo vệ, không phải sức chứa thật. Ràng buộc thật là kích thước stack của thread (thường 8 MB trên Linux, 512 KB cho thread phụ ở một số runtime). Nâng setrecursionlimit vượt quá sức chứa của stack không cho ra exception Python sạch sẽ — nó làm sập interpreter. CPython 3.12+ đã tách lời gọi Python thuần khỏi C stack, khiến đệ quy Python thuần sâu an toàn hơn nhiều so với trước, nhưng đệ quy đi vòng qua C (một __repr__, một toán tử so sánh, re matching, một iterator viết bằng C) vẫn tiêu thụ C stack.

Nếu bạn thực sự cần đệ quy rất sâu, các lựa chọn trung thực là, theo thứ tự ưu tiên:

  1. Viết lại theo kiểu lặp với một stack tường minh. Luôn khả dụng, luôn an toàn.
  2. Giảm độ sâu bằng thuật toán — ví dụ trong quicksort, đệ quy vào partition nhỏ hơn và lặp trên partition lớn hơn, giúp chặn độ sâu ở O(log n) bất kể pivot tốt hay xấu.
  3. Chạy trên một thread có stack lớn hơn, chỉ khi 1 và 2 thực sự bất khả thi:
import sys, threading

def run_deep():
    sys.setrecursionlimit(200_000)
    print(depth(150_000))

threading.stack_size(512 * 1024 * 1024)     # stack 512 MB cho thread này
t = threading.Thread(target=run_deep)
t.start()
t.join()

Đây là mẹo của competitive programming, không phải pattern cho production. Trong production, một đệ quy có độ sâu phụ thuộc kích thước input là một vector tấn công denial-of-service: một JSON document lồng sâu sẽ làm sập recursive-descent parser. Hãy chặn độ sâu tường minh và từ chối input vượt ngưỡng.

Đệ quy trực tiếp và gián tiếp

Đệ quy trực tiếp là hàm gọi chính nó. Đệ quy gián tiếp (hay tương hỗ) là một chu trình qua hai hàm trở lên: A gọi B, B gọi A. Nó ít gặp hơn nhưng hoàn toàn bình thường, và là hình dạng tự nhiên cho các grammar mà hai loại được định nghĩa qua nhau.

def is_even(n):
    """Đệ quy tương hỗ — một phép kiểm tra chẵn lẻ hơi ngớ ngẩn nhưng cho thấy đúng hình dạng."""
    if n == 0:
        return True
    return is_odd(n - 1)

def is_odd(n):
    if n == 0:
        return False
    return is_even(n - 1)

Ứng dụng thật là recursive-descent parser, nơi bản thân grammar đã đệ quy tương hỗ (expr chứa term, term chứa factor, và factor có thể là một expr trong ngoặc):

def parse_expr(tokens, i):
    """expr := term (('+' | '-') term)*"""
    value, i = parse_term(tokens, i)
    while i < len(tokens) and tokens[i] in ("+", "-"):
        op = tokens[i]
        rhs, i = parse_term(tokens, i + 1)
        value = value + rhs if op == "+" else value - rhs
    return value, i

def parse_term(tokens, i):
    """term := factor (('*' | '/') factor)*"""
    value, i = parse_factor(tokens, i)
    while i < len(tokens) and tokens[i] in ("*", "/"):
        op = tokens[i]
        rhs, i = parse_factor(tokens, i + 1)
        value = value * rhs if op == "*" else value / rhs
    return value, i

def parse_factor(tokens, i):
    """factor := NUMBER | '(' expr ')'  -- khép lại chu trình đệ quy tương hỗ"""
    if tokens[i] == "(":
        value, i = parse_expr(tokens, i + 1)
        return value, i + 1          # bỏ qua dấu ')' đóng
    return int(tokens[i]), i + 1

tokens = ["2", "+", "3", "*", "(", "4", "-", "1", ")"]
print(parse_expr(tokens, 0)[0])      # 11

Điều duy nhất cần cẩn thận với đệ quy gián tiếp là lập luận “input nhỏ dần” giờ phải đúng trên toàn bộ chu trình, không phải từng hàm riêng lẻ. Một luật grammar đệ quy trái (expr := expr '+' term) khiến parse_expr gọi chính nó mà index không đổi — đệ quy vô hạn, và đó chính là lý do kinh điển khiến recursive-descent parser đòi hỏi grammar phải được viết lại để loại bỏ đệ quy trái.

Chuyển đệ quy thành lặp

Mọi đệ quy đều có thể viết lại thành lặp, vì call stack chỉ là một stack mà bạn có thể tự quản lý. Có hai mức chuyển đổi.

Mức 1 — đệ quy tuyến tính và phần việc đang chờ là tầm thường. Khi đó một vòng lặp đơn giản với biến tích lũy là đủ:

def factorial_iter(n):
    """O(n) time, O(1) space — không có stack frame nào cả."""
    result = 1
    for k in range(2, n + 1):
        result *= k
    return result

Mức 2 — đệ quy phân nhánh. Khi đó bạn cần một stack tường minh, và phải mã hóa “tôi đang ở đâu trong frame này” thành trạng thái. So sánh hai bản in-order tree traversal:

class Node:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def inorder_recursive(node, out):
    if node is None:                 # base case
        return
    inorder_recursive(node.left, out)
    out.append(node.value)
    inorder_recursive(node.right, out)

def inorder_iterative(root):
    """Cùng traversal, stack tường minh. `stack` ở đây CHÍNH LÀ call stack."""
    out, stack, node = [], [], root
    while stack or node is not None:
        while node is not None:      # đi xuống bên trái, ghi nhớ đường đi
            stack.append(node)
            node = node.left
        node = stack.pop()           # quay lui về node chưa thăm sâu nhất
        out.append(node.value)
        node = node.right            # rồi rẽ phải
    return out

Bản lặp dài hơn và khó đọc hơn, đó chính xác là cái giá phải trả: bạn mua sự tự do khỏi giới hạn độ sâu và một chút tăng tốc ở hằng số, và bạn trả bằng độ rõ ràng. Hãy chuyển khi bắt buộc (độ sâu không chặn được, hoặc profiling cho thấy chi phí gọi hàm đáng kể), chứ không phải vì nguyên tắc.

Tail recursion, và vì sao Python không tối ưu nó

Một lời gọi là tail recursive khi lời gọi đệ quy là việc cuối cùng hàm làm — không còn gì đang chờ sau khi nó return. return n * factorial(n - 1) không phải tail recursive (phép nhân đang chờ). Bản này thì có:

def factorial_tail(n, acc=1):
    if n <= 1:
        return acc
    return factorial_tail(n - 1, acc * n)   # không còn gì chờ — tail call thuần túy

Vì không còn gì đang chờ, frame của hàm gọi đã chết ngay khoảnh khắc tail call được thực hiện. Do đó compiler có thể tái sử dụng frame thay vì push một frame mới, biến đệ quy thành vòng lặp với O(1) stack. Đây là tail call optimization (TCO), và Scheme bắt buộc phải có nó, còn Lua, Scala (cho self-call qua @tailrec), và hầu hết các ngôn ngữ hàm đều cung cấp. LLVM cũng làm điều này cho C/C++ ở -O2 khi hình dạng code cho phép.

CPython cố ý không làm TCO. Lý do Guido van Rossum đưa ra đáng để biết, vì chúng là các phán đoán kỹ thuật chứ không phải sơ suất:

Hệ quả thực tế: trong Python, nếu bạn thấy mình đang viết một hàm tail-recursive, hãy viết vòng lặp thay thế. Phép biến đổi là máy móc — biến tích lũy trở thành một biến, các tham số trở thành biến lặp:

def factorial_loop(n):
    """Khử đệ quy máy móc từ factorial_tail. O(1) stack."""
    acc = 1
    while n > 1:
        n, acc = n - 1, acc * n     # đúng bằng các đối số của tail call
    return acc
Đệ quyLặp
Đọc giống định nghĩa của dữ liệuCó, với cấu trúc đệ quyKhông — phần ghi sổ là tường minh
Stack spaceO(depth)O(1), hoặc O(depth) với stack tường minh
Giới hạn độ sâuCó (RecursionError trong Python)Không
Chi phí gọi hàmMột lần push/pop frame mỗi bướcKhông có
Dễ cho cấu trúc tree/graphCần stack thủ công
Dễ cho quét tuyến tínhThừa thãi

Khái niệm chính

Recursion tree như một công cụ phân tích

Để phân tích một thuật toán đệ quy, hãy vẽ cây các lời gọi: mỗi node là một lời gọi, các con của nó là các lời gọi nó tạo ra, và node được gán nhãn bằng lượng công việc làm tại node đó, không tính các con. Tổng chi phí là tổng trên tất cả node — và cách dễ tính nhất là theo từng mức.

Với merge sort, T(n) = 2T(n/2) + Θ(n):

mức 0:                    n                     công việc = n
                        /   \
mức 1:              n/2      n/2                công việc = 2·(n/2) = n
                   /   \    /   \
mức 2:          n/4   n/4  n/4   n/4            công việc = 4·(n/4) = n
                 ...          ...
mức log n:     1  1  1  1  ...  1  1  1         công việc = n·1     = n
                                                ------------------
                                 tổng = n mỗi mức × (log n + 1) mức
                                      = Θ(n log n)

Mỗi mức tốn n, và có log₂ n + 1 mức vì input bị chia đôi mỗi lần — do đó Θ(n log n).

Giờ dùng cùng công cụ đó cho Fibonacci ngây thơ, T(n) = T(n−1) + T(n−2) + Θ(1):

                        fib(5)
                   /            \
              fib(4)            fib(3)
             /      \          /      \
        fib(3)    fib(2)   fib(2)   fib(1)
        /    \     /   \    /   \
   fib(2) fib(1) f(1) f(0) f(1) f(0)
    /   \
 f(1)  f(0)

Cây gần như là một binary tree đầy đủ với độ sâu n, nên số node — và do đó thời gian chạy — là hàm mũ: Θ(φⁿ) với φ ≈ 1.618. Nhưng hãy nhìn vào sự lặp lại: fib(3) được tính hai lần, fib(2) ba lần, fib(1) năm lần. Sự dư thừa đó chính là overlapping subproblems, và cũng chính là thứ memoization loại bỏ.

Recursion tree còn xử lý được các phép chia không cân bằng mà Master Theorem chịu thua. Với T(n) = T(n/3) + T(2n/3) + n, mỗi mức vẫn tốn nhiều nhất n, lá nông nhất ở độ sâu log₃ n còn lá sâu nhất ở log_{3/2} n, nên T(n) = Θ(n log n) — vẫn như chia cân bằng, chỉ khác hằng số lớn hơn. Đây là lý do hình thức giải thích vì sao quicksort vẫn sống sót với tỉ lệ chia 1:9 liên tục.

Chia để trị: divide, conquer, combine

                +---------------------------+
                |    bài toán kích thước n  |
                +---------------------------+
                             |  DIVIDE thành a subproblem kích thước n/b
              +--------------+--------------+
              v                             v
       +-------------+              +-------------+
       | size n/b    |     ...      | size n/b    |   CONQUER: đệ quy
       +-------------+              +-------------+   (base case nếu đủ nhỏ)
              |                             |
              +--------------+--------------+
                             v  COMBINE các kết quả con
                +---------------------------+
                |         đáp án            |
                +---------------------------+

Kỹ thuật này có lời khi bước combine rẻ hơn việc giải trực tiếp bài toán. Merge sort hiệu quả vì trộn hai nửa đã sắp xếp chỉ tốn O(n) trong khi sắp xếp từ đầu tốn O(n log n). Chia để trị không giúp được gì khi các subproblem chồng lấn nhau (lúc đó bạn cần DP) hoặc khi bước combine đắt ngang bài toán gốc.

Merge sort — ví dụ kinh điển

def merge_sort(arr):
    """Divide: cắt đôi. Conquer: sắp xếp mỗi nửa. Combine: trộn."""
    if len(arr) <= 1:                      # base case: 0 hoặc 1 phần tử là đã sắp xếp
        return arr[:]
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])           # conquer
    right = merge_sort(arr[mid:])
    return merge(left, right)              # combine — bước O(n)

def merge(left, right):
    """Trộn hai list đã sắp xếp thành một list đã sắp xếp. O(len(left) + len(right))."""
    out = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:            # dấu <= giữ cho thuật toán stable
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    out.extend(left[i:])                   # một bên đã hết; nối phần còn lại
    out.extend(right[j:])
    return out

print(merge_sort([5, 2, 9, 1, 5, 6]))      # [1, 2, 5, 5, 6, 9]

Θ(n log n) trong mọi trường hợp — độ sâu đệ quy không phụ thuộc dữ liệu, chỉ phụ thuộc n. Space là Θ(n) cho các buffer trộn cộng Θ(log n) stack. Xem ./08-sorting-algorithms.md để có bảng so sánh đầy đủ với quicksort và heapsort.

Binary search — chia để trị với bước combine rỗng

def binary_search_rec(arr, target, lo=0, hi=None):
    """Divide: chọn điểm giữa. Conquer: đệ quy vào MỘT nửa. Combine: không có gì."""
    if hi is None:
        hi = len(arr) - 1
    if lo > hi:                            # base case: khoảng rỗng
        return -1
    mid = lo + (hi - lo) // 2              # tránh tràn số ở ngôn ngữ có độ rộng cố định
    if arr[mid] == target:
        return mid
    if arr[mid] < target:
        return binary_search_rec(arr, target, mid + 1, hi)
    return binary_search_rec(arr, target, lo, mid - 1)

Đây là trường hợp suy biến: nó chia thành hai subproblem nhưng chỉ conquer một, và không có gì để combine. T(n) = T(n/2) + Θ(1) = Θ(log n). Nó cũng tự nhiên là tail recursive, nên bản lặp trong ./09-search-algorithms.md mới là bản nên dùng thật trong Python.

Đếm inversion — chia để trị nhưng không phải sắp xếp

Một inversion là cặp i < j với arr[i] > arr[j]; số lượng inversion đo mức độ “xa sắp xếp” của một list. Brute force là Θ(n²). Bám vào merge sort làm nó thành Θ(n log n), và insight ở đây là toàn bộ lý do chia để trị đáng học: khi phần tử ở nửa trái lớn hơn, thì mọi phần tử còn lại ở nửa trái cũng lớn hơn right[j], nên một phép so sánh đếm được nhiều inversion cùng lúc.

def count_inversions(arr):
    """Trả về (bản_sao_đã_sắp_xếp, số_inversion). Theta(n log n)."""
    if len(arr) <= 1:
        return arr[:], 0
    mid = len(arr) // 2
    left, a = count_inversions(arr[:mid])
    right, b = count_inversions(arr[mid:])
    merged, cross = _merge_count(left, right)
    return merged, a + b + cross           # combine: trái + phải + cặp vắt qua ranh giới

def _merge_count(left, right):
    out, i, j, inversions = [], 0, 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            out.append(left[i]); i += 1
        else:
            # left[i..] đều > right[j], nên đóng góp len(left) - i cặp
            inversions += len(left) - i
            out.append(right[j]); j += 1
    out.extend(left[i:]); out.extend(right[j:])
    return out, inversions

print(count_inversions([2, 4, 1, 3, 5])[1])   # 3  -> (2,1), (4,1), (4,3)

Nhân Karatsuba — vượt qua giới hạn hiển nhiên

Nhân hai số n chữ số theo cách học ở trường tốn Θ(n²). Cắt đôi mỗi số cho ra x = x₁·10^m + x₀, và khai triển ngây thơ cần bốn tích nửa kích thước — T(n) = 4T(n/2) + Θ(n), vẫn là Θ(n²), nên chẳng được gì. Mẹo của Karatsuba là một đồng nhất thức đại số tính được số hạng giữa từ hai số hạng kia, chỉ cần ba phép nhân:

def karatsuba(x, y):
    """Nhân số nguyên Theta(n^1.585). Ba tích nửa kích thước, không phải bốn."""
    if x < 10 or y < 10:                   # base case: nhân một chữ số
        return x * y
    n = max(x.bit_length(), y.bit_length())
    m = (n // 2)
    high_x, low_x = divmod(x, 1 << m)
    high_y, low_y = divmod(y, 1 << m)

    z0 = karatsuba(low_x, low_y)                       # tích 1
    z2 = karatsuba(high_x, high_y)                     # tích 2
    z1 = karatsuba(low_x + high_x, low_y + high_y) - z0 - z2   # tích 3

    return (z2 << (2 * m)) + (z1 << m) + z0

print(karatsuba(123456789, 987654321) == 123456789 * 987654321)   # True

T(n) = 3T(n/2) + Θ(n) = Θ(n^log₂3) = Θ(n^1.585). Bỏ đi một trong bốn lời gọi đệ quy đã thay đổi cả số mũ — đó là đòn bẩy mà chia để trị mang lại, và cùng dạng mẹo đó là động cơ của thuật toán nhân ma trận Strassen (8T(n/2)7T(n/2), cho Θ(n^2.807) thay vì Θ(n³)).

Master Theorem

Phần lớn các hệ thức truy hồi chia để trị có dạng

T(n) = a·T(n/b) + f(n)          a >= 1,  b > 1

trong đó a là số subproblem, n/b là kích thước của chúng, và f(n) là chi phí chia cộng chi phí ghép. Master Theorem trả lời tất cả bằng cách so sánh f(n) với hàm phân thủy n^(log_b a) — chính là tổng chi phí của các lá.

Điều kiệnKết quảCách đọc
Case 1f(n) = O(n^(log_b a − ε)) với một ε > 0 nào đóT(n) = Θ(n^(log_b a))Lá chiếm ưu thế — công việc nằm ở đáy
Case 2f(n) = Θ(n^(log_b a) · log^k n), k ≥ 0T(n) = Θ(n^(log_b a) · log^(k+1) n)Cân bằng — mọi mức tốn như nhau, và có log n mức
Case 3f(n) = Ω(n^(log_b a + ε)) với một ε > 0, a·f(n/b) ≤ c·f(n) với một c < 1T(n) = Θ(f(n))Gốc chiếm ưu thế — công việc nằm ở đỉnh

Điều kiện thứ hai của Case 3 là điều kiện chính quy (regularity condition); nó loại bỏ những f bệnh lý dao động bất thường. Trên thực tế, nếu f là một đa thức nhân với một polylog thì điều kiện chính quy luôn thỏa.

Ví dụ đã giải:

Hệ thức truy hồiabn^(log_b a)f(n)CaseT(n)
Merge sort: 2T(n/2) + Θ(n)22n2 (k=0)Θ(n log n)
Binary search: T(n/2) + Θ(1)12n⁰ = 112 (k=0)Θ(log n)
Karatsuba: 3T(n/2) + Θ(n)32n^1.585n1Θ(n^1.585)
Strassen: 7T(n/2) + Θ(n²)72n^2.8071Θ(n^2.807)
Nhân ma trận khối ngây thơ: 8T(n/2) + Θ(n²)821Θ(n³)
2T(n/2) + Θ(n²)223Θ(n²)
2T(n/2) + Θ(n log n)22n log n2 (k=1)Θ(n log² n)
Median-of-medians select: T(n/5) + T(7n/10) + Θ(n)chia không đềukhông áp dụng đượcΘ(n) (bằng recursion tree)

Hai kiểu thất bại đáng nhận diện, vì chúng hay xuất hiện trong phỏng vấn:

Hãy kiểm chứng kết quả Master Theorem bằng thực nghiệm trước khi tin vào một trường hợp rắc rối — đếm số lời gọi đệ quy thật chỉ tốn mười dòng:

def merge_sort_counted(arr, counter):
    counter[0] += len(arr)                 # công việc tại node này (phép trộn)
    if len(arr) <= 1:
        return arr[:]
    mid = len(arr) // 2
    return merge(merge_sort_counted(arr[:mid], counter),
                 merge_sort_counted(arr[mid:], counter))

import math
for n in (1024, 2048, 4096, 8192):
    c = [0]
    merge_sort_counted(list(range(n, 0, -1)), c)
    print(n, c[0], round(c[0] / (n * math.log2(n)), 3))
# tỉ lệ giữ nguyên ~1.1 khi n gấp đôi -> dự đoán n log n là đúng

Memoization — cầu nối sang quy hoạch động

Fibonacci ngây thơ có độ phức tạp hàm mũ hoàn toàn chỉ vì nó tính đi tính lại cùng những subproblem. Memoization là cách sửa: cache kết quả của mỗi lời gọi khác biệt và trả về giá trị đã cache khi gặp lại. Hình dạng thuật toán không thay đổi gì — chỉ có phần việc dư thừa biến mất.

def fib_naive(n):
    """Theta(phi^n) ~ Theta(1.618^n). fib_naive(35) đã mất vài giây."""
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

def fib_memo(n, cache=None):
    """Theta(n) time, Theta(n) space. Cùng đệ quy, có memo."""
    if cache is None:
        cache = {}
    if n < 2:
        return n
    if n in cache:                         # subproblem này đã giải rồi
        return cache[n]
    cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
    return cache[n]

Vì sao độ phức tạp sụp đổ: chỉ có n + 1 subproblem khác biệt (fib(0)fib(n)), mỗi cái được tính đúng một lần, và mỗi cái tốn O(1) ngoài các lời gọi đệ quy. Số state khác biệt × chi phí mỗi state là cách chuẩn để phân tích một đệ quy có memo, và đó cũng chính là công thức bạn dùng cho DP.

Trong production, hãy dùng thư viện chuẩn thay vì tự viết dict — nhưng phải biết cái giá của nó:

from functools import lru_cache, cache

@cache                          # Python 3.9+; không giới hạn, tương đương lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

@lru_cache(maxsize=1024)        # có giới hạn — loại bỏ entry ít dùng gần đây nhất
def expensive(x, y):
    ...

@cache không bao giờ evict, nên trên một service chạy dài với đối số biến thiên không giới hạn thì nó là một memory leak; @lru_cache(maxsize=N) chặn được điều đó. Cả hai đều yêu cầu đối số phải hashable, nên loại trừ tham số kiểu list và dict — hãy truyền tuple, hoặc một key dẫn xuất từ chúng. Và lưu ý rằng hàm được decorate vẫn đệ quy: fib(5000) với @cache vẫn vượt recursion limit dù thuật toán là tuyến tính, vì chuỗi gọi đầu tiên vẫn đi xuống sâu n frame.

Memoization chính là quy hoạch động top-down. Lựa chọn thay thế là bottom-up: sắp xếp thứ tự các subproblem sao cho phụ thuộc được tính trước, rồi điền vào bảng bằng vòng lặp — không đệ quy, không stack, và thường tốn ít bộ nhớ hơn vì bạn có thể vứt bỏ các hàng cũ.

def fib_bottom_up(n):
    """Theta(n) time, Theta(1) space. Hoàn toàn không đệ quy."""
    if n < 2:
        return n
    prev, curr = 0, 1
    for _ in range(2, n + 1):
        prev, curr = curr, prev + curr     # chỉ cần hai giá trị cuối là đủ
    return curr
Cách tiếp cậnTimeSpaceTínhGhi chú
Đệ quy ngây thơΘ(φⁿ)Θ(n) stackMỗi subproblem nhiều lầnKhông dùng được quá n ≈ 40
Có memo (top-down)Θ(n)Θ(n) cache + Θ(n) stackChỉ những subproblem thực sự chạm tớiDễ biến đổi nhất từ bản ngây thơ
Lập bảng (bottom-up)Θ(n)Θ(n), hoặc Θ(1) nếu tối ưuMọi subproblem, một lầnKhông vướng giới hạn stack; có thể rút bảng thành cửa sổ trượt

Hai điều kiện khiến memoization hoạt động — overlapping subproblems (cùng một lời gọi lặp lại) và optimal substructure (đáp án được dựng từ các đáp án con) — chính xác là hai điều kiện của quy hoạch động. Ngược lại, chia để trị có optimal substructure mà không có chồng lấn: merge sort không bao giờ sắp xếp cùng một subarray hai lần, nên memoize nó chỉ tổ tốn bộ nhớ. Sự phân biệt đó là cách rõ ràng nhất để quyết định một bài toán cần kỹ thuật nào, và nó được khai triển đầy đủ trong ./22-dynamic-programming.md.

Đệ quy xuất hiện ở đâu khác trong roadmap này

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

Recursion is a way of solving a problem by expressing it in terms of a smaller instance of the same problem. A recursive function calls itself on a reduced input, and keeps doing so until the input is small enough to answer outright. That “small enough” case is the base case; the self-referential step is the recursive case. Every correct recursive function has both, and gets closer to the base case on every call — a recursion that does not shrink its input is an infinite loop with worse failure behaviour, because instead of spinning forever it exhausts memory and crashes.

The reason recursion matters is not that it is elegant — it often is, but elegance does not justify a technique. It matters because a large class of data structures are themselves recursively defined, and code that mirrors the shape of the data is the code that is easiest to get right. A binary tree is a node with a left subtree and a right subtree, each of which is a binary tree. A directory contains files and directories. A JSON value is a scalar, or a list of JSON values, or a map of strings to JSON values. Writing a traversal of any of these iteratively means hand-rolling a stack; writing it recursively means writing down the definition and letting the language’s call stack do the bookkeeping.

Divide and conquer is the algorithmic technique built on top of recursion. It has three named steps: divide the problem into independent subproblems of the same kind, conquer them by recursing (until they are small enough to solve directly), and combine the subresults into an answer for the original. Merge sort and binary search are the canonical examples, and the reason divide and conquer produces such good complexities is arithmetic: cutting the input in half each time gives a recursion depth of log n rather than n, and n log n beats decisively once n gets past a few thousand.

This note covers the mechanics of recursion (the call stack, depth limits, tail calls), the analysis tools (recursion trees, the Master Theorem), the divide-and-conquer pattern with worked implementations, and memoization — which is the exact point where recursion turns into dynamic programming.

Fundamentals

The two components: base case and recursive case

def factorial(n):
    """n! computed recursively. Precondition: n >= 0."""
    if n <= 1:              # base case — answer known without recursing
        return 1
    return n * factorial(n - 1)   # recursive case — same problem, smaller input

Three things must hold for this to work, and every recursion bug is a violation of one of them:

  1. The base case is reachable. factorial(-1) recurses forever because n - 1 moves away from n <= 1. Guarding the precondition, or writing the base case as if n <= 1 rather than if n == 1, is what makes it robust.
  2. The recursive call is on a strictly smaller input. “Smaller” needs a well-founded measure — a shorter list, a smaller integer, a subtree rather than the tree. f(n) calling f(n) is not smaller; f(n) calling f(n // 2) is, as long as n // 2 < n, which fails at n = 0 and n = 1 — so those must be base cases.
  3. You trust the recursive call. This is the psychological hurdle. When writing factorial(n), do not trace the whole stack in your head. Assume factorial(n - 1) already returns the correct answer for n - 1 (the “recursive leap of faith”) and ask only: given that, is n * factorial(n - 1) correct? This is just induction — base case plus inductive step — and it is the same reasoning as a loop invariant.

The call stack

Recursion is not magic; it is the ordinary function-call mechanism used repeatedly. Each call pushes a stack frame holding that call’s parameters, local variables, and the return address. When the call returns, its frame is popped and control resumes in the caller. Because the frames are pushed and popped in LIFO order, the runtime uses a stack for them — the call stack.

For factorial(4), the stack grows to depth 4, then unwinds:

push factorial(4)   ->  needs 4 * factorial(3)
push factorial(3)   ->  needs 3 * factorial(2)
push factorial(2)   ->  needs 2 * factorial(1)
push factorial(1)   ->  base case, returns 1

           frames on the stack at the deepest point
           +------------------+
   top ->  | factorial(1) n=1 |  returns 1
           | factorial(2) n=2 |  waiting: 2 * ?
           | factorial(3) n=3 |  waiting: 3 * ?
   base -> | factorial(4) n=4 |  waiting: 4 * ?
           +------------------+

unwind: 1 -> 2*1=2 -> 3*2=6 -> 4*6=24

Two consequences follow immediately and are the source of most practical trouble:

Stack depth limits and sys.setrecursionlimit

CPython caps recursion depth to stop a runaway recursion from overflowing the operating system’s stack and segfaulting. The default is 1000, and hitting it raises RecursionError.

import sys

print(sys.getrecursionlimit())   # 1000 by default

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

# depth(2000)  ->  RecursionError: maximum recursion depth exceeded

sys.setrecursionlimit(20000)     # raise it — but understand what you are raising
print(depth(15000))              # 15001

The limit is a guard, not the actual capacity. The real constraint is the size of the thread’s stack (commonly 8 MB on Linux, 512 KB for non-main threads in some runtimes). Raising setrecursionlimit past what the stack can hold does not raise a clean Python exception — it crashes the interpreter. CPython 3.12+ decoupled pure-Python calls from the C stack, which makes deep pure-Python recursion much safer than it used to be, but recursion that passes back through C (a __repr__, a comparison operator, re matching, a C-implemented iterator) still consumes C stack.

If you need genuinely deep recursion, the honest options are, in order of preference:

  1. Rewrite it iteratively with an explicit stack. Always available, always safe.
  2. Reduce the depth algorithmically — e.g. in quicksort, recurse into the smaller partition and loop on the larger, which bounds depth at O(log n) regardless of pivot quality.
  3. Run it on a thread with a larger stack, only if 1 and 2 are genuinely impossible:
import sys, threading

def run_deep():
    sys.setrecursionlimit(200_000)
    print(depth(150_000))

threading.stack_size(512 * 1024 * 1024)     # 512 MB stack for this thread
t = threading.Thread(target=run_deep)
t.start()
t.join()

This is a competitive-programming trick, not a production pattern. In production, a recursion whose depth is driven by input size is a denial-of-service vector: a deeply nested JSON document crashes a recursive-descent parser. Bound the depth explicitly and reject input that exceeds it.

Direct vs indirect recursion

Direct recursion is a function calling itself. Indirect (or mutual) recursion is a cycle through two or more functions: A calls B, B calls A. It is less common but perfectly ordinary, and it is the natural shape for grammars where two categories are defined in terms of each other.

def is_even(n):
    """Mutual recursion — a deliberately silly parity check that shows the shape."""
    if n == 0:
        return True
    return is_odd(n - 1)

def is_odd(n):
    if n == 0:
        return False
    return is_even(n - 1)

The real use is a recursive-descent parser, where the grammar itself is mutually recursive (expr contains term, term contains factor, and factor can be a parenthesised expr):

def parse_expr(tokens, i):
    """expr := term (('+' | '-') term)*"""
    value, i = parse_term(tokens, i)
    while i < len(tokens) and tokens[i] in ("+", "-"):
        op = tokens[i]
        rhs, i = parse_term(tokens, i + 1)
        value = value + rhs if op == "+" else value - rhs
    return value, i

def parse_term(tokens, i):
    """term := factor (('*' | '/') factor)*"""
    value, i = parse_factor(tokens, i)
    while i < len(tokens) and tokens[i] in ("*", "/"):
        op = tokens[i]
        rhs, i = parse_factor(tokens, i + 1)
        value = value * rhs if op == "*" else value / rhs
    return value, i

def parse_factor(tokens, i):
    """factor := NUMBER | '(' expr ')'  -- closes the mutual-recursion cycle"""
    if tokens[i] == "(":
        value, i = parse_expr(tokens, i + 1)
        return value, i + 1          # skip the closing ')'
    return int(tokens[i]), i + 1

tokens = ["2", "+", "3", "*", "(", "4", "-", "1", ")"]
print(parse_expr(tokens, 0)[0])      # 11

The one thing to watch with indirect recursion is that the “input gets smaller” argument now has to hold around the whole cycle, not per function. A left-recursive grammar rule (expr := expr '+' term) makes parse_expr call itself with the index unchanged — infinite recursion, and the classic reason recursive-descent parsers require the grammar to be rewritten without left recursion.

Converting recursion to iteration

Every recursion can be made iterative, because the call stack is just a stack you can manage yourself. There are two levels of conversion.

Level 1 — the recursion is linear and the pending work is trivial. Then a plain loop with an accumulator suffices:

def factorial_iter(n):
    """O(n) time, O(1) space — no stack frames at all."""
    result = 1
    for k in range(2, n + 1):
        result *= k
    return result

Level 2 — the recursion branches. Then you need an explicit stack, and you have to encode “where was I in this frame” as state. Compare the recursive and iterative in-order tree traversals:

class Node:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def inorder_recursive(node, out):
    if node is None:                 # base case
        return
    inorder_recursive(node.left, out)
    out.append(node.value)
    inorder_recursive(node.right, out)

def inorder_iterative(root):
    """Same traversal, explicit stack. The `stack` here IS the call stack."""
    out, stack, node = [], [], root
    while stack or node is not None:
        while node is not None:      # descend left, remembering the path
            stack.append(node)
            node = node.left
        node = stack.pop()           # backtrack to the deepest unvisited node
        out.append(node.value)
        node = node.right            # then go right
    return out

The iterative version is longer and harder to read, which is exactly the trade: you buy freedom from the depth limit and a small constant-factor speedup, and you pay in clarity. Convert when you must (depth is unbounded, or profiling shows call overhead matters), not on principle.

Tail recursion, and why Python does not optimize it

A call is tail recursive when the recursive call is the last thing the function does — nothing is pending after it returns. return n * factorial(n - 1) is not tail recursive (the multiply is pending). This version is:

def factorial_tail(n, acc=1):
    if n <= 1:
        return acc
    return factorial_tail(n - 1, acc * n)   # nothing pending — pure tail call

Because nothing is pending, the caller’s frame is dead the moment the tail call is made. A compiler can therefore reuse the frame instead of pushing a new one, turning the recursion into a loop with O(1) stack. This is tail call optimization (TCO), and Scheme mandates it, while Lua, Scala (for self-calls via @tailrec), and most functional languages provide it. LLVM will do it for C/C++ at -O2 when the shape permits.

CPython does not do TCO, deliberately. Guido van Rossum’s stated reasons are worth knowing because they are engineering judgements, not oversights:

The practical consequence: in Python, if you find yourself writing a tail-recursive function, write the loop instead. The transformation is mechanical — the accumulator becomes a variable, the parameters become loop variables:

def factorial_loop(n):
    """Mechanical de-recursion of factorial_tail. O(1) stack."""
    acc = 1
    while n > 1:
        n, acc = n - 1, acc * n     # exactly the arguments of the tail call
    return acc
RecursiveIterative
Reads like the data’s definitionYes, for recursive structuresNo — bookkeeping is explicit
Stack spaceO(depth)O(1), or O(depth) with an explicit stack
Depth limitYes (RecursionError in Python)No
Call overheadOne frame push/pop per stepNone
Easy for tree/graph structuresYesRequires manual stack
Easy for linear scansOverkillYes

Key Concepts

The recursion tree as an analysis tool

To analyse a recursive algorithm, draw the tree of calls: each node is a call, its children are the calls it makes, and the node is labelled with the work done at that node excluding its children. Total cost is the sum over all nodes — which is easiest to compute level by level.

For merge sort, T(n) = 2T(n/2) + Θ(n):

level 0:                  n                     work = n
                        /   \
level 1:            n/2      n/2                work = 2·(n/2) = n
                   /   \    /   \
level 2:        n/4   n/4  n/4   n/4            work = 4·(n/4) = n
                 ...          ...
level log n:   1  1  1  1  ...  1  1  1         work = n·1     = n
                                                ------------------
                                          total = n per level × (log n + 1) levels
                                                = Θ(n log n)

Every level costs n, and there are log₂ n + 1 levels because the input halves each time — hence Θ(n log n).

Now the same tool on naive Fibonacci, T(n) = T(n−1) + T(n−2) + Θ(1):

                        fib(5)
                   /            \
              fib(4)            fib(3)
             /      \          /      \
        fib(3)    fib(2)   fib(2)   fib(1)
        /    \     /   \    /   \
   fib(2) fib(1) f(1) f(0) f(1) f(0)
    /   \
 f(1)  f(0)

The tree is nearly a full binary tree of depth n, so the node count — and therefore the runtime — is exponential: Θ(φⁿ) where φ ≈ 1.618. But look at the repetition: fib(3) is computed twice, fib(2) three times, fib(1) five times. That redundancy is overlapping subproblems, and it is precisely what memoization removes.

The recursion tree also handles unbalanced splits that the Master Theorem cannot. For T(n) = T(n/3) + T(2n/3) + n, every level still costs at most n, the shallowest leaf is at depth log₃ n and the deepest at log_{3/2} n, so T(n) = Θ(n log n) — the same as a balanced split, just with a bigger constant. This is the formal reason quicksort survives a consistently 1:9 split.

Divide and conquer: divide, conquer, combine

                +---------------------------+
                |    problem of size n      |
                +---------------------------+
                             |  DIVIDE into a subproblems of size n/b
              +--------------+--------------+
              v                             v
       +-------------+              +-------------+
       | size n/b    |     ...      | size n/b    |   CONQUER: recurse
       +-------------+              +-------------+   (base case if small)
              |                             |
              +--------------+--------------+
                             v  COMBINE the subresults
                +---------------------------+
                |         answer            |
                +---------------------------+

The technique pays off when the combine step is cheaper than solving the problem directly. Merge sort works because merging two sorted halves is O(n) while sorting from scratch is O(n log n). Divide and conquer fails to help when the subproblems overlap (then you want DP) or when combining is as expensive as the original problem.

Merge sort — the canonical example

def merge_sort(arr):
    """Divide: split in half. Conquer: sort each half. Combine: merge."""
    if len(arr) <= 1:                      # base case: 0 or 1 element is sorted
        return arr[:]
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])           # conquer
    right = merge_sort(arr[mid:])
    return merge(left, right)              # combine — the O(n) step

def merge(left, right):
    """Merge two sorted lists into one sorted list. O(len(left) + len(right))."""
    out = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:            # <= keeps the sort stable
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
    out.extend(left[i:])                   # one side is exhausted; append the rest
    out.extend(right[j:])
    return out

print(merge_sort([5, 2, 9, 1, 5, 6]))      # [1, 2, 5, 5, 6, 9]

Θ(n log n) in all cases — the recursion depth does not depend on the data, only on n. Space is Θ(n) for the merge buffers plus Θ(log n) of stack. See ./08-sorting-algorithms.md for the full comparison against quicksort and heapsort.

Binary search — divide and conquer with an empty combine step

def binary_search_rec(arr, target, lo=0, hi=None):
    """Divide: pick the midpoint. Conquer: recurse into ONE half. Combine: nothing."""
    if hi is None:
        hi = len(arr) - 1
    if lo > hi:                            # base case: empty range
        return -1
    mid = lo + (hi - lo) // 2              # avoids overflow in fixed-width languages
    if arr[mid] == target:
        return mid
    if arr[mid] < target:
        return binary_search_rec(arr, target, mid + 1, hi)
    return binary_search_rec(arr, target, lo, mid - 1)

This is the degenerate case: it divides into two subproblems but only conquers one, and there is nothing to combine. T(n) = T(n/2) + Θ(1) = Θ(log n). It is also naturally tail recursive, so the iterative version in ./09-search-algorithms.md is the one to actually use in Python.

Counting inversions — divide and conquer that is not a sort

An inversion is a pair i < j with arr[i] > arr[j]; the count measures how far from sorted a list is. Brute force is Θ(n²). Piggy-backing on merge sort makes it Θ(n log n), and the insight is the whole reason divide and conquer is worth learning: when the left half’s element is bigger, every remaining element in the left half is also bigger than right[j], so one comparison counts many inversions at once.

def count_inversions(arr):
    """Returns (sorted_copy, inversion_count). Theta(n log n)."""
    if len(arr) <= 1:
        return arr[:], 0
    mid = len(arr) // 2
    left, a = count_inversions(arr[:mid])
    right, b = count_inversions(arr[mid:])
    merged, cross = _merge_count(left, right)
    return merged, a + b + cross           # combine: left + right + cross-boundary

def _merge_count(left, right):
    out, i, j, inversions = [], 0, 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            out.append(left[i]); i += 1
        else:
            # left[i..] are all > right[j], so this contributes len(left) - i pairs
            inversions += len(left) - i
            out.append(right[j]); j += 1
    out.extend(left[i:]); out.extend(right[j:])
    return out, inversions

print(count_inversions([2, 4, 1, 3, 5])[1])   # 3  -> (2,1), (4,1), (4,3)

Karatsuba multiplication — beating the obvious bound

Multiplying two n-digit numbers the schoolbook way is Θ(n²). Splitting each number in half gives x = x₁·10^m + x₀, and the naive expansion needs four half-size products — T(n) = 4T(n/2) + Θ(n), which is still Θ(n²), so nothing is gained. Karatsuba’s trick is an algebraic identity that computes the middle term from the other two, needing only three products:

def karatsuba(x, y):
    """Theta(n^1.585) integer multiplication. Three half-size products, not four."""
    if x < 10 or y < 10:                   # base case: single-digit multiply
        return x * y
    n = max(x.bit_length(), y.bit_length())
    m = (n // 2)
    high_x, low_x = divmod(x, 1 << m)
    high_y, low_y = divmod(y, 1 << m)

    z0 = karatsuba(low_x, low_y)                       # product 1
    z2 = karatsuba(high_x, high_y)                     # product 2
    z1 = karatsuba(low_x + high_x, low_y + high_y) - z0 - z2   # product 3

    return (z2 << (2 * m)) + (z1 << m) + z0

print(karatsuba(123456789, 987654321) == 123456789 * 987654321)   # True

T(n) = 3T(n/2) + Θ(n) = Θ(n^log₂3) = Θ(n^1.585). Dropping one of four recursive calls changed the exponent — that is the leverage divide and conquer gives you, and the same trick shape drives Strassen’s matrix multiplication (8T(n/2)7T(n/2), giving Θ(n^2.807) instead of Θ(n³)).

The Master Theorem

Most divide-and-conquer recurrences have the form

T(n) = a·T(n/b) + f(n)          a >= 1,  b > 1

where a is the number of subproblems, n/b is their size, and f(n) is the cost of dividing plus combining. The Master Theorem answers all of them by comparing f(n) against the watershed function n^(log_b a) — which is the total cost of the leaves.

ConditionResultReading
Case 1f(n) = O(n^(log_b a − ε)) for some ε > 0T(n) = Θ(n^(log_b a))Leaves dominate — the work is at the bottom
Case 2f(n) = Θ(n^(log_b a) · log^k n), k ≥ 0T(n) = Θ(n^(log_b a) · log^(k+1) n)Balanced — every level costs the same, and there are log n levels
Case 3f(n) = Ω(n^(log_b a + ε)) for some ε > 0, and a·f(n/b) ≤ c·f(n) for some c < 1T(n) = Θ(f(n))Root dominates — the work is at the top

Case 3’s second condition is the regularity condition; it rules out pathological f that oscillate. In practice, if f is a polynomial times a polylog, regularity holds.

Worked examples:

Recurrenceabn^(log_b a)f(n)CaseT(n)
Merge sort: 2T(n/2) + Θ(n)22n2 (k=0)Θ(n log n)
Binary search: T(n/2) + Θ(1)12n⁰ = 112 (k=0)Θ(log n)
Karatsuba: 3T(n/2) + Θ(n)32n^1.585n1Θ(n^1.585)
Strassen: 7T(n/2) + Θ(n²)72n^2.8071Θ(n^2.807)
Naive block matmul: 8T(n/2) + Θ(n²)821Θ(n³)
2T(n/2) + Θ(n²)223Θ(n²)
2T(n/2) + Θ(n log n)22n log n2 (k=1)Θ(n log² n)
Median-of-medians select: T(n/5) + T(7n/10) + Θ(n)unequal splitsnot applicableΘ(n) (by recursion tree)

Two failure modes worth recognising, because they show up in interviews:

Verify a Master Theorem result empirically before trusting a fiddly case — counting the actual recursive calls takes ten lines:

def merge_sort_counted(arr, counter):
    counter[0] += len(arr)                 # work done at this node (the merge)
    if len(arr) <= 1:
        return arr[:]
    mid = len(arr) // 2
    return merge(merge_sort_counted(arr[:mid], counter),
                 merge_sort_counted(arr[mid:], counter))

import math
for n in (1024, 2048, 4096, 8192):
    c = [0]
    merge_sort_counted(list(range(n, 0, -1)), c)
    print(n, c[0], round(c[0] / (n * math.log2(n)), 3))
# ratio stays ~1.1 as n doubles -> the n log n prediction holds

Memoization — the bridge to dynamic programming

Naive Fibonacci is exponential purely because it recomputes the same subproblems. Memoization is the fix: cache the result of each distinct call and return the cached value on a repeat. Nothing about the algorithm’s shape changes — only the redundant work disappears.

def fib_naive(n):
    """Theta(phi^n) ~ Theta(1.618^n). fib_naive(35) already takes seconds."""
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

def fib_memo(n, cache=None):
    """Theta(n) time, Theta(n) space. Same recursion, memoized."""
    if cache is None:
        cache = {}
    if n < 2:
        return n
    if n in cache:                         # already solved this subproblem
        return cache[n]
    cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
    return cache[n]

Why the complexity collapses: there are only n + 1 distinct subproblems (fib(0)fib(n)), each is computed exactly once, and each costs O(1) beyond its recursive calls. Number of distinct states × cost per state is the standard way to analyse a memoized recursion, and it is the same formula you use for DP.

In production, use the standard library rather than a hand-rolled dict — but know what it costs:

from functools import lru_cache, cache

@cache                          # Python 3.9+; unbounded, equivalent to lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

@lru_cache(maxsize=1024)        # bounded — evicts least-recently-used entries
def expensive(x, y):
    ...

@cache never evicts, so on a long-running service with unbounded argument variety it is a memory leak; @lru_cache(maxsize=N) bounds it. Both require arguments to be hashable, which rules out list and dict parameters — pass tuples, or a key derived from them. And note that the decorated function still recurses: fib(5000) with @cache blows the recursion limit even though the algorithm is linear, because the first call chain descends n frames deep.

Memoization is top-down dynamic programming. The alternative is bottom-up: order the subproblems so that dependencies are computed first, then fill a table with a loop — no recursion, no stack, and often less memory because you can discard old rows.

def fib_bottom_up(n):
    """Theta(n) time, Theta(1) space. No recursion at all."""
    if n < 2:
        return n
    prev, curr = 0, 1
    for _ in range(2, n + 1):
        prev, curr = curr, prev + curr     # only the last two values are ever needed
    return curr
ApproachTimeSpaceComputesNotes
Naive recursionΘ(φⁿ)Θ(n) stackEvery subproblem many timesUnusable past n ≈ 40
Memoized (top-down)Θ(n)Θ(n) cache + Θ(n) stackOnly the subproblems actually reachedEasiest transform from the naive version
Tabulated (bottom-up)Θ(n)Θ(n), or Θ(1) optimizedEvery subproblem, onceNo stack limit; can drop the table to a rolling window

The two conditions that make memoization work — overlapping subproblems (the same call recurs) and optimal substructure (the answer is built from subanswers) — are exactly the two conditions for dynamic programming. Divide and conquer, by contrast, has optimal substructure without overlap: merge sort never sorts the same subarray twice, which is why memoizing it would only waste memory. That distinction is the cleanest way to decide which technique a problem wants, and it is developed fully in ./22-dynamic-programming.md.

Where recursion shows up elsewhere in this roadmap

Best Practices

References