Đệ quy & Chia để trịRecursion & Divide and Conquer
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Hai thành phần: base case và recursive case
- Call stack
- Giới hạn độ sâu stack và sys.setrecursionlimit
- Đệ quy trực tiếp và gián tiếp
- Chuyển đệ quy thành lặp
- Tail recursion, và vì sao Python không tối ưu nó
- Khái niệm chính
- Recursion tree như một công cụ phân tích
- Chia để trị: divide, conquer, combine
- Master Theorem
- Memoization — cầu nối sang quy hoạch động
- Đệ quy xuất hiện ở đâu khác trong roadmap này
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- The two components: base case and recursive case
- The call stack
- Stack depth limits and sys.setrecursionlimit
- Direct vs indirect recursion
- Converting recursion to iteration
- Tail recursion, and why Python does not optimize it
- Key Concepts
- The recursion tree as an analysis tool
- Divide and conquer: divide, conquer, combine
- The Master Theorem
- Memoization — the bridge to dynamic programming
- Where recursion shows up elsewhere in this roadmap
- Best Practices
- 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 n² 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:
- Base case phải tới được.
factorial(-1)đệ quy vô hạn vìn - 1đi xa dần khỏin <= 1. Kiểm tra điều kiện tiên quyết, hoặc viết base case làif n <= 1thay vìif n == 1, là thứ làm cho nó bền vững. - 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ọif(n)thì không nhỏ hơn;f(n)gọif(n // 2)thì có, miễn làn // 2 < n, điều này sai tạin = 0vàn = 1— nên hai giá trị đó phải là base case. - 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ả địnhfactorial(n - 1)đã trả về đúng đáp án chon - 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ế:
- Đệ quy tốn bộ nhớ tỉ lệ với độ sâu của nó. Một đệ quy sâu
dmức giữdframe sống cùng lúc, nên space complexity làO(d)ngay cả khi bản thân nó không cấp phát gì.factorial(n)làO(n)time vàO(n)space; bản lặp làO(1)space. - Phần việc “sau lời gọi đệ quy” xảy ra trên đường quay ngược lên. Trong
n * factorial(n - 1), phép nhân đang chờ trong khi các lời gọi sâu hơn chạy. Đây chính là thứ làm lời gọi không phải tail call và buộc frame phải sống tiếp.
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:
- 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.
- 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. - 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:
- Nó phá hủy stack trace. Những frame bị loại bỏ chính là những frame bạn muốn thấy trong traceback khi có gì đó hỏng sâu trong đệ quy. Python coi khả năng debug quan trọng hơn tối ưu này.
- Nó khiến tối ưu trở nên vô hình và mong manh. Việc TCO có áp dụng được hay không phụ thuộc vào các tính chất cú pháp tinh vi; một lần refactor thêm
try/finallyhoặcwithsẽ âm thầm đưaO(n)stack quay lại và biến code đang chạy tốt thànhRecursionErrortrên production. - Python không phải ngôn ngữ hàm. Vòng lặp mới là cấu trúc idiomatic. Làm cho đệ quy cạnh tranh được với vòng lặp sẽ khuyến khích một phong cách viết đọc rất tệ trong Python.
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
| Đệ quy | Lặp | |
|---|---|---|
| Đọc giống định nghĩa của dữ liệu | Có, với cấu trúc đệ quy | Không — phần ghi sổ là tường minh |
| Stack space | O(depth) | O(1), hoặc O(depth) với stack tường minh |
| Giới hạn độ sâu | Có (RecursionError trong Python) | Không |
| Chi phí gọi hàm | Một lần push/pop frame mỗi bước | Không có |
| Dễ cho cấu trúc tree/graph | Có | Cần stack thủ công |
| Dễ cho quét tuyến tính | Thừa thãi | Có |
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ện | Kết quả | Cách đọc | |
|---|---|---|---|
| Case 1 | f(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 2 | f(n) = Θ(n^(log_b a) · log^k n), k ≥ 0 | T(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 3 | f(n) = Ω(n^(log_b a + ε)) với một ε > 0, và a·f(n/b) ≤ c·f(n) với một c < 1 | T(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ồi | a | b | n^(log_b a) | f(n) | Case | T(n) |
|---|---|---|---|---|---|---|
Merge sort: 2T(n/2) + Θ(n) | 2 | 2 | n¹ | n | 2 (k=0) | Θ(n log n) |
Binary search: T(n/2) + Θ(1) | 1 | 2 | n⁰ = 1 | 1 | 2 (k=0) | Θ(log n) |
Karatsuba: 3T(n/2) + Θ(n) | 3 | 2 | n^1.585 | n | 1 | Θ(n^1.585) |
Strassen: 7T(n/2) + Θ(n²) | 7 | 2 | n^2.807 | n² | 1 | Θ(n^2.807) |
Nhân ma trận khối ngây thơ: 8T(n/2) + Θ(n²) | 8 | 2 | n³ | n² | 1 | Θ(n³) |
2T(n/2) + Θ(n²) | 2 | 2 | n¹ | n² | 3 | Θ(n²) |
2T(n/2) + Θ(n log n) | 2 | 2 | n¹ | n log n | 2 (k=1) | Θ(n log² n) |
Median-of-medians select: T(n/5) + T(7n/10) + Θ(n) | — | — | chia không đều | — | khô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:
T(n) = 2T(n/2) + n/log n. Ở đâyn^(log_b a) = nvàf(n) = n/log nnhỏ hơn — nhưng không nhỏ hơn theo đa thức: không tồn tạiε > 0nào đển/log n = O(n^(1−ε)). Định lý có một khoảng trống ở đây và đơn giản là không áp dụng được. (Đáp án thật, bằng recursion tree, làΘ(n log log n).)- Chia không đều.
T(n) = T(n/3) + T(2n/3) + nhoàn toàn không thuộc dạng Master. Hãy dùng recursion tree, hoặc phương pháp Akra–Bazzi, tổng quát hóa Master Theorem choT(n) = Σ aᵢT(n/bᵢ) + f(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ận | Time | Space | Tính | Ghi chú |
|---|---|---|---|---|
| Đệ quy ngây thơ | Θ(φⁿ) | Θ(n) stack | Mỗi subproblem nhiều lần | Không dùng được quá n ≈ 40 |
| Có memo (top-down) | Θ(n) | Θ(n) cache + Θ(n) stack | Chỉ những subproblem thực sự chạm tới | Dễ 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 ưu | Mọi subproblem, một lần | Khô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
- Tree traversal — in-order, pre-order, post-order mỗi cái chỉ ba dòng nếu viết đệ quy.
- DFS trên graph — bản đệ quy là bản dễ đọc; bản lặp tồn tại vì lý do an toàn độ sâu.
- Quicksort và merge sort — cả hai đều là chia để trị, khác nhau ở chỗ công việc nằm ở bước divide (partition của quicksort) hay bước combine (merge của merge sort).
- Backtracking — đệ quy cộng với việc hoàn tác lựa chọn trên đường quay ngược lên.
- Segment tree — build, query, và update đều là đệ quy dạng
T(n) = 2T(n/2) + O(1). - Query planner của database cũng phân rã join theo đúng kiểu đệ quy này; xem ../../postgresql-dba/vi/10-query-planning-and-performance-tuning.md.
Best Practices
- Viết base case trước. Đó là phần kết thúc đệ quy và cũng là phần hay sai nhất. Ưu tiên
if n <= 1hơnif n == 1để input bất ngờ dừng lại thay vì đệ quy vô hạn. - Biến “nhỏ hơn” thành một đại lượng đo được. Trước khi viết lời gọi đệ quy, hãy gọi tên giá trị giảm đi và kiểm tra rằng nó giảm thực sự trên mọi nhánh. Nếu bạn không gọi tên được nó, đệ quy của bạn không có nền tảng vững.
- Hãy thực hiện cú nhảy niềm tin đệ quy. Giả định lời gọi đệ quy đúng với input nhỏ hơn và chỉ kiểm chứng bước hiện tại. Trace ba tầng stack bằng tay là cách người ta tự thuyết phục mình rằng một đệ quy đúng là sai.
- Đừng dùng đệ quy khi độ sâu bị điều khiển bởi kích thước input không tin cậy. Một parser JSON hay XML viết đệ quy là một vụ crash chờ sẵn. Hãy chặn độ sâu tường minh và từ chối input quá sâu bằng một lỗi rõ ràng, hoặc dùng parser dạng lặp.
- Đừng viết tail recursion trong Python. Hãy chuyển nó thành vòng lặp — phép biến đổi là máy móc, CPython sẽ không bao giờ tối ưu nó, và vòng lặp vừa nhanh hơn vừa miễn nhiễm với
RecursionError. - Dùng
sys.setrecursionlimitsau cùng, không phải đầu tiên. Đó là lối thoát hiểm của competitive programming. Trong code ứng dụng, việc cần đến nó là dấu hiệu cho thấy phải tái cấu trúc thuật toán. - Trong các đệ quy kiểu quicksort, hãy đệ quy vào bên nhỏ hơn và lặp trên bên lớn hơn. Cách này chặn độ sâu stack ở
O(log n)kể cả khi partition mất cân bằng nặng. - Thêm ngưỡng cắt cho subproblem nhỏ. Các implementation merge sort và quicksort thật đều chuyển sang insertion sort dưới ~16 phần tử, vì ở kích thước đó chi phí đệ quy vượt quá lợi ích tiệm cận. Đây là lý do Timsort của Python có “minrun” và lý do introsort tồn tại.
- Memoize trước khi tối ưu bất cứ thứ gì khác. Nếu một đệ quy chậm, hãy kiểm tra subproblem lặp lại trước tiên — một dòng
@cachethường biến hàm mũ thành tuyến tính, còn không micro-optimization nào làm được điều đó. - Dùng
@lru_cache(maxsize=N), không phải@cache, trên service sống lâu. Một cache không giới hạn được key bởi input của người dùng là một memory leak không giới hạn. - Vẽ recursion tree trước khi với tới Master Theorem. Cây luôn hoạt động, cho biết chi phí nằm ở đâu, và không có khoảng trống giữa các case. Master Theorem là lối tắt cho những hình dạng phổ biến, không phải thứ thay thế cho việc hiểu.
- Kiểm tra điều kiện tiên quyết của Master Theorem. “Nhỏ hơn” trong Case 1 và “lớn hơn” trong Case 3 nghĩa là nhỏ/lớn hơn theo đa thức.
n/log nso vớinkhông thỏa điều đó, và cố áp dụng định lý vẫn sẽ cho ra đáp án sai. - Ưu tiên chia để trị khi các subproblem độc lập, quy hoạch động khi chúng chồng lấn. Memoize merge sort là lãng phí thuần túy; không memoize Fibonacci là lãng phí theo hàm mũ. Có hay không có chồng lấn quyết định bạn đang cầm kỹ thuật nào.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Recursion (computer science) — Wikipedia
- Divide-and-conquer algorithm — Wikipedia
- Master theorem (analysis of algorithms) — Wikipedia
- Akra–Bazzi method — Wikipedia
- Tail call — Wikipedia
- Karatsuba algorithm — Wikipedia
- Memoization — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 4: Divide-and-Conquer
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Python Documentation —
sys.setrecursionlimit - Python Documentation —
functools.lru_cacheandfunctools.cache - cp-algorithms — Divide and Conquer DP
- Big-O Cheat Sheet
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 n² 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:
- The base case is reachable.
factorial(-1)recurses forever becausen - 1moves away fromn <= 1. Guarding the precondition, or writing the base case asif n <= 1rather thanif n == 1, is what makes it robust. - 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)callingf(n)is not smaller;f(n)callingf(n // 2)is, as long asn // 2 < n, which fails atn = 0andn = 1— so those must be base cases. - You trust the recursive call. This is the psychological hurdle. When writing
factorial(n), do not trace the whole stack in your head. Assumefactorial(n - 1)already returns the correct answer forn - 1(the “recursive leap of faith”) and ask only: given that, isn * 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:
- Recursion costs memory proportional to its depth. A recursion
dlevels deep holdsdframes alive at once, so its space complexity isO(d)even if it allocates nothing itself.factorial(n)isO(n)time andO(n)space; the iterative version isO(1)space. - The work “after the recursive call” happens on the way back up. In
n * factorial(n - 1)the multiplication is pending while the deeper calls run. This is what makes the call non-tail and forces the frame to stay alive.
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:
- Rewrite it iteratively with an explicit stack. Always available, always safe.
- 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. - 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:
- It destroys stack traces. The eliminated frames are exactly the ones you want in a traceback when something goes wrong deep in a recursion. Python treats debuggability as more valuable than the optimization.
- It makes the optimization invisible and fragile. Whether TCO applies depends on subtle syntactic properties; a refactor that adds a
try/finallyor awithsilently reintroducesO(n)stack and turns working code into aRecursionErrorin production. - Python is not a functional language. Loops are the idiomatic construct. Making recursion competitive with loops would encourage a style that reads poorly in Python.
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
| Recursive | Iterative | |
|---|---|---|
| Reads like the data’s definition | Yes, for recursive structures | No — bookkeeping is explicit |
| Stack space | O(depth) | O(1), or O(depth) with an explicit stack |
| Depth limit | Yes (RecursionError in Python) | No |
| Call overhead | One frame push/pop per step | None |
| Easy for tree/graph structures | Yes | Requires manual stack |
| Easy for linear scans | Overkill | Yes |
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.
| Condition | Result | Reading | |
|---|---|---|---|
| Case 1 | f(n) = O(n^(log_b a − ε)) for some ε > 0 | T(n) = Θ(n^(log_b a)) | Leaves dominate — the work is at the bottom |
| Case 2 | f(n) = Θ(n^(log_b a) · log^k n), k ≥ 0 | T(n) = Θ(n^(log_b a) · log^(k+1) n) | Balanced — every level costs the same, and there are log n levels |
| Case 3 | f(n) = Ω(n^(log_b a + ε)) for some ε > 0, and a·f(n/b) ≤ c·f(n) for some c < 1 | T(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:
| Recurrence | a | b | n^(log_b a) | f(n) | Case | T(n) |
|---|---|---|---|---|---|---|
Merge sort: 2T(n/2) + Θ(n) | 2 | 2 | n¹ | n | 2 (k=0) | Θ(n log n) |
Binary search: T(n/2) + Θ(1) | 1 | 2 | n⁰ = 1 | 1 | 2 (k=0) | Θ(log n) |
Karatsuba: 3T(n/2) + Θ(n) | 3 | 2 | n^1.585 | n | 1 | Θ(n^1.585) |
Strassen: 7T(n/2) + Θ(n²) | 7 | 2 | n^2.807 | n² | 1 | Θ(n^2.807) |
Naive block matmul: 8T(n/2) + Θ(n²) | 8 | 2 | n³ | n² | 1 | Θ(n³) |
2T(n/2) + Θ(n²) | 2 | 2 | n¹ | n² | 3 | Θ(n²) |
2T(n/2) + Θ(n log n) | 2 | 2 | n¹ | n log n | 2 (k=1) | Θ(n log² n) |
Median-of-medians select: T(n/5) + T(7n/10) + Θ(n) | — | — | unequal splits | — | not applicable | Θ(n) (by recursion tree) |
Two failure modes worth recognising, because they show up in interviews:
T(n) = 2T(n/2) + n/log n. Heren^(log_b a) = nandf(n) = n/log nis smaller — but not polynomially smaller: there is noε > 0withn/log n = O(n^(1−ε)). The theorem has a gap here and simply does not apply. (The true answer, by recursion tree, isΘ(n log log n).)- Unequal splits.
T(n) = T(n/3) + T(2n/3) + nis not of the Master form at all. Use a recursion tree, or the Akra–Bazzi method, which generalises the Master Theorem toT(n) = Σ aᵢT(n/bᵢ) + f(n).
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
| Approach | Time | Space | Computes | Notes |
|---|---|---|---|---|
| Naive recursion | Θ(φⁿ) | Θ(n) stack | Every subproblem many times | Unusable past n ≈ 40 |
| Memoized (top-down) | Θ(n) | Θ(n) cache + Θ(n) stack | Only the subproblems actually reached | Easiest transform from the naive version |
| Tabulated (bottom-up) | Θ(n) | Θ(n), or Θ(1) optimized | Every subproblem, once | No 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
- Tree traversals — in-order, pre-order, post-order are three lines each recursively.
- DFS on graphs — the recursive formulation is the readable one; the iterative one exists for depth safety.
- Quicksort and merge sort — both are divide and conquer, differing in whether the work is in the divide step (quicksort’s partition) or the combine step (merge sort’s merge).
- Backtracking — recursion plus undoing the choice on the way back up.
- Segment trees — build, query, and update are all
T(n) = 2T(n/2) + O(1)recursions. - Database query planners recursively decompose joins the same way; see ../../postgresql-dba/en/10-query-planning-and-performance-tuning.md.
Best Practices
- Write the base case first. It is the part that terminates the recursion and the part most often wrong. Prefer
if n <= 1overif n == 1so that unexpected inputs terminate rather than recurse forever. - Make “smaller” a measurable quantity. Before writing the recursive call, name the value that decreases and check it strictly decreases on every path. If you cannot name it, the recursion is not well-founded.
- Take the recursive leap of faith. Assume the recursive call is correct for smaller inputs and verify only the current step. Tracing three levels of the stack by hand is how people convince themselves that correct recursion is wrong.
- Do not use recursion when the depth is driven by untrusted input size. A recursive JSON or XML parser is a crash waiting to happen. Bound the depth explicitly and reject over-deep input with an error, or use an iterative parser.
- Do not write tail recursion in Python. Convert it to a loop — the transformation is mechanical, CPython will never optimize it, and the loop is both faster and immune to
RecursionError. - Reach for
sys.setrecursionlimitlast, not first. It is a competitive-programming escape hatch. In application code, treat needing it as a signal to restructure the algorithm. - In quicksort-style recursions, recurse on the smaller side and iterate on the larger. This bounds stack depth at
O(log n)even when the partitions are badly unbalanced. - Add a cutoff to small subproblems. Real merge sort and quicksort implementations switch to insertion sort below ~16 elements, because at that size the recursion overhead exceeds the asymptotic benefit. This is why Python’s Timsort has a “minrun” and why introsort exists.
- Memoize before you optimize anything else. If a recursion is slow, check for repeated subproblems first — a one-line
@cachefrequently converts exponential to linear, and no amount of micro-optimization will. - Use
@lru_cache(maxsize=N), not@cache, on long-lived services. An unbounded cache keyed on user input is an unbounded memory leak. - Draw the recursion tree before reaching for the Master Theorem. The tree always works, tells you where the cost is, and does not have gaps between its cases. The Master Theorem is a shortcut for the common shapes, not a substitute for understanding.
- Check the Master Theorem’s preconditions. “Smaller” in Case 1 and “larger” in Case 3 mean polynomially smaller/larger.
n/log nversusnfails that test, and applying the theorem anyway gives the wrong answer. - Prefer divide and conquer when subproblems are independent, dynamic programming when they overlap. Memoizing merge sort is pure waste; not memoizing Fibonacci is exponential waste. The presence or absence of overlap decides which you have.
References
- roadmap.sh — Data Structures & Algorithms
- Recursion (computer science) — Wikipedia
- Divide-and-conquer algorithm — Wikipedia
- Master theorem (analysis of algorithms) — Wikipedia
- Akra–Bazzi method — Wikipedia
- Tail call — Wikipedia
- Karatsuba algorithm — Wikipedia
- Memoization — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 4: Divide-and-Conquer
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Python Documentation —
sys.setrecursionlimit - Python Documentation —
functools.lru_cacheandfunctools.cache - cp-algorithms — Divide and Conquer DP
- Big-O Cheat Sheet