Buổi 09 — Dynamic Programming

Navigation

08-Weighted-Graphs | → 10-Advanced-DP Giai đoạn: FAANG Mastery | Độ khó: ⭐⭐⭐⭐⭐


1. Analogy & Context

Cheat Sheet Analogy

DP = “smart cheating” trong một kỳ thi mà bạn được phép ghi chú vào tờ giấy khi làm bài. Mỗi lần giải xong một sub-problem → ghi đáp án lên tờ giấy. Lần sau hỏi lại câu đó → không giải lại, chỉ cần look it up.

Hai hướng tiếp cận:

ApproachVietnamese NameCách hoạt độngWhen to use
Top-Down (Memoization)Từ trên xuốngĐệ quy + cache kết quả đã tínhProblem structure rõ ràng, dễ viết
Bottom-Up (Tabulation)Từ dưới lênĐiền bảng từ base case lênPerformance tốt hơn, tránh stack overflow

DP là gì? Dynamic Programming áp dụng được khi bài toán có 2 tính chất:

  1. Optimal Substructure (Cấu trúc tối ưu con): Nghiệm tối ưu của bài toán lớn được xây từ nghiệm tối ưu của các bài toán con.
  2. Overlapping Subproblems (Bài toán con lặp): Các bài toán con được giải nhiều lần.

Khi nào dùng DP?

Từ khóa trong đề bài: “minimum/maximum ways”, “count paths”, “longest/shortest subsequence”, “how many ways”, “optimal”, “can you reach”. Nếu greedy không work và brute force quá chậm → thử DP.


2. The FAANG Standard

DP ở FAANG

  • DP là topic được test nhiều nhất trong các vòng phỏng vấn FAANG level.
  • 40% Hard problems trên LeetCode là DP.
  • Google, Meta, Amazon đều có ít nhất 1 DP problem trong onsite.
  • Kỳ vọng: giải được Medium DP trong 20 phút, Hard DP trong 35-40 phút.

Must-Know DP Patterns:

PatternExamplesComplexity
1D DPFibonacci, Climbing Stairs, House Robber
2D DP (Grid)Unique Paths, Min Path Sum
2D DP (String)LCS, Edit Distance
KnapsackCoin Change, Subset Sum
Interval DPBurst Balloons, Matrix Chain
LISLongest Increasing Subsequence

FAANG Interview Framework cho DP:

  1. Identify: đây có phải DP problem không? (optimal + overlapping)
  2. Define State: dp[i] nghĩa là gì?
  3. Write Transition: dp[i] phụ thuộc vào dp[j] nào?
  4. Base Case: dp[0], dp[1] bằng bao nhiêu?
  5. Return: return dp[n] hay dp[n-1]? (verify với ví dụ nhỏ)

3. Visual Thinking (Mermaid)

3.1 Top-Down Memo Call Tree — fib(5)

graph TD
    F5["fib(5)"] --> F4["fib(4)"]
    F5 --> F3a["fib(3) ✓ cached"]
    F4 --> F3b["fib(3)"]
    F4 --> F2a["fib(2) ✓ cached"]
    F3b --> F2b["fib(2)"]
    F3b --> F1a["fib(1) = 1"]
    F2b --> F1b["fib(1) = 1"]
    F2b --> F0a["fib(0) = 0"]

    style F3a fill:#90EE90
    style F2a fill:#90EE90
    style F3a color:#000
    style F2a color:#000

Cache Hits = Green

Các node màu xanh lá là cache hits — không tính lại. Không có memo: . Có memo: .

3.2 Bottom-Up LCS Table — “ABCBDAB” vs “BDCABA”

graph LR
    subgraph LCS Table
        direction TB
        A["dp[i][j] = dp[i-1][j-1] + 1<br/>if s1[i] == s2[j]"]
        B["dp[i][j] = max(dp[i-1][j], dp[i][j-1])<br/>if s1[i] != s2[j]"]
    end

3.3 State Transition — Coin Change

graph LR
    D0["dp[0]=0"] -->|coin=1| D1["dp[1]=1"]
    D0 -->|coin=2| D2["dp[2]=1"]
    D1 -->|coin=1| D2
    D0 -->|coin=5| D5["dp[5]=1"]
    D2 -->|coin=1| D3["dp[3]=2"]
    D2 -->|coin=2| D4["dp[4]=2"]
    D3 -->|coin=2| D5
    D4 -->|coin=1| D5

