Có người và việc, người làm việc mất chi phí . Giao mỗi người đúng một việc, mỗi việc đúng một người, sao cho tổng chi phí nhỏ nhất. Thử mọi hoán vị là cách, với đã là .
Nhưng nhiều cách giao dẫn tới cùng một tình huống. Khi người 0 và người 1 đã nhận xong hai việc 3 và 5, phần còn lại chỉ phụ thuộc vào tập đã bị dùng, không phụ thuộc ai nhận việc nào. Một tập phần tử chỉ có tập con, và chỉ cỡ một triệu.
Quy hoạch động trên tập con (bitmask DP) lấy chính tập đã dùng làm trạng thái, mã hóa thành một số nguyên bit, bit bằng 1 khi phần tử thuộc tập. Nó là công cụ chuẩn cho các bài có cỡ 10 tới 20 mà đáp án phụ thuộc vào phần tử nào đã được dùng: phân công, đi qua mọi đỉnh, xếp thứ tự, chia nhóm, phủ tập. Bài này dùng phép toán bit của bài 14 và cách đặt trạng thái của bài 12.
Trạng thái là tập
Quay lại bài phân công. Ta đánh số người và việc từ 0, vì bit thấp nhất của một số nguyên có chỉ số 0, và giao việc lần lượt cho người 0, 1, 2, cứ thế. Giả sử người đầu đã nhận xong việc, và các việc họ nhận tạo thành tập với . Phần còn lại chỉ phụ thuộc , nên trong mọi cách giao cho người đầu mà dùng đúng tập , ta chỉ cần nhớ cách rẻ nhất. Đặt là tổng chi phí nhỏ nhất khi giao các việc của cho người đầu tiên.
Người cuối cùng trong nhóm đó là người , và người ấy nhận một việc nào đó. Bỏ cặp này đi thì người còn lại giữ đúng các việc của . Thử mọi :
Đáp án là . Tập được mã hóa bằng số nguyên mask: tập rỗng là 0, tập đầy đủ là (1 << n) - 1, là __builtin_popcount(mask), và bỏ việc khỏi tập là mask ^ (1 << j).
Chạy tay với . Người 0 làm việc 0, 1, 2 mất lần lượt 9, 2, 7; người 1 mất 6, 4, 3; người 2 mất 5, 8, 1. Với một việc, chỉ người 0 nhận: , , . Với hai việc, người 1 nhận sau cùng: , và tương tự , . Với ba việc, người 2 nhận sau cùng: .
Hình dưới cho thấy bảng dp. Mỗi ô ghi mask và dp của nó; tầng thứ là các tập có việc, đường xanh là cách giao rẻ nhất.
Mask 011 trong hình là tập (bit 0 ở ngoài cùng bên phải). Đường đậm đi từ tập rỗng tới tập đầy đủ là một cách giao: người 0 nhận việc 1, người 1 nhận việc 0, người 2 nhận việc 2, tổng .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, c[20][20], dp[1 << 20];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++)
for (ll j = 0; j < n; j++) cin >> c[i][j];
for (ll mask = 1; mask < (1 << n); mask++) {
ll k = __builtin_popcountll(mask);
dp[mask] = 1e18;
for (ll j = 0; j < n; j++)
if ((mask >> j) & 1) dp[mask] = min(dp[mask], dp[mask ^ (1 << j)] + c[k - 1][j]);
}
cout << dp[(1 << n) - 1] << '\n';
}
3
9 2 7
6 4 3
5 8 1
9
Có trạng thái, mỗi trạng thái thử tối đa việc, nên tổng là : với khoảng phép tính và bảng 8 MB. Bảng không nhớ ai làm việc gì, chỉ nhớ việc nào đã dùng; và người đang xét không phải một chiều riêng, vì đó luôn là người thứ popcount(mask) - 1.
Hỏi nhanh
Ở bài phân công, một bạn đề nghị thêm chiều thứ hai cho bảng, thành dp[mask][i] với i là người đang được giao việc, “cho chắc”. Nhận xét nào đúng?
Trạng thái chỉ nên giữ những thông tin không suy ra được từ phần còn lại. Ta giao việc cho người 0, 1, 2, ... theo thứ tự, nên khi đã dùng việc thì người đầu đã xong và người kế tiếp là người ; số chính là số bit 1 của mask. Hai cách giao khác nhau cho cùng một mask luôn có cùng số bit 1, nên chúng đang xét cùng một người, và ý “cần thiết” thứ nhất sai. Ý thứ hai bỏ qua rằng hàng của c được suy ra từ popcount(mask). Ý cuối sai về dữ kiện: chi phí rõ ràng phụ thuộc người nhận việc, chỉ là ta không cần nhớ người nào đã làm gì. Thêm chiều i chỉ nhân bộ nhớ lên lần và để ô vô nghĩa ở mỗi mask.
Ví dụGhép Cặp Tương Thích
Đề. Có người nhóm và người nhóm ; ma trận 0/1 cho biết cặp nào tương thích. Đếm số cách ghép hoàn hảo, mỗi người đúng một lần, chỉ dùng cặp tương thích, theo modulo .
Lời giải. Đúng khuôn phân công, chỉ đổi “lấy min” thành “cộng số cách”. dp[mask] là số cách ghép người đầu của nhóm với đúng các người của nhóm trong mask. Người thứ (với là popcount) ghép với một mask tương thích: dp[mask] += dp[mask ^ (1 << j)]. phép, bảng số. Duyệt hết hoán vị ở đây là .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 1e9 + 7;
ll n, a[21][21], dp[1 << 21];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++)
for (ll j = 0; j < n; j++) cin >> a[i][j];
dp[0] = 1;
for (ll mask = 1; mask < (1 << n); mask++) {
ll k = __builtin_popcountll(mask);
for (ll j = 0; j < n; j++)
if (((mask >> j) & 1) && a[k - 1][j]) dp[mask] = (dp[mask] + dp[mask ^ (1 << j)]) % MOD;
}
cout << dp[(1 << n) - 1] << '\n';
}
3
1 1 0
0 1 1
1 0 1
2
Ví dụChuyến Thang Máy
Đề. Có người, người nặng ; thang máy chở tối đa mỗi lượt, . Chia mọi người vào ít lượt nhất.
Lời giải. Ta không cần biết ai đi lượt nào, chỉ cần biết tập người đã xếp, số lượt đã dùng và lượt đang mở đã chở bao nhiêu. Hai con số cuối không so sánh trực tiếp được: một cách dùng ít lượt hơn nhưng lượt cuối đầy, cách khác nhiều lượt hơn nhưng lượt cuối còn trống. Ta cho thứ tự từ điển phân xử: số lượt trước, tải lượt cuối sau. Xếp người lần lượt theo một thứ tự nào đó, mỗi người vào lượt đang mở nếu còn chỗ, nếu không thì mở lượt mới; dp[mask] là cặp (số lượt, tải lượt cuối) nhỏ nhất theo thứ tự từ điển trong mọi thứ tự xếp tập mask, với dp[0] = (1, 0). Chuyển: chọn người xếp sau cùng, từ cặp của mask ^ (1 << i) ta được nếu , ngược lại .
Giữ cặp nhỏ nhất là đủ vì phép “thêm một người” giữ nguyên thứ tự từ điển: cùng số lượt mà tải nhỏ hơn thì sau khi thêm vẫn không tệ hơn, ít lượt hơn thì sau khi thêm nhiều nhất bằng số lượt của bên kia với tải chỉ . Và đáp án đạt được: với cách chia tối ưu, xếp người theo từng lượt một thì quá trình dùng không quá số lượt ấy. phép. Với ví dụ, và cho hai lượt.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, X, w[20];
pair<ll, ll> dp[1 << 20];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> X;
for (ll i = 0; i < n; i++) cin >> w[i];
dp[0] = {1, 0};
for (ll mask = 1; mask < (1 << n); mask++) {
dp[mask] = {n + 1, 0};
for (ll i = 0; i < n; i++) {
if (!((mask >> i) & 1)) continue;
pair<ll, ll> cur = dp[mask ^ (1 << i)];
if (cur.second + w[i] <= X) cur.second += w[i];
else cur = {cur.first + 1, w[i]};
dp[mask] = min(dp[mask], cur);
}
}
cout << dp[(1 << n) - 1].first << '\n';
}
5 10
2 3 5 6 4
2
Kéo, đẩy, đệ quy
Mỗi bước chuyển thêm một phần tử vào tập, tức là bật thêm một bit, nên số nguyên tăng lên. Vì thế khi duyệt mask từ 0 lên (1 << n) - 1, lúc tới mask thì mọi tập con thật sự của nó đã được tính xong: bỏ đi một bit luôn cho số nhỏ hơn. Ta không phải tìm thứ tự tính riêng, một vòng for thường là đủ.
Các chương trình trên viết theo kiểu kéo (pull): tại mask, nhìn lại các trạng thái đứng trước để tính dp[mask]. Kiểu đẩy (push) làm ngược lại: khi tới mask thì dp[mask] đã chốt, từ đó cập nhật các trạng thái đứng sau. Với bài phân công:
for (ll mask = 0; mask < (1 << n) - 1; mask++) {
if (dp[mask] == 1e18) continue;
ll k = __builtin_popcountll(mask);
for (ll j = 0; j < n; j++) {
if ((mask >> j) & 1) continue;
ll nx = mask | (1 << j);
dp[nx] = min(dp[nx], dp[mask] + c[k][j]);
}
}
Hai kiểu cho cùng kết quả, và khác nhau ở hai chỗ khi cài. Thứ nhất, kiểu đẩy phải khởi tạo mọi ô (trừ ô xuất phát) bằng “vô cùng” và chỉ đẩy từ ô đã tới được; quên dòng continue thì INF + chi phí có thể tràn số hoặc tạo ra một giá trị giả trông như hợp lệ. Thứ hai là chỉ số người: lúc đẩy, mask có bit nghĩa là người đã có việc nên người nhận việc mới là người ; lúc kéo, mask mới có bit và người vừa nhận việc là người . Kiểu kéo hợp khi công thức tự nhiên là “phần tử thêm cuối cùng là ai”. Kiểu đẩy hợp khi điều kiện chuyển phụ thuộc vào cả mask hiện tại, ví dụ chỉ được thêm việc khi mọi việc phải làm trước đã nằm trong mask.
Hỏi nhanh
Viết lại chương trình phân công theo kiểu đẩy: tại một mask có bit 1, với mỗi việc chưa dùng, ta cập nhật dp[mask | (1 << j)]. Chi phí cộng thêm vào dp[mask] là gì?
Tại mask đã có bit 1, người 0 tới người đã có việc, nên người nhận việc là người . Con số k - 1 là của kiểu kéo, nơi đếm bit của mask mới; mang nó sang kiểu đẩy làm mỗi người nhận chi phí của người đứng trước, và ở mask rỗng còn truy cập c[-1]. Đáp án c[j][k] đổi chỗ hàng và cột. Đáp án c[k + 1][j] lệch về phía sau, và ở lớp cuối cùng truy cập ra ngoài bảng.
Cách thứ ba là đệ quy có nhớ: solve(mask) gọi solve của các mask nhỏ hơn và lưu kết quả. Cách này chỉ tính những trạng thái thật sự được gọi tới, nên có lợi khi từ trạng thái đầu chỉ tới được một phần nhỏ của trạng thái, ví dụ trò nhảy sỏi trên 12 ô. Khi gần như mọi mask đều tới được thì vòng lặp nhanh hơn và không tốn chi phí gọi hàm.
Ví dụCác Thứ Tự Hợp Lệ
Đề. Có công việc và quan hệ “ phải đứng trước ”, không tạo chu trình. Đếm số thứ tự xếp mọi công việc thỏa mọi quan hệ.
Lời giải. dp[mask] là số cách xếp tập công việc mask thành một tiền tố hợp lệ của thứ tự. Một tiền tố nhận thêm công việc khi và chỉ khi mọi công việc phải đứng trước đã nằm trong tiền tố; gọi tập đó là pre[v], điều kiện là (pre[v] & mask) == pre[v]. Điều kiện phụ thuộc mask hiện tại, nên viết kiểu đẩy là tự nhiên. Đáp án tới , vừa ll; chi phí . Với ví dụ, 2 phải đứng trước 3 và 4, 1 phải đứng trước 3: có 5 thứ tự.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, m, pre[16], dp[1 << 16];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 0; i < m; i++) {
ll x, y;
cin >> x >> y;
pre[y - 1] |= 1LL << (x - 1);
}
dp[0] = 1;
for (ll mask = 0; mask < (1 << n); mask++) {
if (!dp[mask]) continue;
for (ll v = 0; v < n; v++)
if (!((mask >> v) & 1) && (pre[v] & mask) == pre[v]) dp[mask | (1 << v)] += dp[mask];
}
cout << dp[(1 << n) - 1] << '\n';
}
4 3
1 3
2 3
2 4
5
Thêm đỉnh cuối
Không phải bài nào cũng chỉ cần một tập. Bài toán người bán hàng (TSP, Travelling Salesman Problem): có đỉnh đánh số từ 0, chi phí đi thẳng từ tới là (không nhất thiết bằng ). Xuất phát tại đỉnh 0, đi qua mọi đỉnh đúng một lần rồi trở về 0, tổng chi phí nhỏ nhất. Thử mọi thứ tự là cách; đã là .
Thử bê nguyên cách ở mục trước: là chi phí nhỏ nhất của đường đi từ 0 qua đúng các đỉnh của . Công thức chuyển không viết được, vì bước tiếp theo tốn mà không cho biết đỉnh cuối là đỉnh nào. Hai đường đi qua cùng tập nhưng dừng ở hai đỉnh khác nhau có tương lai khác nhau, nên không gộp được. Đặt là chi phí nhỏ nhất của đường đi xuất phát từ 0, đi qua đúng các đỉnh của , mỗi đỉnh một lần, và dừng ở . Đỉnh ngay trước là một :
Các trạng thái không tới được (ví dụ mask không chứa đỉnh 0) giữ giá trị vô cùng. Đáp án là , trong đó phần cộng thêm là chặng quay về. Quy tắc chọn trạng thái rút ra từ hai bài: giữ đúng những gì tương lai cần biết về quá khứ. Thiếu một thông tin thì công thức sai; thừa một thông tin thì bảng to và chậm hơn mà không thêm được gì.
Hỏi nhanh
Đếm số hoán vị của số đôi một khác nhau sao cho hai số đứng kề nhau luôn chênh nhau hơn . Trạng thái nào vừa đủ để viết công thức chuyển đúng?
Số mới thêm vào phải chênh hơn so với số đứng cuối, nên công thức chuyển cần biết số cuối. dp[mask] không cho biết điều đó: hai dãy cùng tập số nhưng khác số cuối có tương lai khác nhau. dp[last] biết số cuối nhưng không biết các số đã dùng, nên không biết số nào còn được thêm và có thể dùng một số hai lần. dp[mask][i] có vị trí i, nhưng vị trí chính là số bit 1 của mask, đã suy ra được từ mask, nên không bổ sung gì về số cuối.
Hình dưới điền bảng dp[mask][v] theo kiểu đẩy: ô cam vừa được cập nhật từ ô xanh. Cuối cùng là chu trình trên đồ thị.
Trong hình, dp[5][2] là trạng thái “đã qua các đỉnh 0 và 2 (mask 0101), đang đứng ở đỉnh 2”; từ đó đi tiếp sang đỉnh 1 cho trạng thái dp[7][1]. Chương trình dưới viết theo kiểu đẩy và in thêm đường đi. Bảng không lưu đỉnh đứng trước, nên truy vết làm bằng cách lùi: từ , tìm thỏa ; mỗi bước tốn , rẻ hơn giữ thêm một mảng cha cùng cỡ với bảng. Ví dụ có 4 đỉnh, đường ngắn nhất là với .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, d[16][16], dp[1 << 16][16];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++)
for (ll j = 0; j < n; j++) cin >> d[i][j];
ll full = (1 << n) - 1;
for (ll mask = 0; mask <= full; mask++)
for (ll v = 0; v < n; v++) dp[mask][v] = 1e18;
dp[1][0] = 0;
for (ll mask = 1; mask <= full; mask++)
for (ll v = 0; v < n; v++) {
if (dp[mask][v] == 1e18) continue;
for (ll u = 0; u < n; u++)
if (!((mask >> u) & 1))
dp[mask | (1 << u)][u] = min(dp[mask | (1 << u)][u], dp[mask][v] + d[v][u]);
}
ll best = 1e18, e = 0;
for (ll v = 1; v < n; v++)
if (dp[full][v] + d[v][0] < best) {
best = dp[full][v] + d[v][0];
e = v;
}
cout << best << '\n';
vector<ll> path;
ll mask = full, v = e;
while (v != 0) {
path.push_back(v);
ll pm = mask ^ (1 << v);
for (ll u = 0; u < n; u++)
if (((pm >> u) & 1) && dp[pm][u] + d[u][v] == dp[mask][v]) {
mask = pm;
v = u;
break;
}
}
path.push_back(0);
reverse(path.begin(), path.end());
for (ll x : path) cout << x << ' ';
cout << 0 << '\n';
}
4
0 7 3 9
4 0 4 2
3 6 0 8
5 2 5 0
16
0 2 1 3 0
Có trạng thái, mỗi trạng thái thử tối đa đỉnh kế tiếp: thời gian và bộ nhớ. Với đó là phép; với là , và riêng bảng int đã 84 MB.
Muốn đếm số đường đi, thay min bằng phép cộng và đặt dp[1][0] = 1. Muốn đường đi tự do, điểm đầu tùy ý và không quay về, khởi tạo dp[1 << v][v] = 0 cho mọi v và lấy .
Kiểu của bảng cũng ảnh hưởng, vì bảng này rất lớn:
Bảng long long chậm hơn bảng int khoảng 1,2 lần và tốn gấp đôi bộ nhớ. Quy tắc: tổng chi phí vừa int thì dùng int, nhất là khi bảng cỡ trăm MB.
Hỏi nhanh
Tìm đường đi ngắn nhất qua mọi đỉnh đúng một lần, đỉnh đầu và đỉnh cuối tùy ý, không cần quay về. Cách khởi tạo và lấy đáp án nào đúng?
Đường đi có thể bắt đầu ở bất cứ đỉnh nào, nên mọi trạng thái một đỉnh đều là điểm xuất phát hợp lệ; và vì không phải quay về nên không cộng chặng về. Cách thứ ba là TSP chu trình với đỉnh 0 cố định. Cách thứ hai ép đường đi phải bắt đầu ở đỉnh 0 nên bỏ sót các đường tốt hơn bắt đầu ở nơi khác. Cách thứ nhất trộn hai thứ: cho phép xuất phát ở mọi đỉnh nhưng lại cộng chặng về đỉnh 0, chặng đó không ứng với điều gì trong đề.
Ví dụChu Trình Qua Các Trạm Bay
Đề. Có trạm trong không gian, đi từ tới tốn . Xuất phát ở trạm 1, ghé mọi trạm ít nhất một lần (được ghé lại), rồi quay về trạm 1. In tổng chi phí nhỏ nhất.
Lời giải. Đề cho phép ghé lại, nhưng chi phí này thỏa bất đẳng thức tam giác: đi thẳng không đắt hơn đi vòng , vì phần , thỏa bất đẳng thức tam giác thường, còn phần leo cao . Vậy mọi lần ghé lại đều bỏ được mà không tốn thêm, và bài đúng là TSP: mỗi trạm đúng một lần, có chặng quay về trạm 1. phép.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, x[17], y[17], z[17], d[17][17], dp[1 << 17][17];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) cin >> x[i] >> y[i] >> z[i];
for (ll i = 0; i < n; i++)
for (ll j = 0; j < n; j++) d[i][j] = abs(x[i] - x[j]) + abs(y[i] - y[j]) + max(0LL, z[j] - z[i]);
ll full = (1 << n) - 1;
for (ll mask = 0; mask <= full; mask++)
for (ll v = 0; v < n; v++) dp[mask][v] = 1e18;
dp[1][0] = 0;
for (ll mask = 1; mask <= full; mask++)
for (ll v = 0; v < n; v++) {
if (dp[mask][v] == 1e18) continue;
for (ll u = 0; u < n; u++)
if (!((mask >> u) & 1))
dp[mask | (1 << u)][u] = min(dp[mask | (1 << u)][u], dp[mask][v] + d[v][u]);
}
ll ans = 1e18;
for (ll v = 1; v < n; v++) ans = min(ans, dp[full][v] + d[v][0]);
cout << ans << '\n';
}
2
0 0 0
2 -1 4
10
Chia nhóm
Có những bài mà bước cuối cùng không thêm một phần tử mà tách cả một nhóm. Ví dụ: vật khối lượng , chia thành các nhóm sao cho tổng khối lượng mỗi nhóm không vượt ; đếm số cách chia, không phân biệt thứ tự các nhóm.
Đặt là số cách chia tập . Nhóm cuối cùng là một tập con hợp lệ, phần còn lại được chia tự do. Nếu cộng trên mọi , một cách chia thành nhóm bị đếm lần, mỗi lần ứng với một thứ tự chọn nhóm cuối. Cách sửa là bắt buộc nhóm tách ra phải chứa phần tử nhỏ nhất của , gọi là . Mỗi cách chia có đúng một nhóm chứa , nên mỗi cách chia chỉ sinh ra đúng một lần:
Với mask là tập và low là bit thấp nhất của nó, nhóm gồm low cộng một tập con bất kỳ của rest = mask ^ low, duyệt bằng vòng s = (s - 1) & rest của bài 14. Dùng dạng đầy đủ của vòng lặp, vì tập con rỗng của rest ứng với nhóm chỉ có một mình low, và nó phải được xử lý trước khi thoát.
Trong hình, bấm bit của mask để chọn tập; mỗi hàng là một nhóm chứa bit thấp nhất.
Cố định low còn cắt đi một nửa chi phí. Một mask có bit chỉ còn nhóm thay cho , nên tổng trên mọi mask chỉ còn khoảng một nửa của , đúng ra là . Với đó là , với là , còn đã là , quá chậm. Chương trình dưới tính sẵn sum[mask], tổng khối lượng của mỗi tập, nên kiểm tra một nhóm hợp lệ tốn .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, X, w[16], sum[1 << 16], ways[1 << 16];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> X;
for (ll i = 0; i < n; i++) cin >> w[i];
ll full = (1 << n) - 1;
for (ll mask = 1; mask <= full; mask++) sum[mask] = sum[mask & (mask - 1)] + w[__builtin_ctzll(mask)];
ways[0] = 1;
for (ll mask = 1; mask <= full; mask++) {
ll low = mask & -mask, rest = mask ^ low;
for (ll s = rest;; s = (s - 1) & rest) {
if (sum[s | low] <= X) ways[mask] += ways[mask ^ (s | low)];
if (s == 0) break;
}
}
cout << ways[full] << '\n';
}
4 9
6 4 3 5
6
Kiểm tay với khối lượng 6, 4, 3, 5 và . Cặp hợp lệ chỉ có , , , ; không có nhóm ba hợp lệ. Bốn nhóm đơn cho 1 cách, một cặp cùng hai nhóm đơn cho 4 cách, hai cặp chỉ có cùng . Tổng 6. Nếu cho lớn để mọi nhóm đều hợp lệ, chương trình in 15, là số Bell .
Hỏi nhanh
Một chương trình đếm số cách chia tập thành các nhóm không rỗng, mọi nhóm đều hợp lệ. Nó duyệt mọi tập con khác rỗng của mask làm nhóm cuối, không bắt buộc chứa phần tử nhỏ nhất. Kết quả nó in ra là bao nhiêu? (Đáp án đúng của bài là 5.)
Không có ràng buộc về phần tử nhỏ nhất thì công thức đếm các dãy nhóm có thứ tự. Một nhóm cho 1; mỗi trong ba cách chia thành một cặp và một đơn cho 2 thứ tự, tức 6; cách chia thành ba nhóm đơn cho thứ tự. Tổng . Con số 6 chính là , nhầm số thứ tự xếp ba phần tử với số cách chia; 8 là số tập con ; 15 là số Bell của tập bốn phần tử, không liên quan tới bài này. Bắt buộc nhóm chứa phần tử nhỏ nhất cố định một thứ tự duy nhất cho các nhóm, và đưa 13 về 5.
Ví dụPhân Nhóm Tối Đa Điểm
Đề. Có thành viên; hai người cùng nhóm thì nhận điểm (ma trận đối xứng, có thể âm, ). Chia mọi người thành các nhóm không rỗng để tổng điểm lớn nhất.
Lời giải. Điểm của một nhóm chỉ phụ thuộc tập thành viên: là tổng trên mọi cặp trong . Tính score[mask] từ score[mask & (mask - 1)], cộng thêm điểm giữa thành viên thấp nhất và mọi thành viên còn lại, mỗi mask. Rồi chia nhóm đúng khuôn trên, thay đếm bằng lấy max: trên các nhóm chứa phần tử nhỏ nhất của . Điểm âm không phá được công thức vì ta xét mọi , không tham lam gộp nhóm; mỗi người một nhóm cho 0, nên đáp án không âm. Chi phí ; điểm tới nên dùng ll. Với ví dụ, nhóm cho 5 và người 3 đứng riêng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, a[16][16], sc[1 << 16], dp[1 << 16];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++)
for (ll j = 0; j < n; j++) cin >> a[i][j];
ll full = (1 << n) - 1;
for (ll mask = 1; mask <= full; mask++) {
ll v = __builtin_ctzll(mask), rest = mask & (mask - 1);
sc[mask] = sc[rest];
for (ll u = 0; u < n; u++)
if ((rest >> u) & 1) sc[mask] += a[v][u];
}
for (ll mask = 1; mask <= full; mask++) {
ll low = mask & -mask, rest = mask ^ low;
dp[mask] = -1e18;
for (ll s = rest;; s = (s - 1) & rest) {
dp[mask] = max(dp[mask], dp[mask ^ (s | low)] + sc[s | low]);
if (s == 0) break;
}
}
cout << dp[full] << '\n';
}
3
0 5 -4
5 0 2
-4 2 0
5
Mask và vị trí
Đôi khi mask chỉ là một phần của trạng thái, phần còn lại là vị trí đang xét trong một dãy. Ví dụ: đếm xâu độ dài trên bảng chữ cái chữ () sao cho mỗi chữ cái xuất hiện ít nhất một lần. Đặt dp[i][mask] là số xâu độ dài mà tập các chữ đã xuất hiện đúng bằng mask. Thêm chữ vào cuối cho tập chữ mask | (1 << c), và đáp án là dp[L][(1 << k) - 1]. Lớp chỉ lấy từ lớp , nên chương trình giữ hai mảng f, g thay vì cả bảng. Thời gian .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll L, k, f[1 << 10], g[1 << 10];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> L >> k;
f[0] = 1;
for (ll i = 0; i < L; i++) {
for (ll mask = 0; mask < (1 << k); mask++) g[mask] = 0;
for (ll mask = 0; mask < (1 << k); mask++)
for (ll c = 0; c < k; c++) g[mask | (1 << c)] += f[mask];
for (ll mask = 0; mask < (1 << k); mask++) f[mask] = g[mask];
}
cout << f[(1 << k) - 1] << '\n';
}
10 5
5103000
Kết quả khớp công thức bao hàm loại trừ . Bài nào có sẵn công thức đóng để đối chiếu như vậy thì nên kiểm chương trình trên vài ví dụ nhỏ trước khi nộp.
Mask không nhất thiết là “tập đã dùng”. Ba vai trò hay gặp khác:
- Tập đã phủ. Chọn các gói, mỗi gói phủ một tập tính năng, để phủ đủ mọi tính năng với tổng giá nhỏ nhất. Chuyển bằng phép hợp:
dp[mask | key[i]]nhậndp[mask] + cost[i]. Vẫn duyệtmasktăng dần được, vìmask | key[i] >= mask. - Vector chẵn lẻ. Tích các số là số chính phương khi mọi số mũ nguyên tố đều chẵn. Mỗi bit của mask ghi tính chẵn lẻ số mũ của một nguyên tố, và thêm một số là phép XOR với mask của số đó.
- Cửa sổ trượt. Đếm hoán vị thỏa : khi điền vị trí chỉ các giá trị trong dùng được, nên mask bit ghi giá trị nào của cửa sổ đã dùng, và dịch phải một bit mỗi khi sang vị trí kế.
Bao nhiêu phần tử thì còn dùng được. Trước khi cài, tính hai con số: số phép tính (số trạng thái nhân số chuyển của mỗi trạng thái) và số byte của bảng. Với một giây ta chạy được cỡ phép đơn giản: ở là , thoải mái; ở là , sát giới hạn; ở là (một nửa nếu cố định phần tử nhỏ nhất). Về bộ nhớ, số int là 84 MB và gấp đôi với long long. Khi quá lớn cho , hướng khác là chia đôi tập (bài 10); khi cần tổng trên mọi tập con thì dùng SOS DP (bài 46); khi mask là một hàng của lưới thì đó là quy hoạch động profile (bài 44). Duyệt các bit đang tắt của mask mà không xét cả bit: for (ll r = full ^ mask; r; r &= r - 1) { ll j = __builtin_ctzll(r); ... }.
Khi đi thi
- (hoặc 15 đến 24) cùng cụm “mỗi phần tử dùng đúng một lần”, “thứ tự”, “chọn tập”: nghĩ tới mask. Chia nhóm chỉ chấp nhận được khi đến 18.
- Trước khi cài, viết một dòng
dp[...]có nghĩa gì và hỏi tương lai còn cần biết gì về quá khứ. Cần đỉnh cuối thì thêm chiềulast; suy ra được từ số bit của mask thì không thêm. Rồi nhân số trạng thái với số chuyển để ước lượng thời gian, bộ nhớ. - Đếm cách chia nhóm hay thứ tự thì chốt nhóm chứa phần tử nhỏ nhất để không đếm lặp. Kiểu đẩy cần bỏ qua trạng thái chưa tới được trước khi cộng.
Tóm tắt
- Khi đáp án phụ thuộc vào việc phần tử nào đã dùng và , dùng mask làm trạng thái: trạng thái thay cho .
dp[mask]đủ khi tương lai chỉ cần tập đã dùng; cần phần tử cuối thì thêm chiều thànhdp[mask][last], với thời gian .- Chuyển trạng thái bật thêm bit nên duyệt
masktăng dần là đúng thứ tự; kéo hay đẩy chỉ là cách cài, và kiểu đẩy cần khởi tạo vô cùng cùng điều kiện bỏ qua ô chưa tới được. - Chia tập thành nhóm thì duyệt tập con bằng
s = (s - 1) & mtrong , và bắt buộc nhóm chứa phần tử nhỏ nhất để mỗi cách chia chỉ được tính một lần. - Mask còn có thể là tập đã phủ, vector chẵn lẻ hay cửa sổ, kết hợp với vị trí quét; luôn ước lượng nhân số chuyển và số byte của bảng trước khi cài.
Bài tập
Đọc thêm
- Quy hoạch động bitmaskVNOI Wiki
- Bitmask DPUSACO Guide
- Submask Enumerationcp-algorithms
- 状压 DPOI Wiki
- AtCoder ABC180 E, bài giải chính thức
- Sức mạnh của Bitmask DP từ cơ bản đến nâng caoViblo