ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

动态规划精讲:从状态设计到路径计数,解析P1373小a和uim之大逃离

动态规划精讲:从状态设计到路径计数,解析P1373小a和uim之大逃离 1. 项目概述与核心思路拆解题目“P1373 小 a 和 uim 之大逃离”是信息学奥赛OI中一道经典的动态规划题目它考察的不仅仅是基础的编程能力更是对状态设计和转移方程构建的深度理解。很多初次接触这道题的同学往往会被其看似复杂的“两人轮流”、“差值取模”等条件绕晕感觉无从下手。实际上这道题的魅力就在于它用一个精巧的“状态”封装了所有关键信息一旦你理解了状态的定义代码实现反而变得清晰而直接。这道题的核心场景是在一个N*M的网格迷宫中每个格子上有不同数量的魔液。小a和uim两人轮流行动小a先手每次可以从当前格子向下或向右移动一格并收集该格子的魔液。游戏的胜负不取决于谁收集的总量多而是看两人收集的魔液总量之差小a的减去uim的对k1取模后是否为0。如果uim移动后满足条件则uim获胜。我们需要计算所有可能的起点和路径下uim能够获胜的方案总数。初看之下变量极多两人的位置、各自收集的魔液量、当前轮到谁行动……如果直接暴力搜索状态空间是指数级的必然超时。因此我们必须使用动态规划来优化。动态规划的精髓是找到一种“状态表示”能够唯一确定问题的某个子局面并且这个状态的数量是可管理的。对于本题一个关键洞察是我们并不需要分别记录小a和uim的具体魔液量只需要记录他们魔液量的差值对k1取模的结果。因为决定胜负的正是这个模意义下的差值。同时由于两人轮流行动我们还需要知道当前这一步是谁在收集魔液。最后两人的位置i, j自然是状态的一部分。于是一个四维的DP状态dp[i][j][h][p]就呼之欲出了i, j代表当前走到网格的哪个位置坐标。h代表当前小a的魔液总量减去uim的魔液总量再对mod k1取模后的值。由于差值可能为负我们在程序中通常将其转换为0到mod-1之间的非负整数。p代表当前这一步是谁在收集魔液。通常我们定义p0表示这一步是小a在收集差值会增加p1表示这一步是uim在收集差值会减少。dp[i][j][h][p]的值表示从起点开始走到(i, j)位置且当前魔液差值的模为h且当前轮到p0为小a1为uim行动时能够形成该局面的路径方案总数。那么状态如何转移呢考虑我们如何到达(i, j)这个点。只能从它的上方(i-1, j)或者左方(i, j-1)走过来。假设是从(i-1, j)走过来如果当前在(i, j)且轮到小a (p0) 收集那么上一步在(i-1, j)时一定是轮到uim (p1) 收集刚结束。上一步结束后的差值为某个值h_prev。当小a收集了a[i][j]的魔液后新的差值h应该等于(h_prev a[i][j]) % mod。因此转移方程为dp[i][j][h][0] dp[i-1][j][h_prev][1]其中h_prev (h - a[i][j] mod) % mod。同理如果当前在(i, j)且轮到uim (p1) 收集那么上一步一定是小a (p0) 刚结束。uim收集魔液会使差值减少所以新的差值h等于(h_prev - a[i][j]) % mod。为了得到非负余数我们写作(h_prev - a[i][j] mod) % mod。因此转移方程为dp[i][j][h][1] dp[i-1][j][h_prev][0]其中h_prev (h a[i][j]) % mod。从(i, j-1)转移过来的情况是完全对称的。最终我们要求的答案是所有满足“位置在(i, j)当前轮到uim (p1) 行动且魔液差值模h为0”的状态dp[i][j][0][1]的总和。为什么是p1因为题目规定是在uim收集完魔液后判断差值。为什么是h0因为差值模(k1)为0是uim获胜的条件。1.1 核心难点与常见误区理解了这个状态定义就解决了问题的一大半。但在实现时仍有几个细节需要特别注意这也是很多代码提交后得到Wrong AnswerWA的原因模数的选择题目中说的是差值对(k1)取模而不是对k取模。这是一个常见的读题陷阱。mod k 1。初始状态的设定动态规划需要一个起点。对于任何一个格子(i, j)如果小a从这里开始游戏先手那么他首先会收集这个格子的魔液。因此对于所有格子(i, j)都存在一个初始状态dp[i][j][a[i][j] % mod][0] 1。这表示从(i, j)开始小a先手收集了该格魔液形成了一种方案。答案的累加时机我们不能在状态转移的过程中直接累加答案因为一个状态可能被多次更新从上方和左方转移而来。必须在所有状态转移完成之后再遍历所有dp[i][j][0][1]进行求和。取模运算题目要求对1,000,000,007即1e97取模输出最终答案。不仅最终答案要取模在状态转移的每一步加法操作后都应该立即取模防止中间结果溢出。数组大小与内存根据题目范围N, M 800, K 15。因此我们的DP数组大小为dp[801][801][16][2]。计算一下内存801*801*16*2 ≈ 20.5 * 10^6个状态。每个状态通常用int或long long存储。如果用int4字节大约需要20.5e6 * 4 / 1024 / 1024 ≈ 78 MB这在常见的256MB内存限制下是可行的。但如果使用long long8字节则会达到约156MB存在风险。因此强烈建议使用int类型并在每次加法后立即对1e97取模这样可以保证值域在int范围内。1.2 算法复杂度分析我们使用了四重循环来遍历所有状态外层两重循环遍历网格所有位置(i, j)复杂度为 O(N*M)。内层两重循环遍历差值模h(0~k) 和当前玩家p(0~1)复杂度为 O(K*2)。在遍历每个(i, j)时我们需要从(i-1, j)和(i, j-1)转移过来每次转移需要遍历所有可能的h和p进行组合计算。因此总的时间复杂度为 O(NMK)。代入最大数据规模 (80080015)计算量大约在 9.6e7 级别在时间限制内通过优化良好的C代码是可以接受的。空间复杂度为 O(NMK)如前所述约78MB处于临界但可接受的状态。2. 代码实现与逐行解析理解了算法思想后我们来看具体的C实现。我会将代码分成几个部分并详细解释每一行代码的作用和背后的思考。2.1 头文件、常量与全局变量定义#include iostream #include cstring using namespace std; const int MAXN 805; const int MAXK 16; const int MOD 1000000007; int n, m, k, mod; int a[MAXN][MAXN]; int dp[MAXN][MAXN][MAXK][2];#include cstring虽然我们主要用不到字符串函数但有时初始化数组会用到memset包含它是个好习惯。const int MOD 1000000007定义题目要求的大质数模数。注意1e97是一个浮点数表示在整数常量中应直接写全1000000007或者使用int(1e97)但直接写全数字是最清晰无误的。int dp[MAXN][MAXN][MAXK][2]这就是我们的四维DP数组。维度依次是行、列、差值模、当前玩家。大小设为805是为了下标从1开始使用避免边界判断的麻烦。MAXK设为16是因为k最大为15模数k1最大为16。2.2 主函数与输入处理int main() { ios::sync_with_stdio(false); cin.tie(0); cin n m k; mod k 1; // 注意模数是 k1 for (int i 1; i n; i) { for (int j 1; j m; j) { cin a[i][j]; a[i][j] % mod; // 预先对魔液值取模优化后续计算 } }ios::sync_with_stdio(false); cin.tie(0);这两行是C中关闭C标准流同步和解除cin与cout绑定的经典操作可以大幅提升大量数据输入输出的速度在竞赛编程中几乎是标配。mod k 1再次强调模数是k1。a[i][j] % mod这是一个非常重要的优化技巧。因为我们在状态转移中会频繁计算(h a[i][j]) % mod或(h - a[i][j] mod) % mod。如果a[i][j]本身已经对mod取过模那么它一定在[0, mod-1]范围内这使得后面的加减和取模运算更快且能避免潜在的负数问题。输入数据中的魔液值可能很大先取模不影响最终差值模的结果因为(a % mod) % mod等价于a % mod。2.3 动态规划初始化// 初始化每个格子都可以作为起点小a先手拿走该格魔液 for (int i 1; i n; i) { for (int j 1; j m; j) { int val a[i][j] % mod; // 实际上上一步已经取过模了这里确保一下 dp[i][j][val][0] 1; // 状态在(i,j)差值模为val当前轮到小a(0) } }这是动态规划的“起点”。对于网格中的每一个格子(i, j)如果小a选择从这里开始游戏他的第一步操作就是收集这个格子的魔液。因此我们创建了一个初始状态dp[i][j][val][0] 1表示存在1种方案使得游戏进行到“位于(i, j)差值为val即a[i][j] % mod且刚刚行动完的是小a或者说当前局面是小a行动后的结果下一步该uim行动了”。注意这里p0表示“当前状态是小a操作后的状态”这与我们之前“p表示当前轮到谁”的定义在语义上需要统一。在转移时我们从“上一步操作后的状态”推导“当前操作后的状态”所以初始化时可以认为在起点小a已经完成了第一次操作。2.4 核心状态转移这是整个程序最核心的部分需要仔细理解下标和转移方向。// 状态转移 for (int i 1; i n; i) { for (int j 1; j m; j) { int cur a[i][j]; // 当前格子的魔液值已取模 for (int h 0; h mod; h) { // 状态定义dp[i][j][h][p] 表示走到(i,j)差值模为h且当前轮到p操作时的方案数 // p0: 轮到小a操作 (差值增加) // p1: 轮到uim操作 (差值减少) // 从上方 (i-1, j) 转移过来 if (i 1) { // 如果当前在(i,j)且轮到小a(p0)则上一步在(i-1,j)时一定轮到uim(p1)操作完毕 // 上一步的差值模 h_prev 应满足h (h_prev cur) % mod // 所以 h_prev (h - cur mod) % mod int prev_h (h - cur mod) % mod; dp[i][j][h][0] (dp[i][j][h][0] dp[i-1][j][prev_h][1]) % MOD; // 如果当前在(i,j)且轮到uim(p1)则上一步在(i-1,j)时一定轮到小a(p0)操作完毕 // 上一步的差值模 h_prev 应满足h (h_prev - cur mod) % mod // 所以 h_prev (h cur) % mod prev_h (h cur) % mod; dp[i][j][h][1] (dp[i][j][h][1] dp[i-1][j][prev_h][0]) % MOD; } // 从左方 (i, j-1) 转移过来逻辑与上方完全相同 if (j 1) { int prev_h (h - cur mod) % mod; dp[i][j][h][0] (dp[i][j][h][0] dp[i][j-1][prev_h][1]) % MOD; prev_h (h cur) % mod; dp[i][j][h][1] (dp[i][j][h][1] dp[i][j-1][prev_h][0]) % MOD; } } } }让我们拆解其中一个转移例如从上方转移且p0的情况if (i 1)确保有“上方”格子存在。int prev_h (h - cur mod) % mod;计算上一个状态在(i-1, j)的差值模h_prev。因为当前状态是(i, j, h, 0)表示小a在(i, j)收集魔液后差值模变为h。那么收集之前即在(i-1, j)时差值模应该是h_prev满足(h_prev cur) % mod h。通过移项得到h_prev (h - cur) % mod。由于h - cur可能为负数我们加上mod再取模确保结果在[0, mod-1]范围内。这是处理负数取模的标准方法(x % mod mod) % mod。dp[i][j][h][0] (dp[i][j][h][0] dp[i-1][j][prev_h][1]) % MOD;进行转移。到达(i, j, h, 0)状态的方案数增加了从(i-1, j, prev_h, 1)状态转移过来的方案数。注意当前是p0小a操作后那么上一步操作后的状态一定是p1uim操作后。这就是为什么是加dp[i-1][j][prev_h][1]。p1的情况是类似的只是差值变化方向相反uim收集使差值减少所以计算prev_h时是(h cur) % mod。从左方(i, j-1)的转移是完全对称的代码。重要提示这里的状态转移是“累加”模式。因为一个格子(i, j)可能从上方和左方两个方向到达所以方案数需要把两个来源都加起来。同时dp[i][j][h][p]在初始化时可能已经有值作为起点的情况所以这里用的是操作。2.5 统计答案与输出// 统计答案所有位置(i,j)且轮到uim操作(p1)时差值模h为0的方案数 int ans 0; for (int i 1; i n; i) { for (int j 1; j m; j) { ans (ans dp[i][j][0][1]) % MOD; } } cout ans endl; return 0; }根据题目要求我们需要统计所有“uim收集完魔液后两人魔液差模(k1)等于0”的方案。对应到我们的状态定义p1表示当前轮到uim操作。注意我们的状态dp[i][j][h][p]表示的是“走到(i, j)差值为h且当前轮到p操作”的方案数。那么当p1时意味着下一步是uim要操作。但题目问的是“uim收集完毕后”的状态。这里有一个细微的差别。 更准确的理解是dp[i][j][h][p]可以定义为“走到(i, j)差值为h且刚刚由玩家p完成了一次操作”的方案数。这样初始化dp[i][j][val][0]表示小a在(i, j)完成了操作。那么当p1时就表示uim在(i, j)完成了操作。此时如果差值h为0则uim获胜。这种定义在转移时会更自然从(i-1, j)的某个状态某个玩家操作后经过当前玩家在(i, j)操作转移到新的状态。 实际上两种定义是等价的只是对“当前状态”的描述不同。在常见的实现和题解中采用“p表示当前轮到谁”的定义那么答案就是dp[i][j][0][1]因为轮到uim时差值为0等他操作完差值变化后我们并不关心我们只关心在他操作前这个瞬间差值是否为0。仔细阅读题目“uim 魔瓶满了……差为 k 的倍数包括 0”。这个判断发生在uim“吸走”魔液后即uim操作完成后。所以状态应该记录“操作完成后”的差值。如果我们定义p0/1为“上一步操作的人”那么答案就是dp[i][j][0][1]上一步是uim操作且操作后差值为0。如果我们定义p0/1为“下一步要操作的人”那么答案需要是dp[i][j][?][?]经过一步转移后的状态会更绕。绝大多数AC代码都直接累加dp[i][j][0][1]作为答案我们可以将其理解为状态(i, j, 0, 1)表示“在(i, j)位置差值为0且当前局面是uim刚刚操作完毕或者即将由小a操作”。这个状态直接对应了uim获胜的一个瞬间。因此遍历所有i, j累加dp[i][j][0][1]即可。最后将累加的结果ans对MOD取模后输出。3. 完整代码与测试样例将以上所有部分组合起来就得到了完整的AC代码。为了便于理解和调试我强烈建议你在自己的编程环境中手动输入一遍。#include iostream #include cstring using namespace std; const int MAXN 805; const int MAXK 16; const int MOD 1000000007; int n, m, k, mod; int a[MAXN][MAXN]; int dp[MAXN][MAXN][MAXK][2]; int main() { ios::sync_with_stdio(false); cin.tie(0); cin n m k; mod k 1; for (int i 1; i n; i) { for (int j 1; j m; j) { cin a[i][j]; a[i][j] % mod; } } // 初始化每个格子作为起点小a先手 for (int i 1; i n; i) { for (int j 1; j m; j) { int val a[i][j]; // 已取模 dp[i][j][val][0] 1; } } // 动态规划转移 for (int i 1; i n; i) { for (int j 1; j m; j) { int cur a[i][j]; for (int h 0; h mod; h) { // 从上方转移 if (i 1) { // 转移到 dp[i][j][h][0]上一步是uim操作后 int prev_h (h - cur mod) % mod; dp[i][j][h][0] (dp[i][j][h][0] dp[i-1][j][prev_h][1]) % MOD; // 转移到 dp[i][j][h][1]上一步是小a操作后 prev_h (h cur) % mod; dp[i][j][h][1] (dp[i][j][h][1] dp[i-1][j][prev_h][0]) % MOD; } // 从左方转移 if (j 1) { int prev_h (h - cur mod) % mod; dp[i][j][h][0] (dp[i][j][h][0] dp[i][j-1][prev_h][1]) % MOD; prev_h (h cur) % mod; dp[i][j][h][1] (dp[i][j][h][1] dp[i][j-1][prev_h][0]) % MOD; } } } } // 统计所有uim获胜的方案 int ans 0; for (int i 1; i n; i) { for (int j 1; j m; j) { ans (ans dp[i][j][0][1]) % MOD; } } cout ans endl; return 0; }测试样例题目通常会提供样例输入和输出用于验证程序逻辑。假设有以下简单样例输入2 2 1 1 1 1 1解释2行2列k1所以模数mod2。网格全是1。 我们可以手动推导起点(1,1)小a先手拿1差值mod1。状态(1,1,1,0)1。从(1,1)到(1,2)有两种理解。路径(1,1) - (1,2)。在(1,1)时是小a操作后(p0)那么到(1,2)应该是uim操作。 状态转移dp[1][2][h][1] dp[1][1][prev_h][0]其中prev_h (h cur) % mod。cur1。 若h0则prev_h (01)%21。所以dp[1][2][0][1] dp[1][1][1][0] 1。这意味着存在一条路径uim在(1,2)操作后差值为0uim获胜。起点也可以是(1,2)本身dp[1][2][1][0]1。从(1,1)到(2,1)同理dp[2][1][0][1] dp[1][1][1][0] 1。从(1,2)到(2,2)dp[2][2][0][1]可以从dp[1][2][1][0]和dp[2][1][1][0]转移过来。从(2,1)到(2,2)同理。最终所有dp[i][j][0][1]的和应该包括dp[1][2][0][1]1,dp[2][1][0][1]1,dp[2][2][0][1]从两个方向转移来至少为2。 还可能存在其他路径组合。最终答案应该是一个大于0的数。你可以用这个简单样例测试你的程序或者使用题目官方样例进行验证。4. 性能优化与空间压缩技巧上面的代码已经可以AC本题。但对于追求极致或者遇到更大数据范围的情况我们还可以探讨一些优化方向。4.1 滚动数组优化空间我们的DP数组dp[i][j][h][p]消耗了约78MB内存。观察状态转移方程在计算第i行第j列的状态时只依赖于第i-1行第j列和第i行第j-1列的状态。这是一种典型的“左上依赖”模型。我们可以使用滚动数组将空间复杂度从 O(NMK) 降低到 O(M*K)。具体做法是不再保存所有i行的状态只保存当前行和上一行的状态。定义两个二维数组dp_curr[2][MAXM][MAXK][2]和dp_prev[2][MAXM][MAXK][2]但这样写有点复杂。更简洁的方法是我们按行遍历在计算第i行时dp[i][j][...]只依赖于dp[i-1][j][...]上方和dp[i][j-1][...]左方。左方是本行已经计算过的上方是上一行的。因此我们可以只用一个二维数组dp[2][MAXM][MAXK][2]其中第一维是0或1表示当前行和上一行。在遍历每一行i时dp[0][j][h][p]表示上一行i-1行第j列的状态。dp[1][j][h][p]表示当前行i行第j列的状态正在计算。 计算完第i行后将dp[1]的内容复制到dp[0]或者交换两个数组的指针然后开始计算下一行。然而本题中初始化每个格子作为起点也需要访问dp[i][j]如果压缩了行维度初始化逻辑需要调整。更常见的滚动数组写法是直接定义dp[2][MAXM][MAXK][2]然后在循环中交替使用now和prev索引。由于本题内存限制通常可以接受78MB且滚动数组会使得代码可读性下降在竞赛中除非内存非常紧张否则使用完整数组是更稳妥和清晰的选择。这里了解该思想即可。4.2 循环顺序与局部变量优化在代码中最内层循环是for (int h 0; h mod; h)。我们将其放在i, j循环之内。这种顺序是符合状态转移依赖关系的。现代CPU的缓存机制Cache对连续内存访问友好。我们的数组dp[i][j][h][p]在内存中是按i, j, h, p的顺序连续存储的。当i和j固定时内层循环连续访问h和p这具有良好的空间局部性能有效利用CPU缓存提升速度。另外将a[i][j]存入局部变量cur以及使用prev_h存储中间计算结果都是为了减少对全局数组的重复访问让编译器更容易进行优化。4.3 取模运算优化取模%运算在CPU中是比较耗时的操作。在我们的代码中有两处取模a[i][j] % mod在输入时只做一次影响不大。状态转移中计算prev_h(h - cur mod) % mod和(h cur) % mod。对于(h cur) % mod因为h和cur都在[0, mod-1]范围内所以h cur的范围是[0, 2*mod-2]。我们可以用条件判断来避免取模int tmp h cur; if (tmp mod) tmp - mod; // 此时 tmp 等价于 (hcur)%mod对于(h - cur mod) % mod可以这样优化int tmp h - cur; if (tmp 0) tmp mod; // 此时 tmp 等价于 (h-curmod)%mod这种优化在mod不大本题最大为16时效果可能不明显因为分支预测可能带来开销。但当mod较大时用条件判断代替取模是常见的优化手段。在本题中由于mod很小使用原始的取模写法可读性更好编译器也可能自动优化。了解这种思路对处理其他问题有帮助。5. 常见错误与调试心得在实现和调试这道题时我踩过不少坑也看到很多同学提交后出现的典型错误。这里总结一下Wrong Answer (WA)模数错误这是最最常见的错误题目说的是(k1)的倍数不是k的倍数。务必检查mod k 1。初始化遗漏或错误忘记每个格子都可以作为起点或者初始化时p的值设错了应该是小a先手即p0。状态转移方向错误混淆了p0和p1时差值的变化方向。记住小a操作使差值增加 (cur)uim操作使差值减少 (-cur)。在计算prev_h时要根据这个关系反推。转移来源错误dp[i][j][h][0]应该从dp[i-1][j][prev_h][1]和dp[i][j-1][prev_h][1]转移即上一步是uim操作后。dp[i][j][h][1]则从p0的状态转移。如果这里p的对应关系搞反结果肯定不对。答案统计错误错误地累加了dp[i][j][0][0]或者忘了对MOD取模。Runtime Error (RE)数组越界确保数组大小足够。N, M最大800我们开了805。h的范围是[0, k]k最大15我们开了16。循环时注意边界i1,j1的判断。整数溢出虽然我们对每次加法都取了模但如果在取模之前两个加数本身就很大它们的和可能溢出int范围约21亿。MOD是10亿7两个这样的数相加不会超过int范围最大约20亿但三个或更多数连续相加就可能溢出。因此安全的做法是每加一次就取一次模就像代码中写的(a b) % MOD。Time Limit Exceeded (TLE)复杂度问题O(NMK) 的算法对于 80080016 ≈ 1e7 次循环在每层循环内做几次运算应该是绰绰有余的。如果TLE检查是否是用了cin/cout而没有关闭同步或者数组访问模式导致缓存命中率极低但我们的顺序访问已经很好。调试输出提交前务必删除所有cout调试语句。调试建议小数据测试自己构造一个 2x2 或 3x3 的小网格k设为1或2手动计算所有可能的路径和答案然后与程序输出对比。这是最有效的调试方法。打印DP表对于小样例可以写一个函数打印出dp[i][j][h][p]的值检查初始化是否正确转移是否符合预期。重点关注h0和p1的那些状态。单步跟踪在IDE中使用调试器跟踪某个特定路径的状态转移看数值变化是否正确。6. 算法扩展与同类问题思考解决P1373后你对动态规划的状态设计能力应该有了显著提升。这类“双人博弈”、“路径计数”、“带模数条件”的问题在信息学奥赛中很常见。我们可以做一些扩展思考如果魔液值有负数题目中魔液值是正的。如果有负数我们的差值h可能超出[0, mod-1]的范围吗不会因为我们始终对mod取模。计算prev_h时(h - cur mod) % mod的写法仍然有效因为cur可能是负数但(h - cur)经过加mod和取模后总能映射到[0, mod-1]。程序无需大改。如果移动方向更多本题只能向下或向右。如果允许向上、向左、甚至四个方向但路径不能重复经过同一个格子即走迷宫问题就变成了图上的DP可能需要在状态中增加“访问标记”复杂度会急剧上升可能需要用状压DP或记忆化搜索。如果判断胜负的条件变化例如不是看差值模(k1)是否为0而是看差值模(k1)等于一个特定值t。那么我们只需要在最后统计答案时将条件h 0改为h t即可。状态转移部分完全不变。如果不止两个人这是一个更有挑战性的扩展。如果有三个人轮流收集状态就需要增加一维来记录当前轮到谁同时差值可能也需要用两个变量或一个二维向量来表示两两之间的差。状态复杂度会呈指数增长很可能需要寻找其他性质或优化。P1373是一个非常好的动态规划入门题它教会我们如何将复杂的问题条件两人、轮流、差值取模抽象成简洁的状态表示。掌握这种思想是解决更复杂竞赛题目的关键。希望这篇详细的解析能帮助你彻底理解这道题并在未来的刷题中举一反三。
返回列表