CHUYÊN ĐỀ COMPETITIVE PROGRAMMING — TUẦN 12

Dynamic Programming — 1D

Pattern then chốt cho phỏng vấn senior. Học cách thiết kế staterecurrence — nền tảng cho mọi bài DP từ Climbing Stairs đến Edit Distance.

10 worked examples · 3 knowledge checks · 12 bài về nhà

Phần 1. Nền tảng DP

Memoisation vs Tabulation, quy trình thiết kế 5 bước
1.1

1.1 Dynamic Programming là gì?

Định nghĩa:
DP áp dụng được khi bài toán có hai tính chất:
  1. Optimal substructure: nghiệm tối ưu của bài lớn = combine nghiệm tối ưu của bài con.
  2. Overlapping subproblems: cùng bài con xuất hiện nhiều lần trong cây đệ quy.
DP = đệ quy + cache kết quả bài con đã tính.

Ví dụ Fibonacci

Tính fib(n) = fib(n-1) + fib(n-2). Đệ quy ngây thơ tính fib(2) rất nhiều lần (overlapping). Cache → mỗi state tính 1 lần → từ O(2ⁿ) xuống O(n).

Tín hiệu nhận biết DP

1.2

1.2 Memoisation (Top-down) vs Tabulation (Bottom-up)

AspectMemoisationTabulation
Hướng tínhLớn → nhỏ (đệ quy với cache)Nhỏ → lớn (vòng for điền bảng)
Cú phápĐệ quy + dict/@lru_cacheMảng dp[] + nested loops
Khởi tạoCache rỗng + base case trong ifInit bảng + base case explicit
Chỉ tính state cần✗ Tính hết
Stack overflowCó thể với n lớnKhông bao giờ
Space optimisationKhóDễ — rolling array O(1)
Phỏng vấnDễ viết hơn từ recursionProduction-ready

Quy tắc chuyển đổi

1.3

1.3 Quy trình thiết kế DP — 5 bước

  1. Định nghĩa state: dp[i] nghĩa là gì? Ví dụ: "dp[i] = số cách lên tới bậc thứ i".
  2. Viết recurrence: dp[i] = ? dựa trên dp[i-1], dp[i-2], ... Ví dụ: dp[i] = dp[i-1] + dp[i-2].
  3. Xác định base case: dp[0], dp[1], ... Ví dụ: dp[0] = 1, dp[1] = 1.
  4. Quyết định thứ tự tính: từ nhỏ đến lớn (tabulation) hay đệ quy (memoisation).
  5. Tối ưu space (nếu có thể): chỉ giữ các state cần. Ví dụ Fibonacci chỉ cần 2 biến.
Bước khó nhất:
Bước 1 — định nghĩa state. State đúng → recurrence tự nhiên ra. State sai → recurrence không có.
Trong phỏng vấn, dành ít nhất 50% thời gian cho bước này. Đừng vội code khi chưa rõ state nghĩa là gì.
1.4

1.4 Backtracking vs DP — Khi nào chuyển?

Tuần 11 đã thấy backtracking giải mọi bài "liệt kê khả năng". Khi nào chuyển sang DP?

BacktrackingDP
Cần liệt kê tất cả nghiệmCần đếm hoặc tìm tối ưu
Mỗi state thăm 1 lầnCùng state thăm nhiều lần (overlapping)
Output lớn (exp)Output nhỏ (số / boolean)
Time expTime poly nhờ cache

Quy trình chuyển đổi

  1. Bắt đầu với backtracking (ngây thơ).
  2. Nhận diện state state = các tham số tham gia quyết định kết quả.
  3. Add @lru_cache vào hàm đệ quy → instant memoisation.
  4. Convert sang tabulation nếu cần tối ưu space.

Đa số bài DP có lời giải Memoisation viết được trong < 5 phút từ backtracking. Đó là lý do nên thuộc backtracking trước (Tuần 11).

Phần 2. Linear DP

Dạng đơn giản nhất — dp[i] phụ thuộc vào dp[i-1], dp[i-2]
2.1

