Người viết:
Reviewer:
Trước khi bắt đầu, các bạn độc giả cần nắm được cách biểu diễn tập hợp nhỏ bằng bitmask và kỹ thuật Quy hoạch động bitmask cơ bản được trình bày ở bài viết này.
Trong bài viết này, các kí hiệu của phép toán trên tập hợp sẽ được áp dụng cho cả ngôn ngữ bitmask.
Quy hoạch động Sum over Subsets (hay DP SoS) là một dạng nâng cao của Quy hoạch động bitmask cho phép chúng ta xử lý các bài toán liên quan đến việc tính tổng tập con của từng tập con cần xét một cách nhanh chóng. Không chỉ dừng lại ở việc tính tổng, DP SoS còn có thể được biến đổi linh hoạt để xử lý nhiều bài toán khác nhau.
Trước tiên, chúng ta hãy nghiên cứu cách sử dụng DP SoS để giải quyết bài toán sau: Xét các tập là tập con của , với mỗi tập như vậy, ta gán cho nó một giá trị . Ta cần tính giá trị hàm cho mọi tập con của tập theo công thức:
Đối với phương pháp vét cạn, chúng ta có thể duyệt từng tập rồi từng tập và kiểm tra xem có phải tập con của hay không, cách làm này tốn độ phức tạp .
Ngoài ra, có thể tối ưu một chút bằng cách với mỗi tập , ta chỉ duyệt đúng các tập là tập con của với phương pháp duyệt tập con. Lúc này, mỗi bit trong bitmask có trạng thái: bit tắt ở tập cha, bit bật ở tập cha nhưng không được bật trong tập con, bit bật trong cả tập cha và tập con. Do đó, độ phức tạp thời gian là .
fill(f, f + (1 << n), a[0]); // do phương pháp duyệt tập con bỏ qua tập rỗng
for (int mask = 0; mask < (1 << n); mask++) {
for (int sub = mask; sub; sub = (sub - 1) & mask) f[mask] += a[sub];
}
Một bitmask là sub-mask của khi và chỉ khi (với là kí hiệu của phép bitwise AND). Đối với ngôn ngữ tập hợp, nếu bitmask biểu diễn một tập con của tập mà bitmask biểu diễn, thì là sub-mask của .
Ý tưởng dễ nghĩ đến nhất khi tiếp cận bài toán đó là đặt là tổng giá trị các sub-mask của mask (với là một bitmask biểu diễn tập hợp), rồi tìm cách tính từ các giá trị (với ). Tuy nhiên, cách này không mấy khả quan do các giá trị được truy hồi có thể trùng lắp nhau.
Xét mask , dễ thấy, các sub-mask của mask này là các mask chỉ có thể khác mask này tại bit thứ ---- những bit được bật của mask đang xét. Ta có thể gọi chung những mask như vậy bằng "pattern" .
Từ đó, ta có ý tưởng: Thay vì đặt trạng thái là một bitmask, ta có thể sử dụng pattern mà mỗi phần tử mang một trong ba giá trị , hoặc . Khi xét một pattern, các giá trị hoặc cho biết bit tương ứng của mask được cố định theo giá trị đó, còn dấu cho biết bit tương ứng mang trạng thái bật/tắt tùy ý.
Khi đó, là tổng giá trị các mask thỏa pattern . Ví dụ (các vị trí được tô màu đỏ là các vị trí không được cố định):
Để tính , ta tìm một dấu bất kì trong rồi truy hồi về hai trạng thái tương ứng được thay dấu đó bằng và . Ví dụ:
Điểm mạnh của cách làm này là không có mask nào thỏa cả hai pattern và nên ta hoàn toàn có thể cộng hai giá trị mà không sợ bị tính trùng.
Tuy nhiên, cách làm này vẫn còn 2 điểm yếu lớn:
Để khắc phục điều này, khi truy hồi thay vì chọn dấu bất kì, ta quy ước chọn dấu bên trái nhất (chỉ số cao nhất). Khi đó, ta chỉ cần xét các pattern mà các giá trị và nằm ở hai bên của pattern.
Ta lưu thêm một biến cho biết vị trí tách phần chứa và phần chứa (giá trị xuất hiện trong cả hai phần). Lúc này, để biểu diễn một pattern, ta chỉ cần một cặp bitmask và số nguyên :
Như vậy, ta đã giải quyết được cả vấn đề về độ phức tạp và việc biểu diễn các pattern.
Bây giờ, hàm Quy hoạch động của chúng ta là tính tổng giá trị của các mask thỏa pattern theo cách đặt trạng thái như trên. Trường hợp cơ sở là (pattern không có dấu ).
Với mỗi trạng thái , ta tìm dấu bên trái nhất để truy hồi. Cụ thể:
Cuối cùng, ta có (pattern chỉ có dấu và giá trị ).
Như vậy, ta có công thứ truy hồi hoàn chỉnh là:
Sau đây là cây biểu diễn các trạng thái mà ta sẽ gọi khi tính
Lưu ý, khi vẽ hết các trạng thái của Quy hoạch động ra, đồ thị không còn là cây nữa mà là một đồ thị có hướng không chu trình (DAG).
Có tổng cộng là
Với công thức truy hồi như trên, khi cài đặt, việc duyệt theo biến
Ta sẽ duyệt theo biến
for (int mask = 0; mask < (1 << n); mask++) {
dp[mask][0] = a[mask]; // trường hợp cơ sở
for (int k = 1; k <= n; k++) {
if (mask & (1 << (k - 1)))
dp[mask][k] = dp[mask ^ (1 << (k - 1))][k - 1] + dp[mask][k - 1];
else dp[mask][k] = dp[mask][k - 1];
}
f[mask] = dp[mask][n];
}
Ưu điểm của cách cài đặt này là khi bắt đầu tính cho một
Tuy nhiên, cách làm này không thể tối ưu bộ nhớ xuống
Ta sẽ duyệt theo biến
for (int mask = 0; mask < (1 << n); mask++) dp[mask][0] = a[mask]; // trường hợp cơ sở
for (int k = 1; k <= n; k++) {
for (int mask = 0; mask < (1 << n); mask++) {
if (mask & (1 << (k - 1)))
dp[mask][k] = dp[mask ^ (1 << (k - 1))][k - 1] + dp[mask][k - 1];
else dp[mask][k] = dp[mask][k - 1];
}
}
for (int mask = 0; mask < (1 << n); mask++) f[mask] = dp[mask][n];
Với cách này, tất cả các giá trị
Bù lại, từ cách cài đặt này, ta có thể tối ưu bộ nhớ xuống
Ở cách 2, có thể thấy rằng khi duyệt đến
for (int k = 1; k <= n; k++)
for (int mask = (1 << n) - 1; mask >= 0; mask--)
if (mask & (1 << (k - 1))) dp[mask] += dp[mask ^ (1 << (k - 1))];
Để cài đặt ngắn gọn hơn nữa, ta còn có thể đưa ra nhận xét rằng đối với DP SoS, việc duyệt các mask từ nhỏ đến lớn cũng không làm sai kết quả. Hơn nữa, vì không còn cần phải đánh số dòng trên bảng QHĐ, ta có thể duyệt
for (int k = 0; k < n; k++)
for (int mask = 0; mask < (1 << n); mask++)
if (mask & (1 << k)) dp[mask] += dp[mask ^ (1 << k)];
Khi duyệt các mask từ nhỏ đến lớn, ta sẽ vô tình truy hồi về giá trị đã được cập nhật sang dòng mới, từ đó, công thức Quy hoạch động của chúng ta sẽ bị "sai" đi một chút:
Điểm khác biệt là ở trường hợp thứ hai, ta truy hồi về
Cho trước các giá trị của hàm
Bằng cách đổi vế công thức truy hồi của DP SoS, ta có công thức truy hồi để xử lý bài toán ngược như sau:
Đáp án bây giờ là
for (int mask = 0; mask < (1 << n); mask++) {
for (int k = n - 1; k >= 0; k--) {
if (mask & (1 << k))
dp[mask][k] = dp[mask][k + 1] - dp[mask ^ (1 << k)][k];
else dp[mask][k] = dp[mask][k + 1];
}
}
Tương tự bài toán gốc, ta cũng có thể tối ưu bộ nhớ.
for (int k = n - 1; k >= 0; k--)
for (int mask = 0; mask < (1 << n); mask++)
if (mask & (1 << k)) sos[mask] -= sos[mask ^ (1 << k)];
Cho
Giả sử với mọi tập con của
Ta có thể áp dụng DP SoS để tính mọi giá trị
for (int mask = 0; mask < (1 << n); mask++)
sos[mask] = intersect[mask] * (__builtin_parity(mask) ? 1 : -1);
for (int k = 0; k < n; k++)
for (int mask = 0; mask < (1 << n); mask++)
if (mask & (1 << k)) sos[mask] += sos[mask ^ (1 << k)];
Ở cuối chương trình, mảng sos
chứa các giá trị của mảng
Ngoài ra, ta có thể sử dụng bao hàm loại trừ để xử lý bài toán DP SoS ngược nêu trên. Trước tiên, khi xét
Với
Sử dụng Bao hàm loại trừ, ta có công thức tổng quát:
Để chứng minh công thức trên, ta tính đóng góp của từng giá trị của sub-mask
Với
Với
Do ta chỉ quan tâm kích thước của
Đem nhân tử chung
Như vậy, đóng góp của mọi giá trị
Đến đây, ta còn phải biến đổi công thức một chút để có thể áp dụng Mobius Transform, cụ thể như sau:
Sau khi biến đổi, từng số hạng trong tổng đã độc lập theo
for (int mask = 0; mask < (1 << n); mask++)
sos[mask] = f[mask] * (__builtin_parity(mask) ? -1 : 1);
for (int k = 0; k < n; k++)
for (int mask = 0; mask < (1 << n); mask++)
if (mask & (1 << k)) sos[mask] += sos[mask ^ (1 << k)];
for (int mask = 0; mask < (1 << n); mask++)
a[mask] = sos[mask] * (__builtin_parity(mask) ? -1 : 1);
Xét bài toán kinh điển của DP SoS, nhưng công thức đổi lại thành:
tức là ta không xét
Thông thường, ta có thể giải bài toán Sum over Subsets như bình thường, sau đó trừ đi
Để làm được điều đó, ta thêm ràng buộc pattern không tính mask được tạo thành bằng cách thay mọi dấu
với
Nâng cấp bài toán kinh điển của DP SoS thêm một chút, lúc này, hàm
với
Ý tưởng chính để giải quyết dạng bài này là ta sẽ duy trì song song giá trị của
for (int mask = 0; mask < (1 << n); mask++) {
dp[mask][0] = 0; // trường hợp cơ sở
for (int k = 1; k <= n; k++) {
// tính dp
if (mask & (1 << (k - 1))) {
int sub = mask ^ (1 << (k - 1));
dp[mask][k] = dp[mask][k - 1] + dp[sub][k - 1] + f[sub];
}
else dp[mask][k] = dp[mask][k - 1];
}
f[mask] = h(dp[mask][n]) + a[mask]; // tính f
}
Nâng cấp bài tập ở trên thêm một chút nữa, ta cần tính hai hàm
với hàm
Lúc này, ta sẽ duy trì đồng thời
Để tính các giá trị này, ta kết hợp các ý tưởng đã sử dụng ở các phần trên.
for (int mask = 0; mask < (1 << n); mask++) {
// tính dpG và f
for (int k = 1; k <= n; k++) {
if (mask & (1 << (k - 1))) { // truy hồi theo kiểu proper subset
int sub = mask ^ (1 << (k - 1));
dpG[mask][k] = dpG[mask][k - 1] + dpG[sub][k - 1] + g[sub];
}
else dpG[mask][k] = dpG[mask][k - 1];
}
f[mask] = h1(dpG[mask][n]) + a[mask];
// tính dpF và g
dpF[mask][0] = f[mask];
for (int k = 1; k <= n; k++) {
if (mask & (1 << (k - 1))) // truy hồi theo kiểu subset
dpF[mask][k] = dpF[mask][k - 1] + dpF[mask ^ (1 << (k - 1))][k - 1];
else dpF[mask][k] = dpF[mask][k - 1];
}
g[mask] = h2(dpF[mask][n]) + b[mask];
}
Bạn đọc nên tự thử sức trước với các bài tập dưới đây trước khi bước vào phần phân tích ý tưởng và cài đặt nhé.
Trong phần này, ta quy ước
Cho một danh sách
Nếu biến đổi thành ngôn ngữ bitmask thuần túy, các yêu cầu lần lượt là: đếm số mask là sub-mask của
Đối với yêu cầu 1, ta dựng mảng thống kê rồi thực hiện DP SoS căn bản trên mảng thống kê này. Tương tự với yêu cầu loại 2, ta cũng thực hiện DP SoS với định nghĩa DP thay đổi một chút: các bit bật của
Đối với yêu cầu cuối cùng, ta sẽ đếm số mask không có bit bật chung với
Đến đây, ta có thể thấy rằng
Nói tóm lại, chúng ta cần tính hai giá trị sau:
#include <bits/stdc++.h>
using namespace std;
const int full = (1 << 20) - 1;
int a[1 << 20], sosSub[1 << 20], sosSup[1 << 20];
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
int n; cin >> n;
for (int i = 0; i < n; i++) {
cin >> a[i];
sosSub[a[i]]++, sosSup[a[i]]++;
}
for (int k = 0; k < 20; k++) {
for (int mask = 0; mask < (1 << 20); mask++) {
if (mask & (1 << k)) sosSub[mask] += sosSub[mask ^ (1 << k)];
else sosSup[mask] += sosSup[mask ^ (1 << k)];
}
}
for (int i = 0; i < n; i++)
cout << sosSub[a[i]] << " " << sosSup[a[i]] << " " << n - sosSub[a[i] ^ full] << "\n";
return 0;
}
Cho dãy
Nhận xét quan trọng ta cần đưa ra ở bài toán này là: Nếu tồn tại một phương án là super-mask của
Bên cạnh việc sử dụng trong các bài toán tìm kiếm nhị phân truyền thống để đạt hằng số thấp, thuật toán tìm kiếm nhị phân trên bit còn có thể giải quyết bài toán tìm
Thuật toán này dựa trên ý tưởng tham lam tương tự thuật toán Walk on Trie đó là thử bật các bit của đáp án theo thứ tự từ lớn đến bé. Ta có thể đưa ra nhận xét là việc bật bit thứ
Nhìn chung, các thuật toán tìm kiếm nhị phân trên bit có mô hình cài đặt như sau:
int ans = 0;
for (int mask = (1 << B); mask; mask >>= 1) {
if (f(ans | mask)) ans |= mask;
}
Nhiệm vụ còn lại là thiết kế một hàm
Dễ thấy, điều kiện duy nhất để
#include <bits/stdc++.h>
using namespace std;
struct helper {
int best, secBest;
helper() : best(0), secBest(0) {}
void push (int cur) {
if (cur > best) secBest = best, best = cur;
else secBest = max(secBest, cur);
}
void push (const helper &o) {
if (best >= o.best) secBest = max(secBest, o.best);
else secBest = max(best, o.secBest), best = o.best;
}
} sos[1 << 21];
int a[1 << 20], n;
bool ok (int mask) {
for (int i = 1; i <= n - 2; i++) {
int miss = mask ^ (mask & a[i]);
if (i < sos[miss].secBest) return 1;
}
return 0;
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
sos[a[i]].push(i);
}
for (int k = 0; k < 21; k++)
for (int mask = 0; mask < (1 << 21); mask++)
if (!(mask & (1 << k))) sos[mask].push(sos[mask ^ (1 << k)]);
int ans = 0;
for (int mask = 1 << 20; mask > 0; mask >>= 1)
if (ok(ans | mask)) ans |= mask;
cout << ans;
return 0;
}
Cho dãy
In ra đáp án modulo
Thực chất thì để giải bài toán này, ta chỉ cần tính
Ta định nghĩa
Dễ thấy, để bitwise OR của một dãy là một sub-mask của
Cuối cùng, ta thực hiện DP SoS ngược trên mảng
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 7;
const int mn = 1e6 + 6;
int fact[mn], ifac[mn], sos[1 << 20];
int add (int a, int b) { return a + b - (a + b < MOD ? 0 : MOD); }
int sub (int a, int b) { return a - b + (a - b >= 0 ? 0 : MOD); }
int mul (int a, int b) { return 1LL * a * b % MOD; }
int binpow (int a, int b = MOD - 2) {
int ans = 1;
for (; b; b >>= 1) {
if (b & 1) ans = mul(ans, a);
a = mul(a, a);
}
return ans;
}
int C (int n, int k) {
if (n < k) return 0;
int ans = mul(fact[n], ifac[k]);
return mul(ans, ifac[n - k]);
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
// tiền xử lý của tổ hợp
fact[0] = 1;
for (int i = 1; i < mn; i++) fact[i] = mul(fact[i - 1], i);
ifac[mn - 1] = binpow(fact[mn - 1]);
for (int i = mn - 2; i >= 0; i--) ifac[i] = mul(ifac[i + 1], i + 1);
// nhập dữ liệu
int n, k, L, R; cin >> n >> k >> L >> R;
for (int i = 0; i < n; i++) {
int a; cin >> a;
sos[a]++;
}
// tính f bằng DP SoS truyền thống
for (int k = 0; k < 20; k++)
for (int mask = 0; mask < (1 << 20); mask++)
if (mask & (1 << k)) sos[mask] += sos[mask ^ (1 << k)];
// tính sub bằng tổ hợp
for (int mask = 0; mask < (1 << 20); mask++) sos[mask] = C(sos[mask], k);
// tính count bằng DP SoS ngược
for (int k = 19; k >= 0; k--)
for (int mask = 0; mask < (1 << 20); mask++)
if (mask & (1 << k)) sos[mask] = sub(sos[mask], sos[mask ^ (1 << k)]);
// lấy đáp án
int ans = 0;
for (int mask = 3 * (L / 3 + (L % 3 ? 1 : 0)); mask <= R; mask += 3) ans = add(ans, sos[mask]);
cout << ans;
return 0;
}
Cho dãy
Một trong những nhận xét quan trọng nhất mà ta cần đưa ra để xử lý bài toán này là: tần suất xuất hiện của ký tự xuất hiện ít nhất là không quá
Ta có thể sử dụng phản chứng: Giả sử tồn tại một pattern mà mọi ký tự đều xuất hiện ít nhất
Đây là giới hạn đủ nhỏ để duyệt sub-mask. Từ đó, ta có ý tưởng trả lời truy vấn bằng cách duyệt sub-mask trên ký tự xuất hiện ít nhất trong
Đây là trường hợp dễ xử lý nhất, ta đơn giản là duyệt tất cả các bitmask thỏa pattern và tính tổng tương ứng là xong. Độ phức tạp của trường hợp này là
Gọi
Gọi
Như vậy, ta giải quyết được trường hợp này trong
Đối với trường hợp này, ta lại sử dụng Bao hàm loại trừ tương tự trường hợp 2. Gọi
Độ phức tạp của trường hợp cuối cùng là
#include <bits/stdc++.h>
using namespace std;
int toxic[1 << 20], sosSub[1 << 20], sosSuper[1 << 20];
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
int k, Q; cin >> k >> Q;
for (int i = 0; i < (1 << k); i++) {
char c; cin >> c;
toxic[i] = sosSub[i] = sosSuper[i] = c - '0';
}
for (int i = 0; i < k; i++) {
for (int mask = 0; mask < (1 << k); mask++) {
if (mask & (1 << i)) sosSub[mask] += sosSub[mask ^ (1 << i)];
else sosSuper[mask] += sosSuper[mask ^ (1 << i)];
}
}
int full = (1 << k) - 1;
while (Q--) {
string s; cin >> s;
reverse(s.begin(), s.end());
int maskZ = 0, maskO = 0, maskQ = 0;
for (int i = 0; i < s.size(); i++) {
if (s[i] == '0') maskZ |= (1 << i);
if (s[i] == '1') maskO |= (1 << i);
if (s[i] == '?') maskQ |= (1 << i);
}
int ans = 0;
if (__builtin_popcount(maskQ) <= 6) {
ans = toxic[maskO];
for (int sub = maskQ; sub; sub = (sub - 1) & maskQ)
ans += toxic[sub | maskO];
}
else if (__builtin_popcount(maskO) <= 6) {
ans = sosSub[maskQ] * (__builtin_parity(maskO) ? -1 : 1);
for (int sub = maskO; sub; sub = (sub - 1) & maskO) {
if (__builtin_parity(sub) ^ __builtin_parity(maskO)) ans -= sosSub[sub | maskQ];
else ans += sosSub[sub | maskQ];
}
}
else if (__builtin_popcount(maskZ) <= 6) {
ans = sosSuper[maskO];
for (int sub = maskZ; sub; sub = (sub - 1) & maskZ) {
if (__builtin_parity(sub)) ans -= sosSuper[sub | maskO];
else ans += sosSuper[sub | maskO];
}
}
cout << ans << "\n";
}
return 0;
}
Ngoài ra, bạn đọc có thể tìm kiếm thêm những bài tập khác để trau dồi kỹ năng xử lý những dạng toán liên quan tại đây.