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 biểu diễn tập hợp, ta có các hàm giá trị , ánh xạ bitmask 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 ( và ), thì với bitmask, phép AND sẽ tương đồng với , phép OR tương ứng với . Việc áp dụng bao hàm loại trừ khi làm các bài tập về đố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 để 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 .
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:
Biến đổi Zeta: .
Biến đổi Mobius:
Biến đổi đan dấu: .
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: .
Định lý 2: , hay .
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 với là tổng thời gian cần thiết để gộp kết quả của các tập con.
// Apply odd-negation transformfor(int mask = 0; mask < (1 << N); mask++) { if((__builtin_popcount(mask) % 2) == 1) { f[mask] *= -1; }}
// Apply zeta transformfor(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 transformfor(int mask = 0; mask < (1 << N); mask++) { if((__builtin_popcount(mask) % 2) == 1) { f[mask] *= -1; }}
// then you have inverse SOSfor(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 đó . Tuỳ vào hàm chúng ta định nghĩa phép gộp tập hợp (), mà 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 số . Với mỗi từ 1 đến đếm số tập con có bằng đúng .
Giới hạn: .
Gọi là số tập con có bằng đúng . Ta định nghĩa là số tập con có chia hết cho .
Ta có và với 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 với là số phần tử chia hết cho trong dãy, ta có thể tính dễ dàng bằng sàng nguyên tố. Sau đó ta tính được bằng công thức ở trên trong .
Bài toán dưới đây cho thấy sự tương đồng giữa với phép AND.
CF449D - Jzzhu and Numbers
Cho số . Với mỗi từ đến đếm số tập con mà AND của các phần tử bằng đúng .
Giới hạn: .
Tương tự, gọi là đáp án với cố định, ta định nghĩa là số tập con có AND là một super-mask của (giống với quan hệ chia hết). Khi này ta có và .
Để tập con có AND là super-mask của thì từng số trong dãy phải là super-mask của . Gọi là số lần xuất hiện của , thì chính là số số trong dãy là super-mask của . Như vậy ta có . Qua implementation ở trên ta tính lại được hiệu quả trong .
Code: https://codeforces.com/contest/449/submission/305550830.
CF1620G - Subsequence Galore
Cho tập gồm xâu kí tự . Các kí tự trong tất cả các xâu này đều được sắp xếp. Ta định nghĩa với một tập xâu là số xâu mà là xâu con của ít nhất một xâu trong .
Tính với mọi .
Giới hạn: .
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 ta có tập xâu con tương ứng của nó. Ta có
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 , khó tính được trực tiếp, nhưng thì khả năng cao là tính được, rồi ta SOS ngược để tính .
Từ giả thiết, không khó để ta viết ra được công thức sau:
với là số lần xuất hiện của kí tự trong các xâu thuộc .
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 thay vì .
Đây cũng là phần ta cần lưu ý. Khi tính 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 để cập nhật trạng thái ở mỗi mask. Nên với bước này ta chỉ mất
Có rồi thì ta SOS ngược để tính . Tổng độ phức tạp cuối cùng là , bộ nhớ là .
ICPC SEERC Regional 2023 bài A - AND-OR Closure
Cho 1 tập gồm số. Ta định nghĩa bao đóng AND-OR của tập là cách thêm ít phần tử nhất vào sao cho:
Với thì .
Với thì .
Tìm kích thước bao đóng AND-OR của .
Giới hạn: .
Trong bài toán này ta cần tìm cách mô tả cấu trúc của bao đóng thay vì mô phỏng.
Bước 1: Lược bỏ bit.
Nếu một bit có cùng giá trị ở mọi phần tử của thì giá trị đó được giữ nguyên qua mọi phép AND, OR. Ta loại bỏ các bit này.
Nếu hai bit bằng nhau ở mọi phần tử của thì chúng cũng bằng nhau ở mọi phần tử của . Ta gộp chúng thành một bit.
Sau bước này còn lại bit.
Bước 2: Quy về đếm antichain.
Ta viết nếu mọi phần tử của có bit đều có bit . Sau bước 1, quan hệ 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 là AND của các phần tử có bit ; khi đó bit thuộc khi và chỉ khi .
Coi mỗi số là tập các bit bật của nó. Ta gọi tập bit là đóng nếu từ và suy ra .
Nhận xét: chính là họ tất cả các tập đóng.
Nói cách khác, một số thuộc 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 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.
Mọi phần tử của đều là tập đóng. Mỗi phần tử của là tập đóng theo định nghĩa của . Nếu và cùng tuân theo thì và 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.
Mọi tập đóng đều thuộc . Ta có và , tức là bit cùng mọi bit mà nó kéo theo. Với tập đóng khác rỗng, là OR của các , nên . Tập rỗng là AND của toàn bộ , 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 . Quan hệ kéo theo duy nhất là , vì số duy nhất có bit là và số này cũng có bit . Các tập đóng là mọi tập con của trừ những tập chứa mà không chứa , tức là các số . Tính bao đóng trực tiếp cũng cho : các số và không xuất hiện vì chúng có bit mà không có bit .
Mỗi tập đóng được xác định duy nhất bởi tập gồm các bit không bị kéo theo bởi bit nào khác của , vì . Tập là một antichain: các bit trong đôi một không so sánh được. Ngược lại, mỗi antichain 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 phần tử.
Bước 3: Đếm antichain bằng chia đôi tập và SOS.
Ta chia bit thành hai nửa: gồm bit và gồm bit. Mỗi antichain tương ứng với một cặp , trong đó lần lượt là antichain của và , và không có bit nào của so sánh được với bit nào của .
Với mỗi antichain , gọi là tập các bit của không so sánh được với bit nào của . Điều kiện ghép cặp là .
Gọi là số antichain có . Số antichain của ghép được với là . Đâ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.
Đáp án là , lấy trên mọi antichain của .
Các antichain của mỗi nửa được duyệt bằng DP trên mask: mask là antichain khi và chỉ khi mask bỏ đi bit thấp nhất là antichain và bit không so sánh được với phần còn lại. Giá trị cũng được cập nhật từ mask bỏ bit thấp nhất. Mỗi mask chỉ tốn .
Độ phức tạp: để tính các , để dựng quan hệ và cho bước chia đôi cùng SOS, với và . Bộ nhớ .
#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';}Continue reading
Related reading
Shares tags, a category or a series with this post
Take another route
Something different from the latest posts