
1. 项目概述从“摘花生”到“方格取数”的DP进阶之路如果你已经刷过“摘花生”和“最低通行费”这类经典的数字三角形模型题目感觉动态规划DP不过如此那么“方格取数”这道题可能会给你带来一些全新的挑战和乐趣。这道题常常是许多朋友在掌握了基础线性DP后遇到的第一个“小坎儿”——它不再是简单的单向、单次路径最优问题而是引入了“两条路径同时走”和“数字不能重复取”的核心约束。我第一次做这道题时也曾卡在如何表示状态、如何处理路径交叉导致的重计数问题上折腾了好一阵子才理清思路。简单来说题目给你一个N*N的方格矩阵每个格子里有一个正整数。现在要求你从左上角(1,1)出发走到右下角(N,N)一共走两次。每次只能向右或向下移动。当你经过一个格子时就可以取走其中的数字但同一个格子里的数字只能被取一次即如果两条路径经过了同一个格子该格子的数字只被计算一次奖励。我们的目标是设计两条行走路径使得最终取到的数字总和最大。这就不再是简单的“再来一遍”的问题了。你不能先走一遍最优路径清空格子再走第二遍因为第一次的走法会直接影响第二次可用的“地图”。这迫使我们必须将两条路径的推进视为一个协同的整体过程来思考这正是本题建模的精妙之处也是DP思想从一维决策向多维协同决策的一次漂亮跃迁。接下来我们就一起拆解这个模型并用C实现它。2. 核心思路拆解为什么是四维状态面对这个问题最直接的暴力想法是枚举所有可能的两条路径组合。但显然路径数量是组合爆炸级别的不可行。DP的核心在于寻找最优子结构和状态定义。我们先回顾最基础的单路径“摘花生”问题。它的状态定义非常直观dp[i][j]表示从(1,1)走到(i,j)所能获得的最大花生数。状态转移来自上方和左方dp[i][j] max(dp[i-1][j], dp[i][j-1]) w[i][j]。现在问题变成了两条路径。一个自然的想法是我们能不能定义两个独立的状态数组dp1和dp2分别计算两条路径的最优值然后想办法合并这个思路的致命缺陷在于两条路径不是独立的它们在格子数字的获取上存在耦合冲突。先算dp1会破坏dp2的环境假设反之亦然。因此我们必须将两条路径的“进度”同时纳入一个状态中。既然每条路径由其当前所在的坐标(i, j)决定那么两条路径的状态自然就是两个坐标的组合。这就是本题状态定义的关键用dp[i1][j1][i2][j2]来表示第一条路径走到(i1, j1)同时第二条路径走到(i2, j2)时已经获得的数字最大和。这里有一个非常重要的优化和理解点。两条路径是同时从(1,1)出发每次各走一步向右或向下。假设总步数为k从起点开始移动的次数。那么对于第一条路径有i1 j1 k 2因为起点是(1,1)。同理对于第二条路径有i2 j2 k 2。这意味着i1 j1恒等于i2 j2它们总是同步的。这个发现让我们可以将四维状态优化为三维状态。我们设总步数k i1 j1 - 2 i2 j2 - 2。那么状态可以定义为dp[k][i1][i2]表示两条路径都走了k步第一条路径当前在第i1行第二条路径当前在第i2行时获得的最大数字和。 此时第一条路径的列坐标j1可以通过k - i1 2计算出来因为i1j1 k2第二条路径的列坐标j2同理为k - i2 2。这样我们通过步数k和行坐标i1,i2唯一地确定了两条路径的完整坐标成功将状态从四维O(N^4)降低到了三维O(N^3)在N10的经典题目范围内是完全可行的。注意这个“步数同步”的优化是此类“同步移动”双路径问题的通用技巧。它背后的原理是在每一步两个决策对象这里是两条路径都在向前推进它们的“进度指标”这里是横纵坐标之和是保持一致的。识别并利用这种一致性是降低状态维度的关键。3. 状态转移方程与数字获取逻辑定义了状态dp[k][i1][i2]之后我们来推导状态转移方程。在每一步总步数为k时每条路径都有两种可能的来向从上方下来i-1或从左方过来j-1。由于有两条路径所以上一步总步数为k-1时的状态组合有四种情况第一条路径从上方来第二条路径从上方来。 (i1-1, i2-1)第一条路径从上方来第二条路径从左方来。 (i1-1, i2)第一条路径从左方来第二条路径从上方来。 (i1, i2-1)第一条路径从左方来第二条路径从左方来。 (i1, i2)因此dp[k][i1][i2]的值应该是这四种前驱状态中的最大值再加上当前步获取的数字奖励。数字奖励的计算是本题另一个核心。我们需要根据当前两条路径是否走到了同一个格子来决定。如果i1 i2(且由步数同步可知此时j1 j2)说明两条路径在k步后走到了同一个格子(i1, j1)。那么这个格子的数字w[i1][j1]只能被加一次。否则说明两条路径走到了不同的格子(i1, j1)和(i2, j2)。那么这两个格子的数字可以分别被加入总和。因此我们可以得到状态转移方程设t dp[k-1][i1][i2]为前驱状态的最大值来自上述四种情况。 设当前格子价值value w[i1][j1]。 如果i1 i2则dp[k][i1][i2] t value。 如果i1 ! i2则dp[k][i1][i2] t w[i1][j1] w[i2][j2]。其中j1 k - i1 2,j2 k - i2 2。在计算前驱状态时必须确保计算出的j1,j2以及对应的j1-1,j2-1都是合法的列坐标在1到N之间。3.1 边界条件与初始化动态规划需要一个合理的起点。我们的状态定义是“走了k步后”的情况。那么k0时代表还没走两条路径都在起点(1,1)。因此我们可以初始化dp[0][1][1] w[1][1]。因为此时两条路径在同一个格子只取一次起点的数字。在实际递推时我们循环步数k从1到2*N-2因为从(1,1)到(N,N)总共需要走2*(N-1)步即横纵坐标各增加N-1。对于每个k循环i1和i2它们的范围都是从max(1, k-N2)到min(N, k1)。这个范围是为了保证由i和k计算出的j坐标在合法的[1, N]范围内。实操心得边界条件的处理是DP代码稳健性的关键。特别是i和j的范围计算很容易因为下标从0开始还是从1开始而混乱。我的习惯是在思考阶段全部使用1-based的索引与题目描述一致在写代码时再根据存储数组是0-based还是1-based进行转换。对于本题如果w[][]和dp[][][]数组都从下标1开始存储有效数据那么循环和判断会清晰很多。务必在纸上画一个3x3或4x4的小网格手动模拟一下k、i、j的关系确认范围公式的正确性这能节省大量的调试时间。4. C代码实现与逐行解析理解了原理我们来看代码实现。这里采用三维状态dp[2*N][N1][N1]并使用1-based索引以贴合题目思维。#include iostream #include algorithm using namespace std; const int N 15; // 根据题目要求N最大一般不超过10或15 int w[N][N]; // 存储方格中的数字1-based索引 int dp[2 * N][N][N]; // dp[k][i1][i2] 注意这里为了展示清晰第三维也用了N实际开[N1][N1]更安全 int main() { int n; cin n; // 读入数据题目输入格式通常是 (行, 列, 值)以(0,0,0)结束 int a, b, c; while (cin a b c, a || b || c) { w[a][b] c; } // 初始化k0时i11, i21, 在起点 // 我们让dp数组的k也从1开始计数dp[0][1][1]对应走了0步。 // 更清晰的做法是dp[2][1][1] w[1][1]其中2 i1j1 11。 // 我们采用另一种常见写法直接开始k从2循环到 2*n。 // 循环总步数k。从(1,1)出发横纵坐标之和从2开始。 for (int k 2; k 2 * n; k) { // 确定i1和i2的合法范围 for (int i1 1; i1 n; i1) { for (int i2 1; i2 n; i2) { int j1 k - i1, j2 k - i2; // 检查计算出的列坐标是否合法 if (j1 1 j1 n j2 1 j2 n) { // 获取上一步四种情况的最大值 int t dp[k - 1][i1][i2]; // 都从上边来 t max(t, dp[k - 1][i1 - 1][i2]); // 路径1从上路径2从左 t max(t, dp[k - 1][i1][i2 - 1]); // 路径1从左路径2从上 t max(t, dp[k - 1][i1 - 1][i2 - 1]); // 都从左边来 // 加上当前步的收益 if (i1 i2) { // 走到同一格数字只加一次 dp[k][i1][i2] t w[i1][j1]; } else { // 走到不同格数字分别加 dp[k][i1][i2] t w[i1][j1] w[i2][j2]; } } } } } // 最终状态两条路径都走了2n步从2到2n共2n-1步这里需要厘清 // 从(1,1)到(n,n)总步数是 (n-1)(n-1) 2n-2。 // 我们k从2开始当i1n, i2n时k i1j1 nn 2n。 // 所以最终结果是 dp[2n][n][n] cout dp[2 * n][n][n] endl; return 0; }代码关键点解析k的起始值与含义这里k被定义为i j即横纵坐标之和。起点(1,1)的k2终点(n,n)的k2n。因此k从2循环到2n。dp[k][i1][i2]表示当两条路径的坐标和均为k时即同步走了k-2步后的状态。合法性检查内层循环i1和i2都是从1到n但通过j1 k - i1和j2 k - i2计算出的列坐标必须在[1, n]范围内该状态才合法。这是保证状态有效的关键过滤条件。前驱状态取值dp[k-1][i1][i2]对应两条路径都从上方来的情况。注意这里的i1,i2是当前状态的行坐标dp[k-1][i1][i2]意味着上一步两条路径的行坐标也是i1和i2那么上一步的列坐标就应该是(k-1) - i1和(k-1) - i2即j1-1和j2-1。这正好对应了“从左边来”因为列坐标减少了1。同理dp[k-1][i1-1][i2]对应路径1从上方来行坐标i1-1路径2从左方来行坐标不变i2列坐标j2-1。这里需要仔细理解dp数组下标与物理位置的对应关系。空间优化提示观察状态转移方程dp[k][i1][i2]只依赖于dp[k-1][...][...]。这是典型的“滚动数组”优化场景。我们可以将dp数组的第一维大小设为2用k 1来交替使用。这能将空间复杂度从O(N^3)降至O(N^2)。对于本题N很小的情况不是必须的但掌握这个技巧对解决更大规模的问题很有帮助。5. 深度剖析与其他DP模型的联系与对比“方格取数”模型是数字三角形模型的自然延伸但它也启发了更多复杂的DP问题。5.1 与“传纸条”问题的等价性另一个经典问题“传纸条”从左上角到右下角再回到左上角找两条不相交路径使得和最大在本质上与“方格取数”是等价的。为什么我们可以把“传纸条”的“来回”想象成两个同学同时从左上角走向右下角。要求路径不相交除了起点终点在数字三角形模型下这等价于在“方格取数”中两条路径除了起点和终点外不能走到同一个格子即i1i2且k≠2, k≠2n时收益为负无穷或直接禁止。所以“方格取数”的代码稍作修改当i1i2且不在起点终点时跳过或赋极小值就可以解决“传纸条”问题。理解这种等价性能极大提升你举一反三的能力。5.2 向高维与费用流的拓展如果问题变成“走K次”呢状态可以拓展为dp[k][i1][i2]...[ik]表示k条路径分别走到某行时的最大和。但状态维度会指数级增长。此时这个问题就露出了它的另一面它可以被转化为一个最小费用最大流问题。将每个格子拆成“入点”和“出点”中间连两条边一条容量为1费用为数字的相反数求最大和相当于求最小费用另一条容量为无穷或K-1费用为0表示可以重复经过但不取数。从源点向(1,1)的入点连容量为K的边从(N,N)的出点向汇点连容量为K的边跑最小费用流结果的相反数就是最大和。这种图论建模为问题提供了更强大的解决工具尤其是当K较大时。经验之谈很多DP问题尤其是这种“多路径”、“有限资源分配”问题往往都有对应的网络流模型。建立这种联系的知识图谱非常重要。当你发现DP状态维度过高难以设计时不妨想想是否能用网络流来解。这不仅是解题技巧更是对问题本质的深刻理解。6. 常见错误与调试技巧实录即便思路清晰实现时也难免踩坑。下面是我和学生们在实现“方格取数”时最常见的几个错误点6.1 下标与范围错误这是最频繁的错误。混乱源于坐标是1-based而数组是0-based或者对k、i、j的关系理解不透。症状程序输出错误结果、访问非法内存导致崩溃。排查在纸上画一个3x3网格手动计算当k3,4,5时合法的(i,j)对有哪些。然后在小数据如N3下打印出每一步的dp[k][i1][i2]值与你的手动推导对比。重点关注j1和j2的计算公式以及if判断条件。技巧统一使用1-based索引思维在数组声明时多开一些空间如int dp[2*N5][N5][N5]并在循环条件中使用i1 n i1 1等严格判断避免边界溢出。6.2 状态转移遗漏四种前驱状态上上、上左、左上、左左必须考虑全面缺一不可。症状结果比正确答案小。排查检查你的max比较是否包含了全部四种情况。特别是dp[k-1][i1][i2-1]和dp[k-1][i1-1][i2]这两种“交叉”来的情况容易漏掉或与i1,i2的循环范围冲突而被跳过。技巧在写状态转移时先用注释把四种情况列出来再写max比较。对于每个前驱状态思考其对应的(i1, j1)和(i2, j2)是否合法即是否在网格内。6.3 数字重复累加逻辑错误在i1 i2时只加一次w[i1][j1]但有时会错误地加成w[i1][j1] w[i2][j2]而由于此时j1也等于j2相当于加了两次。症状当两条路径可能交叉时结果异常偏高。排查设计一个简单的测试用例其中最优解必然涉及路径交叉。例如一个2x2网格所有格子值都为1。最优解是两条路径都走(1,1)-(1,2)-(2,2)和(1,1)-(2,1)-(2,2)但它们在(2,2)重合。正确最大和应为3三个不同的格子如果逻辑错误会得到4。技巧在if (i1 i2)的判断里明确注释// 走到同一格坐标(i1, j1) 和 (i2, j2)相同强化自己的理解。6.4 初始化问题起点(1,1)的数字如何处理如果k从2开始循环那么dp[2][1][1]需要在循环开始前被正确初始化。症状结果完全不对或者为0。排查在循环开始前打印或检查dp[2][1][1]的值是否为w[1][1]。确保你的初始化逻辑覆盖了起点状态。技巧可以采用更清晰的做法在读取完w数组后直接dp[2][1][1] w[1][1];。然后在k从3开始循环到2n。为了帮助你快速定位问题这里提供一个简易的调试检查表问题现象可能原因检查点输出为0或极小值初始化失败或状态转移未生效1. 检查w数组数据是否成功读入。2. 检查dp[2][1][1]初始化。3. 检查k,i1,i2循环范围是否过小漏掉了有效状态。程序运行时错误/崩溃数组越界1. 检查j1,j2的计算和合法性判断(if语句)。2. 检查dp数组的第一维大小是否足够至少2*n1。3. 检查dp[k-1][i1-1][i2]等访问当i11时i1-10是否合法需要确保循环i1从1开始并在访问i1-1前判断i11或者将dp数组行维度从0开始定义并合理初始化。结果比预期大数字被重复计算重点检查i1i2时的累加逻辑是否错误地加了两遍。结果比预期小状态转移不全或取了非最优前驱1. 检查四种前驱状态的max比较是否写全。2. 用一个小案例如3x3网格自己设定数字手动模拟DP过程与程序输出对比。最后分享一个我调试DP的常用“笨”办法可视化打印。对于这类维度不算太高的DP在关键步骤打印出整个dp表或切片极其有效。例如在每轮k循环结束后打印出dp[k][i1][i2]对于所有合法i1,i2的值。肉眼对比你的预期能迅速定位是哪个状态的计算出了错。编程不仅仅是写代码更是与逻辑对话的过程而清晰的“日志”就是最好的对话记录。