news 2026/10/15 23:33:00

不同路径与最小路径和:动态规划网格题的同源解法与初始化陷阱

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
不同路径与最小路径和:动态规划网格题的同源解法与初始化陷阱

1. 先把两道题放进同一个框架里看

1.1 核心需求:数方案和求最优值,共用同一张状态表

动态规划里关于 m*n 矩阵的题目,最常被刷题群拿来当模板的就是这两道:不同路径(从左上角走到右下角一共有多少种走法)和最小路径和(每个格子有代价,从左上角走到右下角怎么走代价最小)。标题把这两题放在一起,其实很有道理——你仔细对比就会发现,它们的状态定义几乎一模一样,都是dp[i][j]表示"从起点走到格子 (i, j) 的某个答案",转移的时候都只看左边和上边两个邻居,区别只是一个是加法一个是取最小值。

这个共同点不是巧合。这两道题本质上都是"二维网格上的递推",满足动态规划的两大前提:有重叠子问题,有最优子结构。你在 (i, j) 这个位置的答案,只依赖它左边和上边的结果,不存在回头路,也不存在跨格子的跳跃,所以只需要一个方向地推下去就能得到最终答案。

很多新手容易犯的毛病是孤立地记题解:背下不同路径的代码,背下最小路径和的代码,结果遇到带障碍物的版本又懵了。我更建议你把这两道题当作同一个模型的两个实例来学,先搞清楚"为什么这个状态转移是对的",再去背代码就不会那么痛苦。

1.2 为什么这两题被当成入门必修

我先说结论:它们是动态规划里最典型的"网格型 DP"代表,难度适中,但延展性极强。互联网公司笔试里,这类题的变体层出不穷——带障碍物、带权重、求最小代价、求最大价值、限定步数、允许走斜线等等,但底层的表格填充逻辑基本不变。

适合谁来读呢?我建议三类人认真看:第一类是刚开始学动态规划、被"状态转移方程"这个概念劝退过的初学者;第二类是刷题刷到中等题卡壳、想把模板题吃透的求职者;第三类是面试官喜欢从这两题切入考察候选人基本功的从业者——说实话,这两题虽然简单,但能把边界条件、空间优化、初始化细节讲得滴水不漏的人,真的不多。

2. 从暴力递归到动态规划:完整推演一遍

2.1 暴力递归:先把最笨的方案写对

很多人上来就写二维循环填表,但我觉得理解动态规划最稳的路径是先从暴力递归开始。以"不同路径"为例,从 (i, j) 出发到右下角,每次只能向右或向下走一步,那么递归函数可以写成:到达 (i, j) 的方案数,等于到达 (i-1, j) 的方案数加上到达 (i, j-1) 的方案数。写成代码就是这样:

def unique_paths_recursive(m: int, n: int) -> int: def dfs(i: int, j: int) -> int: if i == 0 or j == 0: return 1 return dfs(i - 1, j) + dfs(i, j - 1) return dfs(m - 1, n - 1)

这个版本有一个很直观的问题:重复计算。你算dfs(2, 2)的时候会去算dfs(1, 2)和dfs(2, 1),而这两个又会各自去算dfs(1, 1),导致同一个子问题被算了不知道多少遍。m=20、n=20 的时候,这个递归的调用次数是指数级的,跑起来让人绝望。

我为什么非要提这个笨办法?因为动态规划的本质,就是"用空间换时间,把递归里的重复计算缓存起来"。你得先看见重复在哪里,才能理解 dp 表存在的意义。

2.2 记忆化搜索:一张备忘录消掉重复计算

给递归加上一个缓存字典,让每个 (i, j) 只算一次,这就是记忆化搜索。它和自底向上的动态规划在状态定义和转移逻辑上完全等价,只是方向是从上往下"搜"的:

def unique_paths_memo(m: int, n: int) -> int: memo = {} def dfs(i: int, j: int) -> int: if i == 0 or j == 0: return 1 if (i, j) in memo: return memo[(i, j)] memo[(i, j)] = dfs(i - 1, j) + dfs(i, j - 1) return memo[(i, j)] return dfs(m - 1, n - 1)

时间复杂度从指数级降到了 O(m*n),每个格子最多算一次。这里的 memo 就是一张二维表的"懒加载"版本。理解了这一步,你再去看自底向上的填表代码,会豁然开朗:dp 表其实就是 memo,只不过我们提前规划好了计算顺序,从最小的子问题开始填,一层层推到目标答案。

