mirror of
https://gitcode.com/Zengtudor/alg2025.git
synced 2025-10-25 09:32:37 +00:00
docs(P7961): 添加NOIP 2021难题P7961的详细题解文档
添加关于组合计数、动态规划和二进制思想的详细解析,包含思路分析、DP状态设计、转移方程推导及优化方法。文档提供了完整的C++代码实现及注释,帮助理解如何将复杂问题分解为可管理的DP步骤。
This commit is contained in:
parent
a339d17df5
commit
5df5ae14a2
313
src/10/23/P7961.md
Normal file
313
src/10/23/P7961.md
Normal file
@ -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 <iostream>
|
||||
#include <vector>
|
||||
#include <numeric>
|
||||
|
||||
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 步骤。希望这份详尽的解释能帮助你彻底理解这道题目!
|
||||
Loading…
Reference in New Issue
Block a user