2.1 Linear DP Pattern

Đặc trưng:
State dp[i] đại diện cho "câu trả lời cho subproblem trên prefix [0..i]". Recurrence chỉ phụ thuộc vào dp[i-1] hoặc vài state liền kề.

Mẫu chung

n = len(input)
dp = [0] * (n + 1)
dp[0] = base_case
for i in range(1, n + 1):
    dp[i] = recurrence(dp[i-1], dp[i-2], ..., input[i-1])
return dp[n]

Tối ưu space O(1)

Khi recurrence chỉ dùng vài state cuối, có thể bỏ mảng dp, chỉ giữ vài biến:

prev2, prev1 = base_0, base_1
for i in range(2, n + 1):
    cur = recurrence(prev1, prev2)
    prev2, prev1 = prev1, cur
return prev1

Pattern này giảm O(n) space xuống O(1) — lợi rõ khi n lớn.

2.2

2.2 Worked Example 1 — Climbing Stairs (#70)

Đề: n bậc thang. Mỗi bước có thể leo 1 hoặc 2 bậc. Có bao nhiêu cách lên đỉnh?

Phân tích

State: dp[i] = số cách lên đến bậc i. Recurrence: để đến bậc i, hoặc từ bậc i-1 (1 bước) hoặc từ i-2 (2 bước):

$$ dp[i] = dp[i-1] + dp[i-2] $$

Đây chính là Fibonacci.

Ba cách

# Cách 1 — Đệ quy ngây thơ — O(2ⁿ), TLE
def climbStairs_naive(n):
    if n <= 2: return n
    return climbStairs_naive(n-1) + climbStairs_naive(n-2)

# Cách 2 — Memoisation — O(n)
from functools import lru_cache
@lru_cache(None)
def climbStairs_memo(n):
    if n <= 2: return n
    return climbStairs_memo(n-1) + climbStairs_memo(n-2)

# Cách 3 — Tabulation O(1) space — chuẩn vàng
def climbStairs(n):
    if n <= 2: return n
    a, b = 1, 2                      # dp[1], dp[2]
    for _ in range(3, n + 1):
        a, b = b, a + b
    return b
2.3

2.3 Fibonacci (#509) — Cây đệ quy minh hoạ

Tại sao đệ quy ngây thơ là O(2ⁿ)?

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

Cây đệ quy fib(5) — fib(3) tính 2 lần, fib(2) tính 3 lần. Với n lớn, sự lặp lại phát triển theo cấp số mũ.

Memoisation cache fib(3) sau lần đầu → các lần sau tra O(1) → tổng O(n). Đây là tinh thần cốt lõi của DP.

2.4

2.4 Worked Example 3 — House Robber (#198)

Đề: Mảng nums[i] = giá trị nhà thứ i. Không được cướp hai nhà liền kề. Tìm giá trị tối đa cướp được.

Phân tích state

State: dp[i] = giá trị tối đa cướp được trong nums[0..i].

Recurrence — tại nhà i, có 2 lựa chọn:

$$ dp[i] = \max(dp[i-1], \, dp[i-2] + \text{nums}[i]) $$
def rob(nums):
    if not nums: return 0
    if len(nums) == 1: return nums[0]
    prev2 = nums[0]
    prev1 = max(nums[0], nums[1])
    for i in range(2, len(nums)):
        cur = max(prev1, prev2 + nums[i])
        prev2, prev1 = prev1, cur
    return prev1

Time O(n), Space O(1).

2.5

2.5 House Robber — Trace tay

nums = [2, 7, 9, 3, 1]. Kỳ vọng: 12 (nhà 1, 3, 5: 2+9+1, hoặc 2, 4: 7+3 = 10 — kết quả tối ưu là 2+9+1 = 12).

inums[i]prev2prev1cur = max(prev1, prev2+nums[i])
022
1727
2927max(7, 2+9) = 11
33711max(11, 7+3) = 11
411111max(11, 11+1) = 12

Trả 12 ✓. Đường: cướp nhà 0 (2) + nhà 2 (9) + nhà 4 (1) = 12.

Pattern "include or skip":
Tại mỗi i, lựa chọn nhị phân (cướp hoặc bỏ qua) — DP take max. Pattern xuất hiện ở Best Time to Buy and Sell Stock series, Decode Ways, Paint House.
2.6

2.6 Worked Example 4 — House Robber II (Circular) (#213)

Đề: Như #198 nhưng các nhà nối thành vòng tròn — nhà 0 và nhà n-1 liền kề.

Mẹo: Chia thành 2 bài tuyến tính

Vì nhà 0 và nhà n-1 không thể cướp cùng lúc, đáp án = max của:

def rob(nums):
    if len(nums) == 1: return nums[0]
    def rob_linear(arr):
        prev2 = prev1 = 0
        for x in arr:
            prev2, prev1 = prev1, max(prev1, prev2 + x)
        return prev1
    return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))

Time O(n), Space O(1).

Tinh thần "tách bài tuần hoàn":
Mọi bài tuần hoàn (circular) có thể chuyển về 2-3 bài tuyến tính qua việc cố định lựa chọn cho 1 phần tử. Pattern xuất hiện ở Maximum Sum Circular Subarray.
2.Q

Knowledge Check — Linear DP

Q1: DP áp dụng được khi bài có 2 tính chất nào?
Optimal substructure: nghiệm bài lớn = combine nghiệm bài con. Overlapping: cùng bài con xuất hiện nhiều lần. Cả hai mới đảm bảo DP giảm exp → poly. Greedy có optimal substructure nhưng không có overlapping (mỗi state tính 1 lần).
Q2: Bước khó nhất khi thiết kế DP là?
State đúng → recurrence tự nhiên. State sai → recurrence không có hoặc phức tạp. Trong phỏng vấn, dành ít nhất nửa thời gian cho bước này, đừng vội code.
Q3: Trong House Robber, recurrence dp[i] = max(dp[i-1], dp[i-2] + nums[i]) phản ánh quyết định gì?
Pattern "include or skip" — lựa chọn nhị phân tại mỗi bước. Khi cướp, không thể cướp i-1 (cấm liền kề), nên dùng dp[i-2]. Khi bỏ qua, kết quả vẫn là dp[i-1].

Phần 3. Knapsack-like DP

"Choose or skip" với mỗi item — Coin Change, Word Break
3.1

3.1 Choice DP — Pattern Knapsack

Đặc trưng:
Có một tập items và một capacity. Tìm cách chọn items thoả điều kiện capacity với tối ưu (giá trị max, số coins min, đếm số cách...).

Hai loại knapsack

Tín hiệu nhận biết

Quy tắc thứ tự loop:
Outer = items, Inner = capacity cho 0/1 knapsack (đếm cách).
Outer = capacity, Inner = items cho unbounded (đếm hoán vị). Chi tiết ở 3.5.
3.2

3.2 Worked Example 5 — Coin Change (#322, Min Coins)

Đề: Mảng coins (mệnh giá), số amount. Tìm số coins tối thiểu để cấu thành amount. Mỗi coin có thể dùng nhiều lần. -1 nếu không thể.

State design

dp[i] = số coins tối thiểu để cấu thành i. Base: dp[0] = 0.

Recurrence: với mỗi coin, nếu i ≥ coin, có thể dùng coin đó:

$$ dp[i] = \min_{c \in \text{coins}} \{ dp[i - c] + 1 \} $$
def coinChange(coins, amount):
    INF = amount + 1                       # giá trị "không thể đạt"
    dp = [INF] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for c in coins:
            if c <= i:
                dp[i] = min(dp[i], dp[i - c] + 1)
    return dp[amount] if dp[amount] != INF else -1

Time O(amount × len(coins)), Space O(amount).

3.3

3.3 Coin Change — Trace tay

coins = [1, 2, 5], amount = 11. Kỳ vọng: 3 (5+5+1).

idp[i] = min(dp[i-1]+1, dp[i-2]+1, dp[i-5]+1)Kết quả
00
1min(dp[0]+1) = 11
2min(dp[1]+1, dp[0]+1) = 11
3min(dp[2]+1, dp[1]+1) = 22
4min(dp[3]+1, dp[2]+1) = 22
5min(dp[4]+1, dp[3]+1, dp[0]+1) = 11
6min(dp[5]+1, dp[4]+1, dp[1]+1) = 22
7min(...) = 2 (5+2)2
.........
10min(...) = 2 (5+5)2
11min(dp[10]+1, dp[9]+1, dp[6]+1) = 33 ✓

Đường: 11 = 5 + 5 + 1 → 3 coins.

3.4

3.4 Worked Example 6 — Coin Change 2 (#518, Number of Ways)

Đề: Mảng coins, amount. Đếm số cách cấu thành amount. Mỗi coin dùng nhiều lần. Combinations (không quan tâm thứ tự).

def change(amount, coins):
    dp = [0] * (amount + 1)
    dp[0] = 1                              # 1 cách: không dùng coin nào
    for c in coins:                        # OUTER: coins
        for i in range(c, amount + 1):     # INNER: amount
            dp[i] += dp[i - c]
    return dp[amount]

Trace với coins = [1, 2, 5], amount = 5

Saudp[0]dp[1]dp[2]dp[3]dp[4]dp[5]
Init100000
Coin 1111111
Coin 2112233
Coin 5112234

Kết quả: 4 cách (5; 2+2+1; 2+1+1+1; 1+1+1+1+1).

3.5

3.5 #322 vs #518 — Khác biệt loop order

Hai bài cùng pattern unbounded knapsack nhưng thứ tự loop khác nhau:

BàiOuter loopInner loopLý do
#322 (min coins)amountcoinsMỗi i, thử mọi coin → tìm min
#518 (đếm cách)coinsamountTránh đếm trùng cùng combination

Vì sao #518 phải outer = coins?

Nếu đảo (outer = amount, inner = coins), sẽ đếm permutations (1+2 và 2+1 là 2 cách khác). Outer = coins thì khi xử lý coin 2, ta dùng dp[i-2] đã là kết quả "chỉ dùng coin 1 và 2", không có coin 5 — đảm bảo combination unique.

Quy tắc:
Bài đếm combinations (không quan tâm thứ tự) → outer = items, inner = capacity.
Bài đếm permutations (thứ tự khác nhau) → outer = capacity, inner = items.
Bài tối ưu min/max → loop order thường không quan trọng (như #322).
3.6

3.6 Worked Example 7 — Word Break (#139)

Đề: String s, wordDict. Trả True nếu có thể chia s thành chuỗi từ trong dict (cho phép dùng lại).

Ví dụ: s = "leetcode", wordDict = ["leet", "code"] → True.

State

dp[i] = True nếu s[0..i-1] có thể phân chia.

Recurrence: dp[i] = True nếu tồn tại j < i sao cho dp[j] = Trues[j..i-1] ∈ wordDict.

def wordBreak(s, wordDict):
    word_set = set(wordDict)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(1, n + 1):
        for j in range(i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break                      # tìm thấy một cách là đủ
    return dp[n]

Time O(n² × L) (L = chi phí slice + lookup), Space O(n).

3.Q

Knowledge Check — Knapsack DP

Q1: Trong Coin Change 2 (#518), vì sao outer = coins, không phải amount?
Outer = coins giúp đảm bảo "khi xử lý coin c, kết quả chỉ dùng các coin 0..c-1". Đảo lại sẽ đếm cùng combination ở các thứ tự khác nhau như cách khác.
Q2: Bài Word Break có thể giải bằng backtracking nhưng tại sao DP nhanh hơn?
Backtracking thử mọi cách phân chia → exp time. Cùng "đến vị trí i được không" có thể xảy ra qua nhiều đường. DP cache kết quả này → mỗi i chỉ tính 1 lần.

Phần 4. Subsequence DP

LIS, Maximum Subarray, State Machine DP
4.1

4.1 Subsequence DP — Pattern

Đặc trưng:
Bài có dạng "tìm subsequence (không liên tiếp) hoặc subarray (liên tiếp) tốt nhất". State đại diện cho "đang đứng ở phần tử thứ i".

Tín hiệu

Hai loại state design phổ biến

  1. dp[i] = best ending at i: phần tử cuối cùng là i. Cần lấy max(dp) cuối cùng. Áp dụng cho LIS, Maximum Subarray.
  2. dp[i] = best in prefix [0..i]: bao gồm bất kỳ phần tử nào trong tiền tố. Ans = dp[n-1]. Áp dụng cho House Robber.

Khi nào chọn cái nào? Nếu recurrence cần biết "phần tử cuối là gì" (như LIS so sánh giá trị) → loại 1. Nếu không cần → loại 2.

4.2

4.2 Worked Example 8 — Longest Increasing Subsequence (LIS)

Đề (LeetCode #300): Mảng nums. Tìm độ dài subsequence tăng dài nhất (không nhất thiết liên tiếp).

Ví dụ: [10, 9, 2, 5, 3, 7, 101, 18] → 4 (chuỗi [2,3,7,101] hoặc [2,5,7,18]).

Approach DP O(n²)

State: dp[i] = độ dài LIS kết thúc tại i.

Recurrence: dp[i] = max(dp[j]) + 1 với j < i và nums[j] < nums[i].

def lengthOfLIS(nums):
    n = len(nums)
    dp = [1] * n
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

Time O(n²), Space O(n).

4.3

4.3 LIS — Tối ưu O(n log n) bằng Patience Sorting

Khi n = 10⁵, O(n²) = 10¹⁰ → TLE. Cần O(n log n).

Ý tưởng

Duy trì mảng tails với tails[k] = phần tử nhỏ nhất có thể là cuối của subsequence dài k+1.

Với mỗi x: tìm vị trí trong tails để thay (lower_bound):

from bisect import bisect_left
def lengthOfLIS(nums):
    tails = []
    for x in nums:
        idx = bisect_left(tails, x)
        if idx == len(tails):
            tails.append(x)
        else:
            tails[idx] = x
    return len(tails)

Time O(n log n), Space O(n).

Lưu ý:
tails KHÔNG phải LIS thực sự — chỉ đúng độ dài. Để truy hồi LIS thực, cần lưu thêm parent pointers — phức tạp hơn.
4.4

4.4 Worked Example 9 — Maximum Subarray (Kadane's)

Đề (LeetCode #53): Mảng nums. Tìm subarray liên tiếp có tổng lớn nhất.

Kadane's Algorithm — DP O(n) O(1)

State: dp[i] = tổng max của subarray kết thúc tại i.

Recurrence: tại i, hoặc kế tục subarray cũ (dp[i-1] + nums[i]) hoặc bắt đầu mới (nums[i]):

$$ dp[i] = \max(dp[i-1] + \text{nums}[i], \, \text{nums}[i]) $$
def maxSubArray(nums):
    cur = best = nums[0]
    for x in nums[1:]:
        cur = max(x, cur + x)
        best = max(best, cur)
    return best

Time O(n), Space O(1).

Tinh thần "tiếp tục hoặc bắt đầu mới":
Nếu cur + x < x nghĩa là cur âm — đang "kéo" tổng xuống. Bắt đầu mới từ x. Đây là pattern xuất hiện ở nhiều bài "max sum subarray" với biến thể.
4.5

4.5 Worked Example 10 — Best Time to Buy and Sell Stock with Cooldown

Đề (LeetCode #309): Mảng prices. Mua/bán cổ phiếu nhiều lần. Sau khi bán, phải nghỉ 1 ngày trước khi mua lại. Tối đa lợi nhuận.

State Machine DP

3 trạng thái có thể tại mỗi ngày:

$$ \text{hold}[i] = \max(\text{hold}[i-1], \text{rest}[i-1] - \text{prices}[i]) $$ $$ \text{sold}[i] = \text{hold}[i-1] + \text{prices}[i] $$ $$ \text{rest}[i] = \max(\text{rest}[i-1], \text{sold}[i-1]) $$
def maxProfit(prices):
    if not prices: return 0
    hold = -prices[0]; sold = 0; rest = 0
    for i in range(1, len(prices)):
        new_hold = max(hold, rest - prices[i])
        new_sold = hold + prices[i]
        new_rest = max(rest, sold)
        hold, sold, rest = new_hold, new_sold, new_rest
    return max(sold, rest)
4.Q

Knowledge Check — Subsequence DP

Q1: Trong LIS, vì sao state là "dp[i] = LIS kết thúc TẠI i" thay vì "dp[i] = LIS trong prefix [0..i]"?
Định nghĩa "kết thúc tại i" cho phép so sánh nums[j] < nums[i]. Định nghĩa "trong prefix" thì không biết phần tử cuối → không kiểm tra được tăng dần. Đáp án cuối là max(dp).
Q2: Kadane's Algorithm O(n) O(1) áp dụng tinh thần gì?
Kadane = DP với state máy. Khi cur âm → "kéo" tổng xuống → reset. Khi cur dương → tiếp tục có lợi. Pattern này tổng quát cho bài subarray sum với biến thể.
Q3: State machine DP (như Cooldown) phù hợp khi nào?
State machine DP có nhiều state cho mỗi i (hold, sold, rest), mỗi state có công thức riêng. Áp dụng cho stock series, escape problems, knapsack với constraints phức tạp.
5.0

5.0 Decision Tree — 1D DP

Tín hiệu trong đềPattern
"số cách lên bậc/đếm path"Linear DP: dp[i] = dp[i-1] + dp[i-2]
"max/min với constraint liền kề"House Robber pattern: dp[i] = max(dp[i-1], dp[i-2]+x)
"vòng tròn / circular"Tách thành 2 bài tuyến tính
"min coins/items để đạt target"Coin Change (#322): dp[i] = min(dp[i-c]+1)
"đếm số cách combinations"Coin Change 2 (#518): outer items, inner capacity
"đếm số cách permutations"Climbing Stairs / Combination Sum IV
"có thể phân chia / break"Word Break: dp[i] = OR(dp[j] AND s[j:i] valid)
"longest increasing/non-decreasing"LIS: O(n²) DP hoặc O(n log n) patience
"max subarray sum"Kadane's: O(n) O(1)
"buy/sell stock với constraint"State Machine DP (hold/sold/rest)

Quy trình code DP

  1. Định nghĩa state.
  2. Viết recurrence (kèm base case).
  3. Cài memoisation trước (dễ hơn).
  4. Convert tabulation nếu cần tối ưu space.
  5. Tối ưu O(1) space nếu chỉ dùng vài state cuối.
5.1

Tổng kết Tuần 12

Đã học

Bài về nhà (12 bài)

  1. LeetCode #70 — Climbing Stairs
  2. LeetCode #509 — Fibonacci Number
  3. LeetCode #198 — House Robber
  4. LeetCode #213 — House Robber II
  5. LeetCode #322 — Coin Change (min)
  6. LeetCode #518 — Coin Change 2 (count)
  7. LeetCode #139 — Word Break
  8. LeetCode #300 — Longest Increasing Subsequence (cả 2 approach)
  9. LeetCode #53 — Maximum Subarray (Kadane's)
  10. LeetCode #309 — Best Time to Buy and Sell Stock with Cooldown
  11. LeetCode #91 — Decode Ways
  12. LeetCode #152 — Maximum Product Subarray

Tuần 13 sẽ học gì?

2D DP — Edit Distance, LCS, Knapsack 0/1, Interval DP

Mở rộng từ 1D sang 2D state. Học thiết kế state (i, j) cho hai chuỗi (Edit Distance, LCS, Distinct Subsequences), knapsack 0/1 với capacity (Partition Equal Subset Sum), interval DP (Burst Balloons, Matrix Chain Multiplication). Tuần cuối cùng về thuật toán — Tuần 14 sẽ tổng hợp với mock interviews.

Hết Tuần 12 — Cảm ơn các em.

Mục lục

Nhấn T hoặc Esc để đóng