2.3 自底向上填表:最终形态怎么来的

自底向上就是顺着网格从左到右、从上到下地填。因为 (i, j) 只依赖 (i-1, j) 和 (i, j-1),只要保证计算 dp[i][j] 的时候左边和上边已经算好了,按行遍历就天然满足这个依赖顺序。这也是动态规划最常见的代码形态:

def unique_paths(m: int, n: int) -> int: dp = [[1] * n for _ in range(m)] for i in range(1, m): for j in range(1, n): dp[i][j] = dp[i - 1][j] + dp[i][j - 1] return dp[m - 1][n - 1]

我把第一行和第一列全部初始化为 1,这样内层循环就可以从 (1, 1) 开始跑,省去特判。这个初始化逻辑对不对,后面我会专门展开讲,因为它是很多人写错的重灾区。

3. 不同路径:数方案数的关键细节

3.1 状态定义与转移方程推导

"不同路径"的题目表述通常是这样的:一个机器人位于 m x n 网格的左上角,每次只能向下或者向右移动一步,请问到达右下角总共有多少条不同的路径?

状态定义我习惯写成:dp[i][j]表示从起点 (0, 0) 走到格子 (i, j) 的路径条数。转移方程是dp[i][j] = dp[i-1][j] + dp[i][j-1]。

这一步推导逻辑要讲清楚:要走到 (i, j),最后一步只可能来自上方 (i-1, j) 或左方 (i, j-1)。而"从起点到上方格子"的路径条数是dp[i-1][j],"从起点到左方格子"的路径条数是dp[i][j-1],这两类路径不会重叠,因为最后一步的方向不同,所以总数直接相加。

这里有个容易被忽略的点:为什么不能是乘法?你可能会想,到达上方的每种路径都要搭配到达左方的每种路径吗?不是的,因为每个具体路径只对应一个最后一步的方向,你不能既从上方来又从左方来。加法是"二者选其一"的计数,乘法才是"搭配组合"的计数。想通这一点,后面遇到"先走几步再走几步"的组合类题目就不容易错。

3.2 初始化的一行代码,很多人写错

初始化是这道题最容易翻车的地方。第一行的每个格子,只能一路向右走来,所以路径数都是 1;第一列的每个格子,只能一路向下走来,路径数也都是 1。所以dp[0][j] = 1、dp[i][0] = 1天然成立。

有些写法是把整个表先填 0,然后单独给第一行第一列赋 1,再跑双重循环。这也没问题,但更简洁的做法是像我上面那样,初始化的时候就构造[[1] * n for _ in range(m)],把整个表先填成 1,这样第一行第一列天然正确,内层循环直接从 (1, 1) 开始。

这里有一个新手常踩的坑:有人把初始化写成[[1] * n] * m,这在 Python 里会出大问题。外层用乘法复制的是同一个列表对象的引用,你改dp[0][0],会发现整列都跟着变。正确的写法必须是列表推导式[[1] * n for _ in range(m)],每个子列表都是独立对象。这种引用共享问题在面试手写代码时特别容易暴露,我见过不止一个候选人在白板上栽在这里。

3.3 组合数学解法与大数溢出问题

不同路径其实还有一层数学本质:从左上角到右下角,一共要走m-1次向下、n-1次向右,总共m+n-2步。路径的区别只在于这 m+n-2 步中,哪些步选择向下。所以方案数就是组合数C(m+n-2, m-1),也就是从 m+n-2 个位置里选 m-1 个位置作为向下走的步。代码可以写成:

import math def unique_paths_math(m: int, n: int) -> int: return math.comb(m + n - 2, min(m - 1, n - 1))

用min(m-1, n-1)是为了选较小的那个数来计算,减少运算量。这个解法的时间复杂度是 O(min(m, n)),比 DP 的 O(m*n) 更快,但有个致命问题:组合数增长极快。m=100、n=100 的时候,组合数的数量级已经超出了一个普通的 64 位整数能表示的范围。很多在线评测系统里,这道题给的返回值是 int,但如果你在本地测试大矩阵,就会看到整数溢出变成负数。

这时候你得想清楚平台约束:如果题目不要求取模,结果保证在 int 范围内,那么组合数学解法非常优雅;如果要求取模或者矩阵特别大,老老实实写动态规划反而没有溢出烦恼,因为每一步加法都只保留最终计数。另外一个细节是,如果 m 和 n 有一个是 1,组合数学公式依然成立,因为C(n-1, 0) = 1,正好只有一条直线路径。

