Dynamic Programming & Greedy Algorithms: Hướng Dẫn Toàn Diện

Giới Thiệu
Dynamic Programming và Greedy Algorithms là hai paradigm chính để giải các bài toán tối ưu — tìm solution tốt nhất, rẻ nhất, dài nhất, ngắn nhất, hay hiệu quả nhất. Chúng cũng nằm trong số những chủ đề đáng sợ nhất trong coding interviews.
Nhưng tin vui là: cả hai paradigm đều tuân theo pattern. DP chia bài toán thành overlapping subproblems và lưu kết quả để tránh tính toán thừa. Greedy đưa ra lựa chọn tối ưu cục bộ ở mỗi bước, tin rằng local optimality dẫn đến global optimality. Khi bạn nắm vững những pattern này, những bài toán tưởng như impossible bắt đầu clicking.
Trong coding interviews, các bài DP xuất hiện trong khoảng 25-30% các buổi phỏng vấn kỹ thuật. Chúng được coi là category khó nhất — nhưng cũng là có công thức nhất. Bài viết này cho bạn framework có hệ thống để tackle bất kỳ bài DP nào, cộng thêm hiểu biết rõ ràng về khi nào greedy là approach đúng (và đơn giản hơn).
Đây là bài viết cuối cùng trong DSA series. Mọi thứ bạn đã học — arrays, linked lists, trees, graphs, heaps, sorting — hội tụ ở đây. DP và greedy algorithms xây dựng trên tất cả những data structures đó.
Bạn Sẽ Học Được Gì
✅ Hiểu khi nào dùng DP vs greedy vs brute force
✅ Nắm vững framework DP 5 bước cho mọi bài toán
✅ Cài đặt memoization (top-down) và tabulation (bottom-up)
✅ Giải các bài DP 1D và 2D có hệ thống
✅ Nhận diện các DP pattern phổ biến (knapsack, subsequence, grid, string)
✅ Hiểu greedy choice property và khi nào greedy hoạt động
✅ Giải 10 bài DP + 4 bài greedy kinh điển từng bước
Yêu Cầu Trước
- Hiểu về Ký hiệu Big O & Phân tích Độ phức tạp
- Quen thuộc với Các Pattern Giải Quyết Vấn Đề
- Hoàn thành Arrays & Strings và Trees & BSTs
- Kiến thức cơ bản về recursion
1. Nền Tảng Dynamic Programming
1.1 Dynamic Programming Là Gì?
Dynamic Programming (DP) là kỹ thuật tối ưu giải các bài toán phức tạp bằng cách chia thành overlapping subproblems, giải mỗi subproblem một lần, và lưu kết quả để dùng lại.
Hãy nghĩ nó như recursion + caching. Không có DP, recursive solution tính lại cùng subproblems theo cấp số mũ. Với DP, mỗi subproblem được giải một lần rồi tái sử dụng.
Trong cây trên, fib(3) và fib(2) được tính nhiều lần. DP loại bỏ sự dư thừa này.
1.2 Hai Điều Kiện Cho DP
Bài toán có thể giải bằng DP nếu có:
- Overlapping subproblems: Cùng bài toán nhỏ hơn được giải lặp đi lặp lại
- Optimal substructure: Solution tối ưu có thể được xây dựng từ các solution tối ưu của subproblems
| Điều kiện | Ý nghĩa | Ví dụ |
|---|---|---|
| Overlapping subproblems | Cùng phép tính được lặp lại | Fibonacci: fib(3) tính nhiều lần |
| Optimal substructure | Đáp án tối ưu dùng các đáp án con tối ưu | Đường đi ngắn nhất: A→C dùng A→B + B→C ngắn nhất |
1.3 Memoization vs Tabulation
| Approach | Hướng | Cài đặt | Ưu điểm | Nhược điểm |
|---|---|---|---|---|
| Memoization | Top-down | Recursion + cache (hashmap/array) | Trực quan, chỉ giải subproblems cần thiết | Có thể stack overflow, overhead gọi hàm |
| Tabulation | Bottom-up | Iterative, điền bảng từ base cases | Không overhead recursion, dễ tối ưu space | Phải giải tất cả subproblems, thứ tự quan trọng |
Nguyên tắc chung: Bắt đầu với memoization (dễ suy nghĩ hơn), rồi chuyển sang tabulation nếu cần tối ưu.
1.4 Framework DP 5 Bước
Mọi bài DP đều theo approach có hệ thống này:
- Định nghĩa state —
dp[i](hoặcdp[i][j]) biểu diễn gì? - Định nghĩa hệ thức truy hồi — Tính
dp[i]từ các states đã tính trước đó thế nào? - Xác định base cases — Subproblems nhỏ nhất có thể giải tầm thường?
- Quyết định thứ tự tính — Top-down (memoization) hay bottom-up (tabulation)?
- Tối ưu space — Có thể giảm từ 2D xuống 1D? Từ array xuống hai biến?
2. Dynamic Programming 1D
Bài 1: Leo Cầu Thang
Bạn leo cầu thang có
nbậc. Mỗi lần bạn có thể leo 1 hoặc 2 bậc. Có bao nhiêu cách khác nhau để lên đỉnh?
Framework 5 bước:
- State:
dp[i]= số cách để đến bậci - Truy hồi:
dp[i] = dp[i-1] + dp[i-2](đến từ 1 bậc trước hoặc 2 bậc trước) - Base cases:
dp[0] = 1,dp[1] = 1 - Thứ tự: Bottom-up (0 → n)
- Tối ưu space: Chỉ cần hai giá trị trước → O(1) space
// TypeScript - Climbing Stairs
// Memoization (top-down)
function climbStairsMemo(n: number): number {
const memo = new Map<number, number>();
function dp(i: number): number {
if (i <= 1) return 1;
if (memo.has(i)) return memo.get(i)!;
const result = dp(i - 1) + dp(i - 2);
memo.set(i, result);
return result;
}
return dp(n);
}
// Tabulation (bottom-up)
function climbStairsTab(n: number): number {
if (n <= 1) return 1;
const dp = new Array(n + 1);
dp[0] = 1;
dp[1] = 1;
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
// Tối ưu space
function climbStairs(n: number): number {
if (n <= 1) return 1;
let prev2 = 1, prev1 = 1;
for (let i = 2; i <= n; i++) {
const curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
console.log(climbStairs(5)); // 8# Python - Climbing Stairs
from functools import lru_cache
# Memoization (top-down)
def climb_stairs_memo(n: int) -> int:
@lru_cache(maxsize=None)
def dp(i: int) -> int:
if i <= 1:
return 1
return dp(i - 1) + dp(i - 2)
return dp(n)
# Tabulation (bottom-up)
def climb_stairs_tab(n: int) -> int:
if n <= 1:
return 1
dp = [0] * (n + 1)
dp[0] = dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# Tối ưu space
def climb_stairs(n: int) -> int:
if n <= 1:
return 1
prev2, prev1 = 1, 1
for _ in range(2, n + 1):
prev2, prev1 = prev1, prev1 + prev2
return prev1
print(climb_stairs(5)) # 8Time: O(n). Space: O(1) khi tối ưu.
Bài 2: Cướp Nhà
Bạn là tên cướp lên kế hoạch cướp các nhà dọc phố. Mỗi nhà có tiền. Bạn không thể cướp hai nhà liền kề. Tìm số tiền tối đa có thể cướp?
Framework 5 bước:
- State:
dp[i]= tiền tối đa cướp từ nhà0..i - Truy hồi:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])— bỏ qua nhà i, hoặc cướp nó (+ bỏ qua i-1) - Base cases:
dp[0] = nums[0],dp[1] = max(nums[0], nums[1]) - Thứ tự: Bottom-up (0 → n)
- Space: O(1) — chỉ cần hai giá trị trước
// TypeScript - House Robber
function rob(nums: number[]): number {
if (nums.length === 0) return 0;
if (nums.length === 1) return nums[0];
let prev2 = nums[0];
let prev1 = Math.max(nums[0], nums[1]);
for (let i = 2; i < nums.length; i++) {
const curr = Math.max(prev1, prev2 + nums[i]);
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
console.log(rob([2, 7, 9, 3, 1])); // 12 (cướp nhà 0, 2, 4)# Python - House Robber
def rob(nums: list[int]) -> int:
if not nums:
return 0
if len(nums) == 1:
return nums[0]
prev2, prev1 = nums[0], max(nums[0], nums[1])
for i in range(2, len(nums)):
prev2, prev1 = prev1, max(prev1, prev2 + nums[i])
return prev1
print(rob([2, 7, 9, 3, 1])) # 12Time: O(n). Space: O(1).
Bài 3: Đổi Tiền Xu
Cho các đồng xu với mệnh giá khác nhau và tổng số tiền, tìm số đồng xu ít nhất cần để tạo ra số tiền đó. Trả về -1 nếu không thể.
Framework 5 bước:
- State:
dp[amount]= số xu tối thiểu để tạoamount - Truy hồi:
dp[i] = min(dp[i - coin] + 1)cho mỗi loại xu - Base cases:
dp[0] = 0 - Thứ tự: Bottom-up (0 → amount)
- Space: O(amount)
// TypeScript - Coin Change
function coinChange(coins: number[], amount: number): number {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (let i = 1; i <= amount; i++) {
for (const coin of coins) {
if (coin <= i && dp[i - coin] !== Infinity) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}
console.log(coinChange([1, 5, 10, 25], 30)); // 2 (25 + 5)
console.log(coinChange([2], 3)); // -1# Python - Coin Change
def coin_change(coins: list[int], amount: int) -> int:
dp = [float("inf")] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for coin in coins:
if coin <= i and dp[i - coin] != float("inf"):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float("inf") else -1
print(coin_change([1, 5, 10, 25], 30)) # 2
print(coin_change([2], 3)) # -1Time: O(amount × coins). Space: O(amount).
Bài 4: Dãy Con Tăng Dài Nhất (LIS)
Cho mảng số nguyên, tìm độ dài dãy con tăng nghiêm ngặt dài nhất.
Framework 5 bước:
- State:
dp[i]= độ dài LIS kết thúc tại indexi - Truy hồi:
dp[i] = max(dp[j] + 1)cho mọij < imànums[j] < nums[i] - Base cases:
dp[i] = 1cho mọii(mỗi phần tử tự nó là subsequence) - Thứ tự: Bottom-up (0 → n)
- Đáp án:
max(dp)— LIS có thể kết thúc ở index bất kỳ
// TypeScript - Longest Increasing Subsequence
// Solution DP O(n²)
function lengthOfLIS(nums: number[]): number {
const n = nums.length;
const dp = new Array(n).fill(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);
}
// Tối ưu O(n log n) với binary search
function lengthOfLISOptimal(nums: number[]): number {
const tails: number[] = []; // tail nhỏ nhất cho mỗi độ dài LIS
for (const num of nums) {
let lo = 0, hi = tails.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (tails[mid] < num) lo = mid + 1;
else hi = mid;
}
tails[lo] = num;
}
return tails.length;
}
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4 → [2,3,7,101]# Python - Longest Increasing Subsequence
# Solution DP O(n²)
def length_of_lis(nums: list[int]) -> int:
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)
# Tối ưu O(n log n) với binary search
import bisect
def length_of_lis_optimal(nums: list[int]) -> int:
tails = []
for num in nums:
pos = bisect.bisect_left(tails, num)
if pos == len(tails):
tails.append(num)
else:
tails[pos] = num
return len(tails)
print(length_of_lis([10, 9, 2, 5, 3, 7, 101, 18])) # 4Time: O(n²) cơ bản, O(n log n) tối ưu. Space: O(n).
3. Dynamic Programming 2D
Bài 5: Đường Đi Duy Nhất
Robot bắt đầu ở góc trên-trái của lưới
m × n. Nó chỉ có thể đi phải hoặc xuống. Có bao nhiêu đường đi duy nhất để đến góc dưới-phải?
Framework 5 bước:
- State:
dp[i][j]= số đường đi đến ô(i, j) - Truy hồi:
dp[i][j] = dp[i-1][j] + dp[i][j-1](từ trên + từ trái) - Base cases:
dp[0][j] = 1,dp[i][0] = 1(chỉ có một cách dọc theo cạnh) - Thứ tự: Hàng theo hàng, trái sang phải
- Space: O(n) — chỉ cần hàng hiện tại
// TypeScript - Unique Paths
function uniquePaths(m: number, n: number): number {
const dp = new Array(n).fill(1);
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[j] += dp[j - 1]; // dp[j] đã có giá trị từ hàng trên
}
}
return dp[n - 1];
}
console.log(uniquePaths(3, 7)); // 28# Python - Unique Paths
def unique_paths(m: int, n: int) -> int:
dp = [1] * n
for i in range(1, m):
for j in range(1, n):
dp[j] += dp[j - 1]
return dp[n - 1]
print(unique_paths(3, 7)) # 28Time: O(m × n). Space: O(n).
Bài 6: Dãy Con Chung Dài Nhất (LCS)
Cho hai chuỗi, tìm độ dài dãy con chung dài nhất — chuỗi ký tự dài nhất xuất hiện trong cả hai chuỗi theo cùng thứ tự (không nhất thiết liên tiếp).
Framework 5 bước:
- State:
dp[i][j]= độ dài LCS củatext1[0..i-1]vàtext2[0..j-1] - Truy hồi:
- Nếu
text1[i-1] == text2[j-1]:dp[i][j] = dp[i-1][j-1] + 1 - Ngược lại:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
- Nếu
- Base cases:
dp[0][j] = 0,dp[i][0] = 0 - Thứ tự: Hàng theo hàng
- Space: O(n) với rolling array
// TypeScript - Longest Common Subsequence
function longestCommonSubsequence(text1: string, text2: string): number {
const m = text1.length, n = text2.length;
const dp = 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;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
console.log(longestCommonSubsequence("abcde", "ace")); // 3 → "ace"# Python - Longest Common Subsequence
def longest_common_subsequence(text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
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])
return dp[m][n]
print(longest_common_subsequence("abcde", "ace")) # 3Time: O(m × n). Space: O(m × n), có thể tối ưu xuống O(n).
Bài 7: Khoảng Cách Chỉnh Sửa
Cho hai chuỗi
word1vàword2, trả về số thao tác tối thiểu (chèn, xóa, thay thế) để chuyểnword1thànhword2.
Framework 5 bước:
- State:
dp[i][j]= số thao tác tối thiểu chuyểnword1[0..i-1]thànhword2[0..j-1] - Truy hồi:
- Nếu
word1[i-1] == word2[j-1]:dp[i][j] = dp[i-1][j-1](không cần thao tác) - Ngược lại:
dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])(thay, xóa, chèn)
- Nếu
- Base cases:
dp[i][0] = i(xóa hết),dp[0][j] = j(chèn hết) - Thứ tự: Hàng theo hàng
// TypeScript - Edit Distance
function minDistance(word1: string, word2: string): number {
const m = word1.length, n = word2.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
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];
} else {
dp[i][j] = 1 + Math.min(
dp[i - 1][j - 1], // thay thế
dp[i - 1][j], // xóa
dp[i][j - 1] // chèn
);
}
}
}
return dp[m][n];
}
console.log(minDistance("horse", "ros")); // 3
console.log(minDistance("intention", "execution")); // 5# Python - Edit Distance
def min_distance(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(
dp[i - 1][j - 1], # thay thế
dp[i - 1][j], # xóa
dp[i][j - 1], # chèn
)
return dp[m][n]
print(min_distance("horse", "ros")) # 3
print(min_distance("intention", "execution")) # 5Time: O(m × n). Space: O(m × n).
Bài 8: Bài Toán Ba Lô 0/1
Cho
nvật phẩm với trọng lượng và giá trị, và ba lô có sức chứaW, tìm giá trị tối đa có thể mang mà không vượt quá giới hạn trọng lượng. Mỗi vật phẩm chỉ lấy tối đa một lần.
Framework 5 bước:
- State:
dp[i][w]= giá trị tối đa dùng vật phẩm0..i-1với sức chứaw - Truy hồi:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])- Bỏ qua vật phẩm
i, hoặc lấy nó (nếu vừa)
- Bỏ qua vật phẩm
- Base cases:
dp[0][w] = 0cho mọiw - Thứ tự: Từng vật phẩm, sức chứa 0 → W
- Space: O(W) với tối ưu 1D (duyệt sức chứa ngược)
// TypeScript - 0/1 Knapsack
function knapsack(weights: number[], values: number[], capacity: number): number {
const n = weights.length;
// DP 1D tối ưu space
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < n; i++) {
// Duyệt ngược để tránh dùng vật phẩm i hai lần
for (let w = capacity; w >= weights[i]; w--) {
dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[capacity];
}
console.log(knapsack([2, 3, 4, 5], [3, 4, 5, 6], 8)); // 10# Python - 0/1 Knapsack
def knapsack(weights: list[int], values: list[int], capacity: int) -> int:
n = len(weights)
dp = [0] * (capacity + 1)
for i in range(n):
# Duyệt ngược để đảm bảo mỗi vật phẩm dùng tối đa một lần
for w in range(capacity, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
print(knapsack([2, 3, 4, 5], [3, 4, 5, 6], 8)) # 10Time: O(n × W). Space: O(W).
Bài 9: Chia Tập Con Bằng Tổng
Cho mảng số nguyên, xác định xem có thể chia thành hai tập con có tổng bằng nhau không.
Approach: Đây là biến thể 0/1 knapsack. Target = totalSum / 2. Liệu có tìm được tập con tổng bằng target?
// TypeScript - Partition Equal Subset Sum
function canPartition(nums: number[]): boolean {
const total = nums.reduce((a, b) => a + b, 0);
if (total % 2 !== 0) return false;
const target = total / 2;
const dp = new Array(target + 1).fill(false);
dp[0] = true;
for (const num of nums) {
for (let j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
console.log(canPartition([1, 5, 11, 5])); // true → [1,5,5] và [11]
console.log(canPartition([1, 2, 3, 5])); // false# Python - Partition Equal Subset Sum
def can_partition(nums: list[int]) -> bool:
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for j in range(target, num - 1, -1):
dp[j] = dp[j] or dp[j - num]
return dp[target]
print(can_partition([1, 5, 11, 5])) # True
print(can_partition([1, 2, 3, 5])) # FalseTime: O(n × target). Space: O(target).
Bài 10: Tách Từ
Cho chuỗi
svà từ điển, trả vềtruenếuscó thể được tách thành các từ trong từ điển.
Framework 5 bước:
- State:
dp[i]=s[0..i-1]có thể tách được không? - Truy hồi:
dp[i] = truenếu tồn tạij < imàdp[j]là true vàs[j..i-1]nằm trong từ điển - Base case:
dp[0] = true(chuỗi rỗng)
// TypeScript - Word Break
function wordBreak(s: string, wordDict: string[]): boolean {
const words = new Set(wordDict);
const n = s.length;
const dp = new Array(n + 1).fill(false);
dp[0] = true;
for (let i = 1; i <= n; i++) {
for (let j = 0; j < i; j++) {
if (dp[j] && words.has(s.slice(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
console.log(wordBreak("leetcode", ["leet", "code"])); // true
console.log(wordBreak("applepenapple", ["apple", "pen"])); // true# Python - Word Break
def word_break(s: str, word_dict: list[str]) -> bool:
words = set(word_dict)
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 words:
dp[i] = True
break
return dp[n]
print(word_break("leetcode", ["leet", "code"])) # True
print(word_break("applepenapple", ["apple", "pen"])) # TrueTime: O(n² × k) với k = độ dài từ trung bình. Space: O(n).
4. Các Pattern DP Phổ Biến
| Pattern | Định nghĩa State | Ví dụ |
|---|---|---|
| Linear (1D) | dp[i] = đáp án cho i phần tử đầu | Leo cầu thang, Cướp nhà, Đổi xu |
| Hai chuỗi (2D) | dp[i][j] = đáp án cho s1[0..i] và s2[0..j] | LCS, Edit Distance |
| Lưới (2D) | dp[i][j] = đáp án cho ô (i,j) | Unique Paths, Min Path Sum |
| Knapsack | dp[i][w] = đáp án dùng vật 0..i với sức chứa w | 0/1 Knapsack, Subset Sum |
| Interval | dp[i][j] = đáp án cho đoạn [i..j] | Burst Balloons, Matrix Chain |
| Subsequence | dp[i] = đáp án cho subsequence kết thúc tại i | LIS, Maximum Subarray |
| State machine | dp[i][state] = đáp án tại vị trí i ở state cho trước | Best Time to Buy/Sell Stock |
Mẹo Nhận Diện Pattern
- "Tối thiểu / tối đa / số cách" → khả năng cao là DP
- "Có thể làm được không?" (có/không) → DP với boolean states
- "Chia thành tập con" → Biến thể Knapsack
- "Subsequence / substring" → DP 1D hoặc 2D
- "So sánh hai chuỗi" → DP 2D (pattern LCS / Edit Distance)
- "Duyệt lưới có ràng buộc" → DP 2D
5. Greedy Algorithms
5.1 Greedy Algorithm Là Gì?
Greedy algorithm đưa ra lựa chọn tối ưu cục bộ ở mỗi bước, hy vọng tìm được tối ưu toàn cục. Khác với DP, nó không bao giờ xem xét lại các lựa chọn trước.
5.2 Greedy vs DP
| Tính chất | Greedy | Dynamic Programming |
|---|---|---|
| Approach | Local optimal → global optimal | Giải tất cả subproblems → kết hợp |
| Xem xét lại? | Không bao giờ | So sánh tất cả options |
| Cần chứng minh? | Phải chứng minh greedy hoạt động | Optimal substructure đủ |
| Complexity | Thường O(n log n) hoặc O(n) | Thường O(n²) hoặc O(n × W) |
| Ví dụ | Fractional knapsack | 0/1 knapsack |
Insight chính: Greedy đơn giản và nhanh hơn — nhưng chỉ hoạt động khi bạn có thể chứng minh rằng lựa chọn tối ưu cục bộ dẫn đến solution tối ưu toàn cục. Khi không chắc, dùng DP.
5.3 Khi Nào Greedy Hoạt Động
Greedy hoạt động khi bài toán có:
- Greedy choice property: Lựa chọn tối ưu cục bộ dẫn đến solution tối ưu toàn cục
- Optimal substructure: Solution tối ưu chứa các solution tối ưu của subproblems
6. Các Bài Toán Greedy Kinh Điển
Bài 11: Jump Game
Cho mảng mà mỗi phần tử biểu diễn độ dài nhảy tối đa từ vị trí đó, xác định xem bạn có thể đến index cuối không.
Greedy insight: Theo dõi index xa nhất có thể đến. Nếu không đến được index hiện tại, trả về false.
// TypeScript - Jump Game
function canJump(nums: number[]): boolean {
let farthest = 0;
for (let i = 0; i < nums.length; i++) {
if (i > farthest) return false;
farthest = Math.max(farthest, i + nums[i]);
}
return true;
}
console.log(canJump([2, 3, 1, 1, 4])); // true
console.log(canJump([3, 2, 1, 0, 4])); // false# Python - Jump Game
def can_jump(nums: list[int]) -> bool:
farthest = 0
for i in range(len(nums)):
if i > farthest:
return False
farthest = max(farthest, i + nums[i])
return True
print(can_jump([2, 3, 1, 1, 4])) # True
print(can_jump([3, 2, 1, 0, 4])) # FalseTime: O(n). Space: O(1).
Bài 12: Jump Game II
Cùng setup, tìm số lần nhảy tối thiểu để đến index cuối.
Greedy insight: Dùng levels giống BFS. Ở mỗi "level", tìm điểm xa nhất có thể đến. Mỗi level = một lần nhảy.
// TypeScript - Jump Game II
function jump(nums: number[]): number {
let jumps = 0;
let currentEnd = 0;
let farthest = 0;
for (let i = 0; i < nums.length - 1; i++) {
farthest = Math.max(farthest, i + nums[i]);
if (i === currentEnd) {
jumps++;
currentEnd = farthest;
}
}
return jumps;
}
console.log(jump([2, 3, 1, 1, 4])); // 2# Python - Jump Game II
def jump(nums: list[int]) -> int:
jumps = 0
current_end = 0
farthest = 0
for i in range(len(nums) - 1):
farthest = max(farthest, i + nums[i])
if i == current_end:
jumps += 1
current_end = farthest
return jumps
print(jump([2, 3, 1, 1, 4])) # 2Time: O(n). Space: O(1).
Bài 13: Chọn Hoạt Động / Khoảng Không Chồng Lấn
Cho các khoảng
[start, end], tìm số lượng khoảng không chồng lấn tối đa.
Greedy insight: Sắp xếp theo end time. Luôn chọn khoảng kết thúc sớm nhất — điều này để lại nhiều chỗ nhất cho các khoảng sau.
// TypeScript - Non-overlapping Intervals (trả về số khoảng cần xóa tối thiểu)
function eraseOverlapIntervals(intervals: number[][]): number {
intervals.sort((a, b) => a[1] - b[1]); // Sắp xếp theo end time
let count = 0;
let prevEnd = -Infinity;
for (const [start, end] of intervals) {
if (start >= prevEnd) {
prevEnd = end; // Giữ khoảng này
} else {
count++; // Xóa khoảng này (bị chồng)
}
}
return count;
}
console.log(eraseOverlapIntervals([[1,2],[2,3],[3,4],[1,3]])); // 1# Python - Non-overlapping Intervals
def erase_overlap_intervals(intervals: list[list[int]]) -> int:
intervals.sort(key=lambda x: x[1]) # Sắp xếp theo end time
count = 0
prev_end = float("-inf")
for start, end in intervals:
if start >= prev_end:
prev_end = end
else:
count += 1
return count
print(erase_overlap_intervals([[1,2],[2,3],[3,4],[1,3]])) # 1Time: O(n log n). Space: O(1).
Bài 14: Trạm Xăng
Có
ntrạm xăng xếp thành vòng tròn.gas[i]là lượng xăng tại trạmi,cost[i]là xăng cần để đi đến trạmi+1. Tìm trạm bắt đầu để đi hết vòng, hoặc -1 nếu không thể.
Greedy insight: Nếu tổng xăng ≥ tổng chi phí, solution tồn tại. Bắt đầu từ trạm mà running tank lần đầu âm — trạm tiếp theo là đáp án.
// TypeScript - Gas Station
function canCompleteCircuit(gas: number[], cost: number[]): number {
let totalTank = 0;
let currentTank = 0;
let start = 0;
for (let i = 0; i < gas.length; i++) {
const net = gas[i] - cost[i];
totalTank += net;
currentTank += net;
if (currentTank < 0) {
start = i + 1;
currentTank = 0;
}
}
return totalTank >= 0 ? start : -1;
}
console.log(canCompleteCircuit([1,2,3,4,5], [3,4,5,1,2])); // 3# Python - Gas Station
def can_complete_circuit(gas: list[int], cost: list[int]) -> int:
total_tank = 0
current_tank = 0
start = 0
for i in range(len(gas)):
net = gas[i] - cost[i]
total_tank += net
current_tank += net
if current_tank < 0:
start = i + 1
current_tank = 0
return start if total_tank >= 0 else -1
print(can_complete_circuit([1,2,3,4,5], [3,4,5,1,2])) # 3Time: O(n). Space: O(1).
7. Hướng Dẫn Chọn DP vs Greedy
| Loại bài toán | Dùng Greedy | Dùng DP |
|---|---|---|
| Fractional knapsack | ✅ Lấy theo tỷ lệ value/weight | — |
| 0/1 knapsack | ❌ Greedy sai | ✅ |
| Jump Game (đến được?) | ✅ Theo dõi farthest | — |
| Coin Change (ít xu nhất) | ❌ Greedy sai với xu tùy ý | ✅ |
| Activity Selection | ✅ Sort theo end time | — |
| Longest Increasing Subsequence | — | ✅ |
| Interval scheduling | ✅ Sort theo end time | — |
| Edit Distance | — | ✅ |
8. Tham Khảo Complexity
Các Bài DP
| Bài toán | Time | Space | Space Tối ưu |
|---|---|---|---|
| Leo Cầu Thang | O(n) | O(n) | O(1) |
| Cướp Nhà | O(n) | O(n) | O(1) |
| Đổi Xu | O(n × amount) | O(amount) | — |
| LIS | O(n²) / O(n log n) | O(n) | — |
| Unique Paths | O(m × n) | O(m × n) | O(n) |
| LCS | O(m × n) | O(m × n) | O(n) |
| Edit Distance | O(m × n) | O(m × n) | O(n) |
| 0/1 Knapsack | O(n × W) | O(n × W) | O(W) |
| Chia Tập Con | O(n × sum) | O(sum) | — |
| Tách Từ | O(n²) | O(n) | — |
Các Bài Greedy
| Bài toán | Time | Space |
|---|---|---|
| Jump Game | O(n) | O(1) |
| Jump Game II | O(n) | O(1) |
| Non-overlapping Intervals | O(n log n) | O(1) |
| Trạm Xăng | O(n) | O(1) |
9. Bài Tập Thực Hành
Easy (Xây Dựng Nền Tảng)
| # | Bài toán | Pattern | Approach |
|---|---|---|---|
| 1 | Climbing Stairs | 1D DP | dp[i] = dp[i-1] + dp[i-2] |
| 2 | Min Cost Climbing Stairs | 1D DP | dp[i] = cost[i] + min(dp[i-1], dp[i-2]) |
| 3 | Maximum Subarray | Kadane's / DP | Track max ending here |
| 4 | Best Time to Buy and Sell Stock | Greedy | Track min price, max profit |
| 5 | Is Subsequence | Greedy / Two pointer | Match characters greedily |
Medium (Xây Dựng Tự Tin)
| # | Bài toán | Pattern | Approach |
|---|---|---|---|
| 6 | House Robber | 1D DP | max(skip, rob + dp[i-2]) |
| 7 | Coin Change | Knapsack | min coins cho mỗi amount |
| 8 | Longest Increasing Subsequence | Subsequence DP | dp[i] = max(dp[j]+1) for j < i |
| 9 | Unique Paths | Grid DP | dp[i][j] = dp[i-1][j] + dp[i][j-1] |
| 10 | Longest Common Subsequence | Two-string DP | Match hoặc max(skip) |
| 11 | Word Break | 1D DP | dp[i] = any dp[j] && s[j:i] in dict |
| 12 | Partition Equal Subset Sum | Knapsack | Subset sum = total/2 |
| 13 | Jump Game | Greedy | Track farthest reachable |
| 14 | Jump Game II | Greedy (BFS) | Level-by-level jumps |
| 15 | Non-overlapping Intervals | Greedy | Sort by end, count kept |
| 16 | Gas Station | Greedy | Track tank and start |
Hard (Master Level)
| # | Bài toán | Pattern | Approach |
|---|---|---|---|
| 17 | Edit Distance | Two-string DP | insert/delete/replace min |
| 18 | Regular Expression Matching | Two-string DP | Handle '.' và '*' cases |
| 19 | Burst Balloons | Interval DP | dp[i][j] = max(burst last) |
| 20 | Longest Valid Parentheses | 1D DP / Stack | Track valid ranges |
Tóm Tắt
Dynamic Programming và Greedy Algorithms là hai trụ cột của tối ưu trong khoa học máy tính — và là boss cuối cùng của coding interviews.
Framework DP (5 bước):
- Định nghĩa state
- Viết hệ thức truy hồi
- Xác định base cases
- Chọn thứ tự tính (top-down hoặc bottom-up)
- Tối ưu space
Các DP pattern phổ biến:
- 1D: Leo cầu thang, Cướp nhà, Đổi xu, Tách từ
- Lưới 2D: Unique Paths, Min Path Sum
- Hai chuỗi: LCS, Edit Distance
- Knapsack: 0/1 Knapsack, Subset Sum, Coin Change
Khi nào dùng greedy:
- Bạn có thể chứng minh local optimal = global optimal
- Sort + duyệt một lượt thường đủ
- Ví dụ: Jump Game, Activity Selection, Gas Station
Khi nào dùng DP:
- Có overlapping subproblems
- Cần xem xét tất cả khả năng
- Greedy không hoạt động (vd: 0/1 knapsack, coin change với mệnh giá tùy ý)
Đây là bài cuối trong DSA series! Bạn giờ đã có bộ công cụ đầy đủ — từ arrays và strings qua trees, graphs, heaps, sorting, và bây giờ DP và greedy. Chìa khóa để master là practice: chọn bài từ các bảng trên, áp dụng framework, và xây dựng pattern recognition thông qua lặp lại.
Bài trước: Heaps & Priority Queues
Tổng quan series: Data Structures & Algorithms Roadmap
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.