三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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

1. 项目概述与问题拆解

今天我们来啃一块信奥(信息学奥林匹克)动态规划里的硬骨头——方格取数问题。具体来说,是题目 B4140 [信息与未来 2016] 方格取数。很多刚接触动态规划的同学,一看到“方格”、“路径”、“最大值”这些词,可能下意识地想到经典的“只能向右或向下走”的模型,觉得套个模板改改就能过。但如果你真这么想,那这道题大概率会让你栽跟头。它看起来亲切,实则暗藏玄机,对状态定义和转移逻辑的严谨性要求非常高,是区分“背模板选手”和“真正理解DP选手”的一道典型题目。

我们先抛开具体题号,把问题本质抽离出来:你有一个nm列的网格,每个格子里有一个整数(可能是正数、负数或零)。你从左上角(1, 1)出发,要走到右下角(n, m)。每一步,你可以向上、向下或向右走一格。这里的关键限制是:不能重复经过已经走过的方格,并且不能走出网格边界。你的目标是,找到一条路径,使得路径上经过的所有格子里的整数之和最大,并输出这个最大值。

为什么这个问题比经典的“只能向右下走”要复杂得多?核心在于“可以向上走”这个操作。在经典模型中,由于只能向右或向下,路径的“方向性”非常强,不会走回头路,因此我们可以用dp[i][j]表示从起点走到(i, j)的最大和,状态转移只来自于左边和上边,逻辑清晰。但一旦允许向上走,路径就可能出现“折返”、“绕路”的情况,比如先向右走几步,再向下走,然后又向上走回某一行。这直接破坏了传统DP的“无后效性”假设——dp[i][j]的值不仅可能来自左边和上边,还可能来自下边,而这个“下边”的状态dp[i+1][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)、下方(i+1, j)。但是,从上方或下方过来,意味着你是在同一列j内进行上下移动。这启发我们,可以将到达(i, j)这个事件,拆分成两个子问题:

  1. 从左边列j-1的某个格子,一次性移动到(i, j)
  2. 在列j内部,通过上下移动,从列j的某个其他格子(k, j)移动到(i, j)

因此,更高效的状态设计是进行两次扫描,或者说,用两个DP数组(或一个数组的两次更新)来共同决定最终的状态值。

状态定义:我们定义一个二维数组f[i][j],其含义与之前的dp[i][j]一致:从起点到达(i, j)的最大和。但它的值不是一步计算出来的,而是通过两次独立的“更新”过程合成的。

更新策略:

  1. 从左边更新(横向转移):对于当前列j的每一行i,我们首先考虑直接从左边列j-1走过来。即f[i][j]的初始候选值可以是f[i][j-1] + a[i][j]。这代表了路径在列j-1时就在第i行,然后直接向右一步进入(i, j)
  2. 从上往下扫描更新(纵向转移-向下):在列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]更优的解”。
  3. 从下往上扫描更新(纵向转移-向上):同理,在列j内部,我们也允许从下往上走。因此,我们从下到上(in-1到 1)扫描,更新f[i][j] = max(f[i][j], f[i+1][j] + a[i][j])。这个操作的含义是:“如果从起点走到(i+1, j)能得到更大的和,那么从那里再向上走一格到(i, j),可能会得到比当前f[i][j]更优的解”。