4. 最小路径和:求最优值的边界陷阱

4.1 状态转移与第一行第一列的特殊处理

"最小路径和"的题目表述是这样的:给定一个包含非负整数的 m x n 网格 grid,每次只能向下或者向右移动一步,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

状态定义依然是dp[i][j]表示从左上角走到 (i, j) 的最小路径和。转移方程是dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])。因为你最后一步要么从上面下来,要么从左边过来,选择代价更小的那条路,再加上当前格子的值,就是到当前格子的最小代价。

但这里和"不同路径"有一个本质区别:第一行和第一列不能统一赋 1 了,因为每个格子上的数字不同,路径和必须累加。第一行只能一路向右走,所以dp[0][j] = dp[0][j-1] + grid[0][j];第一列只能一路向下走,所以dp[i][0] = dp[i-1][0] + grid[i][0]。起点dp[0][0]就是grid[0][0]本身。

我总结一个规律:凡是"求方案数"的题,第一行第一列通常初始化成 1 或 0 这种定量;凡是"求路径和/代价"的题,第一行第一列必须逐格累加原始数据。很多人在不同路径里习惯了初始化成 1,做最小路径和的时候想当然地也填 1,结果答案完全不对。一定要先搞清楚每个状态的含义,再决定初始化的值。

4.2 三份代码的对比:二维、一维、原地修改

第一版是标准的二维 DP:

def min_path_sum(grid): m, n = len(grid), len(grid[0]) dp = [[0] * n for _ in range(m)] dp[0][0] = grid[0][0] for j in range(1, n): dp[0][j] = dp[0][j - 1] + grid[0][j] for i in range(1, m): dp[i][0] = dp[i - 1][0] + grid[i][0] for i in range(1, m): for j in range(1, n): dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1]) return dp[m - 1][n - 1]

第二版是滚动数组优化,只用一行变量:

def min_path_sum_1d(grid): m, n = len(grid), len(grid[0]) dp = [0] * n dp[0] = grid[0][0] for j in range(1, n): dp[j] = dp[j - 1] + grid[0][j] for i in range(1, m): dp[0] = dp[0] + grid[i][0] for j in range(1, n): dp[j] = grid[i][j] + min(dp[j], dp[j - 1]) return dp[n - 1]

第三版是原地修改原矩阵,不额外开数组:

def min_path_sum_inplace(grid): m, n = len(grid), len(grid[0]) for i in range(m): for j in range(n): if i == 0 and j == 0: continue if i == 0: grid[i][j] += grid[i][j - 1] elif j == 0: grid[i][j] += grid[i - 1][j] else: grid[i][j] += min(grid[i - 1][j], grid[i][j - 1]) return grid[m - 1][n - 1]

三份代码的输出结果完全一样,区别在空间复杂度:第一版 O(m*n),第二版 O(n),第三版 O(1)。做题的时候如果题目允许修改原数组,我建议直接用第三版,又省空间又不用处理额外的初始化逻辑,面试时还能体现你对空间的敏感度。但如果你是在封装给别的模块调用,原数组可能还有别的用途,那就别动它,用第一版最稳妥。

4.3 空间压缩的原理与覆盖顺序讲究

滚动数组为什么能成立?关键在于我们按行遍历的时候,dp[j]这个位置的上一轮值,其实就是上一行同列的结果;而更新时等号右边的dp[j-1],在从左往右遍历的当下已经被更新成了当前行左列的结果。也就是说,dp[j]更新前代表dp[i-1][j],dp[j-1]更新后代表dp[i][j-1],两者正好是转移方程需要的两个值。这就是"边遍历边覆盖"的原理。

这里的覆盖顺序有个必须注意的点:内层循环一定是从左往右,不能从右往左。如果从右往左扫,dp[j-1]还没有被当前行更新,它仍然是上一行的旧值,那你拿到的就是dp[i-1][j-1]而不是dp[i][j-1],结果就错了。这个坑在"01 背包"里恰恰相反——背包从右往左扫是为了避免重复拿同一件物品,很多从背包题转过来的人容易把顺序记混。我的建议是每次写滚动数组都实际推演一行,别死记"从左还是从右"。

5. 常见错误与调试排查实录

