刷洛谷的时候,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 表示存在一条从
