Với mỗi tập con của 20 phần tử, cần tổng trên mọi tập con của . Duyệt tập con của từng mặt nạ tốn bước, quá chậm. Nhưng có thể gom từng bit một: sau bit 0, mỗi ô đã cộng ô chỉ khác nó ở bit 0; sau bit 1, cộng thêm các ô khác nó ở bit 1; sau 20 bit, mỗi ô đã gom đủ mọi tập con.
Đó là quy hoạch động tổng trên tập con (sum over subsets, SOS): , khoảng phép với . Cùng khung đó cho tổng trên tập cha, phép biến đổi ngược để lấy lại , và tích chập theo OR, AND.
Cộng theo từng bit
Sau khi xử lý xong bit , gọi là tổng trên các mà và giống nhau ở mọi bit từ trở lên. Ban đầu . Xử lý bit : nếu không có bit thì cũng không có, . Nếu có bit thì có hoặc không có bit , hai nhóm rời nhau: . Sau bit, là tổng trên mọi tập con. Chỉ cần một mảng: duyệt theo bất kỳ thứ tự nào, vì không có bit nên không bị sửa trong cùng lượt.
for (ll b = 0; b < B; b++)
for (ll m = 0; m < (1 << B); m++)
if (m >> b & 1) F[m] += F[m ^ (1 << b)];
Vòng trong đi qua cả mặt nạ chỉ để bỏ một nửa. Các mặt nạ xếp thành từng khối dài : nửa đầu khối không có bit , nửa sau có. Đi theo khối thì vòng lặp chỉ chạm nửa cần cộng và không còn câu if:
for (ll b = 0; b < B; b++)
for (ll m = 0; m < (1 << B); m += 2 << b)
for (ll j = m; j < m + (1 << b); j++) F[j | 1 << b] += F[j];
Đi theo khối nhanh gần gấp đôi; cùng phép tính, chỉ đổi cách đi.
Tổng trên tập cha () đảo chiều: nếu không có bit thì cộng . Số phần tử giao khác rỗng với là tổng trừ số phần tử là tập con của phần bù của .
Hỏi nhanh
Vì sao SOS xử lý bit theo vòng ngoài, mặt nạ theo vòng trong, mà không đảo hai vòng?
Đảo hai vòng thì khi xử lý mặt nạ , giá trị có thể đã gộp thêm những bit sau hoặc chưa gộp những bit trước, nên một tập con bị đếm nhiều lần hoặc bị bỏ sót.
Ví dụBa Góc Nhìn Của Mặt Nạ
Đề. Dãy số tới . Với mỗi đếm số có , số có , số có .
Lời giải. nên mọi số là mặt nạ 20 bit. Câu một là tổng trên tập con, câu hai là tổng trên tập cha. Câu ba lấy phần bù: không giao khi và chỉ khi là tập con của , nên đáp án là trừ số đó.
Bước 1: đếm. sub[x], sup[x] cùng bắt đầu bằng số lần xuất hiện.
cin >> a[i];
sub[a[i]]++;
sup[a[i]]++;
Bước 2: SOS hai chiều cùng lúc. Theo từng bit : mặt nạ có bit cộng giá trị của mặt nạ tắt bit đó vào sub (gom từ tập con); mặt nạ không có bit cộng giá trị của mặt nạ bật bit đó vào sup (gom từ tập cha). Hai phép không đụng nhau nên làm chung một vòng. Sau khi xử lý các bit , sub[m] là tổng trên các tập con chỉ khác ở các bit ; hết mọi bit thì là tổng trên mọi tập con.
for (ll b = 0; b < B; b++)
for (ll m = 0; m < S; m++) {
if (m >> b & 1) sub[m] += sub[m ^ (1 << b)];
else sup[m] += sup[m ^ (1 << b)];
}
Bước 3: trả lời. Câu ba tra sub ở phần bù (S - 1) ^ a[i].
cout << sub[a[i]] << ' ' << sup[a[i]] << ' ' << n - sub[(S - 1) ^ a[i]] << '\n';
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll B = 20, S = 1 << B;
ll n, a[200005], sub[S], sup[S];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
sub[a[i]]++;
sup[a[i]]++;
}
for (ll b = 0; b < B; b++)
for (ll m = 0; m < S; m++) {
if (m >> b & 1) sub[m] += sub[m ^ (1 << b)];
else sup[m] += sup[m ^ (1 << b)];
}
for (ll i = 1; i <= n; i++) cout << sub[a[i]] << ' ' << sup[a[i]] << ' ' << n - sub[(S - 1) ^ a[i]] << '\n';
}
4
1 2 3 4
1 2 2
1 2 2
3 1 3
1 1 1
Ước lượng phép tính: phép.
Giữ đại diện
Phép gộp không nhất thiết là cộng; bất kỳ phép kết hợp, giao hoán nào cũng được: max, min, OR, hay “giữ một phần tử bất kỳ”. Muốn với mỗi tìm một phần tử trong dãy có , tức : lưu là một phần tử (chẳng hạn lớn nhất) trong các phần tử là tập con của , rồi tra .
Ví dụNgười Bạn Không Giao Bit
Đề. Dãy số nhỏ hơn . Với mỗi , in một giá trị trong dãy không chung bit nào với nó, hoặc .
Lời giải. không chung bit với khi và chỉ khi là tập con của phần bù của . Chỉ cần biết một phần tử bất kỳ trong các tập con của mỗi mặt nạ: SOS với phép max thay cho cộng.
Bước 1: khởi tạo. F[m] = -1 là chưa có phần tử nào; mỗi phần tử đặt chính nó vào ô của nó.
for (ll m = 0; m < S; m++) F[m] = -1;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
F[a[i]] = a[i];
}
Bước 2: SOS với max. Sau cùng F[m] là phần tử lớn nhất trong các phần tử là tập con của , hoặc .
for (ll b = 0; b < B; b++)
for (ll m = 0; m < S; m++)
if (m >> b & 1) F[m] = max(F[m], F[m ^ (1 << b)]);
Bước 3: tra ở phần bù (S - 1) ^ a[i]. F dùng int để mảng phần tử chỉ tốn 16 MB.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll B = 22, S = 1 << B;
ll n, a[1000005];
int F[S];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll m = 0; m < S; m++) F[m] = -1;
for (ll i = 1; i <= n; i++) {
cin >> a[i];
F[a[i]] = a[i];
}
for (ll b = 0; b < B; b++)
for (ll m = 0; m < S; m++)
if (m >> b & 1) F[m] = max(F[m], F[m ^ (1 << b)]);
for (ll i = 1; i <= n; i++) cout << F[(S - 1) ^ a[i]] << ' ';
cout << '\n';
}
5
1 2 3 4 7
4 4 4 3 -1
Ước lượng phép tính: phép max.
Bao hàm loại trừ
Đếm tập con có AND đúng bằng 0 khó, nhưng đếm tập con có AND chứa mặt nạ thì dễ: mọi phần tử được chọn phải là tập cha của . Gọi là số phần tử là tập cha của (SOS trên tập cha); số tập con không rỗng có AND là . Theo bao hàm loại trừ, số tập con có AND bằng 0 là
với là số bit 1 của . Cũng theo khuôn này: hợp đúng bằng là đảo dấu theo tập con của số cách có hợp nằm trong mặt nạ con.
Ví dụGiao Bằng Không
Đề. Dãy số tới . Đếm số tập con vị trí không rỗng có AND bằng 0, modulo .
Lời giải. Đếm thẳng AND bằng 0 khó; dễ hơn là đếm các tập có AND chứa một mặt nạ : đó là các tập con không rỗng của những phần tử chứa . Rồi bao hàm loại trừ theo để chỉ giữ AND bằng đúng 0.
Bước 1: đếm phần tử chứa . g[x] bắt đầu bằng số lần xuất hiện; SOS trên tập cha cho g[m] là số phần tử chứa . Lũy thừa 2 tính sẵn ngay trong vòng đọc.
for (ll b = 0; b < B; b++)
for (ll m = 0; m < S; m++)
if (!(m >> b & 1)) g[m] += g[m ^ (1 << b)];
Bước 2: bao hàm loại trừ. pw[g[m]] - 1 là số tập không rỗng có AND chứa . Cộng khi có chẵn bit 1, trừ khi lẻ: một tập có AND là được đếm ở mọi với tổng dấu , bằng 1 khi và bằng 0 khi .
for (ll m = 0; m < S; m++) {
ll v = (pw[g[m]] - 1 + MOD) % MOD;
if (__builtin_popcountll(m) & 1) ans = (ans - v + MOD) % MOD;
else ans = (ans + v) % MOD;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll B = 20, S = 1 << B, MOD = 1e9 + 7;
ll n, g[S], pw[1000005];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
pw[0] = 1;
for (ll i = 1; i <= n; i++) {
ll x;
cin >> x;
g[x]++;
pw[i] = pw[i - 1] * 2 % MOD;
}
for (ll b = 0; b < B; b++)
for (ll m = 0; m < S; m++)
if (!(m >> b & 1)) g[m] += g[m ^ (1 << b)];
ll ans = 0;
for (ll m = 0; m < S; m++) {
ll v = (pw[g[m]] - 1 + MOD) % MOD;
if (__builtin_popcountll(m) & 1) ans = (ans - v + MOD) % MOD;
else ans = (ans + v) % MOD;
}
cout << ans << '\n';
}
3
1 2 3
2
Ước lượng phép tính: phép.
Tích chập OR
Phép SOS trên tập con là một biến đổi đảo được: chạy lại theo từng bit nhưng trừ thay cho cộng, khi có bit , ta lấy lại (biến đổi Möbius). Với tích chập OR, : tổng trên các là tổng trên các cặp cùng là tập con của , tức . Vậy biến đổi và , nhân từng điểm, rồi biến đổi ngược; tổng . Tích chập AND làm tương tự với tập cha.
Hỏi nhanh
Biến đổi ngược của SOS trên tập con dùng phép gì?
Mỗi bước SOS là trên các cặp độc lập; bước ngược của nó là trừ lại. Làm ngược tất cả các bước (thứ tự bit nào cũng được, vì các bước giao hoán) cho lại mảng ban đầu.
Ví dụTích Chập OR
Đề. Hai dãy đánh chỉ số theo mọi mặt nạ bit. Tính modulo .
Lời giải. Sau biến đổi tổng trên tập con, a[m] là tổng trên , tương tự b[m]. Tích của chúng là tổng trên mọi cặp có , tức tổng của trên tập con của . Vậy biến đổi và , nhân từng điểm, rồi biến đổi ngược lấy lại .
Bước 1: biến đổi và biến đổi ngược trong một hàm. zeta(f, 1) là SOS trên tập con theo mô-đun; zeta(f, -1) cùng vòng lặp nhưng trừ, là biến đổi ngược (Möbius). Đảo được vì mỗi bit là một phép biến đổi độc lập, và trừ hoàn tác đúng phép cộng. Phép trừ theo mô-đun cộng thêm MOD trước khi lấy dư, kết quả luôn trong .
void zeta(ll f[], ll sign) {
for (ll k = 0; k < n; k++)
for (ll m = 0; m < S; m++)
if (m >> k & 1) f[m] = (f[m] + sign * f[m ^ (1 << k)] % MOD + MOD) % MOD;
}
Bước 2: nhân từng điểm.
zeta(a, 1);
zeta(b, 1);
for (ll m = 0; m < S; m++) a[m] = a[m] * b[m] % MOD;
zeta(a, -1);
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll MOD = 998244353;
ll n, S, a[1 << 20], b[1 << 20];
void zeta(ll f[], ll sign) {
for (ll k = 0; k < n; k++)
for (ll m = 0; m < S; m++)
if (m >> k & 1) f[m] = (f[m] + sign * f[m ^ (1 << k)] % MOD + MOD) % MOD;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
S = 1 << n;
for (ll i = 0; i < S; i++) cin >> a[i];
for (ll i = 0; i < S; i++) cin >> b[i];
zeta(a, 1);
zeta(b, 1);
for (ll m = 0; m < S; m++) a[m] = a[m] * b[m] % MOD;
zeta(a, -1);
for (ll m = 0; m < S; m++) cout << a[m] << ' ';
cout << '\n';
}
2
1 2 3 4
5 6 7 8
5 28 43 184
Độ phức tạp: .
Hai giá trị lớn nhất
Có bài cần cặp phần tử tốt nhất trong các tập con của , như với đều là tập con của . Mỗi ô giữ hai chỉ số có giá trị lớn nhất (khác nhau). Gộp hai danh sách: lấy bốn ứng viên, bỏ chỉ số trùng, giữ hai lớn nhất. Hai danh sách của và có thể chứa chung một chỉ số (một tập con của cả hai), nên kiểm trùng theo chỉ số chứ không theo giá trị.
Ví dụHai Chỉ Số Tốt Nhất
Đề. Dãy đánh chỉ số với . Với mỗi , tìm trên các cặp có .
Lời giải. khi và chỉ khi cả và là tập con của . Vậy với mỗi chỉ cần hai chỉ số có giá trị lớn nhất trong các tập con của : tổng của chúng là . Điều kiện nghĩa là với một , nên đáp án là max tiền tố của .
Bước 1: thêm một ứng viên. t1[m], t2[m] là chỉ số lớn nhất và nhì; là chưa có. push bỏ qua chỉ số đã có (hai ô được gộp có thể cùng chứa một chỉ số), rồi chèn vào đúng vị trí.
void push(ll m, ll x) {
if (x < 0 || x == t1[m] || x == t2[m]) return;
if (t1[m] < 0 || A[x] > A[t1[m]]) {
t2[m] = t1[m];
t1[m] = x;
} else if (t2[m] < 0 || A[x] > A[t2[m]]) t2[m] = x;
}
Bước 2: SOS. Ban đầu mỗi ô chỉ có chính nó. Mặt nạ bật bit gộp hai ứng viên của mặt nạ tắt bit đó.
for (ll b = 0; b < n; b++)
for (ll m = 0; m < S; m++)
if (m >> b & 1) {
ll o = m ^ (1 << b);
push(m, t1[o]);
push(m, t2[o]);
}
Bước 3: max tiền tố. ans chạy tăng dần theo .
for (ll k = 1; k < S; k++) {
if (t2[k] >= 0) ans = max(ans, A[t1[k]] + A[t2[k]]);
cout << ans << '\n';
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
ll n, S, A[1 << 18], t1[1 << 18], t2[1 << 18];
void push(ll m, ll x) {
if (x < 0 || x == t1[m] || x == t2[m]) return;
if (t1[m] < 0 || A[x] > A[t1[m]]) {
t2[m] = t1[m];
t1[m] = x;
} else if (t2[m] < 0 || A[x] > A[t2[m]]) t2[m] = x;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
S = 1 << n;
for (ll i = 0; i < S; i++) {
cin >> A[i];
t1[i] = i;
t2[i] = -1;
}
for (ll b = 0; b < n; b++)
for (ll m = 0; m < S; m++)
if (m >> b & 1) {
ll o = m ^ (1 << b);
push(m, t1[o]);
push(m, t2[o]);
}
ll ans = 0;
for (ll k = 1; k < S; k++) {
if (t2[k] >= 0) ans = max(ans, A[t1[k]] + A[t2[k]]);
cout << ans << '\n';
}
}
2
1 4 2 3
5
5
7
Độ phức tạp: .
Khi đi thi
- Câu hỏi “đếm hay tìm trên mọi tập con (hoặc tập cha) của mặt nạ” với tới 20 đến 22: SOS . Kiểm bộ nhớ: số
lllà 32 MB. - “Đúng bằng” khó thì đếm “chứa” hay “nằm trong” rồi bao hàm loại trừ theo số bit, hoặc chạy biến đổi Möbius.
- Phép gộp chỉ cần kết hợp và giao hoán: tổng, max, giữ hai chỉ số tốt nhất. Giữ nhiều phần tử thì kiểm trùng theo chỉ số.
Tóm tắt
- SOS: với mỗi bit , khi có bit ; tập cha thì khi không có bit đó.
- Thay phép cộng bằng max hay giữ đại diện để tìm phần tử không giao bit.
- Bao hàm loại trừ: AND bằng 0 là .
- Biến đổi Möbius là SOS với phép trừ; tích chập OR là biến đổi, nhân điểm, biến đổi ngược.
Bài tập
Đọc thêm
- Quy hoạch động SOS - Phần 1VNOI Wiki
- Quy hoạch động SOS - Phần 2VNOI Wiki
- Enumerating submasks of a bitmaskcp-algorithms
- Sum over Subsets DPUSACO Guide