It's Mobius time - DP SOS

Category: Challenger Tags: competitive-programming

SOS là một kĩ thuật mạnh, và đặc biệt hiệu quả trong các bài toán đếm tổ hợp và bao hàm loại trừ.

Sự tương đồng giữa số học và bitmask

Bitmask là cách dùng các con số để biểu diễn sự có/không có của phần tử trong tập hợp. Với một bitmask ss biểu diễn tập hợp, ta có các hàm giá trị f(s)f(s), g(s)g(s) ánh xạ bitmask ss sang giá trị nguyên.

Trong số học chúng ta có ước chung lớn nhất và bội chung nhỏ nhất (gcd⁡\gcd và lcm⁡\operatorname{lcm}), thì với bitmask, phép AND &\& sẽ tương đồng với gcd⁡\gcd, phép OR ∣| tương ứng với lcm⁡\operatorname{lcm}. Việc áp dụng bao hàm loại trừ khi làm các bài tập về gcd⁡,lcm⁡\gcd, \operatorname{lcm} đối với AND, OR tương đồng nhau.

Đối với số học bình thường chúng ta có hàm Mobius μ(n)\mu(n) để làm các bài toán tổ hợp và bao hàm loại trừ theo ước nguyên tố, thì với bitmask chúng ta cũng có biến đổi Mobius trên hàm ff.

Biến đổi Zeta và biến đổi Mobius

Trong các tài liệu nước ngoài, DP SOS và SOS ngược còn được gọi với cái tên khác là biến đổi Zeta và biến đổi Mobius. Ta định nghĩa các phép biến đổi như sau:

  1. Biến đổi Zeta: z(f(s))=∑s’⊆sf(s’) ∀sz(f(s)) = \sum_{s’ \subseteq s} f(s’) \ \forall s.

  2. Biến đổi Mobius: μ(f(s))=∑s’⊆s(−1)∣s∖s’∣f(s’) ∀s.\mu(f(s)) = \sum_{s’ \subseteq s} (-1)^{|s \setminus s’|}f(s’) \ \forall s.

  3. Biến đổi đan dấu: σ(f(s))=(−1)∣s∣f(s)\sigma(f(s)) = (-1)^{|s|} f(s).

Phần đan dấu của biến đổi Mobius nhìn như vậy, thật ra là việc ta áp dụng logic của bao hàm loại trừ của lý thuyết tập hợp vào. Ta cộng vào nếu tập con kém tập gốc chẵn phần tử và trừ đi nếu kém lẻ phần tử.

Ta có 1 số định lý sau:

Định lý 1: σzσ(f(s))=μ(f(s))\sigma z \sigma (f(s)) = \mu(f(s)).

