资讯动态

动态规划进阶:方格取数问题中按列DP与两次扫描的解法详解

发布时间:2026/8/12 18:29:24 来源:尧图企业网站定制
1. 项目概述与问题拆解今天我们来啃一块信奥信息学奥林匹克动态规划里的硬骨头——方格取数问题。具体来说是题目 B4140 [信息与未来 2016] 方格取数。很多刚接触动态规划的同学一看到“方格”、“路径”、“最大值”这些词可能下意识地想到经典的“只能向右或向下走”的模型觉得套个模板改改就能过。但如果你真这么想那这道题大概率会让你栽跟头。它看起来亲切实则暗藏玄机对状态定义和转移逻辑的严谨性要求非常高是区分“背模板选手”和“真正理解DP选手”的一道典型题目。我们先抛开具体题号把问题本质抽离出来你有一个n行m列的网格每个格子里有一个整数可能是正数、负数或零。你从左上角(1, 1)出发要走到右下角(n, m)。每一步你可以向上、向下或向右走一格。这里的关键限制是不能重复经过已经走过的方格并且不能走出网格边界。你的目标是找到一条路径使得路径上经过的所有格子里的整数之和最大并输出这个最大值。为什么这个问题比经典的“只能向右下走”要复杂得多核心在于“可以向上走”这个操作。在经典模型中由于只能向右或向下路径的“方向性”非常强不会走回头路因此我们可以用dp[i][j]表示从起点走到(i, j)的最大和状态转移只来自于左边和上边逻辑清晰。但一旦允许向上走路径就可能出现“折返”、“绕路”的情况比如先向右走几步再向下走然后又向上走回某一行。这直接破坏了传统DP的“无后效性”假设——dp[i][j]的值不仅可能来自左边和上边还可能来自下边而这个“下边”的状态dp[i1][j]本身可能又依赖于dp[i][j]这就形成了循环依赖用简单的二维DP无法直接处理。因此解决这道题的核心思路不再是简单的二维坐标DP而是需要引入方向和阶段的概念将问题转化为按列进行状态转移。这也是解决此类“可上下右移动”的方格取数问题的标准思路。接下来我们就用C一步步拆解这个思路并实现最终的高效解法。2. 核心思路按列DP与状态设计要破解“可以上下移动”带来的后效性问题我们必须改变思考的角度。既然可以向上、向下、向右但不能向左那么一个非常关键的观察是在到达某一列之后你永远无法再回到左边的列。也就是说“列坐标”j是单调不减的。这为我们提供了一个天然的“阶段”划分依据按列推进。我们可以定义状态dp[i][j]表示从起点(1, 1)出发到达第i行第j列这个格子时所能获得的最大整数和。注意这个定义本身并没有解决后效性因为计算dp[i][j]时可能需要用到同一列j但不同行k的状态dp[k][j]而这些状态之间可能因为上下移动而相互依赖。正确的做法是将到达(i, j)的路径根据其进入该格子的方向进行分类。对于一个格子(i, j)你只可能从三个方向过来正左方(i, j-1)、上方(i-1, j)、下方(i1, j)。但是从上方或下方过来意味着你是在同一列j内进行上下移动。这启发我们可以将到达(i, j)这个事件拆分成两个子问题从左边列j-1的某个格子一次性移动到(i, j)。在列j内部通过上下移动从列j的某个其他格子(k, j)移动到(i, j)。因此更高效的状态设计是进行两次扫描或者说用两个DP数组或一个数组的两次更新来共同决定最终的状态值。状态定义我们定义一个二维数组f[i][j]其含义与之前的dp[i][j]一致从起点到达(i, j)的最大和。但它的值不是一步计算出来的而是通过两次独立的“更新”过程合成的。更新策略从左边更新横向转移对于当前列j的每一行i我们首先考虑直接从左边列j-1走过来。即f[i][j]的初始候选值可以是f[i][j-1] a[i][j]。这代表了路径在列j-1时就在第i行然后直接向右一步进入(i, j)。从上往下扫描更新纵向转移-向下在列j内部我们允许从上往下走。这意味着对于第i行除了直接从左边来还可能从本列j的上一行i-1走下来。因此我们从上到下i从 2 到n扫描更新f[i][j] max(f[i][j], f[i-1][j] a[i][j])。这个操作的含义是“如果从起点走到(i-1, j)能得到更大的和那么从那里再向下走一格到(i, j)可能会得到比当前f[i][j]更优的解”。从下往上扫描更新纵向转移-向上同理在列j内部我们也允许从下往上走。因此我们从下到上i从n-1到 1扫描更新f[i][j] max(f[i][j], f[i1][j] a[i][j])。这个操作的含义是“如果从起点走到(i1, j)能得到更大的和那么从那里再向上走一格到(i, j)可能会得到比当前f[i][j]更优的解”。为什么需要两次纵向扫描考虑一个简单的例子列j的格子值分别为[10, -100, 20]。假设从左边列到达这三行的初始f值都是0。如果只做从上到下扫描f[1][j]更新为10f[2][j]会从f[1][j]更新为10 (-100) -90f[3][j]会从f[2][j]更新为-90 20 -70。这错过了直接从左边进入第3行得到20的可能性。如果只做从下到上扫描f[3][j]更新为20f[2][j]会从f[3][j]更新为20 (-100) -80f[1][j]会从f[2][j]更新为-80 10 -70。这错过了直接从左边进入第1行得到10的可能性。如果结合两次扫描首先f[1][j],f[2][j],f[3][j]都先被初始化为从左边来的值假设为0格子值。然后从上到下扫描f[2][j]可能被更新如果f[1][j]更大f[3][j]可能被更新如果f[2][j]更大。接着从下到上扫描f[2][j]可能被再次更新如果f[3][j]更大f[1][j]可能被更新如果f[2][j]更大。通过这两次“拉扯”f[i][j]最终存储的值代表了从左边列进入第j列后在列内通过任意方式可以上下反复走但根据我们的扫描方式等价于找到一条从进入点走到(i, j)的最佳路径所能达到的最大和。初始化与答案起点(1, 1)是唯一的入口所以f[1][1]应初始化为a[1][1]。对于其他格子初始状态可以设为一个非常小的负数比如-1e18表示尚未可达。最终答案就是f[n][m]即到达右下角的最大和。这个算法的核心思想是将“在网格中寻找路径”的问题转化为了“按列进行动态规划并在每一列内部通过两次扫描来结算该列所有位置的最优值”的问题。时间复杂度为O(n * m)在n, m 1000的数据范围内完全可行。3. 算法实现细节与C代码理解了核心思路后我们来看具体的代码实现。这里有几个关键的细节需要处理否则很容易出错。3.1 数据结构与初始化首先我们需要存储网格的值。题目中n, m最大为10^3网格值绝对值不超过10^4。路径最大和可能达到10^3 * 10^3 * 10^4 10^10这在int型约2e9范围内可能会溢出因此必须使用long long类型来存储DP状态和结果。初始化时除了f[1][1]其他位置都应初始化为一个“负无穷”的值表示不可达。这是因为网格中的值可能为负数如果初始化为0那么算法可能会错误地认为从一个不可达的状态值为负无穷转移过来是可行的因为max(负无穷, 某个值)可能会得到那个值。在C中我们可以用LLONG_MIN/2或者一个绝对值很大的负数如-1e18来模拟负无穷。#include iostream #include vector #include climits using namespace std; int main() { int n, m; cin n m; // 读取网格下标从1开始方便处理边界 vectorvectorint a(n 1, vectorint(m 1, 0)); for (int i 1; i n; i) { for (int j 1; j m; j) { cin a[i][j]; } } // DP数组f[i][j] 表示到达(i,j)的最大和初始为负无穷 const long long INF_NEG -1e18; vectorvectorlong long f(n 2, vectorlong long(m 2, INF_NEG)); // 初始化起点 f[1][1] a[1][1];3.2 动态规划转移过程接下来是核心的三重循环结构。外层循环遍历列j内层处理行i。注意对于第一列j1我们只能从起点开始无法从“左边”转移所以需要特殊处理或者我们的转移逻辑能兼容这种情况。更清晰的做法是外层循环从j 1到m。对于每一列j我们按顺序执行三个步骤从左边转移如果j 1对于该列每一行i尝试用f[i][j-1] a[i][j]来更新f[i][j]。从上到下扫描对于i从2到n尝试用f[i-1][j] a[i][j]来更新f[i][j]。从下到上扫描对于i从n-1到1尝试用f[i1][j] a[i][j]来更新f[i][j]。这里有一个非常重要的顺序问题必须先进行“从左边转移”再进行两次纵向扫描。因为纵向扫描是基于“已经考虑了从左边进入本列”这个前提的它处理的是在本列内部的移动。如果顺序错了逻辑就混乱了。此外对于j1的第一列“从左边转移”这一步实际上没有意义因为没有第0列但我们的算法中f[i][1]在初始化时只有f[1][1]有值其他都是负无穷。接下来的两次纵向扫描会基于f[1][1]将第一列其他位置的值“传播”开如果路径允许。这恰好模拟了从起点开始在第一列内上下移动的情况。// 动态规划转移 for (int j 1; j m; j) { // 第一步从左边一列转移过来 (横向) if (j 1) { // 第一列没有左边一列 for (int i 1; i n; i) { if (f[i][j-1] ! INF_NEG) { // 如果左边位置可达 f[i][j] max(f[i][j], f[i][j-1] a[i][j]); } } } // 第二步在当列内部从上往下走 (纵向-向下) for (int i 2; i n; i) { if (f[i-1][j] ! INF_NEG) { // 如果上方位置可达 f[i][j] max(f[i][j], f[i-1][j] a[i][j]); } } // 第三步在当列内部从下往上走 (纵向-向上) for (int i n-1; i 1; --i) { if (f[i1][j] ! INF_NEG) { // 如果下方位置可达 f[i][j] max(f[i][j], f[i1][j] a[i][j]); } } }3.3 代码整合与输出将以上部分整合并输出最终结果f[n][m]。注意如果f[n][m]仍然是初始的负无穷理论上在本题约束下从左上到右下总有路径不会发生但为了代码健壮性可以判断一下。// 输出结果 cout f[n][m] endl; return 0; }完整的C代码实现如下#include iostream #include vector #include climits using namespace std; int main() { int n, m; cin n m; // 读取网格 vectorvectorint a(n 1, vectorint(m 1, 0)); for (int i 1; i n; i) { for (int j 1; j m; j) { cin a[i][j]; } } // DP数组初始化 const long long INF_NEG -1e18; vectorvectorlong long f(n 2, vectorlong long(m 2, INF_NEG)); f[1][1] a[1][1]; // 动态规划转移 for (int j 1; j m; j) { // 横向转移从左边列过来 if (j 1) { for (int i 1; i n; i) { if (f[i][j-1] ! INF_NEG) { f[i][j] max(f[i][j], f[i][j-1] a[i][j]); } } } // 纵向转移向下在当列内从上往下走 for (int i 2; i n; i) { if (f[i-1][j] ! INF_NEG) { f[i][j] max(f[i][j], f[i-1][j] a[i][j]); } } // 纵向转移向上在当列内从下往上走 for (int i n-1; i 1; --i) { if (f[i1][j] ! INF_NEG) { f[i][j] max(f[i][j], f[i1][j] a[i][j]); } } } cout f[n][m] endl; return 0; }4. 算法正确性分析与复杂度4.1 为什么这个算法是正确的我们需要证明通过“横向转移 两次纵向扫描”得到的结果f[i][j]确实代表了从起点(1,1)到(i,j)的所有合法路径中的最大和。状态定义f[i][j]表示到达(i, j)的最大和。这个定义是完备的覆盖了所有目标。无后效性我们按列j从小到大计算。在计算第j列的状态时第j-1列的状态已经完全确定因为j-1 j。而第j列内部的状态通过两次方向相反的扫描确保了每个f[i][j]都充分考虑了从本列上方和下方转移过来的可能性。由于扫描是单向的先上到下再下到上不会出现循环依赖。你可以这样理解第一次从上到下扫描处理了所有“先向上再向下”的路径段中终点在下面的情况第二次从下到上扫描处理了所有“先向下再向上”的路径段中终点在上面的情况。两次结合覆盖了在列内任意移动的情况。最优子结构假设到达(i, j)的最优路径是P。考虑路径P进入第j列的那个格子(k, j)k可能与i相同。那么路径P在列j之前的部分必然是到达(k, j-1)的一条最优路径否则可以替换成更优的使得P更优矛盾。而路径P在列j内部从(k, j)到(i, j)的部分可以看作是在第j列内的一条垂直移动路径。我们的算法通过“横向转移”计算了所有可能的f[k][j]基于f[k][j-1]再通过两次纵向扫描计算了从任意(k, j)到任意(i, j)在列内移动所能获得的最大增益。因此f[i][j]必然包含了路径P对应的和。4.2 时间复杂度与空间复杂度时间复杂度外层循环m列内层有三个O(n)的循环横向转移、向下扫描、向上扫描。因此总时间复杂度为O(m * (n n n)) O(3 * n * m)即O(n * m)。对于n, m 1000计算量在10^6级别完全可以在1秒内完成。空间复杂度我们使用了两个(n2) * (m2)的二维数组一个存原始数据a一个存DP状态f。空间复杂度为O(n * m)。如果对空间有极致要求可以观察到在计算第j列时只用到第j-1列的状态因此可以用滚动数组优化到O(n)。但本题数据范围下O(n*m)的空间约1000*1000*8字节 ≈ 8MB完全可以接受代码可读性更重要。4.3 边界条件处理我们的代码通过将数组维度定义为n2和m2并让下标从1开始巧妙地避免了判断i-1,i1,j-1是否越界。在纵向扫描时循环i从2到n向下和从n-1到1向上自然避开了对边界外元素的访问。在横向转移时通过if (j 1)判断避免了访问第0列。这是一种简洁有效的边界处理方法。5. 常见错误与调试技巧即便理解了算法实现时也容易踩坑。下面罗列几个常见的错误点及其解决方法。5.1 错误使用int类型导致溢出这是最容易犯的错误。假设nm1000每个格子都是最大值10^4那么路径和最大可能是10^3 * 10^3 * 10^4 10^10这已经超过了32位有符号整数int的最大值约2.1*10^9。在计算过程中中间状态也可能很大。解决方法DP数组f和与和相关的中间变量务必使用long long类型。5.2 错误初始化不当如果将f数组初始化为0会有什么问题考虑一个全是负数的网格。正确的最大和应该是一个负数。但如果初始化为0在状态转移max(f[i][j], f[i][j-1] a[i][j])时即使f[i][j-1]是负无穷不可达0也可能比一个很大的负数负无穷负数在计算机中可能是一个特殊值或未定义行为但通常max比较时0会更大要大导致算法错误地认为存在一条和为0的路径实际上可能根本不存在从起点到该点的路径。解决方法将不可达状态初始化为一个足够小的负数如-1e18。在比较和更新时只有当来源状态! INF_NEG时才进行转移这代表了“只有从可达状态出发的转移才是有效的”。5.3 错误转移顺序错误如果先进行纵向扫描再进行横向转移逻辑就错了。因为纵向扫描处理的是在同一列内的移动它的前提是已经有一个“进入该列”的初始值来自左边列的转移结果。如果先纵向扫描那么用来更新的f[i-1][j]或f[i1][j]可能还是初始的负无穷或者是一个基于本列其他错误初始值计算出的值无法得到正确结果。解决方法严格遵循“横向转移 - 从上到下扫描 - 从下到上扫描”的顺序。这个顺序对于每一列都是固定的。5.4 错误忽略起点初始化忘记将f[1][1]初始化为a[1][1]。这样整个DP过程就没有起点了所有状态都无法从起点转移过来最终结果会是初始的负无穷。解决方法在DP循环开始前务必显式初始化f[1][1] a[1][1]。5.5 调试技巧小数据测试自己构造一些小的网格比如2x2, 3x3手工计算最大和然后与程序输出对比。这是最有效的调试方法。打印DP表在每列DP完成后打印出整个f数组观察状态值的变化是否符合预期。这能帮你发现是哪个环节的转移出了问题。单步跟踪对于复杂的逻辑可以在关键位置设置断点单步执行观察变量值。测试边界数据全正数网格结果应为所有数之和如果路径能覆盖所有格子不路径不能重复所以不是简单求和但可以构造一个全正数网格测试。全负数网格结果应为一条路径上的负数之和理论上应该是一个负数。测试你的程序是否能正确处理不会输出0或正数。n1或m1的网格退化为一维数组只能向右走如果只有一行或只能向下走如果只有一列。检查结果是否正确。6. 算法优化与变种思考6.1 空间优化滚动数组如前所述计算第j列的状态f[1..n][j]时只依赖于第j-1列的状态f[1..n][j-1]。因此我们可以只保留两列的状态交替使用。vectorvectorlong long f(2, vectorlong long(n 2, INF_NEG)); // 只保留两行代表两列 int prev 0, curr 1; f[prev][1] a[1][1]; // 初始化prev对应第1列 for (int j 1; j m; j) { // 横向转移从prev列j-1转移到curr列j if (j 1) { for (int i 1; i n; i) { f[curr][i] (f[prev][i] ! INF_NEG) ? f[prev][i] a[i][j] : INF_NEG; } } else { // j1时只有起点可达 for (int i 1; i n; i) f[curr][i] INF_NEG; f[curr][1] a[1][1]; } // 纵向转移向下 for (int i 2; i n; i) { if (f[curr][i-1] ! INF_NEG) { f[curr][i] max(f[curr][i], f[curr][i-1] a[i][j]); } } // 纵向转移向上 for (int i n-1; i 1; --i) { if (f[curr][i1] ! INF_NEG) { f[curr][i] max(f[curr][i], f[curr][i1] a[i][j]); } } // 交换prev和curr为下一列做准备 swap(prev, curr); } // 最终答案在 f[prev][n] 注意循环结束时prev指向的是最后一列计算完成后的“当前”列索引。 // 更稳妥的方式在循环内部curr始终代表正在计算的第j列。循环结束后最后一列的结果在 f[curr][n] 吗 // 因为最后执行了swap所以需要根据循环结束时的状态确定。一个清晰的做法是循环结束后答案在 f[prev][n]。 cout f[prev][n] endl;滚动数组将空间复杂度从O(n*m)降到了O(n)在处理更大规模的网格时非常有用。但代码逻辑会变得稍微复杂一些需要仔细管理prev和curr指针。6.2 变种问题最小和路径如果题目改为求“最小整数和”只需将状态转移中的max改为min并将不可达状态的初始值INF_NEG改为一个很大的正数INF_POS如1e18同时将f[1][1]初始化为a[1][1]即可。算法框架完全不变。6.3 变种问题路径记录如果需要输出具体路径而不仅仅是最大和我们可以在DP的同时用另一个数组pre[i][j]记录到达(i, j)取得最大和时是从哪个格子转移过来的例如用一个三元组(from_i, from_j, direction)。在状态更新时如果发生了f[i][j]的更新就同步更新pre[i][j]。DP结束后从终点(n, m)根据pre数组回溯到起点即可得到路径。注意由于我们进行了三次更新左、上、下pre需要记录具体是哪一次更新导致了最终的最优值。6.4 与经典“方格取数”问题的对比经典的“方格取数”问题如NOIP 2000提高组通常是两条路径同时走且只能向右或向下。其状态通常设计为dp[i][j][k][l]或优化后的dp[k][i][j]表示两条路径分别走到(i, k-i)和(j, k-j)时的最大和。这与本题“单路径、可上下右”的模型有本质区别状态设计和转移方程完全不同不要混淆。7. 总结与心得这道 B4140 方格取数题目是动态规划学习中一个非常好的进阶案例。它打破了我们对网格DP的简单认知引入了“列阶段”和“两次扫描”的思想。解决这类问题的关键在于识别出“列坐标单调不减”这一特性从而将二维的路径问题分解为多个一维的、列内最优子问题。在实现时务必注意数据类型的选取long long、不可达状态的初始化负无穷、以及严格的转移顺序先横后纵纵向上先下后上或先上后下均可但必须两次方向相反。多用手工小数据验证是调试此类逻辑复杂DP的不二法门。最后这种“按列DP两次扫描”的思路不仅适用于本题还可以解决一系列类似的“可上下右移动”的网格路径问题是一个值得掌握的通用技巧。希望这篇详细的拆解能帮助你彻底理解这个问题并在未来的信奥刷题路上更加游刃有余。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价