ARTICLE DETAIL

资讯详情

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

动态规划实战:从不同路径II解析DP核心与滚动数组优化

动态规划实战:从不同路径II解析DP核心与滚动数组优化 1. 项目概述从一道经典题看动态规划的实战与优化最近在带几个学生冲刺蓝桥杯国赛发现很多同学在动态规划DP这个模块上总是“一看就会一写就废”。尤其是遇到像“不同路径 II”这种带障碍物的变种题状态转移方程写出来了但一跑就错或者勉强做出来但时间和空间复杂度都惨不忍睹。这道题可以说是动态规划入门到精通的绝佳试金石它完美地串联起了基础DP思路、边界条件处理、空间优化滚动数组以及代码实现的诸多细节。今天我就以这道题为引子拆解一下如何从暴力思路一步步优化到最优解并把其中容易踩的坑和实战技巧掰开揉碎了讲清楚。无论你是正在备赛蓝桥杯、刷LeetCode还是单纯想巩固算法这篇深度解析都能让你对动态规划有一个更立体、更实战的理解。“不同路径 II”问题描述很简单一个机器人位于一个m x n网格的左上角每次只能向下或者向右移动一步试图到达网格的右下角。现在网格中加入了障碍物1表示障碍物0表示空位置。问总共有多少条不同的路径这题直接考察的就是带约束条件的二维动态规划。核心点在于遇到障碍物时该点的路径数必须为0并且会影响后续状态的转移。理解并熟练解决这个问题对于处理更复杂的坐标型DP、背包问题变种都大有裨益。2. 问题核心与暴力递归的思维起点在接触任何优化之前我们必须先理解问题的本质。很多同学一上来就想写状态转移方程这其实是本末倒置。动态规划的本质是“记忆化搜索”其思想源头是递归。我们先从最朴素的暴力递归开始。2.1 定义递归函数与基准情况我们定义一个递归函数dfs(i, j)表示从起点(0, 0)走到位置(i, j)的不同路径数。那么要走到(i, j)机器人只能从它的上方(i-1, j)或者左方(i, j-1)过来。因此一个最直观的递归关系就出来了dfs(i, j) dfs(i-1, j) dfs(i, j-1)。当然这只是理想情况。我们需要处理几个关键的基准情况Base Case起点当i 0且j 0时表示就在起点那么路径数为1。这是我们的递归起点。障碍物如果(i, j)位置本身就是障碍物obstacleGrid[i][j] 1那么无论从哪来这里的路径数都是0。这是一个强约束条件必须在递归一开始就判断。边界当i或j小于0时表示坐标出界了没有路径返回0。根据以上分析我们可以写出最原始的递归代码框架以Python为例def uniquePathsWithObstacles(obstacleGrid): m, n len(obstacleGrid), len(obstacleGrid[0]) def dfs(i, j): # 情况1和3坐标非法或为障碍物 if i 0 or j 0 or obstacleGrid[i][j] 1: return 0 # 情况2到达起点 if i 0 and j 0: return 1 # 状态转移从上方或左方来 return dfs(i-1, j) dfs(i, j-1) return dfs(m-1, n-1)2.2 暴力递归的弊端与重叠子问题写出上面的代码后我们可以跑一下小规模的测试用例。但稍微大一点比如20x20的网格程序就会慢得无法接受。原因就在于重叠子问题。我们计算dfs(i, j)时需要计算dfs(i-1, j)和dfs(i, j-1)而计算dfs(i-1, j)时又会计算dfs(i-2, j)和dfs(i-1, j-1)。dfs(i-1, j-1)这个子问题会被重复计算非常多次。其时间复杂度是指数级的O(2^(mn))。实操心得在面试或竞赛中即使你第一时间想到了递归解法也一定要口头分析出其指数级的时间复杂度并指出存在“重叠子问题”。这展示了你的问题分析能力并自然引出动态规划优化的必要性。3. 标准二维动态规划填表法与状态转移既然暴力递归有大量重复计算我们很自然地想到用一张表把中间结果存起来这就是动态规划的记忆化Memoization或制表法Tabulation。我们通常使用制表法因为它更符合动态规划“自底向上”的思维且通常能优化成更优的空间复杂度。3.1 DP数组定义与初始化我们定义一个二维DP数组dp[i][j]其含义与递归函数dfs(i, j)完全一致表示从起点(0, 0)走到(i, j)的路径总数。初始化是DP最容易出错的地方之一尤其是这种带障碍物的场景。初始化起点dp[0][0]的值取决于起点是不是障碍物。如果obstacleGrid[0][0] 1那么机器人一开始就困住了直接返回0。否则dp[0][0] 1。初始化第一行i0机器人只能一直向右走。所以对于第一行的任何位置(0, j)它的路径数等于它左边位置(0, j-1)的路径数。但这里有个关键如果(0, j)是障碍物那么dp[0][j] 0。并且一旦第一行中某个位置是障碍物它右边的所有位置都不可达。因为机器人无法绕过它。因此初始化第一行时应该用循环如果遇到障碍物就立刻把当前及后续的dp[0][j]都设为0。初始化第一列j0同理机器人只能一直向下走。初始化逻辑与第一行对称。很多同学会写一个双重循环去初始化整个dp数组然后在循环内部判断i0和j0这样代码容易混乱。更清晰的做法是分开初始化def uniquePathsWithObstacles(obstacleGrid): m, n len(obstacleGrid), len(obstacleGrid[0]) # 情况1起点就是障碍物 if obstacleGrid[0][0] 1: return 0 dp [[0] * n for _ in range(m)] dp[0][0] 1 # 初始化第一行 for j in range(1, n): if obstacleGrid[0][j] 0: dp[0][j] dp[0][j-1] # 只能从左边来 else: dp[0][j] 0 # 障碍物位置为0且后续循环会自然保持0这里break也可以 # 初始化第一列 for i in range(1, m): if obstacleGrid[i][0] 0: dp[i][0] dp[i-1][0] # 只能从上方来 else: dp[i][0] 03.2 状态转移方程与填表过程初始化完成后对于其他普通位置(i, j) (i0, j0)状态转移方程就非常直观了如果(i, j)是障碍物则dp[i][j] 0。否则dp[i][j] dp[i-1][j] dp[i][j-1]。即从上方和左方来的路径数之和。接下来我们用一个二重循环遍历整个网格进行“填表”# 状态转移 for i in range(1, m): for j in range(1, n): if obstacleGrid[i][j] 0: dp[i][j] dp[i-1][j] dp[i][j-1] else: dp[i][j] 0 return dp[m-1][n-1]这个过程的时间复杂度是O(m*n)空间复杂度也是O(m*n)。对于大多数笔试场景这个解法已经可以拿满分了。它清晰、正确并且易于理解和调试。注意事项在写状态转移方程时务必先判断当前位置是否为障碍物。这是一个非常常见的错误点——先计算了dp[i-1][j] dp[i][j-1]然后再去判断障碍物这样会导致障碍物位置可能被错误地赋予一个非零值。4. 空间优化精要滚动数组的降维打击在算法竞赛中尤其是像蓝桥杯这种对内存和时间都有一定要求的比赛O(m*n)的空间复杂度有时会成为瓶颈或者至少不够优雅。我们观察状态转移方程dp[i][j]只依赖于dp[i-1][j]上一行同列和dp[i][j-1]本行前一列。也就是说在计算第i行时我们只需要第i-1行的数据以及本行已经计算过的数据。4.1 从二维DP到一维DP滚动数组这个依赖关系让我们可以仅用一个一维数组dp[j]来替代整个二维数组。在这个一维数组中dp[j]在更新前存储的是上一行第j列的值即原来的dp[i-1][j]。dp[j-1]在更新时存储的是本行第j-1列已经更新过的值即原来的dp[i][j-1]。因此状态转移可以压缩为dp[j] dp[j] dp[j-1]当obstacleGrid[i][j] 0时 等号右边的dp[j]是上一行的旧值dp[j-1]是本行已计算的新值。4.2 一维DP的初始化与遍历细节使用一维数组后初始化和遍历需要格外小心。初始化dp[0]代表起点。如果起点不是障碍物则dp[0] 1否则为0。这里和二维类似。遍历顺序外层循环遍历行i内层循环遍历列j。内层循环必须从左到右j从0到n-1。因为我们需要在计算dp[j]时dp[j-1]已经是本行更新后的值。障碍物处理当obstacleGrid[i][j] 1时必须将dp[j]显式地设置为0以覆盖掉上一行遗留的值。第一列的处理在一维数组中dp[0]代表每一行的第一列。对于i 0且j 0的情况即每行的第一个格子它的值只能从上方来即上一行的dp[0]。如果当前格子是障碍物则dp[0] 0否则dp[0]保持上一行的值不变因为从左边没有格子。这个逻辑需要融入到内层循环中。下面是优化后的一维DP完整代码def uniquePathsWithObstacles(obstacleGrid): m, n len(obstacleGrid), len(obstacleGrid[0]) dp [0] * n # 初始化起点 dp[0] 1 if obstacleGrid[0][0] 0 else 0 for i in range(m): for j in range(n): if obstacleGrid[i][j] 1: dp[j] 0 # 遇到障碍物此路不通 else: if j 0: # 如果不是第一列可以从左边来 dp[j] dp[j-1] # 如果是第一列(j0)dp[j]的值继承自上一轮循环即上一行代表从上方来 # 注意此处无需额外操作因为dp[j]本身已经存储了上一行的值 # 但需要确保起点初始化正确且当i0时如果第一列是障碍物上面已经置零了 return dp[-1]这段代码非常精简但内涵丰富。空间复杂度从O(m*n)优化到了O(n)。在蓝桥杯等竞赛中这种优化是必备技能。踩坑实录我最开始写一维DP时在内层循环里忘记处理j 0的情况导致第一列的逻辑错误。后来才明白对于第一列dp[0]在每一行的更新逻辑是特殊的它只可能被障碍物清零或者保持上一行的值代表从上方来。dp[j] dp[j-1]这个操作只适用于j 0。5. 边界条件与特殊案例的深度剖析动态规划题目难往往不是难在转移方程而是难在各种边边角角Corner Case的处理。“不同路径 II”的边界条件堪称经典教学案例。5.1 起点或终点是障碍物这是最容易被忽略的两种情况。起点是障碍物无论网格多大路径数都为0。必须在程序最开始就判断并返回。终点是障碍物同样路径数为0。我们的DP过程最终会计算到终点如果终点是障碍物根据规则dp[i][j]会被设为0所以最终返回的dp[m-1][n-1]自然就是0。这一点代码逻辑能覆盖但心里一定要清楚。5.2 单行或单列网格当m1或n1时网格退化成一条线。如果是一条1 x n的水平线机器人只能向右走。那么路径数要么是1整条线无障碍要么是0只要有一个障碍物因为无法绕行。我们的初始化逻辑第一行和遍历逻辑能正确处理这种情况。如果是一条m x 1的垂直线同理。我们的初始化逻辑第一列也能处理。但是在写一维DP时要特别注意单列的情况n1。此时内层循环for j in range(n)只执行一次j0。我们的代码中if j 0的条件不成立因此dp[0]的更新完全依赖于“是否遇到障碍物将其清零”以及它初始化的值。逻辑仍然是正确的。5.3 大网格与结果溢出虽然本题的常见测试用例结果在32位整数范围内但在一些变种题或者自定义输入中路径数可能非常大。在像C、Java这类语言中需要使用long long类型来定义DP数组。在Python中则不用担心。这是一个良好的编程习惯在竞赛中要主动思考数据范围。我们可以设计一个极端测试用例来验证一个100x100的网格没有任何障碍物。这是一个经典的组合数学问题路径数为C(mn-2, m-1)这个数字会非常大。确保你的代码能够处理大数。6. 从本题延伸的动态规划学习路径搞定“不同路径 II”你只是拿到了动态规划世界的入场券。接下来如何系统性地提升6.1 建立DP解题的通用思维框架我总结了一个四步法适用于绝大多数DP问题定义状态明确dp[i]或dp[i][j]代表什么。状态定义是解题的基石直接决定了后续能否顺利推导。确定初始状态边界找到最简单、不可再分的情况下的值。就像本题的dp[0][0]。推导状态转移方程思考如何从已知的、更小的子问题的解得到当前问题的解。这是DP的核心也是最考验思维的一步。确定计算顺序为了保证在计算当前状态时它所依赖的子状态都已经被计算出来需要确定正确的循环顺序。对于二维表格通常从左到右、从上到下对于一维数组要分析依赖关系决定是正序还是逆序。6.2 同类题型举一反三以“不同路径”系列为基础你可以挑战这些题目巩固和拓展DP能力基础变种 LeetCode 62. 不同路径 。无障碍物版本是本题的简化用于理解最基础的二维DP。增加约束 LeetCode 63. 不同路径 II 。就是本题。最小路径和 LeetCode 64. 最小路径和 。从求路径数变为求路径上的最小数字和状态定义从“数量”变为“和”转移方程从“加法”变为“取最小值当前值”。带权值的不同路径如果网格上的点有权重收益求最大收益路径。思路瞬间就切换到另一种经典DP模型。三维DP如果机器人可以向下、向右、向右下移动问题就变成了三维DPdp[i][j][k]但核心框架不变。6.3 滚动数组的应用场景滚动数组是优化空间复杂度的利器但其应用有前提依赖关系有限当前状态仅依赖于有限的“上一轮”状态。例如只依赖上一行二维变一维或者只依赖前两个状态如斐波那契数列dp[i] dp[i-1] dp[i-2]可以用两个变量滚动。遍历顺序可控你必须能安排一种计算顺序使得在覆盖旧状态之前所有依赖它的状态都已经计算完毕。像经典的0-1背包问题如果使用一维数组内层循环必须从大到小遍历就是为了防止物品被重复使用。掌握滚动数组意味着你对状态之间的依赖关系有了透彻的理解。这是区分“背模板”和“真理解”的一个重要标志。7. 蓝桥杯备赛实战建议结合我带赛的经验给正在备赛蓝桥杯的同学几点具体建议关于“不同路径 II”这类题在比赛中的定位 这类题通常出现在省赛或国赛的前几道编程大题中属于必须拿下的分数。它考察的是对基础DP模型的熟练度和代码实现的严谨性。你可能会遇到更复杂的背景包装比如地图寻宝、游戏走格子但内核就是这个模型。备赛训练方法精刷经典题不要贪多。把“不同路径”系列6263、背包问题系列416 494、子序列问题300 1143等每个经典模型找3-5道题反复刷直到能闭着眼睛写出空间优化后的版本并能清晰讲解每一步为什么这么做。重视调试自己构造各种边界测试用例。例如1x1网格带/不带障碍物1xn网格中间有障碍物起点终点障碍物大网格等。用打印DP表的方式跟踪程序运行这对理解DP过程至关重要。总结模板与易错点建立自己的代码模板库。比如二维坐标型DP的初始化模板、一维滚动数组的模板。并把像“障碍物判断顺序”、“第一行第一列初始化”、“一维数组遍历顺序”这样的易错点整理成清单每次写代码前心里过一遍。从暴力递归入手对于每道新DP题先尝试思考暴力递归解法。即使不写出来也要在脑子里过一遍递归树。这能帮你最直观地理解“重叠子问题”在哪从而设计出正确的状态定义。最后动态规划的学习曲线比较陡峭初期感到困难是正常的。关键是把每一个像“不同路径 II”这样的经典题目吃透理解其背后的无后效性未来只依赖于当前状态与如何到达当前状态无关和最优子结构问题的最优解包含子问题的最优解这两大核心思想。当你积累的模型足够多再遇到新题时你就会发现它们大多是这些经典模型的排列组合或细微变形。那时解题就会有一种“看山还是山”的从容感了。
返回列表