F1. Neko Rules the Catniverse (Small Version) - 题解
比赛与标签
比赛: Codeforces Round 554 (Div. 2) 标签: bitmasks, dp, matrices 难度: *2800
题目大意喵~
各位好呀,Neko酱今天也要统治宇宙啦!这次的任务是帮助Neko在一个有 个星球的宇宙中飞行。
我们的目标是,找到Neko访问正好 个不同星球的所有可能路径数量,的说。
路径的生成规则是这样哒:
- 首先,我们为Neko选择一个起始星球。
- 然后,Neko要进行 次移动。
- 每一次从星球 移动到星球 时,必须满足两个条件:
- 星球 之前没有被访问过。
- (这里的 是一个给定的常数)。
只要访问星球的顺序有任何一点不同,就算作是两种不同的方式哦。因为答案可能会很大,所以我们需要对 取模。
输入: 一行,包含三个整数 。 输出: 一个整数,表示总的路径方案数。
解题思路喵~
看到这道题,特别是注意到 (访问星球数)和 (移动范围常数)都非常小,而 (星球总数)可能很大,这就像是猫咪闻到了小鱼干,是动态规划(DP)的香味呀,喵~
我们可以按星球编号从小到大(从1到)的顺序来构建我们的DP状态。
DP状态的设计
一个好的DP状态是成功的一半哦!我们来想想要记录哪些信息。 在考虑第 个星球时,我们需要知道:
- 已经访问了多少个星球了?
- 为了判断下一次移动是否合法(
y <= x + m
),我们需要知道最近访问过的星球的信息。由于m
很小,我们只需要关心当前星球i
附近m
个星球的访问状态就够了。
于是,一个绝妙的状态就诞生啦: dp[i][j][s]
:表示我们已经考虑了前 个星球,总共访问了 个星球,并且在 (i-m, i]
这个区间内的星球访问状态是一个二进制数 s
。s
的第 p
位(从右往左数,0-indexed)为1,表示星球 i-p
被访问过,为0则表示没有。
但是,i
可以达到 ,三维DP数组会爆炸的!不过别担心,我们发现计算第 个星球的状态,只需要第 个星球的信息。所以可以进行状态压缩,去掉第一维 i
!
优化后的DP状态: dp[j][s]
:在当前处理到星球 i
时,这个状态表示已经访问了 j
个星球,并且在 (i-m+1, i]
这个区间内(长度为m
)的星球访问状态为 s
的所有合法路径(序列)的数量。
状态转移的奥秘
当我们从考虑完星球 i
转移到考虑星球 i+1
时,有两种选择:
1. 不选择星球 i+1
加入我们的路径 这种情况比较简单。我们没有访问新的星球,所以已访问星球数 j
不变。我们关心的状态窗口从 (i-m+1, i]
滑动到了 (i-m+2, i+1]
。这对应到 bitmask s
上,就是将 s
左移一位,然后用 m
位的全1掩码 mask_full
来去掉最高位的溢出。 新的状态 s'
就是 (s << 1) & mask_full
。 所以,next_dp[j][(s << 1) & mask_full]
会加上 dp[j][s]
的方案数。
2. 选择星球 i+1
加入我们的路径 这是最有趣的部分!如果我们决定访问星球 i+1
,那么已访问星球数就会从 j
变成 j+1
。 新的状态窗口 (i-m+2, i+1]
中,i+1
这个星球被访问了,所以新的 bitmask s'
应该是 (s << 1 | 1) & mask_full
。
那么,方案数要怎么计算呢?我们是从一个 j
个星球的合法序列,通过加入 i+1
,来构成一个 j+1
个星球的新序列。有多少种方法可以把 i+1
"插" 进原来的序列里,同时保持路径合法呢?
假设原来的序列是 p_1 -> p_2 -> ... -> p_j
。 我们可以把 i+1
插入到序列的开头、中间或末尾。
- 插入开头:
i+1 -> p_1 -> ...
。这个移动i+1 -> p_1
是否合法?条件是p_1 <= (i+1) + m
。因为p_1
是之前选的星球,所以p_1 <= i
,这个条件永远成立!所以总有一种方法是把i+1
放在最前面。 - 插入中间/末尾:
... -> p_t -> i+1 -> p_{t+1} -> ...
。这需要满足i+1 <= p_t + m
并且p_{t+1} <= (i+1) + m
。后一个条件总是成立的。前一个条件i+1 <= p_t + m
等价于p_t >= i+1-m
。
也就是说,i+1
可以被插入到序列的最前端,或者任何一个满足 p_t >= i+1-m
的星球 p_t
的后面。 哪些星球 p_t
满足这个条件呢?正是我们用 bitmask s
记录的那些在 (i-m+1, i]
区间内被访问过的星球!
__builtin_popcount(s)
这个神奇的函数可以计算出 s
中有多少个1,也就是在 (i-m+1, i]
这个区间内有多少个被访问过的星球。 所以,总的插入方法数 ways
就是: ways = 1 (插入开头) + __builtin_popcount(s) (插入到s中记录的星球后面)
所以,next_dp[j+1][(s << 1 | 1) & mask_full]
会加上 dp[j][s] * ways
的方案数。
最终答案
我们从 i=1
循环到 n
,不断更新DP数组。当循环结束后,dp[k][s]
就记录了所有访问了 k
个星球,并且最后 m
个星球状态为 s
的路径数量。我们把所有可能的 s
对应的 dp[k][s]
加起来,就是最终的答案啦!
代码实现喵~
// 完整的AC代码,添加详细注释解释关键逻辑
#include <iostream>
#include <cstring> // 为了使用 memcpy
using namespace std;
// 定义模数
const int mod = 1000000007;
int main() {
int n, k, m;
scanf("%d%d%d", &n, &k, &m);
// m位的全1掩码,例如 m=3, mask_full = (1<<3)-1 = 7 (二进制111)
// 用来在左移后清除溢出的高位
int mask_full = (1 << m) - 1;
// dp[j][s]: 访问了j个星球,最后m个星球访问状态为s的方案数
// j 最大为 k, s 最大为 mask_full
long long dp[13][16] = {0};
// 初始化:一个星球都不访问的方案只有1种(空序列)
dp[0][0] = 1;
// 按顺序考虑每个星球 i+1 (i from 0 to n-1)
for (int i = 0; i < n; i++) {
// 使用 next_dp 数组来存储当前轮计算出的新状态
// 避免在计算过程中覆盖本轮需要用的旧状态
long long next_dp[13][16] = {0};
// 遍历所有可能的前置状态 (j, s)
for (int j = 0; j <= k; j++) { // j: 已访问星球数
for (int s = 0; s <= mask_full; s++) { // s: bitmask
if (dp[j][s] == 0) continue; // 如果方案数为0,跳过以节省时间
// --- 转移情况1: 不选择星球 i+1 ---
// 窗口向右滑动一位,星球数j不变
int ns1 = (s << 1) & mask_full;
next_dp[j][ns1] = (next_dp[j][ns1] + dp[j][s]) % mod;
// --- 转移情况2: 选择星球 i+1 ---
if (j < k) { // 只有在已访问星球数小于k时,才能再选
// 新的mask,将最低位置为1,表示i+1被访问
int ns2 = (s << 1 | 1) & mask_full;
// 计算可以插入新星球的方式数
// __builtin_popcount(s) 计算s中1的个数
// +1 是因为可以把新星球放在序列最前面
int ways = __builtin_popcount(s) + 1;
next_dp[j + 1][ns2] = (next_dp[j + 1][ns2] + dp[j][s] * ways) % mod;
}
}
}
// 将计算好的新状态拷贝回dp数组,准备下一轮迭代
memcpy(dp, next_dp, sizeof(dp));
}
// 循环结束后,dp[k][s] 表示访问了k个星球,且最后m个星球状态为s的方案数
// 我们需要把所有可能的 mask s 的方案数加起来
long long ans = 0;
for (int s = 0; s <= mask_full; s++) {
ans = (ans + dp[k][s]) % mod;
}
printf("%lld\n", ans);
return 0;
}
复杂度分析的说
时间复杂度: O() 的说。 我们有一个主循环遍历 个星球。在每个星球的计算中,我们遍历了所有可能的
j
(从0到k
) 和所有可能的masks
(从0到)。所以总的时间复杂度就是这三者相乘啦。对于这道题的限制 (),这个复杂度是完全可以接受的。空间复杂度: O() 的说。 我们使用了两个
dp
数组(一个当前,一个下一轮),每个的大小都是k \times 2^m
。这个空间非常小,所以完全没问题。
知识点与总结
这道题是 Bitmask DP (状压DP) 的一个非常巧妙的应用!它教会了我们:
- 识别DP模式: 当题目中某些约束条件(如本题的
k
和m
)的范围很小时,可以考虑将它们作为DP状态的一部分。 - 状态设计与压缩: 关键在于如何设计一个状态,既能包含做出下一步决策所需的所有信息,又足够简洁。本题的
dp[j][s]
就是一个完美的例子,它通过一个bitmasks
聪明地记录了做决策所需的“局部”信息。 - 推导转移方程: 最核心的洞察力体现在
ways = __builtin_popcount(s) + 1
这个转移系数上。理解它代表了将新元素插入现有序列的合法方式数,是解题的关键。这需要我们对题目中的移动规则有深刻的理解。 - 滚动数组优化: 当DP的第
i
轮只依赖于第i-1
轮时,我们总可以用滚动数组(或者像代码里一样用一个next_dp
数组)来把空间复杂度从 O(\text{state}$)。
顺便一提,这道题的困难版本(F2),n
会变得非常大,此时 O() 的线性扫描会超时。那种情况下,因为DP转移是固定的,就可以把转移过程看作一个矩阵,然后用 矩阵快速幂 来把时间复杂度优化到 O(),这又是另一个有趣的话题啦!
希望这篇题解能帮到你,喵~ 一起加油,变得更强吧!