5.1 高频错误速查表

我整理了一张表,把这两道题最常见的报错原因和排查方向列出来,都是我在实际刷题和帮人改代码时反复见过的:

错误现象根本原因排查方向
结果比预期小很多初始化第一行第一列错误,比如最小路径和里把第一行初始化成了同一值单独打印第一行和第一列的 dp 值,手动验证累加
报 IndexError遍历范围写错,比如直接用 m 遍历列,或者range(m)和range(n)搞混写出网格的行列数,确认dp[i-1][j]中 i 不为 0
Python 中修改一个格子,整列都变用[[0] * n] * m创建二维数组,子列表是同一个引用改用列表推导式[[0] * n for _ in range(m)]
答案正确但超时用了暴力递归或记忆化搜索没有剪枝改成自底向上填表,复杂度降到 O(m*n)
大矩阵下结果为负数组合数学解法遇到整数溢出改用动态规划,或在题目要求下取模
滚动数组结果混乱内层循环遍历方向错误从左往右扫,并验证 dp[j-1] 是否已被当前行更新

我特别想强调 Python 的引用共享问题。不只是上面说的二维数组乘法,使用copy模块的deepcopy也会带来不必要的开销。写算法题时,最干净的做法就是列表推导式。如果你在调试时发现打印出来的 dp 表对称得诡异,或者改一个格子其它格子跟着变,基本就是这个原因。

5.2 一套可复现的调试方法

遇到 DP 题结果不对,我推荐一个固定的调试套路:先找一个非常小的用例,比如 2x2 或 3x3 的矩阵,手工在纸上把每一步 dp 值算出来,然后让程序打印每一步的 dp 表,逐格对比。

以最小路径和为例,网格[[1, 3, 1], [1, 5, 1], [4, 2, 1]],正确答案是 7。我调试时会在循环里加一行打印:

for i in range(m): for j in range(n): if i == 0 and j == 0: continue if i == 0: grid[i][j] += grid[i][j - 1] elif j == 0: grid[i][j] += grid[i - 1][j] else: grid[i][j] += min(grid[i - 1][j], grid[i][j - 1]) print(grid[i])

你会看到第一行处理后变成[1, 4, 5],第一列处理后第二行变成[2, 5, 1],继续处理最后得到整个矩阵。手动推演一遍后,你会发现错误往往出在"边界格子被错误地用了min而不是累加"上。这类问题肉眼很难看出来,但打印出中间过程就一目了然。

另一个技巧是加断言。如果你确定最终结果应该落在某个范围,比如最小路径和一定不会小于第一行第一列的值,也不会大于所有格子值之和,可以临时加assert帮助快速定位。虽然刷题时不一定需要,但在本地调试阶段它真的能救命。

5.3 两道变式题:障碍物版本与三角版本

搞定了两道基础题,我建议趁热打铁练两道同源变式。

第一道是"不同路径 II",网格中有些格子有障碍物,机器人不能进入。状态转移时,如果当前格子是障碍物,dp[i][j]直接为 0;否则仍然等于上边和左边之和。初始化也要跟着改:第一行第一列一旦遇到障碍物,后面所有格子都到不了,必须是 0。

def unique_paths_with_obstacles(obstacle_grid): m, n = len(obstacle_grid), len(obstacle_grid[0]) dp = [[0] * n for _ in range(m)] if obstacle_grid[0][0] == 1: return 0 dp[0][0] = 1 for j in range(1, n): dp[0][j] = 0 if obstacle_grid[0][j] == 1 else dp[0][j - 1] for i in range(1, m): dp[i][0] = 0 if obstacle_grid[i][0] == 1 else dp[i - 1][0] for j in range(1, n): if obstacle_grid[i][j] == 1: dp[i][j] = 0 else: dp[i][j] = dp[i - 1][j] + dp[i][j - 1] return dp[m - 1][n - 1]

第二道是"三角形最小路径和",三角形类似一个下三角矩阵,从顶点到底部的最小路径和。它的状态转移变成dp[i][j] = triangle[i][j] + min(dp[i-1][j-1], dp[i-1][j]),因为上一行只有两个格子可能到达当前位置。这类变式的核心难点就在于边界格子的处理,比如每行的第一个格子只能从上一行的第一个格子来,每行的最后一个格子只能从上一行的倒数第二个来。我建议你把三角形补齐成一个矩形,多出来的格子填一个大数,这样就能复用矩形的通用逻辑,避免一堆分支判断。