为什么需要两次纵向扫描?考虑一个简单的例子:列j的格子值分别为[10, -100, 20]。假设从左边列到达这三行的初始f值都是0。

  • 如果只做从上到下扫描:f[1][j]更新为10,f[2][j]会从f[1][j]更新为10 + (-100) = -90f[3][j]会从f[2][j]更新为-90 + 20 = -70。这错过了直接从左边进入第3行得到20的可能性。
  • 如果只做从下到上扫描:f[3][j]更新为20,f[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开始方便处理边界 vector<vector<int>> a(n + 1, vector<int>(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; vector<vector<long long>> f(n + 2, vector<long long>(m + 2, INF_NEG)); // 初始化起点 f[1][1] = a[1][1];

3.2 动态规划转移过程

接下来是核心的三重循环结构。外层循环遍历列j,内层处理行i。注意,对于第一列j=1,我们只能从起点开始,无法从“左边”转移,所以需要特殊处理,或者我们的转移逻辑能兼容这种情况。

更清晰的做法是:外层循环从j = 1m。对于每一列j,我们按顺序执行三个步骤:

  1. 从左边转移(如果j > 1):对于该列每一行i,尝试用f[i][j-1] + a[i][j]来更新f[i][j]
  2. 从上到下扫描:对于i2n,尝试用f[i-1][j] + a[i][j]来更新f[i][j]
  3. 从下到上扫描:对于in-11,尝试用f[i+1][j] + a[i][j]来更新f[i][j]

这里有一个非常重要的顺序问题:必须先进行“从左边转移”,再进行两次纵向扫描。因为纵向扫描是基于“已经考虑了从左边进入本列”这个前提的,它处理的是在本列内部的移动。如果顺序错了,逻辑就混乱了。

此外,对于j=1的第一列,“从左边转移”这一步实际上没有意义(因为没有第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[i+1][j] != INF_NEG) { // 如果下方位置可达 f[i][j] = max(f[i][j], f[i+1][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; // 读取网格 vector<vector<int>> a(n + 1, vector<int>(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; vector<vector<long long>> f(n + 2, vector<long 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[i+1][j] != INF_NEG) { f[i][j] = max(f[i][j], f[i+1][j] + a[i][j]); } } } cout << f[n][m] << endl; return 0; }

4. 算法正确性分析与复杂度

4.1 为什么这个算法是正确的?

我们需要证明,通过“横向转移 + 两次纵向扫描”得到的结果f[i][j],确实代表了从起点(1,1)(i,j)的所有合法路径中的最大和。

  1. 状态定义f[i][j]表示到达(i, j)的最大和。这个定义是完备的,覆盖了所有目标。
  2. 无后效性:我们按列j从小到大计算。在计算第j列的状态时,第j-1列的状态已经完全确定(因为j-1 < j)。而第j列内部的状态,通过两次方向相反的扫描,确保了每个f[i][j]都充分考虑了从本列上方和下方转移过来的可能性。由于扫描是单向的(先上到下,再下到上),不会出现循环依赖。你可以这样理解:第一次从上到下扫描,处理了所有“先向上再向下”的路径段中,终点在下面的情况;第二次从下到上扫描,处理了所有“先向下再向上”的路径段中,终点在上面的情况。两次结合,覆盖了在列内任意移动的情况。
  3. 最优子结构:假设到达(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秒内完成。
  • 空间复杂度:我们使用了两个(n+2) * (m+2)的二维数组,一个存原始数据a,一个存DP状态f。空间复杂度为O(n * m)。如果对空间有极致要求,可以观察到,在计算第j列时,只用到第j-1列的状态,因此可以用滚动数组优化到O(n)。但本题数据范围下,O(n*m)的空间(约1000*1000*8字节 ≈ 8MB)完全可以接受,代码可读性更重要。

4.3 边界条件处理

我们的代码通过将数组维度定义为n+2m+2,并让下标从1开始,巧妙地避免了判断i-1,i+1,j-1是否越界。在纵向扫描时,循环i2n(向下)和从n-11(向上),自然避开了对边界外元素的访问。在横向转移时,通过if (j > 1)判断,避免了访问第0列。这是一种简洁有效的边界处理方法。

5. 常见错误与调试技巧

即便理解了算法,实现时也容易踩坑。下面罗列几个常见的错误点及其解决方法。

5.1 错误:使用int类型导致溢出

这是最容易犯的错误。假设n=m=1000,每个格子都是最大值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[i+1][j]可能还是初始的负无穷,或者是一个基于本列其他错误初始值计算出的值,无法得到正确结果。

解决方法:严格遵循“横向转移 -> 从上到下扫描 -> 从下到上扫描”的顺序。这个顺序对于每一列都是固定的。

5.4 错误:忽略起点初始化

忘记将f[1][1]初始化为a[1][1]。这样整个DP过程就没有起点了,所有状态都无法从起点转移过来,最终结果会是初始的负无穷。

解决方法:在DP循环开始前,务必显式初始化f[1][1] = a[1][1]

5.5 调试技巧

  1. 小数据测试:自己构造一些小的网格(比如2x2, 3x3),手工计算最大和,然后与程序输出对比。这是最有效的调试方法。
  2. 打印DP表:在每列DP完成后,打印出整个f数组,观察状态值的变化是否符合预期。这能帮你发现是哪个环节的转移出了问题。
  3. 单步跟踪:对于复杂的逻辑,可以在关键位置设置断点,单步执行,观察变量值。
  4. 测试边界数据
    • 全正数网格:结果应为所有数之和(如果路径能覆盖所有格子?不,路径不能重复,所以不是简单求和,但可以构造一个全正数网格测试)。
    • 全负数网格:结果应为一条路径上的负数之和,理论上应该是一个负数。测试你的程序是否能正确处理(不会输出0或正数)。
    • n=1m=1的网格:退化为一维数组,只能向右走(如果只有一行)或只能向下走(如果只有一列)。检查结果是否正确。

6. 算法优化与变种思考

6.1 空间优化(滚动数组)

如前所述,计算第j列的状态f[1..n][j]时,只依赖于第j-1列的状态f[1..n][j-1]。因此,我们可以只保留两列的状态,交替使用。

vector<vector<long long>> f(2, vector<long 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 { // j==1时,只有起点可达 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][i+1] != INF_NEG) { f[curr][i] = max(f[curr][i], f[curr][i+1] + 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),在处理更大规模的网格时非常有用。但代码逻辑会变得稍微复杂一些,需要仔细管理prevcurr指针。

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+两次扫描”的思路,不仅适用于本题,还可以解决一系列类似的“可上下右移动”的网格路径问题,是一个值得掌握的通用技巧。希望这篇详细的拆解,能帮助你彻底理解这个问题,并在未来的信奥刷题路上,更加游刃有余。

← 返回列表