Định lý 2: z(μ(f(s))=μ(z(f(s))=f(s)z(\mu(f(s)) = \mu(z(f(s)) = f(s), hay z−1=μz^{-1} = \mu.

Nhờ các công thức gọn gàng ở trên mà các phép biến đổi này đều có thể tính một cách hiệu quả trong O(2N×T)O(2^N \times T) với TT là tổng thời gian cần thiết để gộp kết quả của các tập con.

C++
// Apply odd-negation transform
for(int mask = 0; mask < (1 << N); mask++) {
if((__builtin_popcount(mask) % 2) == 1) {
f[mask] *= -1;
}
}
// Apply zeta transform
for(int i = 0; i < N; i++) {
for(int mask = 0; mask < (1 << N); mask++) {
if((mask & (1 << i)) != 0) {
f[mask] += f[mask ^ (1 << i)];
}
}
}
// Apply odd-negation transform
for(int mask = 0; mask < (1 << N); mask++) {
if((__builtin_popcount(mask) % 2) == 1) {
f[mask] *= -1;
}
}
// then you have inverse SOS
for(int mask = 0; mask < (1 << N); mask++) mu[mask] = f[mask];

Đối với phép cộng thông thường, ta duyệt qua tất cả các bit bật của mask và cộng vào, do đó T=O(log⁡n)T = O(\log n). Tuỳ vào hàm ff chúng ta định nghĩa phép gộp tập hợp (+,min⁡,max⁡,gcd⁡,...+,\min, \max, \gcd,...), mà TT có thể thay đổi.

Vì có sự tương đồng như đã nói ở trên, nên tư duy tổ hợp của số học và bitmask cũng tương đồng với nhau. Suy cho cùng thì 2 đối tượng toán học này cũng chỉ là 2 cách biểu diễn hình thức của cùng 1 bản chất.

Vận dụng

Chẳng hạn chúng ta có bài toán kinh điển sau:

Cho nn số a1,…,ana_1, \dots, a_n. Với mỗi kk từ 1 đến 10610^6 đếm số tập con có gcd⁡\gcd bằng đúng kk.

Giới hạn: 1≤n≤106,1≤ai≤1061 \le n \le 10^6, 1 \le a_i \le 10^6.

Gọi f(k)f(k) là số tập con có gcd⁡\gcd bằng đúng kk. Ta định nghĩa g(k)g(k) là số tập con có gcd⁡\gcd chia hết cho kk.

Ta có g(i)=∑j=1∞f(ij)g(i) = \sum_{j=1}^{\infty} f(ij) và f(i)=∑j=1∞g(ij)μ(j)f(i) = \sum_{j = 1}^{\infty} g(ij) \mu(j) với μ\mu là hàm Mobius. Đây là kết quả cơ bản và kinh điển của bao hàm loại trừ.

Dễ thấy g(k)=2ckg(k) = 2^{c_k} với ckc_k là số phần tử chia hết cho kk trong dãy, ta có thể tính ckc_k dễ dàng bằng sàng nguyên tố. Sau đó ta tính được f(k)f(k) bằng công thức ở trên trong O(nlog⁡n)O(n \log n).

Bài toán dưới đây cho thấy sự tương đồng giữa gcd⁡\gcd với phép AND.

Cho nn số a1,…,ana_1, \dots, a_n. Với mỗi ss từ 11 đến 2202^{20} đếm số tập con mà AND của các phần tử bằng đúng ss.

Giới hạn: 1≤n≤220,0≤ai≤2201 \le n \le 2^{20}, 0 \le a_i \le 2^{20}.

Tương tự, gọi f(s)f(s) là đáp án với ss cố định, ta định nghĩa g(s)g(s) là số tập con có AND là một super-mask của ss (giống với quan hệ chia hết). Khi này ta có g(s)=∑s’⊆sf(s’)g(s) = \sum_{s’ \subseteq s} f(s’) và f(s)=μ(g(s))=∑s′⊆s(−1)∣s∖s’∣g(s’)f(s) = \mu(g(s)) = \sum_{s' \subseteq s}(-1)^{|s \setminus s’|} g(s’).

Để tập con có AND là super-mask của ss thì từng số trong dãy phải là super-mask của ss. Gọi cnt(s)\text{cnt}(s) là số lần xuất hiện của ss, thì h(s)=z(cnt(s))h(s) = z(\text{cnt}(s)) chính là số số trong dãy là super-mask của ss. Như vậy ta có g(s)=2h(s)g(s) = 2^{h(s)}. Qua implementation ở trên ta tính lại được f(s)f(s) hiệu quả trong O(nlog⁡n)O(n \log n).

Code: https://codeforces.com/contest/449/submission/305550830.

Cho tập SS gồm nn xâu kí tự s1,s2,…,sns_1, s_2, \dots, s_n. Các kí tự trong tất cả các xâu này đều được sắp xếp. Ta định nghĩa f(T)f(T) với một tập xâu TT là số xâu mà là xâu con của ít nhất một xâu trong TT.

Tính f(T)f(T) với mọi T⊆ST \subseteq S.

Giới hạn: 1≤n≤23,1≤∣si∣≤2×1041 \le n \le 23, 1 \le |s_i| \le 2 \times 10^4.

Với các bài toán ta cần đếm số cấu hình thoả mãn ít nhất một/tất cả/vài điều kiện, điều đầu tiên có thể nghĩ đến là bao hàm loại trừ.

Với mỗi xâu sis_i ta có tập xâu con subi\text{sub}_i tương ứng của nó. Ta có

f(T)=∣⋃si∈T(subi)∣f(T) = |\bigcup_{s_i \in T}(\text{sub}_i)|

Bằng bao hàm loại trừ, ta có thể đưa việc tính tổng hợp về tính tổng giao, và tính tổng giao này thường sẽ có công thức tổng quát. Hay nói cách khác, khi biến đổi SOS xuôi f→gf \rightarrow g, ff khó tính được trực tiếp, nhưng gg thì khả năng cao là tính được, rồi ta SOS ngược để tính ff.

Từ giả thiết, không khó để ta viết ra được công thức sau:

g(T)=∣⋂si∈T(subi)∣=∏ch=az(min⁡T(cntch)+1)g(T) = |\bigcap_{s_i \in T}(\text{sub}_i)| = \prod_{\text{ch} = a}^{z} (\min_T(\text{cnt}_{\text{ch}}) + 1)

với cntch\text{cnt}_{\text{ch}} là số lần xuất hiện của kí tự ch\text{ch} trong các xâu thuộc TT.

Vì mỗi kí tự từ a đến z đóng góp độc lập nhau nên ta có thể tính đóng góp của từng kí tự một rồi nhân các kết quả lại để tối ưu bộ nhớ. (chỉ cần O(2n)O(2^n) thay vì O(2n×26)O(2^n \times 26).

Đây cũng là phần ta cần lưu ý. Khi tính g(T)g(T) theo từng kí tự một, thì khi này thao tác khi áp dụng SOS là phép lấy min. Với các phép toán idempotent (luỹ đẳng, như min, max, gcd, lcm, AND, OR, etc), ta chỉ cần lấy 1 submask để cập nhật kết quả, chứ không phải duyệt toàn bộ. Với phép lấy min thì ta chỉ cần O(1)O(1) để cập nhật trạng thái ở mỗi mask. Nên với bước này ta chỉ mất O(2n(n+A)).O(2^n(n + A)).

Có gg rồi thì ta SOS ngược để tính ff. Tổng độ phức tạp cuối cùng là O(2n(N+A))O(2^n(N + A)), bộ nhớ là O(2n)O(2^n).

Code: Submission #305505198 - Codeforces

Cho 1 tập AA gồm nn số. Ta định nghĩa bao đóng AND-OR của tập AA là cách thêm ít phần tử nhất vào AA sao cho:

  1. Với x,y∈Ax, y \in A thì x & y∈Ax \ \& \ y \in A.

  2. Với x,y∈Ax, y \in A thì x ∣ y∈Ax \ | \ y \in A.

Tìm kích thước bao đóng AND-OR của AA.

Giới hạn: 1≤n≤105,1≤ai≤2401 \le n \le 10^5, 1 \le a_i \le 2^{40}.

Trong bài toán này ta cần tìm cách mô tả cấu trúc của bao đóng BB thay vì mô phỏng.

Bước 1: Lược bỏ bit.

  1. Nếu một bit có cùng giá trị ở mọi phần tử của AA thì giá trị đó được giữ nguyên qua mọi phép AND, OR. Ta loại bỏ các bit này.

  2. Nếu hai bit bằng nhau ở mọi phần tử của AA thì chúng cũng bằng nhau ở mọi phần tử của BB. Ta gộp chúng thành một bit.

Sau bước này còn lại k≤40k \le 40 bit.

Bước 2: Quy về đếm antichain.

Ta viết i→ji \to j nếu mọi phần tử của AA có bit ii đều có bit jj. Sau bước 1, quan hệ →\to có tính phản xạ, bắc cầu và phản đối xứng, tức là tạo ra một thứ tự bộ phận (poset) trên tập bit. Đặt uiu_i là AND của các phần tử có bit ii; khi đó bit jj thuộc uiu_i khi và chỉ khi i→ji \to j.

Coi mỗi số là tập các bit bật của nó. Ta gọi tập bit CC là đóng nếu từ i∈Ci \in C và i→ji \to j suy ra j∈Cj \in C.

Nhận xét: BB chính là họ tất cả các tập đóng.

Nói cách khác, một số thuộc BB khi và chỉ khi tập bit của nó tuân theo mọi quan hệ kéo theo giữa các bit. Nhờ đó ta xác định được BB mà không cần mô phỏng quá trình xây dựng bao đóng.

Chứng minh. Ta chứng minh hai chiều.

  1. Mọi phần tử của BB đều là tập đóng. Mỗi phần tử của AA là tập đóng theo định nghĩa của →\to. Nếu xx và yy cùng tuân theo i→ji \to j thì x & yx \ \& \ y và x ∣ yx \ | \ y cũng vậy, vì giao và hợp của hai tập đóng vẫn là tập đóng. Do đó các phép AND, OR không bao giờ tạo ra phần tử vi phạm.

  2. Mọi tập đóng đều thuộc BB. Ta có ui∈Bu_i \in B và ui={j:i→j}u_i = \{ j : i \to j \}, tức là bit ii cùng mọi bit mà nó kéo theo. Với tập đóng CC khác rỗng, C=⋃i∈CuiC = \bigcup_{i \in C} u_i là OR của các uiu_i, nên C∈BC \in B. Tập rỗng là AND của toàn bộ AA, vì mỗi bit còn lại đều vắng mặt ở ít nhất một phần tử.

Ví dụ. Xét A={1,3,4}={0012,0112,1002}A = \{1, 3, 4\} = \{001_2, 011_2, 100_2\}. Quan hệ kéo theo duy nhất là b1→b0b_1 \to b_0, vì số duy nhất có bit b1b_1 là 33 và số này cũng có bit b0b_0. Các tập đóng là mọi tập con của {b0,b1,b2}\{b_0, b_1, b_2\} trừ những tập chứa b1b_1 mà không chứa b0b_0, tức là các số 0,1,3,4,5,70, 1, 3, 4, 5, 7. Tính bao đóng trực tiếp cũng cho B={0,1,3,4,5,7}B = \{0, 1, 3, 4, 5, 7\}: các số 2=01022 = 010_2 và 6=11026 = 110_2 không xuất hiện vì chúng có bit b1b_1 mà không có bit b0b_0.

Mỗi tập đóng CC được xác định duy nhất bởi tập SS gồm các bit i∈Ci \in C không bị kéo theo bởi bit nào khác của CC, vì C=⋃i∈SuiC = \bigcup_{i \in S} u_i. Tập SS là một antichain: các bit trong SS đôi một không so sánh được. Ngược lại, mỗi antichain SS sinh ra đúng một tập đóng. Vậy đáp án bằng số antichain (kể cả antichain rỗng) của một thứ tự bộ phận trên kk phần tử.

Bước 3: Đếm antichain bằng chia đôi tập và SOS.

Ta chia kk bit thành hai nửa: LL gồm h=⌊k/2⌋h = \lfloor k/2 \rfloor bit và RR gồm r=k−hr = k - h bit. Mỗi antichain SS tương ứng với một cặp (SL,SR)(S_L, S_R), trong đó SL,SRS_L, S_R lần lượt là antichain của LL và RR, và không có bit nào của SLS_L so sánh được với bit nào của SRS_R.

  1. Với mỗi antichain SLS_L, gọi free(SL)⊆R\text{free}(S_L) \subseteq R là tập các bit của RR không so sánh được với bit nào của SLS_L. Điều kiện ghép cặp là SR⊆free(SL)S_R \subseteq \text{free}(S_L).

  2. Gọi cnt(T)\text{cnt}(T) là số antichain SLS_L có free(SL)=T\text{free}(S_L) = T. Số antichain của LL ghép được với SRS_R là F(SR)=∑T⊇SRcnt(T)F(S_R) = \sum_{T \supseteq S_R} \text{cnt}(T). Đây chính là biến đổi Zeta, chỉ khác là lấy tổng trên các tập cha thay vì tập con.

  3. Đáp án là ∑SRF(SR)\sum_{S_R} F(S_R), lấy trên mọi antichain SRS_R của RR.

Các antichain của mỗi nửa được duyệt bằng DP trên mask: mask mm là antichain khi và chỉ khi mask mm bỏ đi bit thấp nhất ii là antichain và bit ii không so sánh được với phần còn lại. Giá trị free(m)\text{free}(m) cũng được cập nhật từ mask bỏ bit thấp nhất. Mỗi mask chỉ tốn O(1)O(1).

Độ phức tạp: O(nlog⁡V)O(n \log V) để tính các uiu_i, O(k2)O(k^2) để dựng quan hệ và O(k⋅2k/2)O(k \cdot 2^{k/2}) cho bước chia đôi cùng SOS, với V=240V = 2^{40} và k≤40k \le 40. Bộ nhớ O(n+2k/2)O(n + 2^{k/2}).

C++
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
const int B = 41; // a_i <= 2^40
int n;
cin >> n;
vector<long long> a(n);
for (auto& x : a) cin >> x;
// up[b]: AND of every number that has bit b; bit c is in up[b] iff b -> c
vector<long long> up(B, -1);
vector<int> cnt(B, 0);
for (long long x : a)
for (int b = 0; b < B; b++)
if (x >> b & 1) cnt[b]++, up[b] &= x;
// keep the bits that are not constant, one per class of equal bits
vector<int> bits;
for (int b = 0; b < B; b++) {
if (cnt[b] == 0 || cnt[b] == n) continue;
bool twin = false;
for (int c : bits) twin |= (up[c] >> b & 1) && (up[b] >> c & 1);
if (!twin) bits.push_back(b);
}
// count the antichains of the poset on these bits
int k = bits.size(), h = k / 2, r = k - h;
auto le = [&](int i, int j) { return up[bits[i]] >> bits[j] & 1; };
vector<int> clash1(h, 0), free2(h, (1 << r) - 1), clash2(r, 0);
for (int i = 0; i < k; i++)
for (int j = 0; j < k; j++) {
if (i == j || !(le(i, j) || le(j, i))) continue;
if (i < h && j < h) clash1[i] |= 1 << j;
if (i < h && j >= h) free2[i] &= ~(1 << (j - h));
if (i >= h && j >= h) clash2[i - h] |= 1 << (j - h);
}
// first half: for each antichain, the second-half bits still allowed with it
vector<long long> f(1 << r, 0);
vector<int> ok(1 << h), allowed(1 << h);
ok[0] = 1, allowed[0] = (1 << r) - 1;
f[allowed[0]]++;
for (int m = 1; m < (1 << h); m++) {
int i = __builtin_ctz(m), rest = m & (m - 1);
ok[m] = ok[rest] && !(clash1[i] & rest);
allowed[m] = allowed[rest] & free2[i];
if (ok[m]) f[allowed[m]]++;
}
// superset sums: f[s] = number of first-half antichains that allow all of s
for (int i = 0; i < r; i++)
for (int m = 0; m < (1 << r); m++)
if (!(m >> i & 1)) f[m] += f[m | 1 << i];
// second half: pair each antichain with the first-half ones that allow it
vector<int> ok2(1 << r);
ok2[0] = 1;
long long ans = f[0];
for (int m = 1; m < (1 << r); m++) {
int i = __builtin_ctz(m), rest = m & (m - 1);
ok2[m] = ok2[rest] && !(clash2[i] & rest);
if (ok2[m]) ans += f[m];
}
cout << ans << '\n';
}