这两道变式题做完,你会发现一个规律:所有的网格型 DP 题目,核心就是"状态定义 + 转移方程 + 边界处理"三件套。题目的包装再多,只要这三件套想清楚,代码基本是一次写对的。

6. 写在最后:几个让我受益的习惯

这两道题我前前后后写了不下五遍,每次重写都有新体会。最后分享几个我自己的习惯,希望能帮你少走弯路。

第一个习惯是先写暴力递归再优化。哪怕面试的时候时间紧张,我也会先在草稿纸上写出递归的思路,再转化成 DP。这个动作看起来多花了半分钟,但它能帮你验证状态转移的正确性。递归版本如果逻辑错了,DP 版本一定也是错的,而且错得更难发现。

第二个习惯是每道 DP 题都追问自己三个问题:状态是什么?转移从哪里来?边界在哪里?这三个问题如果都能准确回答,不用看代码,你就能判断解法对不对。我自己面试别人时也常问这三连,能答得干脆利落的候选人,代码水平一般都不差。

第三个习惯是关于空间优化的。新手阶段我建议先把二维版本写对,再去优化成一维或原地修改。直接跳步写滚动数组,一旦出错,你很难判断是算法本身错了还是空间压缩过程引入的 bug。顺序是:先正确,再高效。这也是为什么我这篇博文把二维版本和一维版本都完整写出来对比。

最后再提一句:如果你是在准备面试,这两道题的"标准解法"要熟练到不用思考就能默写,但更重要的是能把转移方程的推导过程讲清楚。面试官最想看的不是你会背题,而是你能在 chat 里现场分析出一道从没见过的变式题。矩阵里的动态规划千变万化,但核心永远是把大问题拆成"只差一步"的小问题,找到那个递推关系,一切就都顺了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 23:32:14

PaddlePaddle手写数字识别:工业级图像分类Pipeline实战

1. 项目概述:为什么一个“手写数字识别”demo值得你花20分钟认真读完“附源码|PaddlePaddle实现手写数字识别”——这个标题在技术社区里看起来平平无奇,甚至有点过时。毕竟MNIST数据集被用烂了,从TensorFlow到PyTorch&#xff0c…

作者头像 李华
网站建设 2026/10/15 23:31:59

跳表:给有序链表装上电梯,从原理到调优一次讲透

先说一个特别朴素的困惑:为什么有序链表在教科书里永远被当成“低效”的代表,可你跑去拆一个开源数据库的有序集合实现,看到的底层结构却总是跳表(Skip Table,也常叫 Skip List)?第一次接触跳表…

作者头像 李华
网站建设 2026/10/15 23:30:37

AI编程不稳定?将PM与执行者角色拆分是关键解法

说实话,我一开始并没有打算搞什么"角色扮演"。当时的处境很朴素:我在做一个内部项目,代码量一上来,AI编程助手就开始变得时灵时不灵。同一个功能第一版写得挺好的,让它改两个需求点之后,整个模块…

作者头像 李华
网站建设 2026/10/15 23:29:24

SpringBoot与Vue合并部署Tomcat:war包改造与避坑指南

简介:面向初级与中级开发、运维人员的SpringBootVue前后端分离项目Tomcat部署操作文档,详细说明了一套可复用的部署流程。内容从修改Maven打包方式开始,覆盖将后端打包为WAR包并放入Tomcat的webapps目录、构建Vue前端并将dist产物部署到指定位…

作者头像 李华
网站建设 2026/10/15 23:27:45

基于Django的电商数据分析系统设计与实现全解析

1. 为什么选这个题目:说说我的真实想法又到了毕业设计的季节,后台收到不少同学私信,问得最多的就是“数据分析类毕设到底怎么做才能拿高分”。我看了下大家普遍纠结的点:一是怕题目太大做不完,二是担心答辩时被老师问到…

作者头像 李华
网站建设 2026/10/15 23:26:46

PS5从开箱到玩爽:版本选择、设置与故障排查全指南

“AnyPS5”这个标题我琢磨了一会儿,越琢磨越觉得有意思。表面上看它只是游戏主机型号加了一个“Any”前缀,实际上背后藏着一整类需求:不管是打算入手第一台主机的新玩家,还是已经用了一段时间想折腾明白的老玩家,大家关…

作者头像 李华