LEETCODE 064Medium

最小路径和

只能向右或向下,意味着每个格子的来源只有上、左两个——取较小者累加,网格本身就是一张现成的 dp 表。

问题拆解

m × n 的非负整数网格,从左上角走到右下角,每步只能向右或向下,求经过数字总和最小的路径。

路径条数是组合数级别的,逐条枚举不现实。贪心地每步挑相邻较小的格子也不行——眼前的小格子可能把你引进一片大数字区。但“只能向右或向下”这个限制给了 DP 很好的结构:走到格子 (i, j) 的最后一步,要么从上方 (i-1, j) 下来,要么从左方 (i, j-1) 过来,没有第三种可能。

于是定义 dp[i][j] 为走到 (i, j) 的最小路径和,两个来源取较小者,再加上当前格子的值:

dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

首行只能从左来、首列只能从上来,是天然的边界。

移动方向受限的网格题,先问一句“到达每个格子的前驱有哪些”——前驱集合小而固定,就是标准的网格 DP。

一维滚动数组

按行从上往下、行内从左往右填表时,算 dp[i][j] 只用到本行左边一格和上一行同列一格。这两个值恰好可以挤在同一个一维数组里:外层循环推进到第 i 行时,dp[j] 还没被覆盖前存的是上一行的值(正上方),dp[j-1] 已经被覆盖成本行的值(左方),更新顺序天然对齐,不需要第二个数组。

public int minPathSum(int[][] grid) {
    int m = grid.length, n = grid[0].length;
    int[] dp = new int[n];
    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 < m; i++) {
        dp[0] += grid[i][0]; // 首列只能从上来
        for (int j = 1; j < n; j++) {
            // 此刻 dp[j] 是上一行的值,dp[j-1] 是本行的值
            dp[j] = Math.min(dp[j], dp[j - 1]) + grid[i][j];
        }
    }
    return dp[n - 1];
}
def minPathSum(grid: List[List[int]]) -> int:
    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] += grid[i][0]  # 首列只能从上来
        for j in range(1, n):
            # 此刻 dp[j] 是上一行的值,dp[j-1] 是本行的值
            dp[j] = min(dp[j], dp[j - 1]) + grid[i][j]
    return dp[n - 1]
func minPathSum(grid [][]int) int {
    m, n := len(grid), len(grid[0])
    dp := make([]int, n)
    dp[0] = grid[0][0]
    for j := 1; j < n; j++ {
        dp[j] = dp[j-1] + grid[0][j] // 首行只能从左来
    }
    for i := 1; i < m; i++ {
        dp[0] += grid[i][0] // 首列只能从上来
        for j := 1; j < n; j++ {
            // 此刻 dp[j] 是上一行的值,dp[j-1] 是本行的值
            dp[j] = min(dp[j], dp[j-1]) + grid[i][j]
        }
    }
    return dp[n-1]
}
pub fn min_path_sum(grid: Vec<Vec<i32>>) -> i32 {
    let (m, n) = (grid.len(), grid[0].len());
    let mut dp = vec![0; n];
    dp[0] = grid[0][0];
    for j in 1..n {
        dp[j] = dp[j - 1] + grid[0][j]; // 首行只能从左来
    }
    for i in 1..m {
        dp[0] += grid[i][0]; // 首列只能从上来
        for j in 1..n {
            // 此刻 dp[j] 是上一行的值,dp[j-1] 是本行的值
            dp[j] = dp[j].min(dp[j - 1]) + grid[i][j];
        }
    }
    dp[n - 1]
}

最容易忽略的是每行开头那句 dp[0] += grid[i][0]:首列没有左邻居,必须单独累加,忘了它 dp[0] 会一直停在首行的值上,整张表跟着错。如果不想处理这些边界,也可以把 dp 开成 n + 1 长并用一个大数当哨兵,但对这题而言直接特判首行首列更直观。

顺带说明:能压缩成一维的前提是我们只要“最小和”这个数值。如果题目还要求输出路径本身,就得保留整张二维表(或来源方向)用于回溯,压缩会把还原路径需要的信息丢掉。

复杂度

指标 复杂度 原因
时间 O(m × n) 每个格子恰好计算一次
空间 O(n) 滚动数组只保留一行

可以迁移的模式

  • 网格 DP 的骨架:dp[i][j] 由固定的几个前驱转移而来,方向限制越死结构越清晰;
  • 转移只依赖上一行时,按“行外层、列内层且从左到右”的顺序更新,一维数组可同时充当新旧两行;
  • 首行首列这类“来源不全”的边界要单独初始化,这是网格 DP 最高频的出错点。

把 min 换成求和就是不同路径(62 题),换成 max 就是最大礼物价值——同一张表,三种问法。