bigywater.tech
算法与数据结构 · LeetCode 64

LeetCode 64. 最小路径和

给定一个 m × n 的非负整数网格,从左上角走到右下角,每次只能向右或向下走一格,求路径上所有数字之和的最小值。和"不同路径"同一个网格结构,但这次每一步有代价,要的是最小值而不是路径数。动态规划:走到 (i, j) 的最小路径和,等于 (i, j) 自己的值,加上走到它上面和左边这两格的最小路径和里较小的那个;第一行只能从左边来,第一列只能从上面来。本文用真实数据展示"每一步走较小的邻居"这个直觉贪心为什么不对,验证动态规划与枚举全部路径的答案一致,给出五种语言的实现,并把填表做成分步演示。

20,000
组随机对拍,与暴力解的结果全部一致
11 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。基准解是递归枚举所有路径取最小值(不做记忆化,只适合小网格)。一共 12 组用例,覆盖:官方示例、单个格子、单行、单列、全是 0、绕过一个很大的数的情形:

真实实测 · 本机执行输出
输入 [[[1,3,1],[1,5,1],[4,2,1]]]
输出 7
输入 [[[1,2,3],[4,5,6]]]
输出 12
输入 [[[5]]]
输出 5
输入 [[[1,2,3]]]
输出 6
输入 [[[1],[2],[3]]]
输出 6
输入 [[[0,0],[0,0]]]
输出 0

陷阱:每一步走较小的邻居,是局部最优不是全局最优

一个自然的想法:站在某个格子,看看右边和下边哪个数更小,就走过去。但走到一个小数之后,后面可能被迫经过更大的数。下面是这种贪心写法与 solve 的真实结果:

真实实测 · 本机执行输出
# 每一步都走较小邻居的贪心写法
[[1, 2, 3], [4, 5, 6]]: 贪心 -> 12    solve -> 12
[[1, 2, 5], [3, 2, 1]]: 贪心 -> 6    solve -> 6
[[1, 3, 1], [1, 5, 1], [4, 2, 1]]: 贪心 -> 9    solve -> 7
[[1, 2], [1, 1]]: 贪心 -> 3    solve -> 3
[[1, 100, 1], [2, 1, 1]]: 贪心 -> 5    solve -> 5
随机 20000 个网格:贪心与最优解不同的有 4991 个(25.0%)

官方示例 [[1, 3, 1], [1, 5, 1], [4, 2, 1]]:贪心从 1 出发,右边是 3、下边是 1,走下面的 1,然后右边 5、下边 4,走 4,之后 2、1,总和是 1 + 1 + 4 + 2 + 1 = 9;而最优路径是向右走 3、1,再向下走 1、1,总和是 7。前两组和第四、五组贪心碰巧和最优一致。在随机生成的 20000 个网格上,贪心写法与最优解不同的有 4991 个,占 25.0%。

一维动态规划:五种语言的实现

