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:
Approach
Vietnamese Name
Cách hoạt động
When to use
Top-Down (Memoization)
Từ trên xuống
Đệ quy + cache kết quả đã tính
Problem structure rõ ràng, dễ viết
Bottom-Up (Tabulation)
Từ dưới lên
Điền bảng từ base case lên
Performance 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:
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.
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:
Pattern
Examples
Complexity
1D DP
Fibonacci, Climbing Stairs, House Robber
O(N)
2D DP (Grid)
Unique Paths, Min Path Sum
O(MN)
2D DP (String)
LCS, Edit Distance
O(MN)
Knapsack
Coin Change, Subset Sum
O(N⋅W)
Interval DP
Burst Balloons, Matrix Chain
O(N3)
LIS
Longest Increasing Subsequence
O(NlogN)
FAANG Interview Framework cho DP:
Identify: đây có phải DP problem không? (optimal + overlapping)
Define State: dp[i] nghĩa là gì?
Write Transition: dp[i] phụ thuộc vào dp[j] nào?
Base Case: dp[0], dp[1] bằng bao nhiêu?
Return: return dp[n] hay dp[n-1]? (verify với ví dụ nhỏ)
// #70 Climbing Stairs — can climb 1 or 2 steps// dp[i] = number of ways to reach step ifunction 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 replacefunction 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 twofunction 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 obstaclesfunction 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
Problem
Naive
With DP
Space Optimized
Fibonacci
O(2N)
O(N) time, O(N) space
O(N) time, O(1) space
Coin Change
O(coinsamount)
O(N⋅amount)
—
LCS
O(2M+N)
O(MN) time + space
O(min(M,N)) space
Edit Distance
O(3M+N)
O(MN) time + space
O(min(M,N)) space
LIS
O(2N)
O(N2) DP
O(NlogN) patience sort
Unique Paths
O(2M+N)
O(MN)
O(N) (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: O(MN) → O(N).
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 setconst 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 changeconst dp = new Array(amount + 1).fill(0); // SAI!// dp[5] = 0 sẽ khiến dp[6] = 0 + 1 = 1 (sai!)// CORRECT: init với Infinityconst 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