4. TypeScript Template

4.1 Fibonacci — 4 Approaches

// ❌ Naive: O(2^N) time — NEVER use in interviews
function fibNaive(n: number): number {
  if (n <= 1) return n;
  return fibNaive(n - 1) + fibNaive(n - 2);
}
 
// ✅ Top-Down Memoization: O(N) time, O(N) space
function fibMemo(n: number, memo: Map<number, number> = new Map()): number {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n)!;
  const result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  memo.set(n, result);
  return result;
}
 
// ✅ Bottom-Up Tabulation: O(N) time, O(N) space
function fibTab(n: number): number {
  if (n <= 1) return n;
  const dp = new Array(n + 1).fill(0);
  dp[0] = 0;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
}
 
// ✅ Space Optimized: O(N) time, O(1) space
function fibOptimal(n: number): number {
  if (n <= 1) return n;
  let prev2 = 0, prev1 = 1;
  for (let i = 2; i <= n; i++) {
    const curr = prev1 + prev2;
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1;
}

4.2 Climbing Stairs (with Coin Change Generalization)

// #70 Climbing Stairs — can climb 1 or 2 steps
// dp[i] = number of ways to reach step i
function climbStairs(n: number): number {
  if (n <= 2) return n;
  let prev2 = 1, prev1 = 2;
  for (let i = 3; i <= n; i++) {
    const curr = prev1 + prev2;
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1;
}
 
// Generalization: can climb any steps in `coins` array (= coin change count ways)
function climbStairsGeneralized(n: number, steps: number[]): number {
  const dp = new Array(n + 1).fill(0);
  dp[0] = 1; // base case: 1 way to stay at ground
  for (let i = 1; i <= n; i++) {
    for (const step of steps) {
      if (i - step >= 0) {
        dp[i] += dp[i - step];
      }
    }
  }
  return dp[n];
}

4.3 Coin Change — Classic DP

// #322 Coin Change
// State: dp[amount] = minimum coins to make this amount
// Transition: dp[amount] = min(dp[amount], dp[amount - coin] + 1) for each coin
// Base case: dp[0] = 0 (0 coins needed for amount 0)
// Init: dp[i] = Infinity (impossible by default)
function coinChange(coins: number[], amount: number): number {
  // dp[i] = fewest coins needed to make amount i
  const dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0; // base case
 
  for (let amt = 1; amt <= amount; amt++) {
    for (const coin of coins) {
      if (coin <= amt && dp[amt - coin] !== Infinity) {
        // If we use this coin, we need dp[amt - coin] more coins
        dp[amt] = Math.min(dp[amt], dp[amt - coin] + 1);
      }
    }
  }
 
  // Transition visualization for coins=[1,2,5], amount=6:
  // dp[0]=0
  // dp[1]=min(dp[0]+1) = 1          (use coin 1)
  // dp[2]=min(dp[1]+1, dp[0]+1) = 1  (use coin 2)
  // dp[3]=min(dp[2]+1, dp[1]+1) = 2  (use coin 1+2)
  // dp[4]=min(dp[3]+1, dp[2]+1) = 2  (use coin 2+2)
  // dp[5]=min(dp[4]+1, dp[3]+1, dp[0]+1) = 1 (use coin 5)
  // dp[6]=min(dp[5]+1, dp[4]+1, dp[1]+1) = 2 (use coin 1+5)
 
  return dp[amount] === Infinity ? -1 : dp[amount];
}

4.4 Longest Common Subsequence (LCS)

// #1143 LCS
// State: dp[i][j] = LCS length of text1[0..i-1] and text2[0..j-1]
// Transition:
//   if text1[i-1] == text2[j-1]: dp[i][j] = dp[i-1][j-1] + 1
//   else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])
function longestCommonSubsequence(text1: string, text2: string): number {
  const m = text1.length, n = text2.length;
  // dp[i][j] = LCS of first i chars of text1, first j chars of text2
  const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
 
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        // Characters match: extend the LCS
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        // Characters differ: take the best of skip-one-from-either
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
 
  // Table for text1="ABCB", text2="BCB":
  //     ""  B  C  B
  // ""   0  0  0  0
  //  A   0  0  0  0
  //  B   0  1  1  1
  //  C   0  1  2  2
  //  B   0  1  2  3  ← answer = 3 ("BCB")
 
  return dp[m][n];
}

4.5 Edit Distance (Levenshtein)

// #72 Edit Distance
// Operations: insert, delete, replace (each costs 1)
// State: dp[i][j] = min edits to convert word1[0..i-1] to word2[0..j-1]
// Transition:
//   if chars match: dp[i][j] = dp[i-1][j-1]
//   else: dp[i][j] = 1 + min(
//     dp[i-1][j],   // delete from word1
//     dp[i][j-1],   // insert into word1
//     dp[i-1][j-1]  // replace in word1
//   )
function minDistance(word1: string, word2: string): number {
  const m = word1.length, n = word2.length;
  const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
    Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
  );
  // Base cases: dp[i][0] = i (delete i chars), dp[0][j] = j (insert j chars)
 
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (word1[i - 1] === word2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1]; // chars match, no cost
      } else {
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j],     // delete
          dp[i][j - 1],     // insert
          dp[i - 1][j - 1]  // replace
        );
      }
    }
  }
  return dp[m][n];
}

