刷洛谷的时候,P2049 这道“魔术棋子”算是让我印象比较深的一道题。当时我刚学动态规划没多久,看到“从左上角走到右下角”“每次只能向右或向下走”这种描述,第一反应是搜索——把所有路径枚举出来,挨个算乘积不就行了?但冷静下来算了算路径数量,我默默放弃了这个念头。后来才慢慢想明白,这题的本质是一个“坐标 + 余数”的状态型 DP,把余数这个信息塞进状态里,问题立刻变得非常清爽。
这篇文章我想把这题的完整思考过程、状态设计、代码实现、常见坑都梳理一遍。适合两类人看:一是刚学 DP 两三个月、想找一道经典题目练练手的朋友,二是准备蓝桥杯、CSP、NOIP 之类比赛,想把基础状态设计思想打牢固的选手。我会尽量写得细致一点,把我当时绕的弯路也讲出来,你看到的时候就能少踩几个坑。
1. 题目到底在问什么
1.1 先快速把题干翻译成人话
直接说题意吧。题目给定一个 n×m 的棋盘,每个格子上有一个数字,数字范围是 0 到 9。你从左上角 (1, 1) 出发,每次只能向右走一格或者向下走一格,走到右下角 (n, m) 算一条完整路径。路径上的数字会一路相乘,得到一个乘积,然后问你:所有可能路径的乘积,对 k 取模之后,到底可以得到多少个不同的余数?把这些余数按从小到大列出来。
这里有几个关键信息要拎清楚:
- 路径必须是连续的,只能向右或向下,不能回头。
- 乘积是路径上所有格子数字相乘的结果。
- 我们要的不是具体某个路径的乘积,而是所有路径乘积对 k 取模的余数集合。
- 输出分两行:第一行是不同余数的个数,第二行是这些余数从小到大排列。
我第一次读题的时候,只注意到“乘积”两个字,心想完了,这不得把路径全记下来吗?后来才意识到,题目只关心“模 k 的余数”,这个 k 给得很小,这就是突破口。
1.2 手推一个样例,搞清楚输出长什么样
我们拿一个最简单的样例来手动跑一遍。
假设输入是:
text复制2 2 3
1 2
3 4
棋盘是 2×2,从左上角到右下角,总共只有两条路径:
- 路径一:1 → 2 → 4,乘积是 8,8 % 3 = 2
- 路径二:1 → 3 → 4,乘积是 12,12 % 3 = 0
所以所有不同余数就是 {0, 2},一共两个,按从小到大输出:
text复制2
0 2
这个样例看起来简单,但它已经把“不同路径可能产生不同余数”这个核心体现出来了。你可以再自己构造一个 3×3 的棋盘试一下,比如:
text复制3 3 5
1 2 3
4 5 6
7 8 9
先别急着看题解,自己枚举几条路径,看看最终余数集合是什么。这个过程能帮你建立对题目的直觉。
1.3 乘积取模能“拆开算”是关键性质
这题能用动态规划,最根本的数学依据是模运算的一个性质:
(a × b) % k = ((a % k) × (b % k)) % k
也就是说,乘积对 k 取模的结果,和每一步都对 k 取模再相乘取模,是完全一样的。这意味着我们在走路径的过程中,不需要保存那个巨大的真实乘积,只需要保存“当前乘积对 k 取模后的余数”就够了。
举个例子,路径 1 → 2 → 4 对 3 取模:
- 1 × 2 = 2,2 % 3 = 2
- 2 × 4 = 8,8 % 3 = 2
换一种方式,第一步先取模:(1 % 3) × (2 % 3) = 2,(2 % 3) × (4 % 3) = 2 × 1 = 2,2 % 3 = 2。结果一致。
这个性质看起来平平无奇,但它直接决定了状态设计的方向。如果没有这个性质,每一步都得存真实乘积,状态根本压不住。有了这个性质,我们可以把余数范围限制在 0 到 k-1 之间,k 又特别小,这就给 DP 留出了极大的优化空间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力搜索为什么过不了,以及 DP 状态怎么来
2.1 路径数量到底有多大
先说暴力搜索的问题。从 (1, 1) 到 (n, m),一共要走 n+m-2 步,其中必须有 n-1 步是向下,m-1 步是向右。所以路径总数是一个组合数:
C(n+m-2, n-1)
当 n = m = 30 时,这个数大约是 C(58, 29),量级在 3×10^16 左右。你可以理解成三千万亿级别。哪怕一台电脑一秒钟能枚举一亿条路径,也要好几百万秒,完全不现实。
所以,凡是路径数量呈指数爆炸的问题,基本都不能靠暴力枚举。这时候就要想:题目到底需要我保留多少信息?答案只有一个——余数。路径本身、具体经过哪些格子、乘积具体是多少,全都是多余的。我们只需要知道“走到某个格子时,余数能不能取到某个值”。
2.2 状态设计:把余数当作第三维
既然只需要关心余数,那状态就不应该只是坐标。我们可以设计成三维:
dp[i][j][r] = true 表示存在一条从 (1, 1) 走到 (i, j) 的路径,使得这条路径上的乘积模 k 等于 r。
如果不存在这样的路径,这个值就是 false。
为什么用布尔值而不用 int?因为我们不关心有多少条路径能达到余数 r,只关心“是否存在”。这是很多 DP 题目里一个很重要的思维转变:计数问题和存在性问题的状态设计不一样。存在性问题用 bool 数组,能省很多空间,代码也更简洁。
你可能会问,为什么第三维是余数而不是别的?因为题目最终要的就是余数集合,而且模运算的合并性质保证了“余数”在转移时可以独立维护。说白了,我们把“当前乘积是什么”这个连续信息,压缩成了 k 个离散的“余数状态”。
2.3 状态转移方程与无后效性
现在来写转移。
走到 (i, j) 只有两种来源:从上方 (i-1, j) 向下走,或者从左方 (i, j-1) 向右走。假设我们知道了到达上方格子时的余数 r1,那么到达当前格子后的新余数就是 (r1 × a[i][j]) % k。同理,从左方来就是 (r2 × a[i][j]) % k。
转移方程可以写成:
- 如果 dp[i-1][j][r] == true,那么 dp[i][j][(r × a[i][j]) % k] = true
- 如果 dp[i][j-1][r] == true,那么 dp[i][j][(r × a[i][j]) % k] = true
初始化就是起点:
dp[1][1][a[1][1] % k] = true
也就是说,在起点位置,乘积就是 a[1][1],余数也显然只有这一个。
为什么这么转移是正确的?因为路径只能向右或向下,不会走回头路,所以 (i, j) 的状态只可能由更靠近左上角的状态转移过来,不存在循环依赖。我们按行从上到下、每行从左到右递推,就能保证计算 dp[i][j] 时,dp[i-1][j] 和 dp[i][j-1] 都已经计算完成。这就是无后效性。
这里有个直觉类比:你可以把每个格子想象成一面墙,墙上挂着 k 个小盒子,每个盒子代表一个余数,里面有东西就表示这个余数可达。从左上角开始,每走一步就把上一步的数据“乘上当前格子的数、取模”,然后放进对应盒子。走到终点时,数一数终点墙上哪些盒子是满的,就是答案。
2.4 复杂度对比:9000 次的 DP 和指数级的搜索
直接给结论。假设 n、m 都是 30,k 最大是 10:
- 暴力枚举路径:路径数量约 3×10^16 条,没法做。
- DP:状态数最多 30 × 30 × 10 = 9000,每个状态从两个方向转移,每次转移枚举 k 个余数,总复杂度 O(n × m × k),也就是 30 × 30 × 10 = 9000 左右,几乎瞬间出结果。
这个差距是碾压性的。很多 DP 题都是这个套路:用状态换时间,把指数级的东西压缩成多项式级。关键在于你想清楚“到底什么信息是真正有用的”。在这个题里,真正有用的只有“位置”和“余数”。
3. C++ 与 Java 实现:代码、细节、坑
3.1 C++ 完整实现
直接上代码,我用的是洛谷最常见的 C++ 写法:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 35;
const int MAXK = 15;
int a[MAXN][MAXN];
bool dp[MAXN][MAXN][MAXK];
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, m, k;
cin >> n >> m >> k;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
a[i][j] %= k; // 读入时直接取模
}
}
dp[1][1][a[1][1] % k] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (i == 1 && j == 1) continue; // 起点已经初始化过
for (int r = 0; r < k; r++) {
if (dp[i-1][j][r]) {
dp[i][j][(r * a[i][j]) % k] = true;
}
if (dp[i][j-1][r]) {
dp[i][j][(r * a[i][j]) % k] = true;
}
}
}
}
vector<int> ans;
for (int r = 0; r < k; r++) {
if (dp[n][m][r]) {
ans.push_back(r);
}
}
cout << ans.size() << '\n';
for (int i = 0; i < (int)ans.size(); i++) {
if (i > 0) cout << ' ';
cout << ans[i];
}
cout << '\n';
return 0;
}
简单解释几个点。
第一,a[i][j] %= k 在输入时做,这样后面 (r * a[i][j]) % k 就不容易溢出,也更符合前面说的模运算性质。
第二,dp 数组定义成全局变量,自动初始化为 false,省了很多事。MAXN=35 是因为 n、m 最大 30,开大一点留余量。MAXK=15 是因为 k 最大一般不超过 10,15 是保险值。
第三,双层循环从 (1,1) 开始,但遇到起点直接跳过,因为起点已经初始化好了。对于第一行或第一列的格子,比如 i=1, j=2,此时 dp[i-1][j] 访问的是 dp[0][2],全局数组默认是 false,不会影响结果;dp[i][j-1] 访问的是 dp[1][1] 这个有效状态,能正常转移。这算一个小技巧:利用全局数组的零初始化来偷懒,避免写一堆 if 判断边界。
3.2 我踩过的初始化与取模细节
说实话,我第一次写这题 WA 了两次,问题都出在很细节的地方。
第一次 WA 是因为我忘了初始化 dp[1][1]。我当时直接在循环里从 (1,1) 开始,也不跳过起点,结果起点状态全是 false,后面整个 DP 表全是 false。这个问题特别隐蔽,因为代码编译和运行都不报错,但你最后统计答案时一个余数都没有。
正确做法是:先把 dp[1][1][a[1][1] % k] = true 设好,然后在递推循环里跳过 (1,1)。这里要注意,如果你不小心在循环里把起点状态覆盖了,也会出问题。
第二次 WA 是因为我输出没处理好。题目要求第二行余数之间用空格分隔,我的第一版代码在唯一的余数后面多打了一个空格。这个问题在本地可能看不出来,但洛谷的评测要求比较严格,行末多余空格有时候会判格式错误。所以我最后改成用 if (i > 0) cout << ' '; 这种写法,保证行首行尾都没有多余空格。
另外还有一个细节:题目如果限制 n、m 只有 1 的情况,也就是只有起点也是终点,此时 dp[1][1][a[1][1] % k] = true 就已经是答案了。上面的代码里,循环从 (1,1) 开始,但因为跳过起点的逻辑,dp[1][1] 不会被二次处理,答案会正确输出。这个边界情况不用特别写 if,直接就是对的。
3.3 洛谷用 Java 提交时的注意事项
经常有人在洛谷问怎么切换编程语言,这里顺便说一下。提交代码之前,在代码框上方或者提交页面有语言选择下拉框,C++ 的话选 GNU++20、C++14、C++17 都行;要写 Java 就选 Java 8 或者 Java 11,选好再提交,否则默认语言不对容易编译失败。
Java 版本和 C++ 思路完全一样,给你一份可以直接提交的:
java复制import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int k = sc.nextInt();
int[][] a = new int[n + 1][m + 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
a[i][j] = sc.nextInt() % k;
}
}
boolean[][][] dp = new boolean[n + 1][m + 1][k];
dp[1][1][a[1][1] % k] = true;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (i == 1 && j == 1) continue;
for (int r = 0; r < k; r++) {
if (i > 1 && dp[i - 1][j][r]) {
dp[i][j][(r * a[i][j]) % k] = true;
}
if (j > 1 && dp[i][j - 1][r]) {
dp[i][j][(r * a[i][j]) % k] = true;
}
}
}
}
ArrayList<Integer> ans = new ArrayList<>();
for (int r = 0; r < k; r++) {
if (dp[n][m][r]) ans.add(r);
}
System.out.println(ans.size());
for (int i = 0; i < ans.size(); i++) {
if (i > 0) System.out.print(" ");
System.out.print(ans.get(i));
}
System.out.println();
}
}
Java 里我把边界判断写得比 C++ 更明确,i > 1 和 j > 1 都判断了一下,这样就不会访问到无效下标。主要原因是 Java 的数组不会像 C++ 全局数组那样自动铺满整个下标范围,虽然我开了 n+1 和 m+1,但访问第 0 行第 0 列也不会越界,只是值会是 false,所以其实不写也行。不过写出来逻辑更清楚,新手不容易看懵。
提交 Java 的时候还有一个容易忽略的问题:类名必须是 Main,不能带别的名字,也不能写 package 语句,不然判题机找不到主类。这个我当年栽过一次,特此提一句。
4. 常见错误速查与调试方法
4.1 四个高频报错和错误样例
第一个错误:输出余数个数的方式不对。有人会直接把 dp[n][m][r] 为 true 的数量写成 k,那就错了。因为这个数量取决于实际可达的余数,而不是 k 本身。正确做法是遍历 r 从 0 到 k-1,遇到 true 就计数。
第二个错误:漏掉数字 0 的情况。如果某个格子数字是 0,那么任何经过它的路径乘积都会变成 0,余数也一定是 0。这个并不是代码 bug,但如果你用手工推样例时没考虑到,就可能怀疑自己的 DP 写错了。比如一条路径上遇到 0,后面所有乘法的结果都归零,这个性质在 DP 中会自动体现出来,你要相信代码。
第三个错误:数组第三维开太小。有些题解会把第三维开成 k,有些开成 10 或 15。如果开成 k,那么 dp[i][j][r] 中 r 的范围是 0 到 k-1,这本来是准确的。但如果你循环时写 for (int r = 0; r <= k; r++) 就会越界。所以循环边界一定要和数组定义保持一致。
第四个错误:读入时没有做 a[i][j] %= k,等到循环里再乘再取模。这个其实也能过,因为 C++ 的 int 足够大,30 步乘积最多也就是一位数乘 30 次,约 10^30 就超了。所以一定不能不做取模。你可能觉得每步都取模和最后取模结果一样,但如果中间乘积超过 int 范围,就会溢出变成负数或截断,最终答案就错了。
4.2 问题速查表
直接整理成表格,方便你对着排查。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 输出个数为 0 | 忘记初始化 dp[1][1] | 在递推前设置 dp[1][1][a[1][1] % k] = true |
| 答案多出很多余数 | 第三维循环边界写错,访问到无效状态 | 统一使用 r < k,不要写 r <= k |
| 乘积结果为负数 | 中间乘法溢出了 int | 在输入时 a[i][j] %= k,转移时再取模 |
| 行末多空格导致 PE | 输出时没处理空格分隔 | 使用 if (i > 0) cout << ' ' 手法 |
| Java 编译失败 | 类名不是 Main,或带了 package | 类名改为 Main,去掉 package 行 |
| 判题超时 | 用了递归枚举所有路径 | 改写成状态 DP,复杂度 O(nmk) |
4.3 手推 DP 表:一种直观的调试方式
如果你写完之后心里还是没底,我建议你别急着提交,拿一个小样例手动把 DP 表推一遍,用不了几分钟。
比如就用 2×2、k=3、棋盘为 1 2 / 3 4 的例子。初始状态 dp[1][1][1] = true。然后推 (1,2):只能从左边来,左边余数是 1,1×2=2,所以 dp[1][2][2] = true。再推 (2,1):只能从上边来,上边余数是 1,1×3=3,3%3=0,所以 dp[2][1][0] = true。最后推 (2,2):可以从左边来,左边余数是 2,2×4=8,8%3=2;也可以从上边来,上边余数是 0,0×4=0。所以 dp[2][2][2] = true,dp[2][2][0] = true。
最终答案是 2 和 0,输出 2 / 0 2,和前面手推一致。这个过程就是最好的验证方式。
5. 从魔术棋子看一类“坐标 + 余数”DP
5.1 这类题目的通用识别特征
做完这道题,你会发现它代表了一类非常常见的 DP 套路:原问题的状态不够用,就加一维“附加信息”,这个附加信息可能是余数、奇偶性、当前最大值、某种计数状态等。
识别特征很简单:
- 问题是在一个结构上走路径或做选择。
- 最终答案只关心某个属性(如乘积、和)对某个数取模的结果。
- 这个“模数”比较小,可以塞进数组维度。
一旦看到这三个特征,就可以尝试在 DP 状态里加一维余数。这个思路在背包问题(加一维容量)、最大子段和(加一维是否重新开始)、图论最短路(加一维是否使用某条边)里都有体现。模板化的表达就是:dp[主要位置][附加约束] = 所求值或可达性。
所以我说,这道题真正的价值不是让你背住代码,而是让你学会“加维度”这个思维方法。
5.2 可以继续练的方向
如果你想巩固一下,洛谷上有很多类似思路的题目可以刷。大体分成三类:
- 网格路径计数类:比如过河卒、数字三角形这类,练习基础递推。
- 乘积和取模类:找一些“最大乘积”“可被 k 整除的连续子数组数量”之类的题目,加深对余数状态的理解。
- 状态压缩类:当附加信息不是余数而是“某集合是否选过”时,就是状压 DP,思路同源。
练的时候注意一点:别只盯着题解代码,尽量自己先想出“状态”再动手写。状态设计对了,转移基本就是顺水推舟。
5.3 如果 k 变成 1e9,怎么应对
可能有人会问,如果这题的 k 不是 10,而是 1e9 呢?DP 数组第三维肯定开不下,怎么办?
有一个很自然的变通方案:每个格子不存布尔数组,而存一个 set<int>,记录到达这个格子所有可能的余数集合。转移的时候,把上一格子集合里的余数挨个乘上当前值取模,插入当前格子的 set。这样状态数从固定的 k 变成了实际可达余数的数量,空间会小很多。时间复杂度也取决于实际可达的余数数量,最坏可能仍然是 O(nmk),但 k 很大时往往不会达到这个最坏量级。
如果 k 特别大,而且你只需要判断某个特定余数是否可达,还可以用 bitset 来压缩集合运算,每个格子用一个长度为 k 的 bitset,转移时做移位和按位或。这个思路在更进阶的题里非常有用。
不过这些都是后话,做 P2049 的时候,老老实实用三维 bool 数组就够了。先把基础状态设计吃透,再想优化也不迟。
最后再多说两句做 DP 的心态。我自己刷题的时候,经常陷入一个误区:看到题就急着套模板,套不进去就开始焦虑。后来慢慢发现,动态规划题的核心永远是“状态怎么定”和“转移怎么走”,代码反而是最简单的一环。遇到这种看似是搜索、实际是 DP 的题目,多花几分钟想清楚状态,比盲目写暴力划算得多。P2049 这道题,我做完之后最大的收获就是学会了“压缩信息”的视角:从一个完整路径中只留下余数这个关键信息,其他全部丢弃,问题瞬间从指数级降到多项式级。这个思路后来在背包、状压、数位 DP 里反复用到,越用越熟练。如果你也正在入门 DP,建议你把这题当作一个训练思维的小节点,静下心来把状态设计和转移推导走一遍,比直接背二十道题的代码都有用。
