Bài toán
Có n đồ vật, mỗi đồ vật có trọng lượng w[i] và giá trị v[i]. Có một cái túi chứa tối đa W đơn vị trọng lượng. Chọn đồ vật nào để tổng giá trị là lớn nhất?
Ràng buộc “0/1”: mỗi đồ vật hoặc lấy (1) hoặc không lấy (0), không được lấy một phần.
Phân tích
Với mỗi đồ vật i, có hai lựa chọn:
- Không lấy đồ vật
i: giá trị =dp[i-1][w] - Lấy đồ vật
i(nếuw[i] <= w): giá trị =dp[i-1][w - w[i]] + v[i]
Công thức: dp[i][w] = max(dp[i-1][w], dp[i-1][w-w[i]] + v[i])
Cài đặt 2D (dễ hiểu)
def knapsack(n, W, weights, values):
"""
n: số đồ vật, W: sức chứa túi
dp[i][w] = giá trị lớn nhất dùng i đồ vật đầu, túi sức chứa w
"""
dp = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(W + 1):
dp[i][w] = dp[i-1][w] # Không lấy đồ vật i
if weights[i-1] <= w:
lay = dp[i-1][w - weights[i-1]] + values[i-1]
dp[i][w] = max(dp[i][w], lay) # Lấy đồ vật i
return dp[n][W]
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
W = 7
n = len(weights)
print(knapsack(n, W, weights, values)) # 9 (lấy đồ vật 2 và 3: 4+5 trọng 3+4=7)
Cài đặt 1D (tối ưu bộ nhớ)
Vì dp[i] chỉ phụ thuộc dp[i-1], có thể nén xuống một mảng 1D. Lưu ý: duyệt w từ lớn đến nhỏ để tránh dùng lại đồ vật đã lấy.
def knapsack_1d(n, W, weights, values):
"""Phiên bản tối ưu bộ nhớ — O(W) không gian."""
dp = [0] * (W + 1)
for i in range(n):
for w in range(W, weights[i] - 1, -1): # Duyệt ngược!
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[W]
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
W = 7
print(knapsack_1d(len(weights), W, weights, values)) # 9
Tái tạo đồ vật đã chọn
def knapsack_trace(n, W, weights, values):
"""Trả về (giá trị max, danh sách đồ vật đã chọn)."""
dp = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(W + 1):
dp[i][w] = dp[i-1][w]
if weights[i-1] <= w:
lay = dp[i-1][w - weights[i-1]] + values[i-1]
dp[i][w] = max(dp[i][w], lay)
# Truy vết ngược
da_chon = []
w = W
for i in range(n, 0, -1):
if dp[i][w] != dp[i-1][w]: # Đồ vật i được lấy
da_chon.append(i - 1) # Chỉ số 0-based
w -= weights[i-1]
return dp[n][W], da_chon
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
max_val, chosen = knapsack_trace(4, 7, weights, values)
print(f"Giá trị max: {max_val}") # 9
print(f"Đồ vật chọn (0-indexed): {chosen}") # [2, 1] (đồ vật 3 và 2)
Bài tập luyện tập
Dễ — Bài toán chia đôi tập hợp (Partition Equal Subset Sum)
Cho mảng số nguyên dương, kiểm tra xem có thể chia thành hai tập con có tổng bằng nhau không.
def co_the_chia_dau(arr):
"""
Nếu tổng lẻ → không thể. Bằng không:
kiểm tra có thể chọn tập con có tổng = tổng/2 không.
→ Knapsack với target = sum/2, value = weight.
"""
tong = sum(arr)
if tong % 2 != 0:
return False
target = tong // 2
dp = [False] * (target + 1)
dp[0] = True
for x in arr:
for w in range(target, x - 1, -1):
dp[w] = dp[w] or dp[w - x]
return dp[target]
print(co_the_chia_dau([1, 5, 11, 5])) # True (1+5+5 = 11)
print(co_the_chia_dau([1, 2, 3, 5])) # False
print(co_the_chia_dau([1, 1, 1, 1])) # True (1+1 = 1+1)
Trung bình — Knapsack với vật phẩm vô hạn (Unbounded Knapsack)
Mỗi đồ vật có thể lấy bao nhiêu lần tùy ý. Chỉ cần đổi thứ tự duyệt: duyệt xuôi thay vì ngược.
def knapsack_khong_gioi_han(W, weights, values):
"""
Duyệt w từ nhỏ đến lớn (xuôi): cho phép lấy lại đồ vật.
Khác hoàn toàn 0/1 knapsack duyệt từ lớn đến nhỏ.
"""
dp = [0] * (W + 1)
for w in range(1, W + 1):
for i in range(len(weights)):
if weights[i] <= w:
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[W]
# Ví dụ: đúc tượng vàng — có thể dùng nhiều thỏi cùng loại
weights = [1, 3, 4]
values = [10, 40, 50]
print(knapsack_khong_gioi_han(7, weights, values)) # 80 (dùng 2 thỏi loại 2 + 1 thỏi loại 1: 40+40 trọng 6, còn 1 → 40+40+10=90? Hmm, 3+3+1=7, 40+40+10=90)
# Thực ra: 3+3+1=7, value=40+40+10=90
Khó — Số lượng cách đổi tiền (Coin Change II)
Đếm số cách chọn đồng xu để tổng bằng amount (mỗi đồng xu dùng được nhiều lần).
def so_cach_doi_tien(amount, coins):
"""
dp[w] = số cách dùng các loại đồng xu để thành tổng w.
Duyệt đồng xu ở ngoài, tổng ở trong → mỗi tổ hợp tính một lần.
"""
dp = [0] * (amount + 1)
dp[0] = 1 # 1 cách để tạo tổng 0 (không dùng đồng nào)
for coin in coins:
for w in range(coin, amount + 1):
dp[w] += dp[w - coin]
return dp[amount]
print(so_cach_doi_tien(5, [1, 2, 5])) # 4 (5, 2+2+1, 2+1+1+1, 1*5)
print(so_cach_doi_tien(3, [2])) # 0 (không thể)
print(so_cach_doi_tien(10, [10])) # 1
Tổng kết các dạng Knapsack
| Dạng | Mỗi vật dùng | Duyệt w |
|---|---|---|
| 0/1 Knapsack | 0 hoặc 1 lần | Từ lớn đến nhỏ |
| Unbounded Knapsack | Không giới hạn | Từ nhỏ đến lớn |
| Bounded Knapsack | Tối đa k[i] lần | Chia thành 0/1 hoặc tính riêng |
| Group Knapsack | Chọn tối đa 1 trong mỗi nhóm | Vòng ngoài là nhóm |
Kết luận
Bài toán cái túi là nền tảng của một họ bài DP rất rộng trong thi đấu lập trình. Khi gặp bài toán “chọn/bỏ” với giới hạn tổng, hãy nghĩ đến knapsack. Với n, W ≤ 10⁵ thì bảng DP O(n×W) vẫn chạy được; với W lớn hơn cần kỹ thuật tối ưu như meet-in-the-middle hoặc branch and bound. Tiếp tục khám phá Lý thuyết đồ thị — nánh kiến thức quan trọng tiếp theo trong thi đấu lập trình.
Bình luận