4.6 Longest Increasing Subsequence (LIS)

// #300 LIS — O(N²) DP approach
// State: dp[i] = LIS ending at index i
// Transition: dp[i] = max(dp[j] + 1) for all j < i where nums[j] < nums[i]
function lengthOfLIS_N2(nums: number[]): number {
  const n = nums.length;
  const dp = new Array(n).fill(1); // each element is a LIS of length 1
 
  for (let i = 1; i < n; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }
  }
  return Math.max(...dp);
}
 
// O(N log N) — Patience Sorting (Binary Search approach)
// `tails[i]` = smallest tail element of all increasing subsequences of length i+1
// Key insight: tails is always sorted → binary search for position to replace
function lengthOfLIS(nums: number[]): number {
  const tails: number[] = [];
 
  for (const num of nums) {
    // Binary search for first tail >= num
    let lo = 0, hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (tails[mid] < num) lo = mid + 1;
      else hi = mid;
    }
    // lo is the position to place `num`
    tails[lo] = num; // replace or extend
  }
 
  return tails.length; // length of tails = length of LIS
}
// Example: [10,9,2,5,3,7,101,18]
// Process 10: tails=[10]
// Process 9:  tails=[9]    (replace 10 with 9)
// Process 2:  tails=[2]    (replace 9 with 2)
// Process 5:  tails=[2,5]  (extend)
// Process 3:  tails=[2,3]  (replace 5 with 3)
// Process 7:  tails=[2,3,7](extend)
// Process 101:tails=[2,3,7,101]
// Process 18: tails=[2,3,7,18] → length = 4 ✓

4.7 House Robber (Linear + Circular)

// #198 House Robber — cannot rob adjacent houses
// dp[i] = max money robbing houses 0..i
// dp[i] = max(dp[i-1], dp[i-2] + nums[i])
function rob(nums: number[]): number {
  if (nums.length === 0) return 0;
  if (nums.length === 1) return nums[0];
  let prev2 = 0, prev1 = 0;
  for (const num of nums) {
    const curr = Math.max(prev1, prev2 + num);
    prev2 = prev1;
    prev1 = curr;
  }
  return prev1;
}
 
// #213 House Robber II — circular arrangement (first and last are adjacent)
// Key insight: run rob() twice:
//   1. Exclude last house: nums[0..n-2]
//   2. Exclude first house: nums[1..n-1]
//   Answer = max of the two
function robCircular(nums: number[]): number {
  if (nums.length === 1) return nums[0];
  const robRange = (start: number, end: number): number => {
    let prev2 = 0, prev1 = 0;
    for (let i = start; i <= end; i++) {
      const curr = Math.max(prev1, prev2 + nums[i]);
      prev2 = prev1;
      prev1 = curr;
    }
    return prev1;
  };
  return Math.max(robRange(0, nums.length - 2), robRange(1, nums.length - 1));
}

4.8 Unique Paths (Grid DP)

