diff --git a/src/10/23/P7961.md b/src/10/23/P7961.md new file mode 100644 index 0000000..802ad81 --- /dev/null +++ b/src/10/23/P7961.md @@ -0,0 +1,313 @@ +没问题!很乐意与你一同探讨这道有趣的题目。这道题是 NOIP 2021 的一道难题,它巧妙地将组合计数、动态规划和二进制思想结合在一起,确实需要一番思考。 + +让我们遵循“由浅入深”的原则,一步步剖析这道题目,最终打造出一个优雅高效的解决方案。 + +### 思路解析:从核心条件到 DP 模型 + +#### 第一步:理解核心条件——S 的二进制表示 + +题目的核心限制在于 $S = 2^{a_1} + 2^{a_2} + \cdots + 2^{a_n}$ 的二进制表示中 1 的个数不超过 $k$。我们来分析一下这个 $S$ 是如何形成的。 + +$2^p$ 是一个非常特殊的数,它的二进制表示只有第 $p$ 位(从 0 开始计数)是 1,其余都是 0。 +例如:$2^0 = 1$ (二进制 $1$),$2^3 = 8$ (二进制 $1000$)。 + +* **如果所有 $a_i$ 都不相同**,那么 $S$ 就是将这些 $2^{a_i}$ 在二进制位上简单地“或”起来。例如,序列 ${0, 3}$ 对应的 $S = 2^0 + 2^3 = 1 + 8 = 9$,二进制是 $1001$。此时,$S$ 中 1 的个数就是序列的长度 $n$。 + +* **如果 $a_i$ 中有相同值**,情况就变得复杂了。例如,序列 ${1, 1, 1}$ 对应的 $S = 2^1 + 2^1 + 2^1 = 3 \times 2^1 = 6$,二进制是 $110$。 + 这里发生了**二进制进位**:两个 $2^1$ 相加得到一个 $2^2$。 + $2^1 + 2^1 + 2^1 = (2^1+2^1) + 2^1 = 2 \times 2^1 + 2^1 = 2^2 + 2^1$。 + +我们可以将 $S$ 表达为另一种形式。假设在序列 ${a_i}$ 中,数字 $j$ 出现了 $c_j$ 次(其中 $\sum c_j = n$),那么: +$S = c_0 \cdot 2^0 + c_1 \cdot 2^1 + c_2 \cdot 2^2 + \cdots + c_m \cdot 2^m$ + +计算 $S$ 的过程,就等价于**从低位到高位处理这个带权和的二进制进位**。 +* **第 0 位**:有 $c_0$ 个 $2^0$。它们相加后,$S$ 的第 0 位是 $c_0 \pmod 2$,产生的进位是 $\lfloor c_0 / 2 \rfloor$。 +* **第 1 位**:有 $c_1$ 个 $2^1$,再加上从第 0 位来的 $\lfloor c_0 / 2 \rfloor$ 个 $2^1$。总共有 $c_1 + \lfloor c_0 / 2 \rfloor$ 个 $2^1$。$S$ 的第 1 位是 $(c_1 + \lfloor c_0 / 2 \rfloor) \pmod 2$,产生的进位是 $\lfloor (c_1 + \lfloor c_0 / 2 \rfloor) / 2 \rfloor$。 +* **第 j 位**:设从第 $j-1$ 位来的进位是 $carry_j$,那么第 $j$ 位的总贡献是 $c_j + carry_j$。$S$ 的第 $j$ 位是 $(c_j + carry_j) \pmod 2$,向 $j+1$ 位的进位是 $\lfloor (c_j + carry_j) / 2 \rfloor$。 + +$S$ 中 1 的总数就是 $\sum_{j \ge 0} ((c_j + carry_j) \pmod 2)$,这个总数必须 $\le k$。 + +#### 第二步:寻找解题方向——动态规划 + +我们需要计算所有合法序列的权值和。暴力枚举所有 $(m+1)^n$ 种序列显然不可行。 +注意到一个序列的权值 $\prod v_{a_i}$ 只跟每个值 $j$ 出现的次数 $c_j$ 有关,具体为 $\prod_{j=0}^{m} v_j^{c_j}$。同时,合法性判断也只依赖于 $c_j$。 + +这启发我们去枚举 $c_0, c_1, \ldots, c_m$ 的组合。对于一组固定的 ${c_j}$,其对应的序列有 $\binom{n}{c_0}\binom{n-c_0}{c_1}\cdots$ 种,总权值为 $(\binom{n}{c_0}v_0^{c_0}) \cdot (\binom{n-c_0}{c_1}v_1^{c_1}) \cdots$。 +这种结构非常适合使用 **动态规划 (DP)** 来求解。 + +我们可以按 $j = 0, 1, \ldots, m$ 的顺序来决策 $c_j$ 的值。 + +#### 第三步:设计 DP 状态 + +在处理到第 $j$ 位时,我们需要知道哪些信息才能做出决策并转移到 $j+1$ 位? +1. **我们正在考虑 $v_j$**:所以 DP 状态第一维是 $j$。 +2. **已经用了多少个数**:到 $j-1$ 为止,我们已经确定了 $c_0, \ldots, c_{j-1}$,总共用了 $\sum_{i=0}^{j-1} c_i$ 个数。设这个总数为 $i$。 +3. **未来的进位**:决定 $c_j$ 后,我们需要知道向 $j+1$ 位的进位是多少,才能在 $j+1$ 位继续计算。这个进位 $carry_{j+1} = \lfloor (c_j + carry_j) / 2 \rfloor$。它依赖于 $c_j$ 和 $carry_j$。所以我们需要知道 $carry_j$。 +4. **已经产生的 1 的个数**:到 $j$ 位为止,$S$ 的二进制表示中已经产生了多少个 1,这是我们最终判断合法性的依据。 + +综上,一个自然的 DP 状态就浮现了: +$dp[j][i][p][q]$ 表示: +* $j$:已经考虑完 $v_0, \ldots, v_j$ 的贡献。 +* $i$:总共已经从 $n$ 个位置中选了 $i$ 个数(即 $\sum_{l=0}^{j} c_l = i$)。 +* $p$:向第 $j+1$ 位产生的进位是 $p$。 +* $q$:$S$ 的二进制表示的第 $0$ 到 $j$ 位中,已经产生了 $q$ 个 $1$。 + +$dp[j][i][p][q]$ 存储的是满足上述条件的所有部分序列的**权值之和**。 + +#### 第四步:推导状态转移方程 + +考虑从状态 $dp[j-1][i][p][q]$ 转移到 $j$。 +在 $j$ 这一位,我们可以选择 $c_j$ 个数,其中 $0 \le c_j \le n-i$。 +设从 $j-1$ 位过来的进位是 $p$。 +在 $j$ 位上的总贡献(单位 $2^j$)是 $c_j + p$。 +* $S$ 的第 $j$ 位的值是 $(c_j + p) % 2$。 +* 向 $j+1$ 位的进位是 $p' = \lfloor (c_j + p) / 2 \rfloor$。 + +于是,我们可以进行转移: +$dp[j][i+c_j][p'][\;q + (c_j+p)\%2\;]$ += $dp[j-1][i][p][q] \times \text{contribution}(c_j)$ + +$contribution(c_j)$ 是什么呢?是从剩下的 $n-i$ 个位置中选 $c_j$ 个位置放 $j$,并乘上对应的权值。即 $\binom{n-i}{c_j} \times v_j^{c_j}$。 + +所以完整的转移方程是: +$dp[j][i+c_j][p'][q']$ += $dp[j-1][i][p][q] \times \binom{n-i}{c_j} \times v_j^{c_j}$ +其中 $p' = \lfloor (c_j+p)/2 \rfloor$ 且 $q' = q + (c_j+p)\%2$。 + +**初始化**:$dp[-1][0][0][0] = 1$。这可以理解为在考虑 $v_0$ 之前,我们用了 0 个数,进位为 0,1 的个数为 0,方案权值和为 1。 + +**最终答案**: +处理完 $v_m$ 后,还需要考虑更高位的进位。设最终在 $m$ 位结束后,进位为 $p$,1 的个数为 $q$。那么这个进位 $p$ 自身也会在二进制中贡献 $popcount(p)$ 个 1。 +所以,最终答案是 $\sum_{p, q \text{ s.t. } q + popcount(p) \le k} dp[m][n][p][q]$。 + +#### 第五步:分析与优化 + +* **状态维度**:$j \le m+1$ ($\approx 100$),$i \le n$ ($\approx 30$),$q \le k \le n$ ($\approx 30$)。 +* **进位 $p$ 的范围**:$p_j = \lfloor (c_{j-1} + p_{j-1})/2 \rfloor$。由于 $c_{j-1} \le n$,$p_{j-1}$ 也与 $n$ 相关。具体地,$p_j \approx (n + p_{j-1})/2$,所以 $p_j$ 的上界大约是 $n$。更精确地,$p_j \le \lfloor (n + p_{j-1})/2 \rfloor$,$p_0=0$,$p_1 \le n/2$,$p_2 \le (n+n/2)/2=3n/4$... 最终 $p$ 不会超过 $n$。 +* **复杂度**:$O(m \times n \times n \times k \times n) = O(m n^3 k)$。$100 \times 30^3 \times 30 \approx 8 \times 10^7$,这个复杂度太高了。 + +**优化 DP**:使用滚动数组优化第一维 $j$。DP 状态变为 $dp[i][p][q]$,用 $dp_{cur}$ 和 $dp_{next}$ 来回滚动。复杂度降为 $O(m n^3 k)$。仍然不够。 + +**进一步分析**: +DP 的 $j$ 这一层循环,是在枚举 $c_j$。实际上我们是在做多项式乘法或者说背包。 +$\sum_{c_j} \binom{n-i}{c_j} v_j^{c_j} \times x^{i+c_j} y^{\lfloor (c_j+p)/2 \rfloor} z^{q + (c_j+p)\%2}$ +这个结构非常复杂。但是,我们可以把状态转移的内外循环交换一下。 + +**DP 优化后的思路**: +当前 DP 数组 $dp[i][p][q]$ 表示处理到 $j-1$ 的状态。 +为了计算 $j$ 的 DP 数组 $next_dp$,我们遍历 $dp[i][p][q]$ 的所有有效状态: +$for i=0 to n$: + $for p=0 to n$: + $for q=0 to k$: + $if dp[i][p][q] == 0 then continue$ + $for c_j = 0 to n-i$: + $// 转移...$ +这样的复杂度仍是 $O(m \cdot n \cdot n \cdot k \cdot n)$。 + +注意到在 $j$ 层的转移,是从 $dp[i][p]$ 转移到 $next_dp[i+c_j][p']$,这个过程可以理解为对于固定的 $p$,$dp[\cdot][p]$ 和 $\binom{}{c_j}v_j^{c_j}$ 做一个卷积。 + +让我们回到 DP 的定义,看看哪些维度是真正必要的。 +$j$: 正在考虑 $v_j$,这层循环不可少。 +$i$: 已选数的个数,$0 \dots n$。 +$p$: 来自低位的进位,$0 \dots \approx n$。 +$q$: 已产生的 1 的个数,$0 \dots k$。 + +最终的 $m$ 会达到 100,但是 $n$ 只有 30。这意味着 $S$ 的二进制表示中,超过 $m+5$ 左右的位基本都是由进位决定的 ($c_j=0$ for $j > m$)。 +$S = \sum_{j=0}^{m} c_j 2^j$。由于 $\sum c_j = n \le 30$,$S < n \cdot 2^{m+1}$。所以 $S$的最高位不会超过 $m + \log_2 n$,大约 $m+5$。 +这个观察对本题的 DP 结构影响不大,但有助于理解 $S$ 的范围。 + +**最终的 DP 结构** +$dp[j][i][p][q]$ 是可行的。我们来精确估算一下 $p$ 的上界。 +$p_{j+1} = \lfloor (c_j + p_j) / 2 \rfloor$。 +$p_0=0$。 +$p_1 = \lfloor c_0/2 \rfloor \le \lfloor n/2 \rfloor$。 +$p_2 = \lfloor (c_1+p_1)/2 \rfloor \le \lfloor (n + n/2)/2 \rfloor = \lfloor 3n/4 \rfloor$。 +$p_j < n$ 是一个安全的上界。所以 $p$ 的范围是 $0 \dots n$。 +总复杂度 $O(m \cdot n \cdot n \cdot k \cdot n)$,其中 $m \le 100, n \le 30, k \le 30$。 +$100 \times 30 \times 30 \times 30 \times 30 \approx 8.1 \times 10^7$。对于 C++ 来说,这个计算量是可以接受的,尤其是在很多状态不可达(值为0)的情况下。 + +**实现细节**: +1. **预处理组合数** $C[i][j]$ 和 $v_j$ 的幂 $V[j][p] = v_j^p$。 +2. **滚动数组**:DP 状态可以压缩为 $dp[i][p][q]$,用一个 $f$ 和 $g$ 数组交替表示当前层和下一层。 +3. **循环顺序**: + $for j = 0 to m:$ + $// g clear to 0$ + $for i = 0 to n:$ + $for p = 0 to n:$ + $for q = 0 to k:$ + $if f[i][p][q] == 0: continue$ + $for c = 0 to n-i:$ + $// 状态转移...$ + $f = g$ +4. **最终统计答案**: + $for p = 0 to n:$ + $for q = 0 to k:$ + $if q + popcount(p) <= k:$ + $ans += f[n][p][q]$ + +$popcount(p)$ 可以预处理或直接用 `__builtin_popcount`。 + +这个思路是正确的,且在给定的数据范围内是可以通过的。 + +### C++ 代码实现 + +下面是基于上述 DP 思路的 C++ 代码。我添加了详细的注释来解释每一步。 + +```cpp +#include +#include +#include + +using namespace std; + +// 定义模数 +const int MOD = 998244353; +const int MAXN = 35; +const int MAXM = 105; + +// n, m, k, v数组 +int n, m, k; +int v[MAXM]; + +// DP数组: dp[已选数量][进位][1的个数] +// 使用滚动数组优化第一维(当前处理到哪个v_j) +long long dp[MAXN][MAXN][MAXN]; + +// 预处理组合数 C(i, j) +long long C[MAXN][MAXN]; + +// 预处理 v_i 的幂次 V[i][j] = v_i^j +long long V[MAXM][MAXN]; + +// 预处理popcount +int popcount[MAXN]; + +// 预处理组合数 +void precompute_combinations(int size) { + for (int i = 0; i <= size; ++i) { + C[i][0] = 1; + for (int j = 1; j <= i; ++j) { + C[i][j] = (C[i - 1][j - 1] + C[i - 1][j]) % MOD; + } + } +} + +// 预处理v的幂 +void precompute_powers() { + for (int i = 0; i <= m; ++i) { + V[i][0] = 1; + for (int j = 1; j <= n; ++j) { + V[i][j] = (V[i][j - 1] * v[i]) % MOD; + } + } +} + +// 预处理popcount +void precompute_popcount(int size) { + for (int i = 0; i <= size; ++i) { + int cnt = 0; + int temp = i; + while (temp > 0) { + if (temp & 1) cnt++; + temp >>= 1; + } + popcount[i] = cnt; + } +} + +int main() { + // OI/ACM 风格的快速输入输出 + ios_base::sync_with_stdio(false); + cin.tie(NULL); + + cin >> n >> m >> k; + for (int i = 0; i <= m; ++i) { + cin >> v[i]; + } + + // 预处理 + precompute_combinations(n); + precompute_powers(); + precompute_popcount(n); // 进位最大不超过n + + // DP 初始化 + // dp[0][0][0] = 1 表示: + // 在开始之前(处理v_0之前),已选0个数,进位为0,1的个数为0,权值和为1(空积) + dp[0][0][0] = 1; + + // 按 v_j 进行迭代 + for (int j = 0; j <= m; ++j) { + // 创建一个新的dp数组(next_dp)来存储这一轮计算的结果 + long long next_dp[MAXN][MAXN][MAXN] = {0}; + + // 遍历所有可能的状态 + // i: 在处理v_j之前已经选了i个数 + for (int i = 0; i <= n; ++i) { + // p: 来自 j-1 位的进位 + for (int p = 0; p <= n / 2; ++p) { // 进位 p 不会超过 n/2 (从上一位最多进位n/2+p'/2...) 安全起见可以开到n + // q: 在 j-1 位及之前,S的二进制表示中已经有q个1 + for (int q = 0; q <= k; ++q) { + // 如果当前状态的权值和为0,说明无法达到此状态,跳过 + if (dp[i][p][q] == 0) continue; + + // 枚举在当前 j 位选择 c 个数 + // c: 序列中等于j的数的个数 + for (int c = 0; c <= n - i; ++c) { + // total = c + p: 在j位上的总贡献 + int total = c + p; + // next_p: 向 j+1 位的新进位 + int next_p = total / 2; + // new_ones: S在第j位是否为1 + int new_ones = total % 2; + + // 如果产生的1的个数已经超过k,则此路径非法,剪枝 + if (q + new_ones > k) continue; + + // 状态转移 + // 从n-i个剩余位置中选c个给j: C(n-i, c) + // 这c个数的权值贡献: v_j^c + long long contribution = (C[n - i][c] * V[j][c]) % MOD; + + // 更新 next_dp 数组 + next_dp[i + c][next_p][q + new_ones] = + (next_dp[i + c][next_p][q + new_ones] + dp[i][p][q] * contribution) % MOD; + } + } + } + } + + // 用 next_dp 覆盖当前 dp 数组,准备下一轮迭代 + for (int i = 0; i <= n; ++i) { + for (int p = 0; p <= n / 2; ++p) { // 与上面范围保持一致 + for (int q = 0; q <= k; ++q) { + dp[i][p][q] = next_dp[i][p][q]; + } + } + } + } + + long long ans = 0; + // 最终统计答案 + // 遍历所有最终状态 dp[n][p][q],其中n个数已经选完 + for (int p = 0; p <= n / 2; ++p) { // 与上面范围保持一致 + for (int q = 0; q <= k; ++q) { + // 最终的1的个数 = 低位累积的q个 + 最终进位p中包含的1的个数 + if (q + popcount[p] <= k) { + ans = (ans + dp[n][p][q]) % MOD; + } + } + } + + cout << ans << endl; + + return 0; +} +``` + +### 总结与回顾 + +1. **核心转化**:将问题 $S = \sum 2^{a_i}$ 的 $popcount$ 约束,转化为对序列中各数值出现次数 ${c_j}$ 的二进制进位过程的约束。 +2. **DP 设计**:基于上述转化,我们设计了一个按数值 $j$ 递推的 DP。状态 $dp[j][i][p][q]$ 精确地捕捉了递推过程中所需的所有信息:当前处理到 $v_j$,已用 $i$ 个数,来自低位的进位为 $p$,已产生的 1 的个数为 $q$。 +3. **组合计数**:在状态转移时,我们使用组合数 $C(n-i, c)$ 来计算将 $c$ 个 $j$ 放入剩余 $n-i$ 个位置的方案数,并乘以对应的权值 $v_j^c$。 +4. **复杂度与优化**:通过分析,我们确定了 DP 各维度的合理范围,使得 $O(m \cdot n^2 \cdot k \cdot n)$ ($c$ 循环里的$n$和$i$循环绑定的更紧)的理论复杂度在 $n$ 较小的情况下可以接受。使用滚动数组将空间复杂度从 $O(m \cdot n \cdot n \cdot k)$ 降至 $O(n \cdot n \cdot k)$。并且注意到进位 $p$ 的上界实际上比 $n$ 小很多,可以稍微优化常数。 +5. **最终答案**:完成所有 $v_0, \ldots, v_m$ 的决策后,不要忘记最后的进位 $p$ 本身也会贡献 1,需要将其 $popcount$ 加入总数中进行判断。 + +这个解决方案思路清晰,层层递进,既解决了问题,也展示了如何将一个复杂的问题分解为可管理的 DP 步骤。希望这份详尽的解释能帮助你彻底理解这道题目! \ No newline at end of file