Quy hoạch độngDynamic Programming
Mục lục
- Tổng quan
- Kiến thức nền tảng
- Hai điều kiện tiên quyết
- Top-down (memoization) so với bottom-up (tabulation)
- Thiết kế state và transition
- Khái niệm chính
- Nhóm 1 — DP tuyến tính trên một chỉ số: Fibonacci và leo cầu thang
- Nhóm 2 — Lựa chọn không giới hạn: coin change
- Nhóm 3 — 0/1 knapsack
- Nhóm 4 — Hai dãy: LCS và edit distance
- Nhóm 5 — Longest increasing subsequence, hai lần
- Nhóm 6 — DP trên lưới
- Tối ưu bộ nhớ bằng rolling array
- Truy vết lời giải, không chỉ giá trị của nó
- Cách nhận ra một bài DP
- Bảng tổng kết độ phức tạp
- Best Practices
- Tài liệu tham khảo
Table of contents
- Overview
- Fundamentals
- The two prerequisites
- Top-down (memoization) versus bottom-up (tabulation)
- Designing the state and the transition
- Key Concepts
- Family 1 — Linear DP on one index: Fibonacci and climbing stairs
- Family 2 — Unbounded choices: coin change
- Family 3 — 0/1 knapsack
- Family 4 — Two sequences: LCS and edit distance
- Family 5 — Longest increasing subsequence, twice
- Family 6 — Grid DP
- Space optimization with rolling arrays
- Reconstructing the solution, not just its value
- How to recognize a DP problem
- Complexity summary
- Best Practices
- References
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Dynamic programming (DP — quy hoạch động) là thứ bạn thu được khi lấy một lời giải đệ quy đang tính đi tính lại cùng một subproblem và bắt nó dừng việc đó lại. Toàn bộ ý tưởng chỉ có vậy. Mọi thứ còn lại — bảng, thiết kế state, rolling array, đống thuật ngữ nghe đáng sợ — đều là bộ máy dựng quanh một quan sát duy nhất đó.
Cái tên là di sản lịch sử và thực sự gây hiểu nhầm. Richard Bellman đặt ra nó vào những năm 1950 khi làm việc ở RAND; “programming” ở đây nghĩa là lập kế hoạch (giống trong “linear programming”), không phải viết code, còn “dynamic” được chọn một phần vì nghe đủ oai để sống sót qua vòng duyệt ngân sách quốc phòng. Phát biểu toán học đứng sau là nguyên lý tối ưu của Bellman: một chính sách tối ưu có tính chất là dù trạng thái ban đầu và quyết định đầu tiên là gì, các quyết định còn lại phải tạo thành một chính sách tối ưu đối với trạng thái sinh ra từ quyết định đầu tiên. Bỏ hết phần hình thức đi: nếu tuyến đường tốt nhất từ Hà Nội vào TP. Hồ Chí Minh đi qua Đà Nẵng, thì đoạn từ Đà Nẵng vào TP. Hồ Chí Minh cũng chính là tuyến tốt nhất giữa hai thành phố đó.
DP nằm giữa hai kỹ thuật đã được trình bày trong section này. Chia để trị cũng chia bài toán thành subproblem, nhưng các subproblem của nó rời nhau — hai nửa của merge sort không chia sẻ gì cả, nên chẳng có gì để cache. Thuật toán greedy cũng khai thác optimal substructure, nhưng nó chốt luôn một lựa chọn ở mỗi bước và không bao giờ xét lại. DP là điểm ở giữa: các subproblem chồng lấn, nên cache có lời; và bạn không thể chọn đúng bằng quyết định cục bộ, nên phải thử tất cả — nhưng mỗi thứ chỉ đúng một lần.
Phần thưởng thường là bước nhảy từ exponential xuống polynomial. Fibonacci đệ quy ngây thơ là O(φⁿ); memoize lại thì thành O(n). Knapsack đệ quy ngây thơ là O(2ⁿ); lập bảng thì thành O(n·W). Đó không phải cải thiện hằng số, đó là khác biệt giữa “chạy được” và “không chạy được”.
Kiến thức nền tảng
Hai điều kiện tiên quyết
Một bài toán giải được bằng dynamic programming khi và chỉ khi nó có cả hai tính chất sau. Kiểm tra chúng là việc đầu tiên bạn nên làm, và nó chỉ tốn khoảng ba mươi giây.
1. Optimal substructure (cấu trúc con tối ưu). Lời giải tối ưu của bài toán có thể dựng lên từ lời giải tối ưu của các subproblem. Một cách hình thức, nếu OPT(S) là giá trị tối ưu cho instance S, thì tồn tại một công thức truy hồi biểu diễn OPT(S) qua OPT của các instance nhỏ hơn hẳn.
Đây chính là tính chất cho phép bạn viết ra công thức truy hồi. Shortest path có tính chất này: nếu đường đi ngắn nhất s→t đi qua v, thì đoạn đầu s→v của nó là đường ngắn nhất s→v (nếu không, ghép đoạn đầu tốt hơn vào ta được đường s→t ngắn hơn — mâu thuẫn). Đường đi đơn dài nhất thì không có tính chất này: đường đi đơn dài nhất từ A tới D có thể là A→B→C→D, nhưng đường đi đơn dài nhất từ A tới C lại có thể là A→D→B→C, vốn đã dùng D nên không thể kéo dài tới D được nữa. Các subproblem can thiệp lẫn nhau, nên không công thức truy hồi nào theo kiểu “đường dài nhất tới v” là hợp lệ — và đúng như vậy, longest simple path là bài toán NP-hard.
2. Overlapping subproblems (subproblem chồng lấn). Cây đệ quy ghé thăm cùng một subproblem nhiều lần. Đây là thứ khiến việc cache trở nên đáng giá.
Nhìn fib(5) khai triển ngây thơ:
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)
fib(3) được tính hai lần, fib(2) ba lần, fib(1) năm lần. Cây có khoảng φⁿ ≈ 1.618ⁿ node nhưng chỉ chứa n + 1 giá trị khác nhau. Mọi lần tính lại đều là lãng phí. Ngược lại, cây của merge sort có O(n log n) node và không có hai subproblem nào trùng khoảng — chẳng có gì để cache, nên ở đó chia để trị mới là công cụ đúng, không phải DP.
| Tính chất | Chia để trị | Greedy | Dynamic programming |
|---|---|---|---|
| Optimal substructure | Có | Có | Có |
| Overlapping subproblems | Không | Không áp dụng (không đệ quy trên subproblem) | Có |
| Duyệt hết mọi lựa chọn | Có | Không — chốt luôn một cái | Có, nhưng mỗi state một lần |
| Hình dạng điển hình | Chia, đệ quy, gộp | Sort rồi một lượt duyệt tuyến tính | Bảng được điền theo thứ tự phụ thuộc |
Top-down (memoization) so với bottom-up (tabulation)
Có hai cách hiện thực cùng một công thức truy hồi. Chúng tính ra giá trị y hệt nhau và có cùng độ phức tạp tiệm cận; khác nhau ở sự tiện tay khi viết và ở hằng số.
Top-down / memoization là dạng đệ quy cộng thêm một cache. Bạn viết công thức truy hồi đúng như lúc bạn suy ra nó, và kiểm tra cache trước khi đệ quy.
Bottom-up / tabulation duyệt qua các state theo một thứ tự đảm bảo mọi phụ thuộc đã được tính xong, điền dần vào bảng. Không có đệ quy và không có call stack.
Dưới đây là cùng một bài toán — coin change, số đồng xu ít nhất để trả đúng amount — viết theo cả hai cách để thấy sự tương ứng từng dòng.
from functools import lru_cache
INF = float("inf")
def coin_change_topdown(coins, amount):
"""Top-down: viết công thức truy hồi đúng nguyên văn, gắn thêm cache phía trước.
Time O(amount * len(coins)), space O(amount) cho cache + O(amount) call stack."""
memo = {}
def best(rem):
if rem == 0:
return 0 # base case: không còn gì phải trả
if rem < 0:
return INF # trả lố — nhánh này không hợp lệ
if rem in memo: # <-- dòng duy nhất biến nó thành DP
return memo[rem]
result = INF
for c in coins:
result = min(result, 1 + best(rem - c))
memo[rem] = result
return result
answer = best(amount)
return -1 if answer == INF else answer
def coin_change_bottomup(coins, amount):
"""Bottom-up: cùng công thức truy hồi, nhưng duyệt state theo `rem` tăng dần,
nên mọi phụ thuộc (rem - c, luôn nhỏ hơn) đã là giá trị cuối cùng khi ta đọc.
Time O(amount * len(coins)), space O(amount), không đệ quy."""
dp = [INF] * (amount + 1)
dp[0] = 0 # cùng base case
for rem in range(1, amount + 1):
for c in coins:
if c <= rem and dp[rem - c] + 1 < dp[rem]:
dp[rem] = dp[rem - c] + 1
return -1 if dp[amount] == INF else dp[amount]
assert coin_change_topdown([1, 3, 4], 6) == 2 # 3 + 3, không phải 4 + 1 + 1
assert coin_change_bottomup([1, 3, 4], 6) == 2
assert coin_change_bottomup([2], 3) == -1
Chú ý rằng [1, 3, 4] với amount = 6 chính là trường hợp mà greedy hiển nhiên (luôn lấy đồng lớn nhất còn vừa) sai: greedy cho 4 + 1 + 1 = 3 đồng, tối ưu là 3 + 3 = 2 đồng. Greedy tình cờ đúng với hệ mệnh giá của Mỹ/EUR/VND, đó là lý do người ta hay bất ngờ khi nó vỡ — xem thuật toán greedy để hiểu vì sao “chạy đúng với ví dụ của tôi” không phải là một lập luận.
| Top-down (memoization) | Bottom-up (tabulation) | |
|---|---|---|
| Cách viết | Giữ nguyên công thức truy hồi, thêm cache | Lặp qua state theo thứ tự phụ thuộc |
| State nào được tính | Chỉ những state thực sự tới được | Tất cả, dù tới được hay không |
| Stack | Theo độ sâu đệ quy — có thể tràn | Không có |
| Hằng số | Chậm hơn (chi phí gọi hàm, hashing) | Nhanh hơn (index mảng, thân thiện với cache) |
| Tối ưu bộ nhớ | Khó | Dễ (rolling array — xem bên dưới) |
| Hợp với | Không gian state thưa, thứ tự khó, bản nháp đầu tiên | Không gian state dày, production, bộ nhớ eo hẹp |
Trong Python, @lru_cache(maxsize=None) (hoặc @functools.cache từ 3.9 trở lên) cho bạn memoization miễn phí trên bất kỳ hàm nào có tham số hashable. Đây là thứ bạn thực sự sẽ dùng cho lượt viết đầu tiên:
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
Hai lưu ý trước khi dùng nó trong production. Thứ nhất là độ sâu đệ quy: fib(10000) sẽ chạm giới hạn mặc định 1000 frame của Python và ném RecursionError, còn nâng sys.setrecursionlimit lên thì có nguy cơ segfault thật ở C stack. Thứ hai, cache gắn vào chính function object, nên nó sống dai qua nhiều lần gọi và qua nhiều test case — nếu hàm được memoize có closure trên dữ liệu riêng của một bài, các entry cũ sẽ âm thầm trả về đáp án sai. Bottom-up không dính cả hai vấn đề này.
Thiết kế state và transition
Đây mới là phần thực sự khó. Việc hiện thực chỉ là máy móc một khi bạn đã có công thức truy hồi; và để có công thức truy hồi thì phải trả lời bốn câu hỏi theo đúng thứ tự.
1. State là gì? Là tập tham số tối thiểu xác định hoàn toàn đáp án của một subproblem. Chữ “tối thiểu” quan trọng theo cả hai chiều: quá ít thì công thức truy hồi sai (các tình huống khác nhau bị gộp vào cùng một state), quá nhiều thì bảng phình ra. Hãy tự hỏi: nếu tôi dừng thuật toán ngay đây, tôi cần biết những gì để đi tiếp? Với knapsack đó là “đang xét item nào, và còn lại bao nhiêu capacity” — chứ không phải tập item cụ thể đã lấy.
2. Transition là gì? dp[state] được tính từ các state nhỏ hơn như thế nào? Đây chính là công thức truy hồi. Nó hầu như luôn đến từ việc liệt kê các lựa chọn có tại state này: lấy item này hay bỏ qua, khớp hai ký tự này hay insert/delete/replace, dùng đồng xu này hay đi tiếp.
3. Base case là gì? Là những state nhỏ đến mức trả lời được ngay — thường là input rỗng, hoặc capacity bằng 0. Sai base case là lỗi DP phổ biến nhất, và nó thường lộ ra dưới dạng off-by-one ở biên của bảng.
4. Thứ tự tính là gì? Với bottom-up, cần một thứ tự mà mọi phụ thuộc của một state đều đã được tính trước. Nếu dp[i] phụ thuộc dp[i-1], duyệt i tăng dần. Nếu nó phụ thuộc dp[i+1], duyệt giảm dần. Nếu đồ thị phụ thuộc không phải DAG thì bài toán, ở dạng đang phát biểu, không phải DP.
Một cách kiểm tra rất hữu ích: tổng công việc = (số state) × (công việc mỗi transition). Tích đó chính là độ phức tạp thời gian của bạn. Nếu bạn đếm được số state và số lựa chọn, bạn biết độ phức tạp trước cả khi viết dòng code đầu tiên — và nếu tích đó quá lớn, bạn biết phải thiết kế lại state trước khi phí công hiện thực.
Khái niệm chính
Nhóm 1 — DP tuyến tính trên một chỉ số: Fibonacci và leo cầu thang
Không gian state đơn giản nhất là một số nguyên. Cả hai bài dưới đây đều có chung công thức truy hồi dp[i] = dp[i-1] + dp[i-2]; chỉ khác base case — một bài học sớm và tốt rằng “đây là bài nào” thường quy về “đây là công thức truy hồi nào”.
def fib(n):
"""dp[i] = dp[i-1] + dp[i-2]; dp[0]=0, dp[1]=1.
Time O(n), space O(1) — chỉ cần hai giá trị gần nhất."""
prev, cur = 0, 1 # dp[0], dp[1]
for _ in range(n):
prev, cur = cur, prev + cur
return prev
def climbing_stairs(n):
"""Số cách lên tới bậc n khi mỗi lần bước 1 hoặc 2 bậc.
Để đang đứng ở bậc i thì bạn đã tới từ i-1 (bước 1 bậc) hoặc i-2 (bước 2 bậc),
và hai tập đường đi đó rời nhau, nên số cách cộng lại.
Time O(n), space O(1)."""
if n <= 2:
return n
one_back, two_back = 2, 1 # số cách tới bậc 2 và bậc 1
for _ in range(3, n + 1):
one_back, two_back = one_back + two_back, one_back
return one_back
assert [fib(i) for i in range(10)] == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
assert climbing_stairs(5) == 8
Cả hai đã được tối ưu bộ nhớ sẵn: công thức truy hồi chỉ ngoái lại đúng hai vị trí, nên một cửa sổ trượt hai biến là đủ. Đây là phiên bản nhỏ nhất của ý tưởng rolling array sẽ nói ở dưới. (Fibonacci còn có lời giải O(log n) bằng lũy thừa ma trận và cả công thức đóng, nhưng không cái nào tổng quát hóa được sang các bài DP khác, nên ở đây chúng chỉ là chuyện vui chứ không phải kỹ thuật.)
Nhóm 2 — Lựa chọn không giới hạn: coin change
Đã trình bày ở trên. Có hai biến thể đáng tách bạch vì rất dễ nhầm:
- Số đồng xu ít nhất (ở trên) — thứ tự vòng lặp không quan trọng, vì mỗi state lấy
minđộc lập trên mọi đồng xu. - Số cách tạo ra số tiền đó — thứ tự vòng lặp quan trọng cực kỳ.
def coin_change_count_combinations(coins, amount):
"""Đếm tổ hợp không phân biệt thứ tự: {1,2} và {2,1} tính là một.
Vòng lặp coin nằm NGOÀI, nên mỗi đồng xu chỉ được "đưa vào" một lần và
không hoán vị nào của tập xu trước đó bị đếm lại.
Time O(amount * len(coins)), space O(amount)."""
dp = [0] * (amount + 1)
dp[0] = 1
for c in coins: # ngoài: đồng xu
for rem in range(c, amount + 1): # trong: số tiền, tăng dần (dùng lại được)
dp[rem] += dp[rem - c]
return dp[amount]
def coin_change_count_permutations(coins, amount):
"""Đếm dãy có thứ tự: {1,2} và {2,1} tính riêng.
Vòng lặp amount nằm NGOÀI, nên mọi đồng xu đều có thể là đồng "cuối cùng"
ở mọi số tiền.
Time O(amount * len(coins)), space O(amount)."""
dp = [0] * (amount + 1)
dp[0] = 1
for rem in range(1, amount + 1): # ngoài: số tiền
for c in coins: # trong: đồng xu
if c <= rem:
dp[rem] += dp[rem - c]
return dp[amount]
assert coin_change_count_combinations([1, 2, 5], 5) == 4 # 5, 2+2+1, 2+1+1+1, 1x5
assert coin_change_count_permutations([1, 2, 5], 5) == 9
Đổi chỗ hai dòng for làm đáp án đổi từ 4 thành 9. Đây là lỗi kinh điển kiểu “DP của tôi sai tinh vi mà không nhìn ra”, và cách sửa luôn là tự hỏi vòng lặp ngoài có ý nghĩa gì — vòng ngoài định nghĩa bạn đang tiến theo chiều nào của state.
Nhóm 3 — 0/1 knapsack
Cho n item với trọng lượng và giá trị, và một capacity W, hãy tối đa hóa tổng giá trị với mỗi item dùng nhiều nhất một lần.
State: dp[i][w] = giá trị tốt nhất khi chỉ dùng i item đầu với capacity w. Transition: với item i, hoặc bỏ qua (dp[i-1][w]), hoặc lấy (value[i] + dp[i-1][w - weight[i]], nếu còn vừa).
def knapsack_2d(weights, values, capacity):
"""Bảng 2D đầy đủ — phiên bản nên viết trước, và cũng là phiên bản bạn cần
nếu muốn truy vết lại xem những item nào đã được chọn.
Time O(n*W), space O(n*W)."""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
wi, vi = weights[i - 1], values[i - 1]
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w] # lựa chọn A: bỏ qua item i
if wi <= w: # lựa chọn B: lấy item i
dp[i][w] = max(dp[i][w], vi + dp[i - 1][w - wi])
return dp[n][capacity]
def knapsack_1d(weights, values, capacity):
"""Rolling array. dp[w] đang giữ hàng i-1 trong lúc ta tính hàng i tại chỗ.
Vòng lặp capacity BẮT BUỘC chạy giảm dần: dp[w - wi] phải vẫn là giá trị của
hàng trước (item i chưa được dùng). Chạy tăng dần sẽ cho phép cùng một item
được chọn hai lần, âm thầm biến bài này thành unbounded knapsack.
Time O(n*W), space O(W)."""
dp = [0] * (capacity + 1)
for wi, vi in zip(weights, values):
for w in range(capacity, wi - 1, -1): # giảm dần — cực kỳ quan trọng
dp[w] = max(dp[w], vi + dp[w - wi])
return dp[capacity]
w = [1, 3, 4, 5]
v = [1, 4, 5, 7]
assert knapsack_2d(w, v, 7) == 9 # item có weight 3 và 4
assert knapsack_1d(w, v, 7) == 9
Độ phức tạp O(n·W) đáng được cảnh báo: đây là pseudo-polynomial, không phải polynomial. W là một giá trị, mã hóa nó tốn log W bit, nên thời gian chạy thực ra là hàm mũ theo kích thước input. Knapsack là NP-hard; DP không thay đổi điều đó, nó chỉ chạy tốt khi W tình cờ nhỏ. Với W = 10⁹ và n = 100, bảng có 10¹¹ ô và thuật toán vô dụng — đó là tín hiệu để chuyển sang DP đánh index theo giá trị (dp[value] = weight nhỏ nhất) hoặc một approximation scheme.
Nhóm 4 — Hai dãy: LCS và edit distance
Khi input là hai chuỗi hoặc hai mảng, state hầu như luôn là “một prefix của mỗi bên”: dp[i][j] phủ a[:i] và b[:j]. Chỉ riêng thói quen đó đã giải được phần lớn các bài DP trên chuỗi.
Longest common subsequence (LCS) — dãy con dài nhất xuất hiện trong cả hai, đúng thứ tự nhưng không nhất thiết liền nhau.
def lcs_length(a, b):
"""dp[i][j] = độ dài LCS của a[:i] và b[:j].
Nếu hai ký tự cuối khớp nhau thì ghép cặp chúng luôn là an toàn (lập luận trao đổi:
mọi LCS đều có thể viết lại để dùng cặp đó), nên dp[i][j] = dp[i-1][j-1] + 1.
Ngược lại, bỏ một ký tự ở một bên và lấy phương án tốt hơn.
Time O(n*m), space O(n*m)."""
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[n][m]
assert lcs_length("ABCBDAB", "BDCABA") == 4 # ví dụ "BCBA"
Bảng đã điền cho a = "ABCB", b = "BDCB" — hàng và cột ứng với prefix rỗng đều bằng 0, và đáp án nằm ở góc dưới bên phải:
"" B D C B
"" [ 0 0 0 0 0 ]
A [ 0 0 0 0 0 ]
B [ 0 1 1 1 1 ]
C [ 0 1 1 2 2 ]
B [ 0 1 1 2 3 ] <- LCS("ABCB","BDCB") = 3 ("BCB")
Edit distance (khoảng cách Levenshtein) — số phép chèn, xóa hoặc thay thế một ký tự ít nhất để biến a thành b. Cùng state prefix, nhưng có ba lựa chọn thay vì hai.
def edit_distance(a, b):
"""dp[i][j] = chi phí biến a[:i] thành b[:j].
Base case: biến một prefix thành "" tốn một phép xóa cho mỗi ký tự,
còn biến "" thành một prefix tốn một phép chèn cho mỗi ký tự.
Time O(n*m), space O(n*m)."""
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = i # xóa hết a[:i]
for j in range(m + 1):
dp[0][j] = j # chèn hết b[:j]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # miễn phí: hai ký tự đã trùng
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # xóa a[i-1]
dp[i][j - 1], # chèn b[j-1]
dp[i - 1][j - 1], # thay a[i-1] bằng b[j-1]
)
return dp[n][m]
def edit_distance_rolling(a, b):
"""Cùng công thức truy hồi, chỉ giữ hai hàng thay vì n+1. Time O(n*m),
space O(min(n,m)) sau khi hoán đổi để b là chuỗi ngắn hơn."""
if len(b) > len(a):
a, b = b, a
prev = list(range(len(b) + 1))
for i in range(1, len(a) + 1):
cur = [i] + [0] * len(b)
for j in range(1, len(b) + 1):
if a[i - 1] == b[j - 1]:
cur[j] = prev[j - 1]
else:
cur[j] = 1 + min(prev[j], cur[j - 1], prev[j - 1])
prev = cur
return prev[len(b)]
assert edit_distance("kitten", "sitting") == 3
assert edit_distance_rolling("kitten", "sitting") == 3
Đây không phải bài tập đồ chơi: edit distance là cách trình kiểm tra chính tả xếp hạng gợi ý, là cách git diff và diff tìm changeset nhỏ nhất (thông qua LCS), và là cách tin sinh học căn chỉnh chuỗi DNA (Needleman–Wunsch chính là edit distance với ma trận điểm số).
Nhóm 5 — Longest increasing subsequence, hai lần
Phiên bản O(n²) là DP tự nhiên: dp[i] = độ dài dãy con tăng dài nhất kết thúc tại chỉ số i.
def lis_quadratic(nums):
"""dp[i] = độ dài LIS kết thúc đúng tại i. Để mở rộng, nhìn ngược lại mọi
j < i có nums[j] < nums[i]. Time O(n^2), space O(n)."""
if not nums:
return 0
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
Phiên bản O(n log n) vứt bỏ bảng DP để dùng một bất biến greedy cộng binary search. Duy trì mảng tails, trong đó tails[k] là giá trị đuôi nhỏ nhất có thể của một dãy con tăng độ dài k+1 đã gặp cho tới lúc này. Theo cách xây dựng, tails luôn tăng nghiêm ngặt, nên có thể binary search trên nó.
from bisect import bisect_left
def lis_nlogn(nums):
"""tails[k] = đuôi nhỏ nhất của một dãy con tăng độ dài k+1.
Với mỗi x: nếu nó lớn hơn mọi đuôi thì nó nối dài dãy dài nhất (append);
ngược lại nó thay thế đuôi đầu tiên >= x, khiến độ dài đó dễ nối dài hơn
về sau mà không làm thay đổi bất kỳ độ dài nào.
Time O(n log n) — mỗi phần tử một lần binary search. Space O(n).
LƯU Ý: `tails` KHÔNG phải là một dãy con hợp lệ; chỉ ĐỘ DÀI của nó là đáp án."""
tails = []
for x in nums:
pos = bisect_left(tails, x) # dùng bisect_right nếu LIS không giảm
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
return len(tails)
nums = [10, 9, 2, 5, 3, 7, 101, 18]
assert lis_quadratic(nums) == 4 # [2, 3, 7, 18] hoặc [2, 3, 7, 101]
assert lis_nlogn(nums) == 4
assert lis_nlogn([7, 7, 7, 7]) == 1
Vì sao thay thế là an toàn: đưa một giá trị nhỏ hơn vào tails[pos] không bao giờ làm mất đi thứ đã đạt được, mà chỉ khiến việc mở rộng độ dài đó trong tương lai dễ hơn hẳn. Lựa chọn giữa bisect_left và bisect_right mã hóa việc dãy tăng nghiêm ngặt hay không giảm, và chọn ngược là lỗi tiêu chuẩn — [7,7,7,7] trả về 1 với bisect_left và 4 với bisect_right. bisect là module stdlib viết bằng C và chính là thứ bạn sẽ dùng trong production; tự viết binary search chẳng được gì ngoài bug (xem thuật toán tìm kiếm).
Nếu bạn cần chính dãy con chứ không chỉ độ dài, tails là không đủ — bạn phải ghi lại, với mỗi phần tử, chỉ số của phần tử đứng trước nó và độ dài mà nó đạt được, rồi đi ngược từ phần tử đạt cực đại. Phần truy vết dưới đây trình bày kỹ thuật tổng quát.
Nhóm 6 — DP trên lưới
Lưới là dạng DP dễ chịu nhất, vì state đúng nghĩa đen là ô bạn đang đứng và thứ tự phụ thuộc là “trái sang phải, trên xuống dưới”.
def unique_paths_with_obstacles(grid):
"""Đếm số đường đi từ góc trên-trái tới góc dưới-phải, chỉ được đi phải hoặc xuống.
grid[r][c] == 1 đánh dấu vật cản. dp[c] cuộn từng hàng một.
Time O(rows*cols), space O(cols)."""
if not grid or grid[0][0] == 1:
return 0
cols = len(grid[0])
dp = [0] * cols
dp[0] = 1 # có một cách để đứng ở ô xuất phát
for row in grid:
for c in range(cols):
if row[c] == 1:
dp[c] = 0 # ô không tới được
elif c > 0:
dp[c] += dp[c - 1] # từ trên (dp[c]) + từ trái (dp[c-1])
return dp[cols - 1]
def min_path_sum(grid):
"""Tổng nhỏ nhất dọc theo một đường đi phải/xuống từ góc này sang góc kia.
Cùng state, dùng min thay vì cộng. Time O(rows*cols), space O(cols)."""
rows, cols = len(grid), len(grid[0])
dp = [float("inf")] * cols
dp[0] = 0
for r in range(rows):
for c in range(cols):
if c == 0:
dp[c] = dp[c] + grid[r][c] # chỉ có thể từ trên xuống
else:
dp[c] = min(dp[c], dp[c - 1]) + grid[r][c]
return dp[cols - 1]
assert unique_paths_with_obstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]]) == 2
assert min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]) == 7 # 1→3→1→1→1
Thủ thuật cuộn mảng hoạt động được vì ngay trước khi ghi dp[c] cho hàng r, mảng vẫn còn giữ hàng r-1 ở vị trí c (giá trị “từ trên xuống”) và đã giữ hàng r ở vị trí c-1 (giá trị “từ trái sang”). Một mảng duy nhất đang làm hai nhiệm vụ, và hiểu vì sao nó đúng cũng chính là hiểu vì sao vòng lặp 1D của knapsack phải chạy ngược.
Tối ưu bộ nhớ bằng rolling array
Nếu dp[i][*] chỉ phụ thuộc dp[i-1][*], bạn không bao giờ cần quá hai hàng — và thường chỉ cần một, nếu duyệt đúng chiều. Quy tắc:
| Phụ thuộc | Số hàng cần | Chiều duyệt (1D) |
|---|---|---|
dp[i][w] từ dp[i-1][w] và dp[i-1][w-k] (k > 0) | 1 | Giảm dần theo w — bảo vệ giá trị của hàng trước |
dp[i][w] từ dp[i][w-k] (cùng hàng, k > 0) | 1 | Tăng dần theo w — vì ta muốn dùng hàng hiện tại |
dp[i][j] từ dp[i-1][j-1], dp[i-1][j], dp[i][j-1] | 2 (hoặc 1 + lưu riêng ô chéo) | Tăng dần, giữ hàng prev |
dp[i] từ dp[i-1], dp[i-2] | 2 biến vô hướng | Tăng dần |
0/1 knapsack là hàng đầu tiên của bảng đó (giảm dần, mỗi item một lần). Unbounded knapsack và bài đếm cách trả tiền là hàng thứ hai (tăng dần, item dùng lại được). Đây chính xác là lý do hai chiều vòng lặp cho ra hai bài toán khác nhau, và vì sao “cứ đảo vòng lặp lại” là một phép biến đổi thật sự chính xác chứ không phải mẹo.
Cái giá của việc tối ưu này là bạn phá hủy thông tin cần cho việc truy vết. Điều đó dẫn tới:
Truy vết lời giải, không chỉ giá trị của nó
Phần lớn tài liệu về DP dừng lại ở con số. Phỏng vấn và ứng dụng thực tế thì gần như luôn muốn đáp án thật: những item nào, dãy con nào, những phép sửa nào. Có hai kỹ thuật:
A. Giữ nguyên bảng đầy đủ rồi đi ngược, suy lại ở mỗi ô xem lựa chọn nào đã sinh ra giá trị đang lưu.
def lcs_string(a, b):
"""Trả về chính LCS, không chỉ độ dài.
Dựng bảng đầy đủ, rồi đi từ (n, m) ngược về (0, 0):
một bước đi chéo nghĩa là hai ký tự đã được ghép cặp và thuộc về đáp án.
Time O(n*m), space O(n*m). Bước đi ngược tốn O(n+m)."""
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
out = []
i, j = n, m
while i > 0 and j > 0:
if a[i - 1] == b[j - 1]:
out.append(a[i - 1]) # cặp này đã được ghép
i, j = i - 1, j - 1
elif dp[i - 1][j] >= dp[i][j - 1]:
i -= 1 # giá trị đến từ việc bỏ a[i-1]
else:
j -= 1 # giá trị đến từ việc bỏ b[j-1]
return "".join(reversed(out))
assert lcs_string("ABCBDAB", "BDCABA") == "BCBA"
B. Lưu thêm một con trỏ parent/lựa chọn bên cạnh giá trị. Tốn thêm một bảng nhưng khiến việc đi ngược thành hiển nhiên, và hữu ích khi việc suy lại lựa chọn là rắc rối.
def knapsack_items(weights, values, capacity):
"""Trả về (best_value, chosen_indices). `take[i][w]` ghi lại item i có được
dùng trong phương án tối ưu ở capacity w hay không. Time O(n*W), space O(n*W)."""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
take = [[False] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
wi, vi = weights[i - 1], values[i - 1]
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w]
if wi <= w and vi + dp[i - 1][w - wi] > dp[i][w]:
dp[i][w] = vi + dp[i - 1][w - wi]
take[i][w] = True
chosen, w = [], capacity
for i in range(n, 0, -1):
if take[i][w]:
chosen.append(i - 1)
w -= weights[i - 1]
return dp[n][capacity], sorted(chosen)
assert knapsack_items([1, 3, 4, 5], [1, 4, 5, 7], 7) == (9, [1, 2])
Sự đánh đổi là không tránh được: bạn không thể vừa cuộn mảng xuống còn O(W) vừa truy vết một cách ngây thơ. Nếu bộ nhớ buộc bạn phải cuộn mảng mà vẫn cần đường đi, cách chuẩn là thuật toán Hirschberg — chia để trị trên dãy, tính điểm cắt tối ưu ở đường giữa với bộ nhớ tuyến tính rồi đệ quy trên cả hai nửa. Nó cho O(n·m) thời gian và O(min(n,m)) bộ nhớ mà vẫn truy vết đầy đủ, và đó chính là thứ các bản diff thực tế đang dùng.
Cách nhận ra một bài DP
Các dấu hiệu, xếp theo thứ tự độ tin cậy giảm dần:
- Câu hỏi đòi một số đếm, một giá trị lớn nhất, nhỏ nhất, hoặc “có khả thi không” trên một không gian tổ hợp — “có bao nhiêu cách”, “dài nhất”, “rẻ nhất”, “có tới được không”. Nếu nó bảo bạn liệt kê tất cả lời giải thì đó là backtracking, không phải DP.
- Mỗi bước có nhiều lựa chọn, và chọn theo greedy là sai có bằng chứng. Bạn tìm được phản ví dụ cho greedy (như
[1,3,4]với số tiền 6). Chính phản ví dụ đó là bằng chứng rằng bạn phải duyệt hết mọi lựa chọn — và DP là cách để bạn kham nổi việc đó. - Lời giải brute-force đệ quy của bạn là hàm mũ, nhưng tham số của nó lặp lại. Cứ viết brute force trước, vẽ hai tầng của cây đệ quy, và tìm một lời gọi bị lặp. Thấy một cái là xong: thêm cache.
- Ràng buộc input gợi ý kích thước bảng.
n ≤ 20gợi ý bitmask DP trên tập con (O(2ⁿ·n)).n ≤ 100kèm một chiều thứ hai gợi ýO(n²)hoặcO(n³).n ≤ 10⁵loại trừ mọi thứ hai chiều theonvà chỉ vềO(n log n)— một cấu trúc kiểu LIS chứ không phải bảng. Kích thước ràng buộc trong một đề bài được viết tử tế luôn là gợi ý về độ phức tạp mong muốn, và độ phức tạp quyết định hình dạng của state. - State nhỏ và mô tả được trong một câu. Nếu bạn không nói được
dp[i][j]có nghĩa là gì bằng một câu tiếng Việt, thì bạn chưa có công thức DP — bạn chỉ có một cái bảng và mấy vòng lặp, và nó sẽ sai.
Ngược lại: nếu subproblem rời nhau thì đó là chia để trị; nếu một quy tắc cục bộ duy nhất là tối ưu có chứng minh thì đó là greedy; nếu cần liệt kê mọi lời giải thì đó là backtracking; và nếu các subproblem can thiệp lẫn nhau (longest simple path, phần lớn bài lập lịch với ràng buộc tùy ý) thì có thể chẳng có thuật toán đa thức nào cả.
Bảng tổng kết độ phức tạp
| Bài toán | State | Số transition mỗi state | Time | Space (đã tối ưu) |
|---|---|---|---|---|
| Fibonacci / leo cầu thang | i | 2 | O(n) | O(1) |
| Coin change (min / đếm) | amount | len(coins) | O(amount·C) | O(amount) |
| 0/1 knapsack | (item, capacity) | 2 | O(n·W) pseudo-poly | O(W) |
| LCS | prefix (i, j) | 2–3 | O(n·m) | O(min(n,m)) nếu chỉ cần giá trị |
| Edit distance | prefix (i, j) | 3 | O(n·m) | O(min(n,m)) nếu chỉ cần giá trị |
| LIS (DP) | i | O(n) | O(n²) | O(n) |
| LIS (patience + binary search) | — | O(log n) | O(n log n) | O(n) |
| Đường đi trên lưới / min path sum | (r, c) | 2 | O(r·c) | O(c) |
| Matrix chain multiplication | đoạn (i, j) | O(n) điểm cắt | O(n³) | O(n²) |
| Bitmask DP (ví dụ TSP) | (mask, last) | O(n) | O(2ⁿ·n²) | O(2ⁿ·n) |
Hai mục ở trên đáng được gọi tên dù chúng nằm ngoài phạm vi node của roadmap: interval DP (state là một đoạn [i, j], transition cắt tại mọi k bên trong — matrix chain multiplication, optimal BST, burst balloons) và bitmask DP (state chứa một tập con mã hóa trong một số nguyên — bài toán người bán hàng, bài toán phân công). Cả hai chỉ là bốn câu hỏi cũ trả lời với một state khác; nhận ra chúng chủ yếu là chuyện đã từng gặp qua một lần.
Best Practices
- Viết brute-force đệ quy trước, rồi mới thêm cache. Đừng cố viết thẳng bảng bottom-up. Phần đệ quy là nơi diễn ra việc suy nghĩ; memoize nó là một bước máy móc một dòng; chuyển sang bottom-up sau đó là một bước máy móc nữa. Bắt đầu từ cái bảng nghĩa là bạn phải thiết kế state và thứ tự vòng lặp cùng lúc, và bạn sẽ sai cả hai.
- Nói thành lời
dp[i][j]nghĩa là gì trước khi viết bất kỳ vòng lặp nào. Một câu tiếng Việt, với biên được chốt rõ (“dp[i][j]là LCS củaiký tự đầu củaavàjký tự đầu củab” — chứ không phải “củaa[i]vàb[j]”). Phần lớn bug DP là bug định nghĩa state khoác áo off-by-one. - Đếm số state × số transition trước khi hiện thực. Tích đó là độ phức tạp của bạn. Nếu nó vượt quá khoảng
10⁸phép toán đơn giản, hãy thiết kế lại state — đừng viết ra rồi cầu may. - Làm base case cho đúng và test chúng một cách tường minh. Chuỗi rỗng, capacity bằng 0,
n = 0,n = 1. Đó là nơi off-by-one trú ngụ, và một cái bảng đúng ở giữa nhưng sai ở rìa thì khó debug hơn hẳn một cái bảng sai toàn tập. - Tối ưu bộ nhớ sau cùng, và chỉ khi thực sự cần. Rolling array làm code khó đọc hơn và phá hủy khả năng truy vết.
O(n·m)trên bài 1000×1000 là một triệu ô — hoàn toàn ổn. Hãy cuộn mảng khi giới hạn bộ nhớ là thật, chứ không phải theo phản xạ. - Canh chừng chiều vòng lặp như canh trộm. Giảm dần nghĩa là “hàng trước, mỗi item một lần”; tăng dần nghĩa là “hàng hiện tại, item dùng lại được”. Đó là hai bài toán khác nhau, cả hai đều chạy được, và cả hai đều trông có lý.
- Trong Python, ưu tiên bottom-up cho bất cứ thứ gì sâu quá vài nghìn tầng.
RecursionErrortrên một đệ quy memoize sâu là một sự cố production có thật, vàsys.setrecursionlimitkhông phải cách sửa — đặt giới hạn quá cao sẽ làm crash C stack của interpreter thay vì ném exception tử tế. - Đừng dùng
@lru_cachecho hàm có closure trên state thay đổi được của bài toán. Cache sống lâu hơn lời gọi, và test case thứ hai sẽ nhận đáp án của test case thứ nhất. Nếu vẫn muốn dùng, hãy xóa cache (f.cache_clear()) giữa các instance, hoặc truyền state vào như một tham số hashable. - Cẩn thận với độ phức tạp pseudo-polynomial.
O(n·W)trông như đa thức mà không phải. Hãy kiểm tra độ lớn củaW(hoặc amount, hoặc target sum) so với ràng buộc đề bài trước khi cam kết với hướng tiếp cận này. - Khi đáp án phải truy vết được, hãy quyết định điều đó trước khi tối ưu. Giữ bảng đầy đủ, hoặc giữ bảng parent pointer, hoặc lên kế hoạch dùng thuật toán Hirschberg. Gắn truy vết vào một mảng đã cuộn nghĩa là viết lại từ đầu.
- Đối chiếu với brute force trên input ngẫu nhiên nhỏ. Một bộ test ngẫu nhiên 20 dòng so sánh DP với duyệt vét cạn trên input kích thước ≤ 10 bắt được gần như mọi bug DP trong vài giây, và nhanh hơn nhiều so với ngồi nhìn chằm chằm vào công thức truy hồi.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Dynamic programming — Wikipedia
- Bellman equation — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 15: Dynamic Programming
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- cp-algorithms — Longest Increasing Subsequence
- cp-algorithms — Knapsack problem
- cp-algorithms — Edit distance
- Knapsack problem — Wikipedia
- Longest common subsequence — Wikipedia
- Levenshtein distance — Wikipedia
- Hirschberg’s algorithm — Wikipedia
- Python Documentation —
functools.lru_cache - Python Documentation —
bisect
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
Dynamic programming (DP) is what you get when you take a recursive solution that recomputes the same subproblems over and over, and make it stop doing that. That is the whole idea. Everything else — tables, state design, rolling arrays, the intimidating vocabulary — is machinery built around that one observation.
The name is historical and actively misleading. Richard Bellman coined it in the 1950s while working at RAND; “programming” meant planning (as in “linear programming”), not writing code, and “dynamic” was chosen partly because it sounded impressive enough to survive a defence-budget review. The underlying mathematical statement is Bellman’s principle of optimality: an optimal policy has the property that whatever the initial state and initial decision, the remaining decisions must constitute an optimal policy with regard to the state resulting from the first decision. Stripped of the formality: if the best route from Hanoi to Ho Chi Minh City goes through Da Nang, then the portion from Da Nang to Ho Chi Minh City is itself the best route between those two cities.
DP sits between two techniques already covered in this section. Divide and conquer also breaks a problem into subproblems, but its subproblems are disjoint — merge sort’s two halves share nothing, so there is nothing to cache. Greedy algorithms also exploit optimal substructure, but they commit to one choice at each step and never reconsider. DP is the middle ground: subproblems overlap, so caching pays; and you cannot pick the right choice locally, so you try all of them — but only once each.
The payoff is usually a jump from exponential to polynomial. Naive recursive Fibonacci is O(φⁿ); memoized it is O(n). Naive recursive knapsack is O(2ⁿ); tabulated it is O(n·W). That is not a constant-factor win, it is the difference between “runs” and “does not run”.
Fundamentals
The two prerequisites
A problem admits a dynamic programming solution if and only if it has both of these properties. Checking for them is the first thing you should do, and it takes about thirty seconds.
1. Optimal substructure. The optimal solution to the problem can be built from optimal solutions to its subproblems. Formally, if OPT(S) is the optimal value for instance S, there is a recurrence expressing OPT(S) in terms of OPT of strictly smaller instances.
This is the property that lets you write a recurrence at all. Shortest path has it: if the shortest s→t path passes through v, its s→v prefix is a shortest s→v path (otherwise splice in the better prefix and get a shorter s→t path — a contradiction). Longest simple path does not have it: the longest simple path from A to D might go A→B→C→D, but the longest simple path from A to C might be A→D→B→C, which uses D and therefore cannot be extended to D. The subproblems interfere with each other, so no recurrence over “longest path to v” is valid, and indeed longest simple path is NP-hard.
2. Overlapping subproblems. The recursion tree revisits the same subproblem many times. This is what makes caching worthwhile.
Look at fib(5) expanded naively:
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)
fib(3) is computed twice, fib(2) three times, fib(1) five times. The tree has roughly φⁿ ≈ 1.618ⁿ nodes but only n + 1 distinct values in it. Every recomputation is waste. Merge sort’s tree, by contrast, has O(n log n) nodes and no two subproblems are ever the same interval — nothing to cache, so divide and conquer is the right tool there, not DP.
| Property | Divide & conquer | Greedy | Dynamic programming |
|---|---|---|---|
| Optimal substructure | Yes | Yes | Yes |
| Overlapping subproblems | No | N/A (no recursion over subproblems) | Yes |
| Explores all choices | Yes | No — commits to one | Yes, but each state once |
| Typical shape | Split, recurse, combine | Sort, then one linear pass | Table filled in dependency order |
Top-down (memoization) versus bottom-up (tabulation)
There are two ways to implement the same recurrence. They compute identical values and have identical asymptotic complexity; they differ in ergonomics and constant factors.
Top-down / memoization is the recursive formulation plus a cache. You write the recurrence exactly as you derived it, and check the cache before recursing.
Bottom-up / tabulation iterates over states in an order that guarantees every dependency is already computed, filling a table. There is no recursion and no call stack.
Here is the same problem — coin change, minimum number of coins to make amount — written both ways so the correspondence is visible line by line.
from functools import lru_cache
INF = float("inf")
def coin_change_topdown(coins, amount):
"""Top-down: the recurrence, written literally, with a cache in front of it.
Time O(amount * len(coins)), space O(amount) for the cache + O(amount) call stack."""
memo = {}
def best(rem):
if rem == 0:
return 0 # base case: nothing left to pay
if rem < 0:
return INF # overshot — this branch is invalid
if rem in memo: # <-- the only line that makes it DP
return memo[rem]
result = INF
for c in coins:
result = min(result, 1 + best(rem - c))
memo[rem] = result
return result
answer = best(amount)
return -1 if answer == INF else answer
def coin_change_bottomup(coins, amount):
"""Bottom-up: same recurrence, but states are visited in increasing order of `rem`,
so every dependency (rem - c, which is smaller) is already final when we read it.
Time O(amount * len(coins)), space O(amount), no recursion."""
dp = [INF] * (amount + 1)
dp[0] = 0 # same base case
for rem in range(1, amount + 1):
for c in coins:
if c <= rem and dp[rem - c] + 1 < dp[rem]:
dp[rem] = dp[rem - c] + 1
return -1 if dp[amount] == INF else dp[amount]
assert coin_change_topdown([1, 3, 4], 6) == 2 # 3 + 3, not 4 + 1 + 1
assert coin_change_bottomup([1, 3, 4], 6) == 2
assert coin_change_bottomup([2], 3) == -1
Note that [1, 3, 4] with amount = 6 is exactly the case where the obvious greedy (always take the largest coin that fits) fails: greedy gives 4 + 1 + 1 = 3 coins, the optimum is 3 + 3 = 2. Greedy happens to be correct for the US/EUR/VND denomination systems, which is why people are surprised when it breaks — see greedy algorithms for why “it works on my examples” is not an argument.
| Top-down (memoization) | Bottom-up (tabulation) | |
|---|---|---|
| How you write it | Recurrence as-is; add a cache | Loop over states in dependency order |
| Which states are computed | Only the reachable ones | All of them, reachable or not |
| Stack | Recursion depth — can overflow | None |
| Constant factor | Slower (call overhead, hashing) | Faster (array indexing, cache-friendly) |
| Space optimization | Hard | Easy (rolling arrays — see below) |
| Best for | Sparse state spaces, tricky orders, first draft | Dense state spaces, production, tight memory |
In Python, @lru_cache(maxsize=None) (or @functools.cache on 3.9+) gives you memoization for free on any function with hashable arguments. It is what you would actually use for a first pass:
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
Two caveats before you reach for it in production. First, recursion depth: fib(10000) will hit Python’s default limit of 1000 frames and raise RecursionError, and raising sys.setrecursionlimit risks a real C-stack segfault. Second, the cache is attached to the function object, so it persists across calls and across test cases — if the memoized function closes over problem-specific data, stale entries will silently return wrong answers. Bottom-up has neither problem.
Designing the state and the transition
This is the part that is actually hard. The implementation is mechanical once you have the recurrence; getting the recurrence means answering four questions in order.
1. What is the state? The minimal set of parameters that fully determines the answer to a subproblem. “Minimal” matters in both directions: too few and the recurrence is wrong (different situations collapse into the same state), too many and you blow up the table. Ask: if I paused the algorithm here, what would I need to know to continue? For knapsack that is “which item am I considering, and how much capacity is left” — not the specific set of items already taken.
2. What is the transition? How is dp[state] computed from smaller states? This is the recurrence. It almost always comes from enumerating the choices available at this state: take this item or skip it, match these characters or insert/delete/replace, use this coin or move on.
3. What are the base cases? The states small enough to answer directly — usually empty input, or zero remaining capacity. Getting these wrong is the single most common DP bug, and it usually shows up as an off-by-one at the boundary of the table.
4. What is the evaluation order? For bottom-up, an order in which every state’s dependencies come first. If dp[i] depends on dp[i-1], iterate i upward. If it depends on dp[i+1], iterate downward. If the dependency graph is not a DAG, the problem is not DP as stated.
A useful sanity check: the total work is (number of states) × (work per transition). That product is your time complexity. If you can count states and count choices, you know the complexity before writing a line of code — and if the product is too big, you know to redesign the state before wasting time implementing it.
Key Concepts
Family 1 — Linear DP on one index: Fibonacci and climbing stairs
The simplest state space is a single integer. Both problems below have the identical recurrence dp[i] = dp[i-1] + dp[i-2]; only the base cases differ, which is a good early lesson that “which problem is this” often reduces to “which recurrence is this”.
def fib(n):
"""dp[i] = dp[i-1] + dp[i-2]; dp[0]=0, dp[1]=1.
Time O(n), space O(1) — only the last two values are ever needed."""
prev, cur = 0, 1 # dp[0], dp[1]
for _ in range(n):
prev, cur = cur, prev + cur
return prev
def climbing_stairs(n):
"""Number of ways to reach step n taking 1 or 2 steps at a time.
To be standing on step i you arrived from i-1 (one step) or i-2 (two steps),
and those two sets of paths are disjoint, so the counts add.
Time O(n), space O(1)."""
if n <= 2:
return n
one_back, two_back = 2, 1 # ways to reach step 2 and step 1
for _ in range(3, n + 1):
one_back, two_back = one_back + two_back, one_back
return one_back
assert [fib(i) for i in range(10)] == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
assert climbing_stairs(5) == 8
Both are already space-optimized: the recurrence reaches back exactly two positions, so a two-variable rolling window suffices. This is the smallest instance of the rolling-array idea developed below. (Fibonacci also has an O(log n) matrix-exponentiation solution and a closed form, but neither generalizes to DP problems, so they are curiosities here rather than technique.)
Family 2 — Unbounded choices: coin change
Already shown above. Two variants worth separating because they are easy to confuse:
- Minimum coins (above) — loop order does not matter, because each state takes a
minover all coins independently. - Number of ways to make the amount — loop order matters enormously.
def coin_change_count_combinations(coins, amount):
"""Counts unordered combinations: {1,2} and {2,1} count once.
The coin loop is OUTSIDE, so each coin is 'introduced' once and no
permutation of an earlier coin set can be recounted.
Time O(amount * len(coins)), space O(amount)."""
dp = [0] * (amount + 1)
dp[0] = 1
for c in coins: # outer: coins
for rem in range(c, amount + 1): # inner: amounts, ascending (unbounded use)
dp[rem] += dp[rem - c]
return dp[amount]
def coin_change_count_permutations(coins, amount):
"""Counts ordered sequences: {1,2} and {2,1} count separately.
The amount loop is OUTSIDE, so every coin can be the 'last' one at every amount.
Time O(amount * len(coins)), space O(amount)."""
dp = [0] * (amount + 1)
dp[0] = 1
for rem in range(1, amount + 1): # outer: amounts
for c in coins: # inner: coins
if c <= rem:
dp[rem] += dp[rem - c]
return dp[amount]
assert coin_change_count_combinations([1, 2, 5], 5) == 4 # 5, 2+2+1, 2+1+1+1, 1x5
assert coin_change_count_permutations([1, 2, 5], 5) == 9
Swapping two for lines changes the answer from 4 to 9. This is the classic “my DP is subtly wrong and I cannot see why” bug, and the fix is always to ask what does the outer loop mean — the outer loop defines which dimension of the state you are advancing.
Family 3 — 0/1 knapsack
Given n items with weights and values, and a capacity W, maximize total value with each item used at most once.
State: dp[i][w] = best value using only the first i items with capacity w. Transition: for item i, either skip it (dp[i-1][w]) or take it (value[i] + dp[i-1][w - weight[i]], if it fits).
def knapsack_2d(weights, values, capacity):
"""Full 2D table — the version to write first, and the one you need
if you want to reconstruct which items were chosen.
Time O(n*W), space O(n*W)."""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
wi, vi = weights[i - 1], values[i - 1]
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w] # choice A: skip item i
if wi <= w: # choice B: take item i
dp[i][w] = max(dp[i][w], vi + dp[i - 1][w - wi])
return dp[n][capacity]
def knapsack_1d(weights, values, capacity):
"""Rolling array. dp[w] holds row i-1 while we compute row i in place.
The capacity loop MUST run downward: dp[w - wi] must still be the value
from the previous row (item i not yet used). Iterating upward would let
the same item be picked twice, silently turning this into unbounded knapsack.
Time O(n*W), space O(W)."""
dp = [0] * (capacity + 1)
for wi, vi in zip(weights, values):
for w in range(capacity, wi - 1, -1): # descending — critical
dp[w] = max(dp[w], vi + dp[w - wi])
return dp[capacity]
w = [1, 3, 4, 5]
v = [1, 4, 5, 7]
assert knapsack_2d(w, v, 7) == 9 # items with weight 3 and 4
assert knapsack_1d(w, v, 7) == 9
The O(n·W) complexity deserves a warning: this is pseudo-polynomial, not polynomial. W is a value, and its encoding takes log W bits, so the running time is exponential in the input size. Knapsack is NP-hard; DP does not change that, it just performs well when W happens to be small. With W = 10⁹ and n = 100 the table has 10¹¹ cells and the algorithm is useless — that is the signal to switch to a value-indexed DP (dp[value] = minimum weight) or an approximation scheme.
Family 4 — Two sequences: LCS and edit distance
When the input is two strings or arrays, the state is almost always “a prefix of each”: dp[i][j] covers a[:i] and b[:j]. That single habit solves most string DP problems.
Longest common subsequence — the longest sequence appearing in both, in order but not necessarily contiguously.
def lcs_length(a, b):
"""dp[i][j] = LCS length of a[:i] and b[:j].
If the last characters match, they can safely be paired (exchange argument:
any LCS can be rewritten to use that pair), so dp[i][j] = dp[i-1][j-1] + 1.
Otherwise drop one character from one side and take the better option.
Time O(n*m), space O(n*m)."""
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[n][m]
assert lcs_length("ABCBDAB", "BDCABA") == 4 # e.g. "BCBA"
The filled table for a = "ABCB", b = "BDCB" — the row and column of empty prefixes are zero, and the answer sits in the bottom-right corner:
"" B D C B
"" [ 0 0 0 0 0 ]
A [ 0 0 0 0 0 ]
B [ 0 1 1 1 1 ]
C [ 0 1 1 2 2 ]
B [ 0 1 1 2 3 ] <- LCS("ABCB","BDCB") = 3 ("BCB")
Edit distance (Levenshtein) — the minimum number of single-character insertions, deletions, or substitutions to turn a into b. Same prefix state, three choices instead of two.
def edit_distance(a, b):
"""dp[i][j] = cost to turn a[:i] into b[:j].
Base cases: turning a prefix into "" costs one deletion per character,
and "" into a prefix costs one insertion per character.
Time O(n*m), space O(n*m)."""
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = i # delete all of a[:i]
for j in range(m + 1):
dp[0][j] = j # insert all of b[:j]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # free: characters already agree
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # delete a[i-1]
dp[i][j - 1], # insert b[j-1]
dp[i - 1][j - 1], # replace a[i-1] with b[j-1]
)
return dp[n][m]
def edit_distance_rolling(a, b):
"""Same recurrence, two rows instead of n+1. Time O(n*m), space O(min(n,m))
after swapping so that b is the shorter string."""
if len(b) > len(a):
a, b = b, a
prev = list(range(len(b) + 1))
for i in range(1, len(a) + 1):
cur = [i] + [0] * len(b)
for j in range(1, len(b) + 1):
if a[i - 1] == b[j - 1]:
cur[j] = prev[j - 1]
else:
cur[j] = 1 + min(prev[j], cur[j - 1], prev[j - 1])
prev = cur
return prev[len(b)]
assert edit_distance("kitten", "sitting") == 3
assert edit_distance_rolling("kitten", "sitting") == 3
This is not a toy: edit distance is how spell checkers rank suggestions, how git diff and diff find minimal changesets (via LCS), and how bioinformatics aligns DNA sequences (Needleman–Wunsch is edit distance with a scoring matrix).
Family 5 — Longest increasing subsequence, twice
The O(n²) version is the natural DP: dp[i] = length of the longest increasing subsequence ending at index i.
def lis_quadratic(nums):
"""dp[i] = LIS length ending exactly at i. To extend, look back at every
j < i with nums[j] < nums[i]. Time O(n^2), space O(n)."""
if not nums:
return 0
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
The O(n log n) version abandons the DP table for a greedy invariant plus binary search. Maintain tails, where tails[k] is the smallest possible tail value of any increasing subsequence of length k+1 seen so far. tails is strictly increasing by construction, so it can be binary-searched.
from bisect import bisect_left
def lis_nlogn(nums):
"""tails[k] = smallest tail of an increasing subsequence of length k+1.
For each x: if it beats every tail, it extends the longest run (append);
otherwise it replaces the first tail >= x, making that length cheaper to
extend later without changing any length.
Time O(n log n) — one binary search per element. Space O(n).
NOTE: `tails` is NOT itself a valid subsequence; only its LENGTH is the answer."""
tails = []
for x in nums:
pos = bisect_left(tails, x) # use bisect_right for non-decreasing LIS
if pos == len(tails):
tails.append(x)
else:
tails[pos] = x
return len(tails)
nums = [10, 9, 2, 5, 3, 7, 101, 18]
assert lis_quadratic(nums) == 4 # [2, 3, 7, 18] or [2, 3, 7, 101]
assert lis_nlogn(nums) == 4
assert lis_nlogn([7, 7, 7, 7]) == 1
Why replacing is safe: swapping a smaller value into tails[pos] never shortens anything already achievable, and it makes future extensions of that length strictly easier. The bisect_left/bisect_right choice encodes strict versus non-strict increase, and getting it backwards is the standard bug — [7,7,7,7] returns 1 with bisect_left and 4 with bisect_right. bisect is a C-implemented stdlib module and is exactly what you would use in production; hand-rolling the binary search buys nothing but bugs (see search algorithms).
If you need the actual subsequence and not just its length, tails is not enough — you must record, for each element, the index of its predecessor and the length it achieved, then walk backwards from the element that achieved the maximum. The reconstruction section below shows the general technique.
Family 6 — Grid DP
Grids are the friendliest DP because the state is literally the cell you are standing in and the dependency order is “left to right, top to bottom”.
def unique_paths_with_obstacles(grid):
"""Count paths from top-left to bottom-right moving only right or down.
grid[r][c] == 1 marks an obstacle. dp[c] rolls one row at a time.
Time O(rows*cols), space O(cols)."""
if not grid or grid[0][0] == 1:
return 0
cols = len(grid[0])
dp = [0] * cols
dp[0] = 1 # one way to be at the start
for row in grid:
for c in range(cols):
if row[c] == 1:
dp[c] = 0 # unreachable cell
elif c > 0:
dp[c] += dp[c - 1] # from above (dp[c]) + from left (dp[c-1])
return dp[cols - 1]
def min_path_sum(grid):
"""Minimum sum of values along a right/down path from corner to corner.
Same state, min instead of sum. Time O(rows*cols), space O(cols)."""
rows, cols = len(grid), len(grid[0])
dp = [float("inf")] * cols
dp[0] = 0
for r in range(rows):
for c in range(cols):
if c == 0:
dp[c] = dp[c] + grid[r][c] # only from above
else:
dp[c] = min(dp[c], dp[c - 1]) + grid[r][c]
return dp[cols - 1]
assert unique_paths_with_obstacles([[0, 0, 0], [0, 1, 0], [0, 0, 0]]) == 2
assert min_path_sum([[1, 3, 1], [1, 5, 1], [4, 2, 1]]) == 7 # 1→3→1→1→1
The rolling trick works because when we are about to write dp[c] for row r, the array still holds row r-1 at position c (the value “from above”) and already holds row r at position c-1 (the value “from the left”). That single array is doing double duty, and understanding why it is correct is the same insight that makes the 1D knapsack loop run backwards.
Space optimization with rolling arrays
If dp[i][*] depends only on dp[i-1][*], you never need more than two rows — and often just one, if you iterate in the right direction. The rule:
| Dependency | Rows needed | Iteration direction (1D) |
|---|---|---|
dp[i][w] from dp[i-1][w] and dp[i-1][w-k] (k > 0) | 1 | Descending w — protects the previous row’s values |
dp[i][w] from dp[i][w-k] (same row, k > 0) | 1 | Ascending w — you want the current row |
dp[i][j] from dp[i-1][j-1], dp[i-1][j], dp[i][j-1] | 2 (or 1 + a saved diagonal) | Ascending, keep prev row |
dp[i] from dp[i-1], dp[i-2] | 2 scalars | Ascending |
0/1 knapsack is the first row of that table (descending, each item once). Unbounded knapsack and the coin-change count are the second (ascending, items reusable). This is exactly why the two loop directions produce different problems and why “just reverse the loop” is a real, precise transformation rather than a trick.
The cost of the optimization is that you destroy the information needed for reconstruction. Which brings us to:
Reconstructing the solution, not just its value
Most DP write-ups stop at the number. Interviews and real applications almost always want the actual answer: which items, which subsequence, which edits. Two techniques:
A. Keep the full table and walk backwards, re-deriving at each cell which choice produced the stored value.
def lcs_string(a, b):
"""Return the actual LCS, not just its length.
Build the full table, then walk from (n, m) back to (0, 0):
a diagonal step means the characters were matched and belong to the answer.
Time O(n*m), space O(n*m). Walk-back is O(n+m)."""
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
out = []
i, j = n, m
while i > 0 and j > 0:
if a[i - 1] == b[j - 1]:
out.append(a[i - 1]) # this pair was matched
i, j = i - 1, j - 1
elif dp[i - 1][j] >= dp[i][j - 1]:
i -= 1 # value came from dropping a[i-1]
else:
j -= 1 # value came from dropping b[j-1]
return "".join(reversed(out))
assert lcs_string("ABCBDAB", "BDCABA") == "BCBA"
B. Store a parent/choice pointer alongside the value. Costs an extra table but makes the walk-back trivial and works when re-deriving the choice is awkward.
def knapsack_items(weights, values, capacity):
"""Return (best_value, chosen_indices). `take[i][w]` records whether item i
was used in the optimum for capacity w. Time O(n*W), space O(n*W)."""
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
take = [[False] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
wi, vi = weights[i - 1], values[i - 1]
for w in range(capacity + 1):
dp[i][w] = dp[i - 1][w]
if wi <= w and vi + dp[i - 1][w - wi] > dp[i][w]:
dp[i][w] = vi + dp[i - 1][w - wi]
take[i][w] = True
chosen, w = [], capacity
for i in range(n, 0, -1):
if take[i][w]:
chosen.append(i - 1)
w -= weights[i - 1]
return dp[n][capacity], sorted(chosen)
assert knapsack_items([1, 3, 4, 5], [1, 4, 5, 7], 7) == (9, [1, 2])
The trade-off is unavoidable: you cannot both roll the array down to O(W) and reconstruct naively. If memory forces the rolling array and you still need the path, the standard fix is Hirschberg’s algorithm — divide and conquer on the sequence, computing the optimal crossing point of the midline with linear space and recursing on both halves. It gives O(n·m) time and O(min(n,m)) space with full reconstruction, and it is what real diff implementations use.
How to recognize a DP problem
The signals, roughly in order of reliability:
- The question asks for a count, a maximum, a minimum, or “is it possible” over a combinatorial space — “how many ways”, “the longest”, “the cheapest”, “can you reach”. If it asks you to list all solutions instead, that is backtracking, not DP.
- Choices at each step, and choosing greedily is provably wrong. You found a counterexample to the greedy (like
[1,3,4]for amount 6). That counterexample is the evidence that you must explore all choices — and DP is how you afford to. - Your brute-force recursion is exponential, but its arguments repeat. Write the brute force first, draw two levels of the recursion tree, and look for a duplicated call. If you see one, add a cache and you are done.
- The input constraints hint at the table size.
n ≤ 20suggests bitmask DP over subsets (O(2ⁿ·n)).n ≤ 100with a second dimension suggestsO(n²)orO(n³).n ≤ 10⁵rules out anything two-dimensional innand points atO(n log n)— an LIS-style structure, not a table. Constraint sizes in a well-posed problem are a hint about the intended complexity, and complexity determines the shape of the state. - The state is small and describable in a sentence. If you cannot say what
dp[i][j]means in one English sentence, you do not have a DP formulation yet — you have a table and some loops, and it will be wrong.
Conversely, if subproblems are disjoint it is divide and conquer; if a single local rule is provably optimal it is greedy; if you need every solution enumerated it is backtracking; and if the subproblems interfere (longest simple path, most scheduling with arbitrary constraints) there may be no polynomial algorithm at all.
Complexity summary
| Problem | State | Transitions per state | Time | Space (optimized) |
|---|---|---|---|---|
| Fibonacci / climbing stairs | i | 2 | O(n) | O(1) |
| Coin change (min / count) | amount | len(coins) | O(amount·C) | O(amount) |
| 0/1 knapsack | (item, capacity) | 2 | O(n·W) pseudo-poly | O(W) |
| LCS | (i, j) prefixes | 2–3 | O(n·m) | O(min(n,m)) value only |
| Edit distance | (i, j) prefixes | 3 | O(n·m) | O(min(n,m)) value only |
| LIS (DP) | i | O(n) | O(n²) | O(n) |
| LIS (patience + binary search) | — | O(log n) | O(n log n) | O(n) |
| Grid paths / min path sum | (r, c) | 2 | O(r·c) | O(c) |
| Matrix chain multiplication | (i, j) interval | O(n) split points | O(n³) | O(n²) |
| Bitmask DP (e.g. TSP) | (mask, last) | O(n) | O(2ⁿ·n²) | O(2ⁿ·n) |
Two entries above are worth naming even though they are beyond the roadmap node: interval DP (state is a range [i, j], transition splits at every k inside it — matrix chain multiplication, optimal BST, burst balloons) and bitmask DP (state includes a subset encoded in an integer — travelling salesman, assignment problems). Both are just the same four questions answered with a different state; recognizing them is mostly a matter of having seen one.
Best Practices
- Write the brute-force recursion first, then add a cache. Do not attempt to write the bottom-up table straight away. The recursion is where the thinking happens; memoizing it is a one-line mechanical step; converting to bottom-up afterwards is another mechanical step. Starting from the table means designing the state and the loop order simultaneously, and you will get both wrong.
- Say out loud what
dp[i][j]means before writing any loop. One English sentence, with the boundaries pinned down (“dp[i][j]is the LCS of the firsticharacters ofaand the firstjofb” — not “ofa[i]andb[j]”). Most DP bugs are state-definition bugs wearing an off-by-one costume. - Count states × transitions before implementing. That product is your complexity. If it exceeds roughly
10⁸simple operations, redesign the state — do not implement and hope. - Get the base cases right and test them explicitly. Empty string, zero capacity,
n = 0,n = 1. These are where the off-by-ones live, and a table that is right in the middle and wrong at the edges is much harder to debug than one that is wrong everywhere. - Optimize space last, and only when it matters. The rolling array makes the code harder to read and destroys reconstruction.
O(n·m)on a 1000×1000 problem is a million cells — fine. Roll it when the memory bound is real, not reflexively. - Watch the loop direction like a hawk. Descending means “previous row, each item once”; ascending means “current row, items reusable”. These are different problems, both compile, and both look plausible.
- In Python, prefer bottom-up for anything with depth over a few thousand.
RecursionErroron a deep memoized recursion is a real production failure andsys.setrecursionlimitis not a fix — a too-high limit crashes the interpreter’s C stack rather than raising cleanly. - Do not use
@lru_cacheon a function that closes over mutable problem state. The cache outlives the call, and the second test case gets the first one’s answers. If you do use it, clear it (f.cache_clear()) between problem instances, or pass the state as a hashable argument. - Beware pseudo-polynomial complexity.
O(n·W)looks polynomial and is not. Check the magnitude ofW(or the amount, or the target sum) against the input constraints before committing to the approach. - When the answer must be reconstructed, decide that before optimizing. Keep the full table, or keep a parent-pointer table, or plan for Hirschberg’s algorithm. Retrofitting reconstruction onto a rolled array means rewriting.
- Verify against the brute force on small random inputs. A 20-line random tester comparing the DP with an exhaustive search on inputs of size ≤ 10 catches essentially every DP bug in a couple of seconds, and is far faster than staring at the recurrence.
References
- roadmap.sh — Data Structures & Algorithms
- Dynamic programming — Wikipedia
- Bellman equation — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 15: Dynamic Programming
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- cp-algorithms — Longest Increasing Subsequence
- cp-algorithms — Knapsack problem
- cp-algorithms — Edit distance
- Knapsack problem — Wikipedia
- Longest common subsequence — Wikipedia
- Levenshtein distance — Wikipedia
- Hirschberg’s algorithm — Wikipedia
- Python Documentation —
functools.lru_cache - Python Documentation —
bisect