尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

动态规划实战:从方格取数问题掌握线性DP核心思想与优化技巧

动态规划实战:从方格取数问题掌握线性DP核心思想与优化技巧 1. 项目概述从“方格取数”到线性DP的实战演练“方格取数”这个题目但凡刷过一些算法题的朋友应该都不陌生。它常常作为动态规划DP的经典入门案例出现但别被它的“入门”标签骗了这里面能挖的细节和能延伸的思路足够我们好好聊上一壶。简单来说题目给你一个N*N的方格矩阵每个格子里有一个数字可能是正数、负数或零你从左上角出发每次只能向右或向下移动一步目标是走到右下角。在这个过程中你经过的格子里的数字会被累加起来。问题通常有两种变体一是求从起点到终点所能获得的最大数字和二是求从起点到终点再找一条路径返回起点或另一条从终点到起点的路径且两条路径除起点终点外不重复经过同一格子所能获得的最大数字和。我们今天要深挖的主要是第一种也就是最基本的“最大和路径”问题并借此彻底讲透线性DP在这种网格类问题中的应用心法。为什么它如此重要因为在面试和竞赛中网格DP是动态规划最常考的形态之一它的状态定义、转移方程和初始化构成了理解更复杂DP问题比如后面提到的“两条路径”问题其实就是多维DP或状态压缩DP的雏形的基石。弄懂了它像是“最小路径和”、“不同路径”这些题基本上就是换汤不换药。更重要的是通过这个具体的模型我们可以把“状态”、“阶段”、“决策”这些抽象的DP概念变得非常具体和可操作。接下来我不会只给你一个冷冰冰的递推公式而是会带你一起像解一道真实的工程问题一样拆解我们是如何一步步思考并最终得到那个优雅的解法的。2. 核心思路拆解如何将“走路”问题转化为“状态转移”面对一个方格我们的第一直觉可能是搜索尝试所有可能的路径然后比较它们的和。这在格子很少的时候可行但对于稍大的N比如100路径数量会爆炸式增长这就是所谓的“组合爆炸”。动态规划的核心思想就是避免重复计算而网格的结构天然地适合我们记录“子问题”的结果。2.1 状态定义的艺术dp[i][j]到底代表了什么这是最关键的一步也是新手最容易迷糊的地方。状态定义不对后面全白费。对于“从左上角到(i, j)的最大路径和”这个问题最直接且正确的状态定义是dp[i][j]表示从起点(0, 0)走到格子(i, j)时所能获得的累计最大数字和。注意这里dp[i][j]是一个结果是一个确定的数值而不是一个过程。它存储的是“到达这个状态时的最优解”。i和j共同描述了一个“位置”也就是一个“状态”。为什么这么定义因为网格的移动具有“无后效性”你未来怎么走只取决于你现在站在哪个格子上以及这个格子上的数字而不依赖于你是通过哪条路走过来的。这完美符合DP的应用条件。一旦我们定义了这个状态我们的目标就非常清晰了求出dp[N-1][N-1]的值。2.2 状态转移方程递推关系的建立知道了dp[i][j]的含义我们怎么求它呢既然每次只能向右或向下走那么要走到(i, j)上一步只可能来自两个地方正上方(i-1, j)或者正左方(i, j-1)。因为我们要的是最大和所以当然选择从这两个来源中能带来更大累计和的那一条路走过来然后加上当前格子(i, j)本身的数字grid[i][j]。于是我们就得到了那个经典的转移方程dp[i][j] max(dp[i-1][j], dp[i][j-1]) grid[i][j]这个方程就是整个算法的灵魂。它告诉我们大规模问题的最优解可以通过其更小规模子问题的最优解来构造。这就是最优子结构。2.3 初始化一切开始的起点递推需要一个起点。对于我们的状态定义起点就是(0, 0)。显然dp[0][0] grid[0][0]因为从起点到起点路径和就是起点格子的数字。但还有边界情况需要考虑第一行i0和第一列j0。对于第一行的格子(0, j)它只能从左边的格子(0, j-1)走过来因为不可能从上方来。同样对于第一列的格子(i, 0)它只能从上方的格子(i-1, 0)走过来。因此我们需要单独初始化这些边界dp[0][0] grid[0][0]对于第一行dp[0][j] dp[0][j-1] grid[0][j](j从1开始)对于第一列dp[i][0] dp[i-1][0] grid[i][0](i从1开始)注意这里有一个非常容易出错的点。有些朋友会试图用转移方程去计算边界比如计算dp[0][1]时max(dp[-1][1], dp[0][0])会导致数组越界。所以务必先处理好边界初始化或者在你的循环判断中显式处理边界。我个人的习惯是总是先初始化第一行和第一列这样主循环就可以从(1, 1)开始逻辑更清晰不易出错。3. 从理论到代码完整的实现与逐行解析理论清晰了我们来看代码实现。这里以C为例因为它在算法竞赛中很常见但思路完全适用于其他语言。#include iostream #include vector #include algorithm using namespace std; int main() { int N; cin N; vectorvectorint grid(N, vectorint(N)); vectorvectorint dp(N, vectorint(N, 0)); // 1. 读入网格数据 for (int i 0; i N; i) { for (int j 0; j N; j) { cin grid[i][j]; } } // 2. 初始化DP数组 dp[0][0] grid[0][0]; // 初始化第一行 for (int j 1; j N; j) { dp[0][j] dp[0][j-1] grid[0][j]; } // 初始化第一列 for (int i 1; i N; i) { dp[i][0] dp[i-1][0] grid[i][0]; } // 3. 状态转移填充DP表其余部分 for (int i 1; i N; i) { for (int j 1; j N; j) { dp[i][j] max(dp[i-1][j], dp[i][j-1]) grid[i][j]; } } // 4. 输出结果 cout dp[N-1][N-1] endl; return 0; }逐行解析与实操心得数据存储使用vectorvectorint来存储网格和DP表比原生数组更安全方便。注意dp数组初始化为0但它的值很快会被覆盖。初始化顺序务必先初始化dp[0][0]再初始化第一行和第一列。这是一个拓扑顺序确保在计算dp[0][1]时dp[0][0]已经有值了。主循环双重循环从(1,1)开始。这里i和j的循环顺序其实可以互换先行后列或先列后行因为计算dp[i][j]时它依赖的dp[i-1][j]上一行和dp[i][j-1]同一行前一列都已经被计算出来了。这种计算顺序保证了状态的正确递推。空间复杂度优化细心的你可能发现了我们用了O(N²)的额外空间。实际上这是可以优化的。因为计算第i行时我们只需要第i-1行的数据和当前行已计算的部分。我们可以只用一个一维数组dp[j]来滚动更新。但作为初学者我强烈建议先理解和掌握二维DP表的写法这是理解问题本质的基础。优化空间是后续的事切忌本末倒置。4. 问题变体与进阶思考当一条路变成两条路经典的“方格取数”往往指的是更复杂的那一题要求找两条从左上到右下的路径使得两条路径经过的数字总和最大且两条路径除了起点和终点外不能经过同一个格子。这直接把我们带入了更高级的DP领域。4.1 思路升维从坐标到路径步数当只有一条路径时状态用二维(i, j)表示位置就够了。但现在有两条路径同时走我们需要同时追踪两个“光标”的位置。最直接的想法是定义一个四维状态dp[x1][y1][x2][y2]表示第一条路径走到(x1, y1)第二条路径走到(x2, y2)时获得的最大和。但这样复杂度是O(N⁴)在N较大时难以承受。我们需要寻找等价关系来降维。一个关键的观察是两条路径是同步走的。假设每次两条路径都各走一步那么从起点开始走完k步后第一条路径的坐标是(i, k-i)第二条是(j, k-j)因为横纵坐标之和等于步数k。这样我们就可以把状态压缩到三维dp[k][i][j]表示走了k步第一条路径在第i行第二条路径在第j行时获得的最大和。对应的列坐标可以通过k-i和k-j算出。4.2 状态转移与路径交叉判断状态dp[k][i][j]可以从哪里转移而来上一步是k-1步两条路径的上一步各有两种可能上或左所以有2x24种组合第一条从上(i-1)第二条从上(j-1) -dp[k-1][i-1][j-1]第一条从上(i-1)第二条从左(j) -dp[k-1][i-1][j]第一条从左(i)第二条从上(j-1) -dp[k-1][i][j-1]第一条从左(i)第二条从左(j) -dp[k-1][i][j]我们需要取这四种前驱状态的最大值然后加上当前两个格子的数字。这里就是关键如果当前两个格子重合即i j意味着(i, k-i)和(j, k-j)是同一个点那么根据题目要求除起点终点外不重复这个点只能被计算一次贡献。否则两个格子的数字都可以加上。因此转移方程的核心逻辑如下int t grid[i][k-i]; if (i ! j) { // 不重合 t grid[j][k-j]; } dp[k][i][j] max(max(dp[k-1][i-1][j-1], dp[k-1][i-1][j]), max(dp[k-1][i][j-1], dp[k-1][i][j])) t;当然在实现时必须严格判断i, j, k-i, k-j这些坐标的合法性不越界。4.3 实现细节与复杂度分析实现这个三维DP步数k的范围是从2起点不算步数这里通常把起点状态设为0步到2*N-2从(0,0)到(N-1,N-1)共走2N-2步。i和j的范围是[0, N-1]但要满足k-i和k-j也在合法范围内。空间复杂度是O(K * N * N) ≈ O(N³)。时间复杂度也是O(N³)。虽然比二维问题复杂但相比四维的O(N⁴)已是巨大优化。这个“步数-行坐标”的降维技巧在处理双路径、多线程类网格DP问题时非常经典。实操心得在编写这类复杂DP时一定要先写清楚状态定义和转移方程的伪代码把边界条件和特殊情况如坐标重合用注释标出来。然后在循环中把数组下标的范围用min和max函数框定好避免无尽的调试。例如i的循环范围可以是max(0, k-(N-1))到min(N-1, k)确保列坐标k-i不越界。5. 常见“坑点”与调试技巧实录即便思路正确实现时也难免踩坑。下面是我和许多同行在解决这类问题时总结出来的血泪教训。5.1 初始化陷阱问题dp数组初始化为0但在网格数字全为负数时我们的算法会出错吗分析与解决会的考虑一个所有格子都是-1的网格。按照我们的转移方程dp[i][j] max(来自上来自左) (-1)。如果dp初始为0那么对于非边界的第一行第一列格子max(0, 0) (-1) -1这看起来没问题。但仔细想如果有一条路径的和是-10它应该比-1更差。然而我们的状态定义是“从起点到该点的最大和”在全是负数的网格里这个“最大和”应该是一个绝对值更大的负数即更小的数。但是如果我们把dp数组初始化为0当计算一个负数和0取max时0会被选中这相当于“凭空创造”了一条和为0的虚拟路径干扰了真实的最优解。正确做法对于求最大值的问题如果允许路径和为零或正通常初始化dp为负无穷大或一个非常小的数以确保只有从起点真实可达的状态才会被更新。在我们的单路径问题中因为移动方向受限所有格子都是可达的且我们显式初始化了第一行和第一列所以用0初始化在常规含非负数数据下是安全的。但在更通用或复杂的DP问题中初始化负无穷是一个好习惯。在双路径问题中初始化尤为重要dp[0][0][0]应为grid[0][0]起点值其他状态初始为负无穷。5.2 数组越界与边界处理问题在双重循环中直接访问dp[i-1][j]和dp[i][j-1]当i0或j0时会越界。解决这就是为什么我们需要单独处理第一行和第一列。另一种写法是在循环内部加判断for (int i 0; i N; i) { for (int j 0; j N; j) { if (i 0 j 0) dp[i][j] grid[i][j]; else if (i 0) dp[i][j] dp[i][j-1] grid[i][j]; else if (j 0) dp[i][j] dp[i-1][j] grid[i][j]; else dp[i][j] max(dp[i-1][j], dp[i][j-1]) grid[i][j]; } }这种写法把初始化整合进了主循环减少了代码行数但逻辑上稍微绕一点。两种方式都可以选择你习惯的、不易出错的一种。5.3 路径还原如何输出具体路径题目往往只要求输出最大和但有时我们需要知道具体是哪条路径取得了这个最大和。方法在状态转移时额外使用一个path数组与dp同维记录最优决策。例如path[i][j]可以记录走到(i,j)时最优决策是从哪里来的‘U’代表从上’L’代表从左。 在计算dp[i][j]时if (i 0 j 0) { dp[i][j] grid[i][j]; path[i][j] S; // Start } else if (i 0) { dp[i][j] dp[i][j-1] grid[i][j]; path[i][j] L; } else if (j 0) { dp[i][j] dp[i-1][j] grid[i][j]; path[i][j] U; } else { if (dp[i-1][j] dp[i][j-1]) { dp[i][j] dp[i-1][j] grid[i][j]; path[i][j] U; } else { dp[i][j] dp[i][j-1] grid[i][j]; path[i][j] L; } }计算结束后从终点(N-1, N-1)开始根据path数组记录的方向逆向回溯到起点即可得到路径。注意当两条路径的最大和相同时上述代码会选择来自上方的路径‘U’。如果需要所有最优路径则需要记录多个前驱问题会变得更复杂。5.4 空间优化技巧滚动数组当N很大时O(N²)的空间可能成为瓶颈。观察状态转移方程dp[i][j] max(dp[i-1][j], dp[i][j-1]) grid[i][j]计算第i行时只需要第i-1行的数据。因此我们可以只保留两行数组上一行和当前行甚至只保留一行数组进行原地滚动更新。两行数组版本vectorint pre(N, 0), cur(N, 0); pre[0] grid[0][0]; for (int j 1; j N; j) pre[j] pre[j-1] grid[0][j]; // 初始化第一行到pre for (int i 1; i N; i) { cur[0] pre[0] grid[i][0]; // 当前行的第一个元素 for (int j 1; j N; j) { cur[j] max(pre[j], cur[j-1]) grid[i][j]; } swap(pre, cur); // 当前行计算完毕变为下一轮的“上一行” } // 最终结果在 pre[N-1] 中单行数组版本更巧妙vectorint dp(N, 0); dp[0] grid[0][0]; for (int j 1; j N; j) dp[j] dp[j-1] grid[0][j]; // 初始化第一行 for (int i 1; i N; i) { dp[0] dp[0] grid[i][0]; // 更新当前行的第一列 for (int j 1; j N; j) { // 此时的dp[j]在未更新前存储的是上一行第j列的值即dp[i-1][j] // dp[j-1]在本次内循环中已经被更新为当前行第j-1列的值即dp[i][j-1] dp[j] max(dp[j], dp[j-1]) grid[i][j]; } } // 最终结果在 dp[N-1] 中单行数组的写法非常简洁但需要理解dp[j]在max函数中被使用时其值代表的是“上一行同列”的旧值而dp[j-1]代表的是“当前行前列”的新值。这种“就地滚动”是DP空间优化的常用手段。掌握“方格取数”及其变体不仅仅是解决一道题更是掌握了解决一大类网格动态规划问题的通用框架。从状态定义、转移方程、初始化到空间优化每一步的思考过程都比记住代码本身更重要。下次遇到类似问题不妨先拿出纸笔画一画网格定义清楚你的dp[i][j]想想它从哪里来要到哪里去边界怎么处理。多练习几次这种建模能力就会内化成你的本能反应。
返回列表