Bài toán cái túi — 0/1 Knapsack, Unbounded và tối ưu không gian
Knapsack chọn items tối đa value trong giới hạn W. Nắm 0/1 knapsack (dp[i][w] 2D → 1D), unbounded knapsack, vì sao greedy sai với counter-example, và liên hệ coin change.
TL;DR: Bài toán cái túi (knapsack) hỏi: chọn tập items sao cho tổng weight không vượt W và tổng value lớn nhất. Với 0/1 knapsack (mỗi item dùng tối đa 1 lần), DP 2D dp[i][w] = giá trị tốt nhất khi xét i items đầu, sức chứa w. Transition: chọn hoặc không chọn item thứ i. Tối ưu 1D bằng cách duyệt w từ W xuống 0. Greedy (chọn theo value/weight ratio) sai với 0/1 knapsack — counter-example đơn giản bác bỏ.
Một công ty logistics phải chọn đơn hàng nào để xếp vào chuyến xe tải 1.000 kg cuối ngày. Mỗi đơn hàng có trọng lượng và lợi nhuận riêng. Mục tiêu: tối đa hoá lợi nhuận mà không vượt tải trọng. Không thể chia nhỏ đơn hàng — mỗi đơn hoặc lấy hết hoặc bỏ qua. Đây chính xác là cấu trúc của 0/1 Knapsack.
Bài toán xuất hiện ở khắp nơi: danh mục đầu tư (chọn dự án với ngân sách cố định), bộ nhớ cache (quyết định object nào giữ trong RAM), scheduling (chọn task nào làm trong giờ làm việc). Hiểu Knapsack là hiểu nguyên mẫu của một lớp bài toán tối ưu rời rạc.
1. Analogy — Packer thông minh so với packer tham lam
Hãy tưởng tượng hai nhân viên xếp hàng vào kho:
- Packer tham lam luôn chọn món hàng có tỷ lệ value/weight cao nhất. Nghe có lý, nhưng hay bị kẹt: chọn món đắt-nặng-vừa rồi không còn chỗ cho hai món nhẹ-rẻ mà tổng cộng lại còn lời hơn.
- Packer DP tư duy khác: "Nếu còn
wkg, và tôi đang xem xét item thứ i, thì lựa chọn tối ưu là gì?" Mỗi quyết định nhỏ được lưu lại, dùng để xây dựng quyết định lớn hơn.
| Đặc trưng | Packer tham lam | Packer DP |
|---|---|---|
| Chiến lược | Chọn item tốt nhất tức thì | Xét mọi kết hợp qua subproblem |
| Đảm bảo tối ưu | Không (sai với 0/1) | Có |
| Độ phức tạp | O(n log n) | O(n × W) |
| Phù hợp khi nào | Fractional knapsack (chia được) | 0/1 và unbounded |
2. Phát biểu bài toán và ký hiệu
Cho n items, mỗi item i có:
weight[i]: trọng lượng (số nguyên dương)value[i]: giá trị (số nguyên dương)
Và sức chứa tối đa W. Tìm tập con items có tổng weight không vượt W và tổng value lớn nhất.
0/1 knapsack: mỗi item dùng tối đa 1 lần (lấy hoặc bỏ). Unbounded knapsack: mỗi item có thể dùng nhiều lần tùy ý.
3. Cơ chế — DP 2D cho 0/1 Knapsack
3.1 Định nghĩa subproblem
dp[i][w] = giá trị tối đa có thể đạt được khi xét i items đầu tiên (items 1..i), với sức chứa còn lại là w.
Base case:
dp[0][w] = 0với mọi w — chưa xét item nào, value = 0dp[i][0] = 0với mọi i — sức chứa 0, không thể lấy gì
Recurrence (transition):
Với mỗi item i và sức chứa w:
- Nếu
weight[i] > w: không thể lấy item i →dp[i][w] = dp[i-1][w] - Nếu
weight[i] <= w: có thể lấy hoặc không lấy item i- Không lấy:
dp[i-1][w] - Lấy:
value[i] + dp[i-1][w - weight[i]] - Chọn max:
dp[i][w] = max(dp[i-1][w], value[i] + dp[i-1][w - weight[i]])
- Không lấy:
Knapsack_2D(weight[], value[], n, W):
dp[0..n][0..W] <- 0 -- khởi tạo tất cả bằng 0
for i <- 1 đến n:
for w <- 0 đến W:
dp[i][w] <- dp[i-1][w] -- mặc định: không lấy item i
if weight[i] <= w:
dp[i][w] <- max(dp[i][w],
value[i] + dp[i-1][w - weight[i]])
-- so sánh: lấy vs không lấy item i
return dp[n][W] -- đáp án cuối cùng
Time: O(n × W) Space: O(n × W)
3.2 Trace ví dụ — 4 items, W = 5
| Item | weight | value |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 2 | 6 |
| 3 | 3 | 10 |
| 4 | 2 | 16 |
Bảng dp[i][w] (i = item 0-4, w = 0-5):
w=0 w=1 w=2 w=3 w=4 w=5
i=0: 0 0 0 0 0 0
i=1: 0 1 1 1 1 1 (item1: w=1,v=1)
i=2: 0 1 6 7 7 7 (item2: w=2,v=6)
i=3: 0 1 6 10 11 16 (item3: w=3,v=10)
i=4: 0 1 16 17 22 26 (item4: w=2,v=16)
Đáp án: dp[4][5] = 26 — lấy item 3 (w=3,v=10) + item 4 (w=2,v=16) = 26. Sức chứa dùng: 5/5.
flowchart LR
subgraph Quyet_dinh["Quyết định tại mỗi ô dp[i][w]"]
direction TB
ITEM["Item i<br/>weight[i], value[i]"]
CHECK{"weight[i] <= w?"}
SKIP["Không lấy item i<br/>dp[i][w] = dp[i-1][w]"]
TAKE["Lấy item i<br/>value[i] + dp[i-1][w - weight[i]]"]
MAX["dp[i][w] = max(skip, take)"]
ITEM --> CHECK
CHECK -- "Không" --> SKIP
CHECK -- "Có" --> TAKE
SKIP --> MAX
TAKE --> MAX
end4. Tối ưu không gian — 2D xuống 1D
Nhận xét: dp[i][w] chỉ phụ thuộc vào hàng dp[i-1][...]. Không cần giữ toàn bộ bảng 2D — chỉ cần 1 mảng 1D, cập nhật tại chỗ.
Trick quan trọng: duyệt w từ W xuống 0 (ngược chiều). Điều này đảm bảo khi cập nhật dp[w], giá trị dp[w - weight[i]] vẫn là của hàng i-1 (chưa bị ghi đè bởi item i trong vòng lặp hiện tại).
Knapsack_1D(weight[], value[], n, W):
dp[0..W] <- 0 -- mảng 1D, khởi tạo 0
for i <- 1 đến n:
for w <- W xuống weight[i]: -- QUAN TRỌNG: duyệt ngược
dp[w] <- max(dp[w],
value[i] + dp[w - weight[i]])
-- dp[w]: không lấy item i (giá trị từ vòng lặp trước)
-- value[i] + dp[w-weight[i]]: lấy item i
return dp[W]
Time: O(n × W) Space: O(W)
Nếu duyệt từ 0 lên W, khi tính dp[w], giá trị dp[w - weight[i]] đã bị update bởi item i trong cùng vòng lặp. Điều này vô tình cho phép item i được lấy nhiều lần — biến thành unbounded knapsack thay vì 0/1. Duyệt ngược là "khóa" ngăn mỗi item được dùng quá một lần.
5. Greedy sai — counter-example
Greedy cho 0/1 knapsack: chọn items theo thứ tự value/weight giảm dần cho đến khi túi đầy.
Counter-example W = 10:
| Item | weight | value | value/weight |
|---|---|---|---|
| A | 6 | 12 | 2.0 (tốt nhất) |
| B | 5 | 10 | 2.0 (tốt nhất) |
| C | 5 | 10 | 2.0 (tốt nhất) |
Greedy chọn A (ratio 2.0, weight 6), còn 4 kg — B và C đều nặng 5 kg, không vào được. Kết quả: value = 12.
DP tối ưu: bỏ qua A, lấy B + C (5 + 5 = 10 kg = W). Kết quả: value = 20.
Greedy cho 12, DP cho 20 — greedy kém hơn 40%. Greedy bị lừa bởi item A "tốt nhất" nhưng chiếm quá nhiều chỗ, chặn kết hợp tốt hơn.
Nếu items có thể chia nhỏ (fractional knapsack), greedy theo value/weight ratio lại cho kết quả tối ưu. Khi có thể lấy 0.7 của item A, ta không bị kẹt như trên. Hai bài toán có cấu trúc khác nhau căn bản — cần nhận diện đúng trước khi chọn thuật toán.
6. Unbounded Knapsack
Mỗi item có thể được lấy nhiều lần tùy ý. Transition thay đổi:
UnboundedKnapsack(weight[], value[], n, W):
dp[0..W] <- 0
for w <- 1 đến W:
for i <- 1 đến n:
if weight[i] <= w:
dp[w] <- max(dp[w],
value[i] + dp[w - weight[i]])
-- dp[w - weight[i]]: lấy item i lần này,
-- nhưng dp[...] đã được tính với w tăng dần
-- => item i có thể được lấy lần tiếp theo
return dp[W]
Time: O(n × W) Space: O(W)
Khác biệt cốt lõi: với 0/1 knapsack, duyệt w từ W xuống để tránh dùng item nhiều lần. Với unbounded, duyệt w từ 1 lên để cho phép dùng item nhiều lần — dp[w - weight[i]] đã bao gồm giá trị lấy item i trước đó.
Ứng dụng unbounded: Coin change (số lần dùng mỗi mệnh giá không giới hạn — xem liên hệ bên dưới), rod cutting (cắt thanh kim loại thành các đoạn dài bất kỳ), word break (dùng từ trong từ điển nhiều lần).
7. Liên hệ các bài khác
- DP Framework: Knapsack là ví dụ kinh điển của DP — bài này bổ sung concrete implementation sau khi bạn đọc framework tổng quát về subproblem, transition, base case.
- Greedy — tham lam có chứng minh: Bài tiếp theo giải thích vì sao greedy đúng với Fractional Knapsack và Interval Scheduling, và cách chứng minh bằng exchange argument — đối chiếu trực tiếp với counter-example ở bài này.
- Coin Change — unbounded variant: Bài toán đếm số đồng xu tối thiểu để ra đúng số tiền là unbounded knapsack với value = 1 cho mọi coin — cùng template
dp[w] = min(dp[w], 1 + dp[w - coin[i]]), chỉ đổi từ max sang min và thêm 1 thay vì value[i]. - Hàng đợi ưu tiên: Greedy cho fractional knapsack dùng priority queue sort theo ratio — hiểu heap giúp implement greedy variant hiệu quả O(n log n).
8. Deep Dive
0/1 Knapsack là NP-Complete — không có thuật toán polynomial theo cả n và W. DP O(n × W) là pseudo-polynomial: polynomial theo giá trị W (input), nhưng W có thể được encode bằng log₂(W) bits. Nếu W = 2^32, mảng dp có 4 tỷ phần tử — không khả thi.
Khi W quá lớn: dùng DP theo value thay vì weight — dp[v] = weight tối thiểu để đạt đúng value v. Transition: dp[v] = min(dp[v], dp[v - value[i]] + weight[i]). Nếu tổng value tối đa là V_max, complexity O(n × V_max). Hiệu quả khi n và V_max nhỏ nhưng W lớn.
Fully Polynomial Time Approximation Scheme (FPTAS): thuật toán xấp xỉ với ratio (1 - ε) trong O(n³/ε) — cho phép trade accuracy lấy speed. Dùng trong logistics và scheduling thực tế khi n vượt 10.000.
Tham khảo:
- Introduction to Algorithms (CLRS, 4th ed. 2022), Chapter 15.4 (Greedy vs DP) và Problem 14-2 (knapsack variants).
- Pisinger (1995) — "A minimal algorithm for the 0-1 knapsack problem": thuật toán thực tế hiệu quả cho sparse items.
- Kellerer, Pferschy, Pisinger — Knapsack Problems (2004): monograph 500 trang, comprehensive nhất về variants.
9. Tóm tắt
- 0/1 Knapsack:
dp[i][w] = max(dp[i-1][w], value[i] + dp[i-1][w - weight[i]])— xét từng item, với từng sức chứa, chọn max giữa lấy và không lấy. - Tối ưu 1D: duyệt w từ W xuống 0 trong vòng lặp item — đảm bảo mỗi item chỉ được dùng một lần.
- Unbounded: duyệt w từ 1 lên W —
dp[w - weight[i]]đã bao gồm giá trị từ lần lấy trước của item i. - Greedy sai với 0/1: counter-example W=10, items
{(6,12),(5,10),(5,10)}— greedy cho 12, DP cho 20. Greedy đúng với fractional knapsack (items chia được). - Complexity: O(n × W) time, O(W) space sau tối ưu 1D. Pseudo-polynomial — không polynomial theo kích thước input thực.
10. Tự kiểm tra
Q1Tại sao 0/1 Knapsack dùng DP chứ không dùng greedy (chọn theo value/weight ratio)? Cho counter-example cụ thể với W = 6.▸
Greedy chọn item tốt nhất tức thì mà không xem xét kết hợp toàn cục. Với 0/1 knapsack, item được chọn chiếm không gian cố định và không thể hoàn trả — nên chọn sai ở bước đầu có thể chặn kết hợp tốt hơn ở bước sau.
Counter-example W = 6: items A (weight=4, value=8, ratio=2.0), B (weight=3, value=5, ratio=1.67), C (weight=3, value=5, ratio=1.67). Greedy chọn A (ratio cao nhất), còn 2 kg — B và C không vào. Value = 8. DP chọn B + C (3+3=6=W), value = 10. Greedy kém hơn DP 25%.
DP đúng vì nó xét mọi kết hợp qua subproblem dp[i][w] — mỗi trạng thái lưu giá trị tốt nhất cho sức chứa w khi xét i items đầu, đảm bảo không bỏ sót kết hợp nào.
Q2Giải thích recurrence dp[i][w] = max(dp[i-1][w], value[i] + dp[i-1][w - weight[i]]). Hai nhánh nghĩa là gì?▸
Recurrence thể hiện quyết định nhị phân tại mỗi bước: với item thứ i và sức chứa còn lại w, ta có đúng hai lựa chọn.
Nhánh 1: không lấy item i — dp[i-1][w]. Giá trị tốt nhất với sức chứa w chỉ dùng i-1 items trước đó, item i bị bỏ qua.
Nhánh 2: lấy item i — value[i] + dp[i-1][w - weight[i]]. Lấy item i đóng góp value[i], và sức chứa còn lại cho i-1 items trước là w - weight[i]. Chỉ khả thi khi weight[i] <= w. Chọn max giữa hai nhánh cho kết quả tối ưu tại trạng thái đó.
Q3Vì sao 1D knapsack phải duyệt w từ W xuống weight[i], không phải từ 0 lên W?▸
Trong 1D optimization, mảng dp[] được cập nhật tại chỗ (in-place). Khi xét item i, ta muốn dp[w - weight[i]] vẫn là giá trị từ trạng thái trước khi xét item i (tức là hàng i-1 trong bảng 2D).
Nếu duyệt từ 0 lên, khi tính dp[w] với w lớn, dp[w - weight[i]] đã bị ghi đè trong vòng lặp hiện tại (vì w - weight[i] < w và đã được xử lý trước). Điều này cho phép item i được "lấy" ở dp[w - weight[i]] lần nữa khi xét dp[w] — tương đương lấy item i hai lần, vi phạm ràng buộc 0/1.
Duyệt từ W xuống đảm bảo khi tính dp[w], mọi giá trị dp[< w] chưa bị chạm trong vòng lặp item i này — an toàn.
Q4Unbounded knapsack khác 0/1 knapsack ở điểm nào trong implementation? Cho coin change là ví dụ.▸
Điểm khác biệt duy nhất về implementation: hướng duyệt w trong vòng lặp inner.
0/1 knapsack: for w <- W xuống weight[i] — ngăn item được dùng nhiều lần. Unbounded: for w <- weight[i] lên W — cho phép item được dùng nhiều lần (vì dp[w - weight[i]] đã phản ánh việc dùng item đó trước đó trong vòng lặp hiện tại).
Coin change với coins = [1, 5, 10], amount = W: dp[w] = min(dp[w], 1 + dp[w - coin[i]]), duyệt w từ 1 lên W. Dùng mỗi coin nhiều lần không giới hạn — đúng bản chất unbounded. Thay max bằng min, thay value bằng 1 (đếm số đồng).
Q5Bảng trace dp[i][w] của ví dụ 4 items W=5 — kiểm tra dp[3][4] = 11 bằng tay.▸
Tại dp[3][4], ta xét item 3 (weight=3, value=10) với sức chứa w=4.
Nhánh không lấy: dp[2][4] = 7 (lấy item 1+2: 1+6=7, weight 1+2=3 <= 4). Nhánh lấy item 3: value[3] + dp[2][4-3] = 10 + dp[2][1] = 10 + 1 = 11 (dp[2][1] = 1 vì chỉ lấy được item 1 với sức chứa 1). Max(7, 11) = 11. Đúng như bảng.
Ý nghĩa: kết hợp tốt nhất khi xét 3 items đầu, sức chứa 4 là lấy item 3 (w=3,v=10) + item 1 (w=1,v=1) = weight 4, value 11.
Q60/1 Knapsack là NP-Complete — vậy DP O(n×W) có mâu thuẫn không? Pseudo-polynomial nghĩa là gì?▸
Không mâu thuẫn. DP O(n×W) là pseudo-polynomial: polynomial theo giá trị số W, nhưng W không phải kích thước input thực. Kích thước input thực của W là số bit cần để biểu diễn nó, tức log₂(W).
Nếu W = 2^30 (khoảng 1 tỷ), mảng dp cần 1 tỷ phần tử — không khả thi dù O(n×W) "trông" polynomial. Với n = 100 items, W = 2^30, time thực tế là 100 × 10^9 = 100 tỷ operations.
NP-Complete phát biểu: không có thuật toán polynomial theo kích thước input bit. Pseudo-polynomial vẫn bị "tấn công" khi W lớn — đó là lý do bài toán knapsack thực tế dùng FPTAS (xấp xỉ) hoặc DP theo value khi W quá lớn.
Q7Nếu muốn reconstruct tập items được chọn (không chỉ giá trị tối đa), cần thêm gì vào thuật toán 2D?▸
Cần giữ bảng 2D đầy đủ và trace ngược từ dp[n][W] về dp[0][0].
Reconstruction: bắt đầu tại i=n, w=W. Nếu dp[i][w] != dp[i-1][w] thì item i đã được lấy — ghi nhận item i, giảm w xuống w - weight[i], giảm i về i-1. Nếu bằng nhau, item i không được lấy — chỉ giảm i về i-1. Lặp cho đến khi i=0 hoặc w=0.
Với bản 1D, không thể reconstruct trực tiếp (thông tin hàng trước bị xóa). Phải dùng bảng 2D O(n×W) space, hoặc lưu riêng mảng boolean taken[i][w] để đánh dấu khi nào "lấy" item i thắng nhánh "không lấy".
Bài tiếp theo: Greedy — tham lam có chứng minh
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên