Chương 4·Bài 46

SOS DP

Với mỗi tập con mm của 20 phần tử, cần tổng f[s]f[s] trên mọi tập con ss của mm. Duyệt tập con của từng mặt nạ tốn 320≈3,5⋅1093^{20} \approx 3{,}5 \cdot 10^9 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): O(B⋅2B)O(B \cdot 2^B), khoảng 2⋅1072 \cdot 10^7 phép với B=20B = 20. Cùng khung đó cho tổng trên tập cha, phép biến đổi ngược để lấy lại ff, và tích chập theo OR, AND.

Cộng theo từng bit

Sau khi xử lý xong bit 0,…,b−10, \dots, b - 1, gọi Fb[m]F_b[m] là tổng f[s]f[s] trên các s⊆ms \subseteq m mà ss và mm giống nhau ở mọi bit từ bb trở lên. Ban đầu F0=fF_0 = f. Xử lý bit bb: nếu mm không có bit bb thì ss cũng không có, Fb+1[m]=Fb[m]F_{b+1}[m] = F_b[m]. Nếu mm có bit bb thì ss có hoặc không có bit bb, hai nhóm rời nhau: Fb+1[m]=Fb[m]+Fb[m⊕2b]F_{b+1}[m] = F_b[m] + F_b[m \oplus 2^b]. Sau BB bit, FB[m]F_B[m] là tổng trên mọi tập con. Chỉ cần một mảng: duyệt mm theo bất kỳ thứ tự nào, vì m⊕2bm \oplus 2^b không có bit bb nên không bị sửa trong cùng lượt.

Mỗi bước xử lý một bit: ô cam (mask có bit đó) cộng thêm ô mask bỏ bit, theo cung nối.
C++
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ả 2B2^B mặt nạ chỉ để bỏ một nửa. Các mặt nạ xếp thành từng khối dài 2b+12^{b+1}: nửa đầu khối không có bit bb, 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:

C++
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];
SOS trên 2²² mặt nạ, trung vị 5 lần chạy, i7-10850H, g++ -O2.mọi m, kiểm bit b87 ms · gấp 1,8 lầnchỉ m có bit b, theo khối49 ms
SOS trên 2²² mặt nạ, duyệt mọi m kiểm bit b và đi theo khối, đo trên i7-10850H, g++ -O2.

Đ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 (s⊇ms \supseteq m) đảo chiều: nếu mm không có bit bb thì cộng F[m⊕2b]F[m \oplus 2^b]. Số phần tử giao khác rỗng với xx là tổng trừ số phần tử là tập con của phần bù của xx.

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?

Ví dụBa Góc Nhìn Của Mặt Nạ

Đề. Dãy n≤2⋅105n \le 2 \cdot 10^5 số tới 10610^6. Với mỗi aia_i đếm số jj có aj⊆aia_j \subseteq a_i, số jj có aj⊇aia_j \supseteq a_i, số jj có aiANDaj≠0a_i \mathbin{\mathrm{AND}} a_j \ne 0.

Lời giải. 106<22010^6 < 2^{20} 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ù: aja_j không giao aia_i khi và chỉ khi aja_j là tập con của 220−1−ai2^{20} - 1 - a_i, nên đáp án là nn trừ số đó.

Bước 1: đếm. sub[x], sup[x] cùng bắt đầu bằng số lần xx xuất hiện.

C++
cin >> a[i];
sub[a[i]]++;
sup[a[i]]++;

Bước 2: SOS hai chiều cùng lúc. Theo từng bit bb: mặt nạ có bit bb 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 bb 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 0..b0..b, sub[m] là tổng trên các tập con chỉ khác mm ở các bit 0..b0..b; hết mọi bit thì là tổng trên mọi tập con.

C++
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].

C++
cout << sub[a[i]] << ' ' << sup[a[i]] << ' ' << n - sub[(S - 1) ^ a[i]] << '\n';
Code đầy đủ
#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

Ước lượng phép tính: 20⋅220≈2⋅10720 \cdot 2^{20} \approx 2 \cdot 10^7 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 xx tìm một phần tử yy trong dãy có xANDy=0x \mathbin{\mathrm{AND}} y = 0, tức y⊆x‾y \subseteq \overline{x}: lưu F[m]F[m] 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 mm, rồi tra F[x‾]F[\overline{x}].

Ví dụNgười Bạn Không Giao Bit

Đề. Dãy n≤106n \le 10^6 số nhỏ hơn 2222^{22}. Với mỗi aia_i, in một giá trị trong dãy không chung bit nào với nó, hoặc −1-1.

Lời giải. aja_j không chung bit với aia_i khi và chỉ khi aja_j là tập con của phần bù của aia_i. 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ó.

C++
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 mm, hoặc −1-1.

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 2222^{22} phần tử chỉ tốn 16 MB.

Code đầy đủ
#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

Ước lượng phép tính: 22⋅222≈9⋅10722 \cdot 2^{22} \approx 9 \cdot 10^7 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ạ mm thì dễ: mọi phần tử được chọn phải là tập cha của mm. Gọi g[m]g[m] là số phần tử là tập cha của mm (SOS trên tập cha); số tập con không rỗng có AND ⊇m\supseteq m là 2g[m]−12^{g[m]} - 1. Theo bao hàm loại trừ, số tập con có AND bằng 0 là

∑m(−1)∣m∣(2g[m]−1),\sum_m (-1)^{|m|} \left(2^{g[m]} - 1\right),

với ∣m∣|m| là số bit 1 của mm. Cũng theo khuôn này: hợp đúng bằng SS 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 n≤106n \le 10^6 số tới 10610^6. Đếm số tập con vị trí không rỗng có AND bằng 0, modulo 109+710^9 + 7.

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ạ mm: đó là các tập con không rỗng của những phần tử chứa mm. Rồi bao hàm loại trừ theo mm để chỉ giữ AND bằng đúng 0.

Bước 1: đếm phần tử chứa mm. g[x] bắt đầu bằng số lần xx xuất hiện; SOS trên tập cha cho g[m] là số phần tử chứa mm. Lũy thừa 2 tính sẵn ngay trong vòng đọc.

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 mm. Cộng khi mm có chẵn bit 1, trừ khi lẻ: một tập có AND là tt được đếm ở mọi m⊆tm \subseteq t với tổng dấu ∑m⊆t(−1)∣m∣\sum_{m \subseteq t} (-1)^{|m|}, bằng 1 khi t=0t = 0 và bằng 0 khi t≠0t \ne 0.

C++
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;
}
Code đầy đủ
#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

Ước lượng phép tính: 20⋅220≈2⋅10720 \cdot 2^{20} \approx 2 \cdot 10^7 phép.

Tích chập OR

Phép SOS trên tập con là một biến đổi f↦f^f \mapsto \hat f đảo được: chạy lại theo từng bit nhưng trừ thay cho cộng, F[m]−=F[m⊕2b]F[m] \mathrel{-}= F[m \oplus 2^b] khi mm có bit bb, ta lấy lại ff (biến đổi Möbius). Với tích chập OR, ck=∑i∣j=kaibjc_k = \sum_{i \mid j = k} a_i b_j: tổng c^[m]\hat c[m] trên các k⊆mk \subseteq m là tổng trên các cặp i,ji, j cùng là tập con của mm, tức a^[m]⋅b^[m]\hat a[m] \cdot \hat b[m]. Vậy biến đổi aa và bb, nhân từng điểm, rồi biến đổi ngược; tổng O(B⋅2B)O(B \cdot 2^B). 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ì?

Ví dụTích Chập OR

Đề. Hai dãy a,ba, b đánh chỉ số theo mọi mặt nạ n≤20n \le 20 bit. Tính ck=∑i∣j=kaibjc_k = \sum_{i \mid j = k} a_i b_j modulo 998244353998244353.

Lời giải. Sau biến đổi tổng trên tập con, a[m] là tổng aia_i trên i⊆mi \subseteq m, tương tự b[m]. Tích của chúng là tổng aibja_i b_j trên mọi cặp có i∣j⊆mi \mid j \subseteq m, tức tổng của cc trên tập con của mm. Vậy biến đổi aa và bb, nhân từng điểm, rồi biến đổi ngược lấy lại cc.

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 [0,p)[0, p).

C++
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.

C++
zeta(a, 1);
zeta(b, 1);
for (ll m = 0; m < S; m++) a[m] = a[m] * b[m] % MOD;
zeta(a, -1);
Code đầy đủ
#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

Độ phức tạp: O(n⋅2n)O(n \cdot 2^n).

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 mm, như max⁡Ai+Aj\max A_i + A_j với i≠ji \ne j đều là tập con của mm. Mỗi ô F[m]F[m] 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 mm và m⊕2bm \oplus 2^b 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 AA đánh chỉ số 0..2N−10..2^N - 1 với N≤18N \le 18. Với mỗi K≥1K \ge 1, tìm max⁡Ai+Aj\max A_i + A_j trên các cặp i<ji < j có (i∣j)≤K(i \mid j) \le K.

Lời giải. i∣j⊆mi \mid j \subseteq m khi và chỉ khi cả ii và jj là tập con của mm. Vậy với mỗi mm chỉ cần hai chỉ số có giá trị lớn nhất trong các tập con của mm: tổng của chúng là best[m]best[m]. Điều kiện i∣j≤Ki \mid j \le K nghĩa là i∣j=mi \mid j = m với một m≤Km \le K, nên đáp án là max tiền tố của bestbest.

Bước 1: thêm một ứng viên. t1[m], t2[m] là chỉ số lớn nhất và nhì; −1-1 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í.

C++
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 bb gộp hai ứng viên của mặt nạ tắt bit đó.

C++
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 KK.

C++
for (ll k = 1; k < S; k++) {
    if (t2[k] >= 0) ans = max(ans, A[t1[k]] + A[t2[k]]);
    cout << ans << '\n';
}
Code đầy đủ
#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

Độ phức tạp: O(N⋅2N)O(N \cdot 2^N).

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 BB tới 20 đến 22: SOS O(B2B)O(B 2^B). Kiểm bộ nhớ: 2222^{22} số ll là 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 bb, F[m]+=F[m⊕2b]F[m] \mathrel{+}= F[m \oplus 2^b] khi mm có bit bb; tập cha thì khi mm 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à ∑m(−1)∣m∣(2g[m]−1)\sum_m (-1)^{|m|} (2^{g[m]} - 1).
  • 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