Có đồ vật; vật nặng và có giá trị . Chọn một số vật có tổng khối lượng không quá sức chứa của chiếc ba lô sao cho tổng giá trị lớn nhất. Đây là bài ba lô (knapsack), bài toán gốc của cả một họ bài quy hoạch động.
Thử mọi tập con của vật mất lần, quá nhiều khi . Nhưng với bài này, một tình huống con chỉ cần hai số: ta đã xét vật đầu và đang dùng hết nhiều nhất đơn vị sức chứa. Chỉ có tình huống như vậy, mỗi tình huống tính bằng một phép so sánh.
IBa lô 0/1
Khi mỗi vật chỉ được lấy tối đa một lần, ta có bài ba lô 0/1: chọn một tập vật có tổng khối lượng không quá , tổng giá trị lớn nhất. Mỗi vật có hai khả năng, lấy hoặc không lấy, và có lấy được hay không còn tùy sức chứa còn lại. Vậy trạng thái cần hai tham số: là tổng giá trị lớn nhất khi chỉ xét vật đầu và tổng khối lượng không quá .
Xét vật . Không lấy nó thì còn lại . Lấy nó (nếu ) thì được thêm và phần còn lại là vật đầu với sức chứa :
Đáp án là . Bảng có ô, mỗi ô tính trong , tổng cộng .
Hình dùng bốn vật và . Ô cuối vì lấy thêm vật 4 chỉ được , kém . Ô (lấy vật 3) và (lấy vật 2), nên tập tốt nhất là vật 2 và vật 3, nặng và có giá trị .
Điều kiện “không quá ” ứng với khởi tạo cho mọi . Nếu đề đòi khối lượng đúng bằng , chỉ còn các ô khác khởi tạo (hoặc khi tìm giá trị nhỏ nhất).
Hàng chỉ dùng hàng , nên ta giữ một mảng dp[c] và sửa tại chỗ: dp[c] = max(dp[c], dp[c - w] + v). Công thức cần dp[c - w] của hàng cũ, tức là khi chưa xét vật . Vì , nếu duyệt giảm dần thì ô chưa bị sửa trong lượt này. Nếu duyệt tăng dần, dp[c - w] đã chứa vật và vật sẽ bị lấy lại nhiều lần, tức là ta đã giải một bài khác (ba lô không giới hạn, ở mục sau). Với một vật , và : duyệt giảm cho , duyệt tăng cho vì vật được lấy hai lần.
Hỏi nhanh
Ta sửa vòng lặp ba lô thành duyệt tăng dần, rồi chạy trên đúng một vật có , và sức chứa . Bấm Chạy để xem chương trình in gì:
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll w, v, W, dp[105];
signed main() {
cin >> w >> v >> W;
for (ll c = w; c <= W; c++) dp[c] = max(dp[c], dp[c - w] + v);
cout << dp[W] << '\n';
}
3 5 7
10
Với ba lô 0/1 thì đáp án đúng là . Vì sao chương trình in ?
Duyệt tăng: dp[3] = 5, dp[4] = dp[1] + 5 = 5, dp[5] = 5, rồi dp[6] = dp[3] + 5 = 10: ô đã chứa vật nên vật được lấy lần hai. Chương trình đã giải nhầm sang bài ba lô không giới hạn. Mảng toàn cục được khởi tạo bằng nên không có giá trị rác, và kiểu dữ liệu không làm đổi kết quả.
Ví dụTủ Sách Trong Ngân Sách
Đề. Cửa hàng có quyển sách khác nhau; quyển giá và có trang (). Mỗi quyển mua tối đa một lần. Chọn các quyển sao cho tổng giá không quá và tổng số trang lớn nhất.
Lời giải. Đây đúng là ba lô 0/1 với khối lượng là giá sách và giá trị là số trang. Dùng một mảng, duyệt ngân sách từ xuống .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, x, h[1005], s[1005], dp[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 1; i <= n; i++) cin >> h[i];
for (ll i = 1; i <= n; i++) cin >> s[i];
for (ll i = 1; i <= n; i++)
for (ll c = x; c >= h[i]; c--) dp[c] = max(dp[c], dp[c - h[i]] + s[i]);
cout << dp[x] << '\n';
}
5 13
6 4 7 3 9
8 5 10 4 13
18
Độ phức tạp: , với và cần khoảng phép tính.
IIBa lô không giới hạn: đổi tiền
Khi mỗi vật được lấy bao nhiêu lần cũng được, ta có ba lô không giới hạn. Đổi tiền là ví dụ quen thuộc nhất: có các mệnh giá , mỗi mệnh giá dùng bao nhiêu đồng cũng được. Tạo tổng đúng bằng bằng ít đồng nhất.
Cách nghĩ đầu tiên là tham lam (bài 0.5): luôn lấy đồng lớn nhất không vượt phần còn lại. Với các mệnh giá và , tham lam lấy ( đồng), trong khi chỉ cần đồng. Lấy đồng lớn trước có thể phá hỏng phần còn lại, nên ta xét mọi khả năng một cách có hệ thống.
Gọi là số đồng ít nhất để tạo tổng đúng , bằng nếu không tạo được. Trong một phương án tối ưu cho , lấy ra một đồng bất kỳ, mệnh giá ; phần còn lại tạo tổng và phải dùng ít đồng nhất có thể. Ta chưa biết là mệnh giá nào nên thử tất cả:
Số hạng bằng thì bỏ qua. Duyệt tăng dần vì phụ thuộc các ô nhỏ hơn. Với ta có ; riêng .
Ví dụÍt Đồng Xu Nhất
Đề. Có mệnh giá đồng xu đôi một khác nhau, mỗi mệnh giá dùng tùy ý. Tạo tổng đúng bằng với ít đồng nhất; in nếu không tạo được.
Lời giải. Dùng công thức trên với khởi tạo cho ; tổng không tạo được vẫn giữ giá trị rất lớn, nên cộng thêm không làm nó nhỏ đi hay tràn số. Cuối cùng, nếu còn là giá trị rất lớn thì in .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll n, x, c[105], dp[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 1; i <= n; i++) cin >> c[i];
for (ll s = 1; s <= x; s++) {
dp[s] = 1e18;
for (ll i = 1; i <= n; i++)
if (c[i] <= s) dp[s] = min(dp[s], dp[s - c[i]] + 1);
}
if (dp[x] >= 1e18) cout << -1 << '\n';
else cout << dp[x] << '\n';
}
3 10
4 6 7
2
Độ phức tạp: , với và cần khoảng phép tính.
Hỏi nhanh
Một bạn quên gán cho các ô () trong chương trình đổi tiền, nên chúng giữ giá trị . Bấm Chạy để xem với một mệnh giá duy nhất bằng và :
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, x, c[10], dp[100];
signed main() {
cin >> n >> x;
for (ll i = 1; i <= n; i++) cin >> c[i];
for (ll s = 1; s <= x; s++)
for (ll i = 1; i <= n; i++)
if (c[i] <= s) dp[s] = min(dp[s], dp[s - c[i]] + 1);
cout << dp[x] << '\n';
}
1 3
2
0
Tổng không thể tạo bằng đồng , đáp án phải là , nhưng chương trình in . Vì sao?
Ở điều kiện c[i] <= s đúng, nhưng min(dp[3], dp[1] + 1) = min(0, 1) = 0: một ô đã bằng thì không tăng được nữa. Ô không tạo được phải bắt đầu từ giá trị rất lớn, để min thay nó bằng một đáp án thật khi có. Vòng lặp vẫn chạy tới , và min đúng là trả về giá trị nhỏ hơn, chính điều đó làm số thắng.
Đổi câu hỏi thành đếm số cách thì trước hết phải nói rõ hai cách khác nhau khi nào. Hai cách hiểu cho hai bài toán, và hai đoạn code chỉ khác nhau ở thứ tự của hai vòng for.
Nếu và là hai cách (dãy có thứ tự), mỗi dãy có đúng một phần tử cuối , bỏ nó đi còn một dãy tạo tổng . Vậy (tổng các qua mọi mệnh giá ), với (dãy rỗng). Tổng nằm ở vòng ngoài, mệnh giá ở vòng trong.
Nếu chỉ quan tâm mỗi mệnh giá dùng bao nhiêu đồng (tập mệnh giá), ta xử lý lần lượt từng mệnh giá để mỗi tập chỉ được sinh một lần. Gọi là số cách tạo chỉ dùng mệnh giá đầu. Hoặc không dùng mệnh giá , còn cách; hoặc dùng ít nhất một đồng , bỏ một đồng ấy đi thì còn cách:
Nén thành một mảng, mệnh giá ở vòng ngoài và tăng dần ở vòng trong: dp[s] += dp[s - c]. Duyệt tăng chính là để dp[s - c] đã chứa các cách dùng . Chương trình dưới in số dãy có thứ tự (f) rồi số tập mệnh giá (g).
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7, N = 1e6 + 5;
ll n, x, c[105], f[N], g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 0; i < n; i++) cin >> c[i];
f[0] = g[0] = 1;
for (ll s = 1; s <= x; s++)
for (ll i = 0; i < n; i++)
if (c[i] <= s) f[s] = (f[s] + f[s - c[i]]) % MOD;
for (ll i = 0; i < n; i++)
for (ll s = c[i]; s <= x; s++) g[s] = (g[s] + g[s - c[i]]) % MOD;
cout << f[x] << '\n' << g[x] << '\n';
}
2 5
1 2
8
3
Độ phức tạp: cho mỗi cách đếm, với và cần khoảng phép tính.
Với và hai mệnh giá : có tập (, , năm đồng ) nhưng dãy có thứ tự, vì ba tập đó cho lần lượt , và cách xếp. Đổi chỗ hai vòng for là đổi sang bài toán khác, nên phải đọc kỹ đề xem “cách” có phân biệt thứ tự không.
Hỏi nhanh
Mệnh giá , , dp[0] = 1. Chương trình có vòng ngoài duyệt từ tới , vòng trong duyệt từng mệnh giá và cộng dp[s] += dp[s - c]. Cuối cùng dp[3] bằng bao nhiêu và nó đếm gì?
Tính tay: dp[1] = dp[0] = 1, dp[2] = dp[1] + dp[0] = 2, dp[3] = dp[2] + dp[1] = 3. Với tổng ở vòng ngoài, mỗi dãy được phân loại theo phần tử cuối của nó, nên và là hai dãy khác nhau. Để đếm tập mệnh giá phải đưa mệnh giá ra vòng ngoài và được . Chương trình không có phép nào nên con số không liên quan tới “ít đồng nhất”, và không có chuyện đếm trùng thành , vì mỗi dãy có đúng một phần tử cuối nên chỉ rơi vào đúng một số hạng của tổng.
IIITổng con: những tổng nào tạo được
Có thẻ, thẻ mang số nguyên dương , mỗi thẻ chọn tối đa một lần. Tổng nào của một tập thẻ có thể đạt được? Đây là ba lô 0/1 nhưng không cần giá trị: chỉ hỏi đúng hay sai. Gọi là “có tập thẻ có tổng đúng hay không”, đúng. Khi thêm thẻ , tổng tạo được nếu trước đó đã tạo được , hoặc đã tạo được :
Giống ba lô 0/1, ta duyệt từ lớn xuống nhỏ để mỗi thẻ chỉ được dùng một lần.
Ví dụDanh Sách Tổng Khả Dụng
Đề. Có thẻ, thẻ mang số nguyên dương . In tất cả các tổng dương khác nhau của các tập con không rỗng, theo thứ tự tăng dần: dòng đầu là số lượng tổng, dòng sau là các tổng.
Lời giải. Tổng lớn nhất là tổng mọi thẻ, không quá . Khi thêm thẻ , ta cộng vào tổng hiện có rồi duyệt từ xuống .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, S, ok[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
ok[0] = 1;
for (ll i = 1; i <= n; i++) {
ll x;
cin >> x;
S += x;
for (ll s = S; s >= x; s--)
if (ok[s - x]) ok[s] = 1;
}
ll cnt = 0;
for (ll s = 1; s <= S; s++) cnt += ok[s];
cout << cnt << '\n';
for (ll s = 1; s <= S; s++)
if (ok[s]) cout << s << ' ';
cout << '\n';
}
4
3 5 6 8
12
3 5 6 8 9 11 13 14 16 17 19 22
Độ phức tạp: , với và cần khoảng phép tính.
Cũng theo cách này, ta đếm được số cách chia một nhóm số thành hai nhóm có tổng bằng nhau: thay phép “hoặc” bằng phép cộng số cách, và đích là nửa tổng.
IVBa lô khi khối lượng quá lớn
Nếu sức chứa tới thì bảng không thể lập. Khi đó, nếu tổng giá trị nhỏ, ta đổi vai: trạng thái là giá trị, và đại lượng cần tối ưu là khối lượng. Gọi là tổng khối lượng nhỏ nhất của một tập vật có tổng giá trị đúng , với và các ô khác . Mỗi vật sửa mảng với giảm dần: . Đáp án là lớn nhất có .
Ví dụBa Lô Theo Tổng Giá Trị
Đề. Có vật, vật nặng và có giá trị ; ba lô chịu tổng khối lượng . Chọn một tập vật, mỗi vật tối đa một lần, có tổng khối lượng không quá để tổng giá trị lớn nhất.
Lời giải. Bảng theo khối lượng cần ô, không khả thi, nhưng tổng mọi giá trị nhỏ. Dùng như trên. Giữ khối lượng nhỏ nhất cho mỗi giá trị là đủ, vì ràng buộc chỉ là tổng khối lượng không quá : tập nhẹ hơn cùng giá trị luôn thay được tập nặng hơn. Khối lượng cộng lại tới nên dùng ll, vô cực đặt để cộng thêm một khối lượng vẫn không tràn. Với ví dụ của đề, vật 1 và 2 cho giá trị , nặng đúng ; mọi giá trị lớn hơn đều cần từ trở lên.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, C, w[105], v[105], dp[100005];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> C;
ll S = 0;
for (ll i = 0; i < n; i++) {
cin >> w[i] >> v[i];
S += v[i];
}
for (ll x = 1; x <= S; x++) dp[x] = 1e18;
for (ll i = 0; i < n; i++)
for (ll x = S; x >= v[i]; x--) dp[x] = min(dp[x], dp[x - v[i]] + w[i]);
ll ans = 0;
for (ll x = 0; x <= S; x++)
if (dp[x] <= C) ans = x;
cout << ans << '\n';
}
4 10
6 8
4 5
5 7
3 4
13
Độ phức tạp: với , cần khoảng phép tính; bộ nhớ .
VTruy vết phương án
Cho đến giờ ta chỉ in giá trị tốt nhất. Muốn in cả tập vật được chọn, ta phải giữ cả bảng hai chiều , vì mảng một chiều đã ghi đè mất thông tin của các hàng trước. Từ ô đi ngược: nếu thì vật không cần lấy; ngược lại, vật được lấy, ta ghi lại nó rồi lùi sức chứa đi . Danh sách ghi được theo thứ tự ngược nên in từ cuối về đầu.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, W, w[105], v[105], dp[105][1005], pick[105];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> W;
for (ll i = 1; i <= n; i++) cin >> w[i] >> v[i];
for (ll i = 1; i <= n; i++)
for (ll c = 0; c <= W; c++) {
dp[i][c] = dp[i - 1][c];
if (c >= w[i]) dp[i][c] = max(dp[i][c], dp[i - 1][c - w[i]] + v[i]);
}
ll cnt = 0, c = W;
for (ll i = n; i >= 1; i--)
if (dp[i][c] != dp[i - 1][c]) {
pick[cnt++] = i;
c -= w[i];
}
cout << dp[n][W] << '\n';
for (ll i = cnt - 1; i >= 0; i--) cout << pick[i] << ' ';
cout << '\n';
}
4 6
3 4
2 3
4 6
1 1
9
2 3
Độ phức tạp: thời gian và bộ nhớ, với và cần khoảng phép tính.
Trên ví dụ này chương trình in vật 2 và vật 3, đúng như đường truy ngược trong hình ở đầu trang. Nếu bảng quá lớn để giữ, ta chỉ lưu thêm một bit “có lấy vật ở sức chứa hay không”, nhưng vẫn là bit.
Bài nâng cao
Xem 1 bài nâng cao
Ví dụTháp Khối Chịu Lực
Đề. Có khối; khối có trọng lượng , sức chịu tải và giá trị (, ). Chọn một số khối rồi xếp thành tháp theo thứ tự tùy ý, sao cho với mỗi khối, tổng trọng lượng của các khối nằm trên nó không vượt . Tối đa hóa tổng giá trị.
Lời giải. Nếu đã biết thứ tự xếp, bài trở thành ba lô: duyệt các khối từ trên xuống dưới, nhớ tổng trọng lượng của các khối đã xếp ở trên, và khối mới chỉ đặt xuống dưới được khi ; khi đó tổng trọng lượng thành . Còn phải chọn thứ tự. Ta sẽ xếp các khối theo tăng dần từ trên xuống dưới (cách sắp theo một khóa như bài 0.5).
Chứng minh
Giả sử một tháp hợp lệ có hai khối kề nhau, nằm trên , với , và là tổng trọng lượng các khối phía trên . Hợp lệ nghĩa là và . Đổi chỗ để nằm trên . Khối chịu tải . Khối chịu tải , và ta có , nên cũng đúng. Các khối nằm dưới cặp này chịu cùng tổng tải như trước. Vậy tháp sau khi đổi vẫn hợp lệ, với cùng tập khối. Lặp lại, mọi tháp hợp lệ đều có thể sắp lại theo tăng dần từ trên xuống dưới mà vẫn hợp lệ.
Sau khi sắp, ta chạy ba lô 0/1 theo thứ tự đó. Gọi là tổng giá trị lớn nhất của các khối đã chọn có tổng trọng lượng đúng . Với khối (xét từ trên xuống), duyệt từ xuống : nếu đạt được thì . Tổng trọng lượng mới không quá . Đáp án là lớn nhất.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
struct Block {
ll w, s, v;
};
const ll N = 1005;
ll n, dp[20005];
Block b[N];
bool cmp(Block x, Block y) {
return x.s + x.w < y.s + y.w;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> b[i].w >> b[i].s >> b[i].v;
sort(b + 1, b + n + 1, cmp);
for (ll j = 1; j < 20005; j++) dp[j] = -1e18;
for (ll i = 1; i <= n; i++)
for (ll j = b[i].s; j >= 0; j--)
if (dp[j] >= 0) dp[j + b[i].w] = max(dp[j + b[i].w], dp[j] + b[i].v);
ll ans = 0;
for (ll j = 0; j < 20005; j++) ans = max(ans, dp[j]);
cout << ans << '\n';
}
3
3 6 3
1 3 1
5 1 6
10
Độ phức tạp: cho bước sắp xếp và cho ba lô, với và cần khoảng phép tính.
Khi đi thi
- Ba lô 0/1 duyệt sức chứa giảm dần, ba lô không giới hạn và đổi tiền duyệt tăng dần; đếm tập mệnh giá thì mệnh giá ở vòng ngoài, đếm dãy có thứ tự thì tổng ở vòng ngoài.
- Sức chứa lớn mà tổng giá trị nhỏ thì đổi vai: trạng thái là giá trị, đại lượng tối ưu là khối lượng.
- Trạng thái chưa tạo được phải mang giá trị vô cực (hoặc khi tìm lớn nhất) chứ không phải ; muốn truy vết thì giữ bảng hai chiều.
Tóm tắt
- Ba lô 0/1: , nén một mảng và duyệt giảm dần; thời gian .
- Ba lô không giới hạn và đổi tiền: duyệt tăng dần để một vật được lấy nhiều lần; tham lam có thể sai khi tìm số đồng ít nhất.
- Hai vòng lặp đổi chỗ cho hai bài đếm khác nhau: mệnh giá ở vòng ngoài đếm tập mệnh giá, tổng ở vòng ngoài đếm dãy có thứ tự.
- Bài tổng con dùng mảng đúng/sai; sức chứa quá lớn thì đổi vai khối lượng và giá trị.
- Truy vết đi ngược bảng hai chiều từ ô đáp án: nghĩa là vật được lấy.