// #62 Unique Paths — robot moves right or down only
// dp[i][j] = number of paths to reach cell (i,j)
// dp[i][j] = dp[i-1][j] + dp[i][j-1]
function uniquePaths(m: number, n: number): number {
  const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(1));
  // Base case: first row and first column = 1 (only one way to move along edge)
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }
  return dp[m - 1][n - 1];
}
 
// #63 Unique Paths II — with obstacles
function uniquePathsWithObstacles(obstacleGrid: number[][]): number {
  const m = obstacleGrid.length, n = obstacleGrid[0].length;
  if (obstacleGrid[0][0] === 1 || obstacleGrid[m-1][n-1] === 1) return 0;
  const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(0));
  dp[0][0] = 1;
  // Fill first column
  for (let i = 1; i < m; i++) dp[i][0] = obstacleGrid[i][0] === 1 ? 0 : dp[i-1][0];
  // Fill first row
  for (let j = 1; j < n; j++) dp[0][j] = obstacleGrid[0][j] === 1 ? 0 : dp[0][j-1];
  // Fill rest
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      dp[i][j] = obstacleGrid[i][j] === 1 ? 0 : dp[i-1][j] + dp[i][j-1];
    }
  }
  return dp[m - 1][n - 1];
}

5. Complexity Deep-dive

Complexity Summary

ProblemNaiveWith DPSpace Optimized
Fibonacci time, space time, space
Coin Change
LCS time + space space
Edit Distance time + space space
LIS DP patience sort
Unique Paths (single row)

Space Optimization Pattern:

Khi dp[i] chỉ phụ thuộc vào dp[i-1] (và đôi khi dp[i-2]), có thể dùng biến thay vì mảng:

  • 1 dependency → 1 biến
  • 2 dependencies → 2 biến (prev1, prev2)
  • Row dependency (2D DP) → chỉ giữ 2 rows

Khi dp[i][j] chỉ phụ thuộc row trên → rolling array: .


6. Edge Cases & Pitfalls

Pitfall 1: Sai State Definition

Đây là bug DP phổ biến nhất. Luôn hỏi: “dp[i] đại diện cho cái gì chính xác?”

  • dp[i] = max profit at day i hay up to day i?
  • dp[i] = length của LIS ending at i hay up to i? Một câu hỏi sai định nghĩa = toàn bộ solution sai.

Pitfall 2: Sai Base Case (Off-by-one)

// BUG: dp[0] = 0 nhưng dp[1] chưa được set
const dp = new Array(n + 1).fill(0);
// dp[1] = 1 phải được set tường minh cho Fibonacci

Với Fibonacci: dp[0] = 0, dp[1] = 1. Với Climbing Stairs: dp[1] = 1, dp[2] = 2. Luôn verify base cases với n=1, n=2 trước khi code.

Pitfall 3: Thứ tự tính Bottom-Up

Với coin change dp[amt] phụ thuộc vào dp[amt - coin] — phải tính từ amt nhỏ đến lớn. Với 2D DP: tính từ (0,0) đến (m,n) để đảm bảo dp[i-1][…] và dp[…][j-1] đã có sẵn.

Pitfall 4: Circular DP

House Robber II: không thể dùng 1 lần dp trực tiếp cho circular array. Fix: chạy DP 2 lần — lần 1 exclude phần tử cuối, lần 2 exclude phần tử đầu.

hay dp[n-1]?

  • climbStairs(n) → return dp[n] (n bậc cầu thang, index = n)
  • rob(nums) với nums.length = n → return dp[n-1] (index 0-based) Luôn verify với ví dụ nhỏ (n=2, n=3) trước khi submit.

Pitfall 6: Init Infinity cho bài "minimum"

// BUG: init với 0 cho coin change
const dp = new Array(amount + 1).fill(0); // SAI!
// dp[5] = 0 sẽ khiến dp[6] = 0 + 1 = 1 (sai!)
 
// CORRECT: init với Infinity
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;

7. Pattern Recognition

Nhận Diện DP Pattern

Pattern 1: “Minimum/Maximum to reach target” → Coin Change, Jump Game II, Min Cost Climbing Stairs → dp[i] = min/max over all choices

Pattern 2: “Count paths/combinations” → Climbing Stairs, Unique Paths, Decode Ways → dp[i] = sum of all valid previous states

Pattern 3: “Choose or skip each element” → House Robber, 0/1 Knapsack, Partition Equal Subset Sum → dp[i] = max(include nums[i], exclude nums[i])

Pattern 4: “String alignment/transformation” → LCS, Edit Distance, Longest Palindromic Subsequence → 2D DP: dp[i][j] on two string prefixes

Pattern 5: “Longest subsequence” → LIS, LCS, Longest Palindromic Subsequence → dp[i] = length of best subsequence ending at/up to i

Pattern 6: “Interval optimization” → Burst Balloons, Matrix Chain Multiplication, Strange Printer → dp[l][r] = optimal answer for interval [l, r] → Try all split points k: dp[l][r] = min/max(dp[l][k] + dp[k+1][r] + cost)


8. Top LeetCode Problems

#ProblemDifficultyPatternKey Insight
#70Climbing StairsEasy1D DPFibonacci variant
#198House RobberMedium1D DP, choose/skipdp[i] = max(dp[i-1], dp[i-2]+nums[i])
#322Coin ChangeMediumKnapsackInit Infinity, transition per coin
#1143LCSMedium2D String DPMatch → diagonal+1, else max of neighbors
#72Edit DistanceHard2D String DP3 operations: insert/delete/replace
#300LISMedium1D DP / Binary Search patience sort
#213House Robber IIMediumCircular DPRun DP twice on subarrays
#312Burst BalloonsHardInterval DPThink: which balloon is popped LAST

Complete Solution: #322 Coin Change

function coinChange(coins: number[], amount: number): number {
  const dp = new Array(amount + 1).fill(Infinity);
  dp[0] = 0;
  for (let amt = 1; amt <= amount; amt++) {
    for (const coin of coins) {
      if (coin <= amt && dp[amt - coin] !== Infinity) {
        dp[amt] = Math.min(dp[amt], dp[amt - coin] + 1);
      }
    }
  }
  return dp[amount] === Infinity ? -1 : dp[amount];
}
// Time: O(coins * amount) | Space: O(amount)

Complete Solution: #1143 LCS

function longestCommonSubsequence(text1: string, text2: string): number {
  const m = text1.length, n = text2.length;
  const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;  // chars match
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // skip one
      }
    }
  }
  return dp[m][n];
}
// Time: O(M*N) | Space: O(M*N) — can optimize to O(min(M,N))

Complete Solution: #300 LIS (Both Approaches)

// O(N²) approach
function lengthOfLIS_slow(nums: number[]): number {
  const dp = new Array(nums.length).fill(1);
  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
    }
  }
  return Math.max(...dp);
}
 
// O(N log N) Patience Sorting
function lengthOfLIS(nums: number[]): number {
  const tails: number[] = [];
  for (const num of nums) {
    let lo = 0, hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      tails[mid] < num ? (lo = mid + 1) : (hi = mid);
    }
    tails[lo] = num;
  }
  return tails.length;
}
// Time: O(N log N) | Space: O(N)

9. Self-Assessment Checklist

Checklist — DP Fundamentals

  • Giải thích được optimal substructure và overlapping subproblems
  • Viết Fibonacci theo 4 cách: naive, memo, tabulation, O(1) space
  • Định nghĩa đúng state cho Coin Change (dp[amount] = min coins)
  • Viết transition cho LCS từ đầu không nhìn gợi ý
  • Viết Edit Distance với cả 3 operations (insert/delete/replace)
  • Giải thích tại sao House Robber II cần chạy DP 2 lần
  • Implement LIS O(N log N) với patience sorting
  • Biết khi nào return dp[n] vs dp[n-1]
  • Space optimize 2D DP → O(N) với rolling array

FAANG Interview Readiness

  • Solve #322 Coin Change trong < 15 phút
  • Solve #1143 LCS trong < 20 phút
  • Solve #72 Edit Distance trong < 25 phút
  • Explain O(N log N) LIS với patience sorting (không code)
  • Recognize DP pattern từ problem statement trong < 2 phút

Next Steps

10-Advanced-DP — Bitmask DP, Tree DP, Digit DP Các patterns nâng cao hơn dùng ở L6/Staff interviews. Prerequisite hoàn thành: tất cả checkbox ở trên ✓