ARTICLE DETAIL

资讯详情

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

动态规划经典题解:P1541乌龟棋的多维状态设计与优化

动态规划经典题解:P1541乌龟棋的多维状态设计与优化 1. 从一道经典题目说起P1541 乌龟棋如果你在刷动态规划DP的题目尤其是线性DP或者多维状态DP那么“P1541 乌龟棋”这道题几乎是绕不开的。我第一次遇到它时感觉题目描述像是一个简单的棋盘游戏但仔细一想状态设计却有点无从下手。题目大意是你有一个长度为N的棋盘从起点1走到终点N每个格子有一个分数。你有M张爬行卡片卡片分为1、2、3、4四种分别代表可以前进1、2、3、4格。你必须恰好用完所有卡片并且不能走出棋盘范围。目标是计算从起点到终点所能获得的最大总分数。这听起来像是一个搜索或者贪心问题但M可以很大暴力搜索显然会超时。它的核心魅力在于它摒弃了“位置”这个最直观的状态维度转而用一种更巧妙的方式来定义“状态”。很多朋友卡在这里就是因为跳不出“我现在走到哪了”这个思维定式。今天我们就来彻底拆解这道题不仅讲清楚标准解法更分享几种不同的状态定义思路和它们背后的优化逻辑以及我在反复提交中遇到的那些“坑”。2. 问题重述与核心难点分析我们先形式化地描述一下题目这有助于我们抓住关键约束条件。你有一个一维棋盘格子编号从1到N。起点是1终点是N。对于每个格子i1 ≤ i ≤ N有一个对应的分数score[i]。你拥有M张爬行卡片每张卡片上只标有1、2、3、4四个数字之一。使用一张数字为k的卡片你可以从当前位置向前移动恰好k格。你必须把所有M张卡片全部用完并且要保证在使用的过程中任何时刻你的位置都在1到N的范围内包括终点。最终目标是最大化你经过的所有格子的分数之和起点和终点的分数也计算在内且每个格子的分数只计算一次即使多次经过也不重复累加。输入格式通常为第一行两个整数N, M。第二行N个整数表示每个格子的分数。第三行M个整数表示你拥有的卡片每个数字是1、2、3、4中的一个。输出格式一个整数表示能获得的最大分数。核心难点在于状态的定义。最直接的想法是用dp[pos][a][b][c][d]表示走到位置pos并且使用了a张1步卡、b张2步卡、c张3步卡、d张4步卡时获得的最大分数。但这里有一个致命问题pos这个信息是冗余的为什么因为当你已知使用了a, b, c, d张卡片后你当前的位置是唯一确定的pos 1 a*1 b*2 c*3 d*4。起点是1每用一张卡就前进相应的步数。所以我们完全可以用四维状态dp[a][b][c][d]来替代五维状态从而将空间和时间复杂度降一个维度。这就是本题的第一个也是最重要的洞见通过卡片使用情况间接确定位置从而消除冗余状态维度。理解这一点就解开了这道题最大的结。3. 标准四维DP解法状态定义与转移方程基于上面的分析我们定义状态dp[a][b][c][d]表示使用了a张1步卡、b张2步卡、c张3步卡、d张4步卡时所能获得的最大分数。那么我们如何计算当前的位置呢根据公式current_pos 1 a*1 b*2 c*3 d*4这个位置就是我们当前所在的格子编号。状态转移方程怎么来我们考虑最后一步使用了哪种卡片。要达到状态(a, b, c, d)有四种可能的前驱状态最后一步用的是一张1步卡。那么前一个状态是(a-1, b, c, d)当时的位置是current_pos - 1。最后一步用的是一张2步卡。那么前一个状态是(a, b-1, c, d)当时的位置是current_pos - 2。最后一步用的是一张3步卡。那么前一个状态是(a, b, c-1, d)当时的位置是current_pos - 3。最后一步用的是一张4步卡。那么前一个状态是(a, b, c, d-1)当时的位置是current_pos - 4。我们要取这四种可能来源中能使当前分数最大的那个然后加上当前位置current_pos的分数。注意起点1的分数在任何状态下都会被计算因为它对应的是abcd0的状态我们需要初始化这个状态。因此状态转移方程可以写作dp[a][b][c][d] max(dp[a-1][b][c][d], dp[a][b-1][c][d], dp[a][b][c-1][d], dp[a][b][c][d-1]) score[current_pos]当然在转移时必须确保前驱状态是合法的即a, b, c, d非负。初始化dp[0][0][0][0] score[1]因为一开始就在起点1分数就是score[1]。最终答案假设我们统计出四种卡片的数量分别为cnt[1], cnt[2], cnt[3], cnt[4]那么答案就是dp[cnt[1]][cnt[2]][cnt[3]][cnt[4]]。注意在实现时current_pos的计算必须确保在1到N之间。不过由于我们是从初始状态逐步增加卡片使用量进行转移的只要最终状态合法中间状态的位置也必然是合法的因为题目保证有解。但在代码中加一个判断if (current_pos N) continue;是个好习惯可以防止某些边界情况或错误数据导致数组越界。4. 代码实现细节与空间优化技巧理论清晰了我们来看看如何用代码实现这里有几个非常实际的细节和技巧。首先我们需要统计四种卡片的数量。输入中第三行有M个数我们遍历一遍分别累加到cnt[1],cnt[2],cnt[3],cnt[4]即可。DP数组的大小是(cnt[1]1) * (cnt[2]1) * (cnt[3]1) * (cnt[4]1)。如果每张卡最多有40张根据常见数据范围那么四维数组最大是41*41*41*41约280万每个元素是int4字节总内存大约10MB在现代OJ上完全可行。所以我们可以直接开一个四维数组。#include iostream #include algorithm using namespace std; int score[355]; // 棋盘分数索引从1开始 int dp[41][41][41][41]; // DP数组维度大小根据卡片最大数量设定 int cnt[5] {0}; // 卡片计数索引1-4有效 int main() { int N, M; cin N M; for (int i 1; i N; i) { cin score[i]; } for (int i 0; i M; i) { int step; cin step; cnt[step]; } // 初始化 dp[0][0][0][0] score[1]; // 起点分数 // 状态转移 for (int a 0; a cnt[1]; a) { for (int b 0; b cnt[2]; b) { for (int c 0; c cnt[3]; c) { for (int d 0; d cnt[4]; d) { int pos 1 a b*2 c*3 d*4; // 计算当前位置 if (pos N) continue; // 安全判断虽然理论上不会发生 int maxPrev 0; // 分别从四种前驱状态转移过来 if (a 0) maxPrev max(maxPrev, dp[a-1][b][c][d]); if (b 0) maxPrev max(maxPrev, dp[a][b-1][c][d]); if (c 0) maxPrev max(maxPrev, dp[a][b][c-1][d]); if (d 0) maxPrev max(maxPrev, dp[a][b][c][d-1]); // 注意当a,b,c,d都为0时maxPrev为0但dp[0][0][0][0]已被初始化这里不会覆盖它。 // 对于其他状态需要加上当前位置的分数。 // 关键点要判断是否是从某个有效前驱转移来的否则不能加分数。 // 实际上只要a,b,c,d不全为0maxPrev一定是从某个已计算的状态取到的。 if (a b c d 0) { // 不是初始状态 dp[a][b][c][d] maxPrev score[pos]; } } } } } cout dp[cnt[1]][cnt[2]][cnt[3]][cnt[4]] endl; return 0; }几个极易出错的细节数组下标与卡片步数的对应在代码中我使用cnt[1]到cnt[4]来计数这很直观。但在计算pos时务必注意乘法因子a*1 b*2 c*3 d*4。我曾因为写成a b c d而调试了很久。初始状态的分数累加dp[0][0][0][0] score[1]是独立的初始化。在四重循环中当(a,b,c,d)为(0,0,0,0)时maxPrev为0如果我们直接执行dp[a][b][c][d] maxPrev score[pos]就会用score[1]覆盖掉已经初始化的score[1]结果虽然一样但逻辑不清晰。更好的做法是加一个判断当不是初始状态时才进行maxPrev score[pos]的赋值。上面的代码通过if (abcd 0)实现了这一点。循环顺序这里采用的四重循环顺序a, b, c, d依次递增是可行的因为任何状态(a,b,c,d)都只依赖于“更小”的状态某个维度的数量减1。这是一种“自底向上”的填表法确保在计算当前状态时其依赖的前驱状态都已经被计算过了。空间优化思路虽然四维数组可以接受但我们也可以考虑用滚动数组优化。注意到状态转移只依赖于a-1, b-1, c-1, d-1我们可以尝试压缩掉一维。但四维压缩起来比较麻烦而且代码可读性会下降。在竞赛中除非内存非常紧张否则清晰易懂的代码更重要。一个折中的优化是使用“记忆化搜索”递归缓存代码结构更贴近状态定义但可能会有栈开销。5. 思维扩展其他状态定义与问题变形虽然四维DP是标准解但理解其他思考角度能极大提升我们对DP的驾驭能力。我们来看看两种不同的思路。思路二一维DP 卡片使用顺序的枚举我们能不能只用“位置”作为状态呢即dp[pos]表示走到位置pos的最大分数。但这样我们无法记录卡片的使用情况会重复使用卡片。一个天真的想法是把卡片的使用顺序也考虑进去但这相当于全排列复杂度是O(M!)不可行。所以纯一维DP在此题是行不通的因为它丢失了关键的“资源使用情况”信息。思路三转化为背包问题有同学可能会想这像不像一种“分组背包”总步数N-1是背包容量每张卡片是一个物品体积是它的步数1,2,3,4价值是……等等这里就卡住了。背包问题的价值通常附着在物品上但本题的价值格子分数是附着在“路径位置”上的。使用不同的卡片组合会走过不同的位置序列从而获得不同的价值和。这与标准背包模型有本质区别。背包模型更适用于“选择一些物品放入背包每个物品有独立价值”而本题是“按顺序使用物品卡片以走过一条路径路径上的点有价值”。所以不能直接套用。问题变形思考 如果规则稍作修改这道题的解法可能就完全不同了这能帮助我们加深对原题解法的理解。变形1每个格子的分数可以重复累加。即每次经过都算分。那么最优策略一定是把高分格子尽可能多地走遍。这可能会变成一个更复杂的规划问题甚至可能用贪心结合DP。变形2卡片不是必须用完。那么状态定义就需要增加一维来表示“当前所在位置”因为卡片没用完位置和卡片使用情况不再有确定关系。状态可能变成dp[pos][a][b][c][d]表示在位置pos剩下a张1步卡……时的最大分数。初始状态是dp[1][cnt1][cnt2][cnt3][cnt4] score[1]然后进行转移。变形3卡片种类更多比如1到10。四维DP的维度会爆炸。这时需要换一种状态定义比如dp[i][j]表示总共使用了i张卡片并且这些卡片的总步数为j时的最大分数。但这需要知道每种卡片的数量限制可能又需要结合多维背包的思想。这说明了原题将卡片种类限定为4种且数量不多正是为了引导出四维DP这个精巧而可行的解法。6. 实战调试与常见“坑点”复盘即便思路正确实现时也可能掉进坑里。下面是我在多次提交中遇到或看到别人常犯的错误。坑点1数组越界这是最经典的错误。计算出的current_pos可能大于N。虽然在有解且循环逻辑正确的情况下不会发生但一旦测试数据有误比如卡片总和导致位置超出N程序就会崩溃。所以if (pos N) continue;这行防御性代码建议加上。更隐蔽的越界是DP数组访问dp[a-1][b][c][d]时要确保a0其他维度同理。上面的代码通过if (a0)等判断规避了。坑点2分数累加错误最容易出错的就是起点分数的初始化与后续状态的分数累加关系。一定要明确初始状态(0,0,0,0)对应位置1分数是score[1]。对于其他状态(a,b,c,d)(不全为0)它的分数 max(前驱状态分数) score[current_pos]。每个格子的分数只加一次。这个条件在我们的状态转移中自动满足了因为我们的状态dp[a][b][c][d]定义的是“使用这么多卡片后”的最大分数这个分数已经包含了从起点到当前位置所有不同格子的分数和。由于我们的移动是单向向前的卡片步数正数并且状态精确记录了卡片使用组合因此路径是唯一的不会重复经过同一个格子。坑点3整数溢出题目中分数和N、M都可能比较大最大分数可能会超过int的表示范围约21亿。虽然很多OJ的数据可能没到那么大但为了安全使用long long类型来定义DP数组和分数数组是更稳妥的做法。特别是在比赛时如果没把握无脑用long long可以避免这类失分。坑点4输入与初始化别忘了棋盘格子编号是从1到N。在读取score数组时循环要从1开始。DP数组的初始化除了dp[0][0][0][0]其他状态应该设为一个很小的值比如 -1e9表示不可达还是像上面代码那样通过判断(abcd0)来区分两种方式都可以。前者是更通用的DP初始化方式设-INF后者更简洁。但要注意如果设-INF在状态转移时只有当前驱状态可达值 -INF时才进行转移。调试技巧 当程序结果不对时不要急着看整个大数据。构造一个小数据比如N5, M2卡片是[1,2]分数是[1,2,3,4,5]。然后手动模拟DP过程或者打印出整个DP表对于四维表可以固定其中两维打印一个二维切片对比你的程序计算结果和手动计算的结果很快就能定位问题所在。7. 算法复杂度分析与适用场景总结最后我们来分析一下这个算法的效率并总结一下这类问题的识别特征。时间复杂度我们的算法核心是四重循环分别遍历四种卡片的使用数量。设四种卡片的数量上限分别为A, B, C, D。则循环次数为 O(A * B * C * D)。在本题常见数据范围下每种卡片最多40张最坏情况约为40*40*40*40 2,560,000次循环每次循环内部是常数时间的比较和加法操作完全在1秒内可完成。空间复杂度主要就是四维DP数组O(A * B * C * D)。同样在百万级别是可行的。那么什么样的问题适合用这种“多维计数DP”来解决呢我认为有几个关键特征有若干种有限的“资源”或“操作”比如本题的四种步数卡片。每种资源有数量限制。这些资源的“使用顺序”不影响某些关键条件但影响最终结果在本题中使用卡片的顺序影响了走过的路径从而影响分数但题目没有对顺序本身做额外限制。我们的状态定义用了多少张A卡、多少张B卡…巧妙地规避了对具体顺序的枚举只关心每种卡片的消耗总量。存在一个与资源使用总量线性相关的“进度”指标在本题中位置pos 1 sum(卡片步数 * 卡片使用数量)。这个指标使得我们可以从资源使用情况唯一确定当前进度。目标是优化一个与“路径”或“序列”相关的值如最大分数、最小成本等。遇到同时具备这些特征的问题就可以考虑是否能用类似的多维状态DP将“顺序”信息压缩为“计数”信息从而大幅降低复杂度。回过头看“P1541 乌龟棋”它确实是一道训练DP思维的好题。它教会我们有时候最直观的状态维度如位置并不是最高效的通过挖掘题目中隐含的等量关系位置与卡片使用总数的关系可以设计出更简洁、更高效的状态表示。这种“消除冗余”的思想在动态规划乃至整个算法设计中都非常重要。下次再遇到类似有多种操作、操作次数有限、且操作效果可累加的问题时不妨先想想能不能用这种“计数”来代替“序列”进行状态定义。
返回列表