dp[j] 是走到当前行第 j 列的最小路径和。对每个格子:起点是它自己;第一行只能从左边来:dp[j] = dp[j-1] + grid[i][j];第一列只能从上面来:dp[j] 保持上一行的值再加上自己;其他格子取 min(dp[j], dp[j-1])(前者是从上面来,后者是从左边来)再加上自己。返回 dp[n-1]。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(grid): m, n = len(grid), len(grid[0]) dp = [0] * n # dp[j]:走到当前行第 j 列的最小路径和 for i in range(m): for j in range(n): if i == 0 and j == 0: dp[j] = grid[0][0] elif i == 0: dp[j] = dp[j - 1] + grid[i][j] # 第一行:只能从左边来 elif j == 0: dp[j] = dp[j] + grid[i][j] # 第一列:只能从上面来(dp[j] 还是上一行的值) else: dp[j] = min(dp[j], dp[j - 1]) + grid[i][j] # 从上面或左边来,取较小的 return dp[n - 1]
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[[[1,3,1],[1,5,1],[4,2,1]]]  →  7
[[[1,2,3],[4,5,6]]]  →  12
[[[5]]]  →  5
[[[1,2,3]]]  →  6
[[[1],[2],[3]]]  →  6
[[[0,0],[0,0]]]  →  0
Java · 解法代码class Solution { public int minPathSum(int[][] grid) { int m = grid.length, n = grid[0].length; int[] dp = new int[n]; // dp[j]:走到当前行第 j 列的最小路径和 for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (i == 0 && j == 0) dp[j] = grid[0][0]; else if (i == 0) dp[j] = dp[j - 1] + grid[i][j]; // 第一行:只能从左边来 else if (j == 0) dp[j] = dp[j] + grid[i][j]; // 第一列:只能从上面来(dp[j] 还是上一行的值) else dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j]; // 从上面或左边来,取较小的 } } return dp[n - 1]; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[[[1,3,1],[1,5,1],[4,2,1]]]  →  7
[[[1,2,3],[4,5,6]]]  →  12
[[[5]]]  →  5
[[[1,2,3]]]  →  6
[[[1],[2],[3]]]  →  6
[[[0,0],[0,0]]]  →  0
Go · 解法代码func minPathSum(grid [][]int) int { m, n := len(grid), len(grid[0]) dp := make([]int, n) // dp[j]:走到当前行第 j 列的最小路径和 for i := 0; i < m; i++ { for j := 0; j < n; j++ { switch { case i == 0 && j == 0: dp[j] = grid[0][0] case i == 0: // 第一行:只能从左边来 dp[j] = dp[j-1] + grid[i][j] case j == 0: // 第一列:只能从上面来(dp[j] 还是上一行的值) dp[j] += grid[i][j] default: // 从上面或左边来,取较小的 if dp[j-1] < dp[j] { dp[j] = dp[j-1] } dp[j] += grid[i][j] } } } return dp[n-1] }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[[[1,3,1],[1,5,1],[4,2,1]]]  →  7
[[[1,2,3],[4,5,6]]]  →  12
[[[5]]]  →  5
[[[1,2,3]]]  →  6
[[[1],[2],[3]]]  →  6
[[[0,0],[0,0]]]  →  0
JavaScript · 解法代码function minPathSum(grid) { const m = grid.length, n = grid[0].length; const dp = new Array(n).fill(0); // dp[j]:走到当前行第 j 列的最小路径和 for (let i = 0; i < m; i++) { for (let j = 0; j < n; j++) { if (i === 0 && j === 0) dp[j] = grid[0][0]; else if (i === 0) dp[j] = dp[j - 1] + grid[i][j]; // 第一行:只能从左边来 else if (j === 0) dp[j] = dp[j] + grid[i][j]; // 第一列:只能从上面来(dp[j] 还是上一行的值) else dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j]; // 从上面或左边来,取较小的 } } return dp[n - 1]; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[[[1,3,1],[1,5,1],[4,2,1]]]  →  7
[[[1,2,3],[4,5,6]]]  →  12
[[[5]]]  →  5
[[[1,2,3]]]  →  6
[[[1],[2],[3]]]  →  6
[[[0,0],[0,0]]]  →  0
C++ · 解法代码#include <algorithm> #include <vector> using namespace std; class Solution { public: int minPathSum(vector<vector<int>>& grid) { int m = grid.size(), n = grid[0].size(); vector<int> dp(n, 0); // dp[j]:走到当前行第 j 列的最小路径和 for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (i == 0 && j == 0) dp[j] = grid[0][0]; else if (i == 0) dp[j] = dp[j - 1] + grid[i][j]; // 第一行:只能从左边来 else if (j == 0) dp[j] = dp[j] + grid[i][j]; // 第一列:只能从上面来(dp[j] 还是上一行的值) else dp[j] = min(dp[j], dp[j - 1]) + grid[i][j]; // 从上面或左边来,取较小的 } } return dp[n - 1]; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[[[1,3,1],[1,5,1],[4,2,1]]]  →  7
[[[1,2,3],[4,5,6]]]  →  12
[[[5]]]  →  5
[[[1,2,3]]]  →  6
[[[1],[2],[3]]]  →  6
[[[0,0],[0,0]]]  →  0
真实实测 · 本机执行输出
共 12 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 12/12
  Java        通过 12/12
  Go          通过 12/12
  JavaScript  通过 12/12
  C++         通过 12/12

耗时实测:动态规划对递归枚举

网格边长 m = n 分别为 6、8、10、12,格子里是 0 到 9 的整数,每个规模取三次中最快的一次:

真实实测 · 本机执行输出
m = n | 暴力解 | 本文解法
         6 |     0.120 ms | 0.002 ms
         8 |     1.749 ms | 0.004 ms
        10 |    24.729 ms | 0.006 ms
        12 |   358.840 ms | 0.009 ms

递归枚举(基准)随网格边长每增加 2,耗时增加约 14 倍到 15 倍(0.120ms、1.749ms、24.729ms、358.840ms),是指数增长;动态规划的耗时只有 0.002 毫秒到 0.009 毫秒,随格子数(n²)缓慢增长。n = 12 时,枚举用了 358.8ms,动态规划 0.009ms。

随机对拍

Python 版本另外和递归枚举做了随机对拍:行数、列数各取 1 到 6,格子取 0 到 9 的随机整数:

真实实测 · 本机执行输出
随机对拍 20000 组(行数、列数各取 1 到 6,格子取 0 到 9 的随机整数),solve 与 brute 的结果全部一致。

另外四种语言只在上面 12 组固定用例上验证过,没有做大规模随机对拍。

分步演示:每格取上面和左边中较小的

下面的演示用官方示例 [[1, 3, 1], [1, 5, 1], [4, 2, 1]]。第一行依次是 1、4、5(只能从左边来);第一列依次是 1、2、6(只能从上面来);其余格子取 min(上面, 左边) 加上自己,例如 (1, 1) 是 min(4, 2) + 5 = 7。最后右下角是 7。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

最小路径和:[[1, 3, 1], [1, 5, 1], [4, 2, 1]]
正在计算的格子已算好的格子

    参考与说明

    • 官方题目页:LeetCode 64. 最小路径和。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:同时输出路径本身(需要保留完整的 dp 表再回溯);格子里有负数的情形。
    京ICP备2026067502号-1京公网安备11010502063487号