集合划分 - 题解
标签与难度
标签: 动态规划, 分治, NTT, 卷积, 组合计数, 数论 难度: 2600
题目大意喵~
主人你好呀,喵~ 这道题是这样的:
我们有一个长度为 的序列 和一个常数 。 首先,我们要理解一个集合的“价值”是什么。对于一个由 的下标组成的集合 ,它的价值被定义为 。这里的 (Minimum Excluded value) 是指“最小的未出现的非负整数”。比如说, 就是 啦。
我们的任务是,要把全集 划分成两个不相交的集合 和 (它们的并集是全集)。我们需要对每个 (从 到 ),计算出有多少种这样的划分方法,能够使得 的价值和 的价值之和恰好等于 。
也就是说,如果 并且 ,我们要对每个 ,求出满足 的有序对 的数量。
解题思路分析
这道题看起来好复杂呀,又是集合划分又是 Mex 的,猫猫的脑袋都要绕晕了呢 >.<。但是别怕,只要我们一步一步来,肯定能想明白的!
第一步:理解 Mex 和划分条件
首先,我们来仔细分析一下 Mex 的定义。Mex(S) = v 意味着两件事:
- 对于所有非负整数 ,数字 必须在集合 中出现过。
- 数字 本身,绝对不能在集合 中出现。
为了方便讨论,我们先预处理一下,计算出每个数值 在序列 中出现了多少次。我们用 表示值等于 的元素个数,也就是 。
现在,我们想要求有多少对 使得 且 。直接计算满足这个条件的划分数(我们记作 )非常困难,因为它同时对 和 提出了严格的等于条件。
第二步:化“等于”为“大于等于”
在组合计数问题中,当“等于”不好处理时,一个常见的技巧是先计算“大于等于”的情况,然后通过容斥原理变回来。
我们定义一个辅助函数 ,表示满足 且 的划分方案数。
的条件是:对于所有 ,数值 必须在 中出现。 的条件是:对于所有 ,数值 必须在 中出现。
现在我们来考虑如何分配所有下标 到 或 中,来满足 和 。我们按 的值来分类讨论:
- 对于一个值 :我们既要保证 在 的值集合里,也要保证它在 的值集合里。这意味着,所有值为 的下标(也就是 个)不能全部被分到 ,也不能全部被分到 。总共有 种分配方式,排除掉这两种极端情况,就有 种方式。
- 对于一个值 满足 :假设 ,那么对于 ,我们只需要保证 在 的值集合里。这意味着 个下标不能全部被分到 。所以有 种方式。
- 对于一个值 :没有任何限制! 个下标可以任意分配,有 种方式。
把它们全部乘起来,我们就得到了 的表达式。假设 :
有了 ,我们就可以用容斥原理(或者说是二维差分)来求 了!
这个式子看起来很可怕,但是经过一番神奇的代数化简(把 的表达式代入,提取公因式),对于 ,我们可以得到一个非常简洁的结果,喵~
是不是很神奇?在 和 位置的项,它们的贡献互相抵消,最后变成了 !
第三步:卷积!是卷积的味道!
我们的目标是求 。 这个形式让我们立刻想到了多项式乘法(卷积)!
我们将 的计算分为三部分:, , 和 。
Case 1: 我们想计算 。 这个 的表达式里,连乘的范围 把 和 耦合在了一起,不方便直接卷积。但是我们可以把它拆开! 令 , , 。 那么 看!我们成功地把 分解成了只和 有关的项与只和 有关的项的乘积!
定义两个多项式(生成函数):
那么 的第 项系数 。 哇!我们要求的和就是这个卷积的结果!我们可以用 NTT (快速数论变换) 在 的时间里计算这个卷积。
但是,这个卷积计算了所有 对的贡献。我们只想要 的。直接这样算会把 和 的情况也算进去。 一个聪明的办法是使用分治 NTT (CDQ-NTT)。我们将 和 的范围 [0, K] 一分为二。 solve(l, r) 的任务是计算所有 (以及对称的 )的贡献。 在 solve(l, r) 中:
- 递归调用
solve(l, mid)和solve(mid+1, r)。 - 构造两个多项式,一个的系数是 对应的项,另一个是 对应的项。
- 用 NTT 计算它们的卷积,把结果加到全局的答案数组
Ans中。
Case 2: 由于 和 的表达式结构是对称的,所以 的总贡献和 的总贡献是一样的。所以我们把 CDQ-NTT 的结果乘以 2 就好啦。
Case 3: 最后,我们来处理对角线上的情况 。 通过类似的容斥推导,可以发现 只有在 (即序列 中不存在值 ) 时才可能不为零。 当 时,要满足 和 :
- 对于 , 必须被划分到 和 中,有 种方法。
- 对于 ,由于值 根本不存在,所以 为 的第二个条件(值 未出现)自动满足。所以对 的下标分配没有限制,有 种方法。 所以,如果 ,则 。 我们单独计算这些项,并加到对应的 上。
总结一下我们的算法:
- 预处理出每个值的计数 。
- 预处理计算过程中需要用到的各种前缀积和后缀积,以及它们的逆元。
- 使用 CDQ-NTT 计算所有 的 对 的贡献。
- 将 CDQ-NTT 的结果乘以 2,得到所有 的贡献。
- 单独计算并累加所有 的情况的贡献。
- 输出最终的
Ans数组。
这样,一只聪明的我就把一个复杂的问题分解成可以解决的小块啦,喵~
代码实现
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
using namespace std;
// 使用 long long 防止溢出
using ll = long long;
// 模数
const int MOD = 998244353;
// NTT的原根
const int G = 3;
// 快速幂,用于计算乘法逆元
ll power(ll base, ll exp) {
ll res = 1;
base %= MOD;
while (exp > 0) {
if (exp % 2 == 1) res = (res * base) % MOD;
base = (base * base) % MOD;
exp /= 2;
}
return res;
}
ll modInverse(ll n) {
return power(n, MOD - 2);
}
// NTT 实现
void ntt(vector<ll>& a, bool invert) {
int n = a.size();
// 位逆序置换
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1)
j ^= bit;
j ^= bit;
if (i < j)
swap(a[i], a[j]);
}
// 蝴蝶操作
for (int len = 2; len <= n; len <<= 1) {
ll wlen = power(G, (MOD - 1) / len);
if (invert) wlen = modInverse(wlen);
for (int i = 0; i < n; i += len) {
ll w = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j], v = (a[i + j + len / 2] * w) % MOD;
a[i + j] = (u + v) % MOD;
a[i + j + len / 2] = (u - v + MOD) % MOD;
w = (w * wlen) % MOD;
}
}
}
if (invert) {
ll n_inv = modInverse(n);
for (ll& x : a)
x = (x * n_inv) % MOD;
}
}
const int MAX_N_K = 200005;
int n, K;
int a[MAX_N_K];
ll counts[MAX_N_K * 2]; // 值的范围可能比n大
// 预处理各种乘积
ll pref_termA[MAX_N_K * 2]; // prod(2^c - 2)
ll pref_termB[MAX_N_K * 2]; // prod(2^c - 1)
ll inv_pref_termB[MAX_N_K * 2];
ll suff_termC[MAX_N_K * 2]; // prod(2^c)
ll ans[MAX_N_K * 2];
// 分治NTT
void cdq_solve(int l, int r) {
if (l >= r) {
return;
}
int mid = l + (r - l) / 2;
cdq_solve(l, mid);
cdq_solve(mid + 1, r);
// 构造多项式 A(x) 和 B(x)
vector<ll> poly_A, poly_B;
for (int i = l; i <= mid; ++i) {
// A_i = (prefA_{i}) / (prefB_{i+1})
ll term_A = (pref_termA[i] * inv_pref_termB[i + 1]) % MOD;
poly_A.push_back(term_A);
}
for (int i = mid + 1; i <= r; ++i) {
// B_i = prefB_i * suffC_{i+1}
ll term_B = (pref_termB[i] * suff_termC[i + 1]) % MOD;
poly_B.push_back(term_B);
}
int len_A = poly_A.size();
int len_B = poly_B.size();
if (len_A == 0 || len_B == 0) return;
int fft_size = 1;
while (fft_size < len_A + len_B) fft_size <<= 1;
poly_A.resize(fft_size);
poly_B.resize(fft_size);
ntt(poly_A, false);
ntt(poly_B, false);
for (int i = 0; i < fft_size; ++i) {
poly_A[i] = (poly_A[i] * poly_B[i]) % MOD;
}
ntt(poly_A, true);
for (int i = 0; i < len_A; ++i) {
for (int j = 0; j < len_B; ++j) {
int k = (l + i) + (mid + 1 + j);
if (k <= K) {
// P(i,j)的贡献是 poly_A[i+j]
// 乘以2是因为P(i,j)和P(j,i)贡献相同
ans[k] = (ans[k] + 2 * poly_A[i + j]) % MOD;
}
}
}
}
int main() {
// 加速喵~
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cin >> n >> K;
int max_val = 0;
for (int i = 0; i <= n; ++i) {
cin >> a[i];
counts[a[i]]++;
max_val = max(max_val, a[i]);
}
// 值的范围最大可能到K
int limit = max(K, max_val) + 2;
// 预处理
pref_termA[0] = 1;
pref_termB[0] = 1;
for (int i = 0; i < limit; ++i) {
ll p2c = power(2, counts[i]);
pref_termA[i + 1] = (pref_termA[i] * (p2c - 2 + MOD)) % MOD;
pref_termB[i + 1] = (pref_termB[i] * (p2c - 1 + MOD)) % MOD;
}
inv_pref_termB[limit] = modInverse(pref_termB[limit]);
for (int i = limit - 1; i >= 0; --i) {
ll p2c = power(2, counts[i]);
inv_pref_termB[i] = (inv_pref_termB[i + 1] * (p2c - 1 + MOD)) % MOD;
}
suff_termC[limit] = 1;
for (int i = limit - 1; i >= 0; --i) {
suff_termC[i] = (suff_termC[i + 1] * power(2, counts[i])) % MOD;
}
// 分治NTT计算 v1 != v2 的情况
cdq_solve(0, K);
// 处理 v1 = v2 的情况
for (int i = 0; 2 * i <= K; ++i) {
if (counts[i] == 0) {
ll term_diag = (pref_termA[i] * suff_termC[i]) % MOD;
ans[2 * i] = (ans[2 * i] + term_diag) % MOD;
}
}
for (int k = 0; k <= K; ++k) {
cout << ans[k] << (k == K ? "" : " ");
}
cout << endl;
return 0;
}复杂度分析
时间复杂度: , 其中 。
- 预处理计数和前缀积需要 的时间(主要是快速幂)。
- CDQ-NTT 的递归关系是 ,解得 。在这里 是 的范围。
- 处理对角线情况需要 。
- 所以总时间复杂度由 CDQ-NTT 主导。
空间复杂度: 。
counts,pref_term,suff_term等数组都需要 的空间。- NTT 过程中需要 的临时空间来存储多项式。
知识点总结
这真是一道精彩的题目,融合了好多有趣的知识点呢,喵!
- 组合计数与容斥原理: 题目的核心在于计数。当直接计算“恰好”很困难时,转而计算“至少”,再通过容斥原理(在这里是二维差分)得到精确解,是非常强大和常用的思路。
- 生成函数与卷积: 我们将复杂的求和式 识别为卷积的形式,这是从组合问题迈向多项式算法的关键一步。
- NTT (快速数论变换): NTT 是在模意义下计算卷积的利器,是解决这类问题的标准工具。
- 分治 (CDQ): 对于一些不能直接用一次卷积解决,但具有良好分治结构的问题(例如本题中 的限制),CDQ 分治可以和数据结构或FFT/NTT等算法结合,有效地处理区间之间的贡献。
- 代数变形: 将 的复杂表达式变形为可以卷积的形式,需要一定的代数推导能力。这是解题过程中最需要灵感和技巧的一步!
希望这篇题解能帮助到你,主人!如果还有不懂的地方,随时可以再来问我哦,喵~