DP bitmask
DP bitmask (quy hoạch động trên mặt nạ bit) là kỹ thuật dùng một số nguyên nhị phân để mã hoá trạng thái là một tập con của phần tử: bit thứ bằng 1 nghĩa là phần tử đã được chọn/đã xử lý. Nhờ đó ta có thể xét tất cả tập con một cách có hệ thống, đổi bài toán hoán vị (ví dụ TSP duyệt mọi thứ tự) thành — với thì hoàn toàn chạy được.
Ý tưởng / Trực giác
Vì sao bitmask DP đúng? Mấu chốt nằm ở chỗ: với nhiều bài toán "chọn/sắp xếp phần tử", kết quả tối ưu chỉ phụ thuộc vào TẬP các phần tử đã dùng, không phụ thuộc vào THỨ TỰ ta đã dùng chúng.
Ví dụ trong bài người đưa thư (TSP): nếu ta đã thăm tập thành phố và hiện đang đứng ở thành phố , thì chi phí đi tiếp không quan tâm ta đã đi qua theo thứ tự nào — chỉ cần biết "đã đi những thành phố nào" và "đang ở đâu". Tất cả các thứ tự khác nhau cho cùng đều dẫn tới cùng một bài toán con trong tương lai. Đó chính là tính chất chồng lấp bài toán con (overlapping subproblems): thay vì thứ tự, ta gộp chúng lại thành trạng thái và chỉ giữ giá trị tốt nhất cho mỗi trạng thái.
Một số nguyên 32-bit thừa sức chứa bit, lại cho phép so sánh, đánh chỉ số mảng và thao tác tập hợp (hợp, giao, thêm phần tử) bằng một phép toán bit duy nhất. Vì vậy "tập con" trở thành "chỉ số mảng" , và ta quy hoạch động trên đó như bình thường.
Điều kiện áp dụng cốt lõi: nhỏ (thường , hay gặp ), vì không gian trạng thái là nhân thêm vài thông tin phụ.
Nhắc lại thao tác bit
mask | (1 << i) // bật bit i (thêm phần tử i vào tập)
mask & ~(1 << i) // tắt bit i (loại phần tử i)
mask ^ (1 << i) // đảo bit i
(mask >> i) & 1 // bit i có bật không? (phần tử i có trong tập?)
mask & (mask - 1) // tắt bit 1 thấp nhất
__builtin_popcount(mask) // đếm số bit 1 (kích thước tập)
(1 << n) - 1 // mask gồm n bit 1 (tập đầy đủ)
Lưu ý:
1 << ilà số nguyênint. Nếu cần thì phải viết1LL << i.
Ví dụ chạy tay: TSP với 3 thành phố
Xét ma trận chi phí cho thành phố (xuất phát từ 0, đi hết rồi quay về 0):
dist | tới 0 tới 1 tới 2
------+---------------------
từ 0 | 0 10 15
từ 1 | 10 0 20
từ 2 | 15 20 0
Trạng thái dp[mask][u] = chi phí nhỏ nhất để đi qua đúng tập thành phố mask, kết thúc ở u. Ta điền bảng theo thứ tự mask tăng dần (vì mask lớn luôn nhận chuyển từ mask nhỏ hơn). Ô . nghĩa là (chưa với tới được). Mask viết dạng nhị phân, bit 0 là thành phố 0.
Khởi tạo: chỉ dp[001][0] = 0 (đứng ở thành phố 0, mới thăm mình nó).
mask=001 (chỉ {0})
u=0: dp=0 --> đi tới 1: dp[011][1]=0+10=10
đi tới 2: dp[101][2]=0+15=15
mask=011 ({0,1})
[u=1]: dp=10 --> đi tới 2: dp[111][2]=10+20=30 <-- đang xét
mask=101 ({0,2})
[u=2]: dp=15 --> đi tới 1: dp[111][1]=15+20=35 <-- đang xét
Bảng dp sau khi điền xong (cột = thành phố kết thúc u):
mask \ u | 0 1 2
---------+-------------------
001 | 0 . .
011 | . 10 .
101 | . . 15
111 | . 35 [30]
Cuối cùng cộng chặng quay về 0:
- qua :
- qua :
Đáp số 45 (chu trình ). Quan trọng: hai cách đi tới mask=111 đều được gộp về cùng bảng, ta chỉ giữ giá trị tốt nhất cho mỗi cặp .
Cài đặt
TSP (bài toán người đưa thư)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
vector<vector<int>> dist(n, vector<int>(n));
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> dist[i][j];
const int INF = 1e9;
int FULL = (1 << n) - 1;
// dp[mask][u] = chi phí nhỏ nhất, đã đi qua đúng tập mask, đang đứng ở u
vector<vector<int>> dp(1 << n, vector<int>(n, INF));
dp[1][0] = 0; // bắt đầu tại thành phố 0, tập mới có {0}
// Duyệt mask tăng dần: mọi chuyển đều đi từ tập nhỏ sang tập lớn hơn
for (int mask = 1; mask <= FULL; mask++) {
for (int u = 0; u < n; u++) {
if (!((mask >> u) & 1)) continue; // u phải nằm trong mask
if (dp[mask][u] == INF) continue; // trạng thái chưa với tới
for (int v = 0; v < n; v++) {
if ((mask >> v) & 1) continue; // v đã đi rồi thì bỏ qua
int nm = mask | (1 << v);
dp[nm][v] = min(dp[nm][v], dp[mask][u] + dist[u][v]);
}
}
}
int ans = INF;
for (int u = 1; u < n; u++) // cộng chặng quay về 0
ans = min(ans, dp[FULL][u] + dist[u][0]);
cout << ans << "\n";
}
Bài toán phân công (Assignment) — bitmask 1 chiều
Có người, việc; người làm việc tốn . Mỗi người một việc, cực tiểu tổng. Mẹo: số người đã phân công chính bằng số bit 1 của mask, nên không cần chiều thứ hai.
vector<int> dp(1 << n, INT_MAX);
dp[0] = 0;
for (int mask = 0; mask < (1 << n); mask++) {
if (dp[mask] == INT_MAX) continue;
int person = __builtin_popcount(mask); // người tiếp theo cần phân công
if (person == n) continue;
for (int job = 0; job < n; job++) {
if ((mask >> job) & 1) continue; // việc đã có người nhận
int nm = mask | (1 << job);
dp[nm] = min(dp[nm], dp[mask] + c[person][job]);
}
}
cout << dp[(1 << n) - 1] << "\n"; // mọi việc đã được giao
SOS DP (Sum over Subsets) — tính tổng trên mọi tập con
Cho mảng kích thước . Tính . Duyệt trâu là ; SOS DP hạ xuống bằng cách "góp" theo từng bit.
vector<long long> f(a.begin(), a.end());
for (int i = 0; i < n; i++)
for (int mask = 0; mask < (1 << n); mask++)
if ((mask >> i) & 1)
f[mask] += f[mask ^ (1 << i)]; // gộp các tập con khác nhau ở bit i
Độ phức tạp
| Bài toán | Trạng thái | Thời gian | Bộ nhớ |
|---|---|---|---|
| TSP | dp[mask][u] |
||
| Assignment | dp[mask] |
||
| Lát gạch / broken profile | dp[i][mask] |
||
| SOS DP | f[mask] |
Vì sao TSP là : có giá trị mask, mỗi mask xét điểm kết thúc u, mỗi u thử điểm tiếp theo v → phép cập nhật. Bộ nhớ vì lưu một bảng dp có hàng và cột.
Vì sao SOS DP là : ta lặp bit, mỗi bit quét hết mask một lần, mỗi lần .
Giới hạn thực tế của (mỗi phần tử int ~ 4 byte):
| Ghi chú | ||
|---|---|---|
| 18 | ~262K | rất thoải mái |
| 20 | ~1M | DP bitmask thường OK |
| 22 | ~4M | cần để ý bộ nhớ |
| 25 | ~33M | dễ MLE/TLE |
| 30+ | ~1B | KHÔNG dùng DP bitmask thuần |
⚠️ Lỗi thường gặp
- Tràn
intkhi dịch bit:1 << ivới là hành vi không xác định, và1 << 31đã âm. Khi lớn hoặc cầnmaskrộng, dùng1LL << ivà kiểulong long. Tương tự, tổng chi phí TSP/đếm số cách có thể vượtint→ dùnglong longchodpkhi cần. - Quên
INFkhi cộng dồn: trong TSP, cộngdp[mask][u] + dist[u][v]mà không kiểm tradp[mask][u] == INFtrước sẽ làmINF + disttràn hoặc lan giá trị rác ra toàn bảng. Luôncontinuekhi trạng thái nguồn còn là . - Sai thứ tự duyệt mask: công thức chuyển đi từ tập nhỏ sang tập lớn (thêm bit), nên phải duyệt
masktăng dần. Nếu vô tình duyệt giảm dần (hoặc đặt vòng lặp sai), trạng thái nguồn chưa được tính xong đã bị đọc → kết quả sai âm thầm, không báo lỗi. - Nhầm "phần tử " với "bit thứ ": chỉ số phần tử và vị trí bit phải nhất quán (đánh số từ 0). Trộn lẫn
1 << (i+1)ở chỗ này,1 << iở chỗ khác là lỗi off-by-one kinh điển. - Độ ưu tiên toán tử:
mask >> i & 1đúng, nhưngmask & 1 << i == 0thì SAI vì==được tính trước<</&. Luôn đóng ngoặc:(mask & (1 << i)) == 0. - Duyệt tập con quên
sub = 0: vòngfor (int sub = mask; sub > 0; sub = (sub - 1) & mask)không xét tập rỗngsub = 0. Nếu bài cần cả tập rỗng, xử lý riêng.
Biến thể / Mở rộng
- Broken profile / lát gạch: khi DP theo từng hàng/ô của bảng,
maskmã hoá "đường biên lởm chởm" giữa phần đã lát và chưa lát. Xem trang DP broken profile. - Duyệt mọi tập con của một mask trong tổng cộng:
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
// xử lý sub là tập con khác rỗng của mask
}
- Shortest Superstring: ghép xâu thành xâu ngắn nhất,
dp[mask][i]= độ dài nhỏ nhất khi đã ghép tậpmask, kết thúc bằng xâu ; chuyển bằng độ chồng (overlap) lớn nhất giữa hai xâu. - Meet in the middle: khi tới 30–40, chia đôi tập, làm mỗi nửa rồi ghép — thay thế cho bitmask DP thuần.
Bài tập luyện
- Xếp chuồng (juststall) — (Apprentice) Bài nhập môn bitmask DP, làm quen biểu diễn tập con và chuyển trạng thái thêm từng phần tử.
- Dây Điện Chuồng Bò (haywire) — (Amateur) Tối ưu cách xếp/ghép một số nhỏ đối tượng, luyện
dp[mask]cổ điển. - QUIDDITCH Đội Quidditch (quidditch) — (Advanced) Bài phân công (assignment) dùng mẹo
popcount(mask)để bỏ chiều thứ hai. - Đếm Cách Lát Gạch (cnttiling) — (Veteran) Đếm số cách phủ bảng bằng DP bitmask theo hàng / broken profile.
- Du Lịch Đảo (islandtravel) — (Veteran) Hành trình kiểu TSP
dp[mask][u]kết hợp BFS tính khoảng cách giữa các đảo. - Mảnh Ghép Lời Tiên Tri (prophecy) — (Expert) Shortest Superstring: ghép xâu bằng
dp[mask][i]với độ chồng cực đại.