📖 第81课:最小路径和

0 阅读17分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第81课:最小路径和

模块:动态规划 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/mi… 前置知识:第80课(不同路径)、第72课(杨辉三角) 预计学习时间:25分钟


🎯 题目描述

给定一个 m x n 的网格,网格中每个格子都有一个非负数。你需要从左上角出发,走到右下角,每一步只能向右或向下移动。请找出一条路径,使得路径上的所有数字之和最小。

示例:

输入:grid = [[1,3,1],
             [1,5,1],
             [4,2,1]]
输出:7
解释:路径 13111 的总和最小

约束条件:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 200
  • 0 <= grid[i][j] <= 100

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
单格子[[5]]5最小输入
单行[[1,2,3]]6只能向右
单列[[1],[2],[3]]6只能向下
包含0[[0,0],[0,0]]0特殊值处理
最大规模200x200网格性能边界

💡 思路引导

生活化比喻

想象你在一个山地公园徒步,从西北角的入口走到东南角的出口。每走过一个路段都要消耗一定的体力(格子上的数字),你只能向东或向南走。

🐌 笨办法:尝试所有可能的路径,记录每条路径的总体力消耗,最后选最省力的那条。这意味着每个岔路口都要"分身"去探索,共有C(m+n-2, m-1)条路径,数量庞大。

🚀 聪明办法:站在任何一个岔路口时,只需要知道"从起点到这个路口的最省力走法"。因为无论过去怎么走,从这个路口往后的最优路径只取决于"这个路口的最小累计体力值"。这就是动态规划的核心思想!

关键洞察

每个格子(i,j)的最小路径和 = 当前格子的值 + min(从上方来的最小路径和, 从左方来的最小路径和)


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:m x n的二维网格,每个格子有非负整数
  • 输出:从(0,0)到(m-1,n-1)的最小路径和(整数)
  • 限制:每步只能向右或向下,无法回退或斜走

Step 2:先想笨办法(暴力法)

用DFS遍历所有可能的路径,每条路径记录总和,最后取最小值。

  • 时间复杂度:O(2^(m+n)) — 每个格子都有向右/向下两种选择
  • 瓶颈在哪:大量重复计算,比如到达(1,1)的路径会被多次计算

Step 3:瓶颈分析 → 优化方向

观察发现,到达同一个格子(i,j)的最小路径和是固定的,与之后的路径无关。

  • 核心问题:重复计算到达每个格子的最小路径和
  • 优化思路:用表格记录每个格子的最小路径和,避免重复计算 → 动态规划

Step 4:选择武器

  • 选用:网格DP
  • 理由:符合最优子结构(大问题可分解为子问题),且有重叠子问题(同一格子会被多次访问)

🔑 模式识别提示:当题目出现"网格中的路径问题+求最值",优先考虑"网格DP"


🔑 解法一:二维DP(标准网格DP)

思路

建立一个与原网格同样大小的dp数组,dp[i][j]表示从(0,0)到(i,j)的最小路径和。通过填表的方式,从左上角逐步推导到右下角。

图解过程

原始网格:
  0  1  2
0[1, 3, 1]
1[1, 5, 1]
2[4, 2, 1]

Step 1: 初始化第一行和第一列(只有一个方向可走)
dp[0][0] = 1
dp[0][1] = 1+3 = 4
dp[0][2] = 4+1 = 5
dp[1][0] = 1+1 = 2
dp[2][0] = 2+4 = 6

  0  1  2
0[1, 4, 5]
1[2, ?, ?]
2[6, ?, ?]

Step 2: 填充dp[1][1]
从上方来:dp[0][1] + grid[1][1] = 4+5 = 9
从左方来:dp[1][0] + grid[1][1] = 2+5 = 7
取较小值:dp[1][1] = 7

  0  1  2
0[1, 4, 5]
1[2, 7, ?]
2[6, ?, ?]

Step 3: 继续填充
dp[1][2] = min(dp[0][2], dp[1][1]) + 1 = min(5,7) + 1 = 6
dp[2][1] = min(dp[1][1], dp[2][0]) + 2 = min(7,6) + 2 = 8
dp[2][2] = min(dp[1][2], dp[2][1]) + 1 = min(6,8) + 1 = 7

最终DP表:
  0  1  2
0[1, 4, 5]
1[2, 7, 6]
2[6, 8, 7] ← 答案

边界示例 — 单行网格[1,2,3]:

dp = [1, 3, 6] (每步累加)

Python代码

from typing import List


def minPathSum(grid: List[List[int]]) -> int:
    """
    解法一:二维DP
    思路:dp[i][j]表示从(0,0)到(i,j)的最小路径和
    """
    if not grid or not grid[0]:
        return 0

    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]


# ✅ 测试
print(minPathSum([[1,3,1],[1,5,1],[4,2,1]]))  # 期望输出:7
print(minPathSum([[1,2,3],[4,5,6]]))          # 期望输出:12
print(minPathSum([[1]]))                       # 期望输出:1

复杂度分析

  • 时间复杂度:O(m*n) — 需要填充整个dp表,每个格子计算一次
    • 具体地说:如果网格是100x100,大约需要10,000次操作
  • 空间复杂度:O(m*n) — dp表与原网格同样大小

优缺点

  • ✅ 思路清晰,容易理解和实现
  • ✅ 适合初学者掌握网格DP的标准模式
  • ❌ 空间占用较大,当网格很大时可能内存不够

🏆 解法二:一维DP滚动数组(最优解 — 空间优化)

优化思路

观察到填表时,计算dp[i][j]只需要用到dp[i-1][j]和dp[i][j-1],即只需要"上一行"和"当前行的左边"。因此可以用一维数组滚动更新,节省空间。

💡 关键想法:一维数组中的dp[j]在更新前存储的是"上一行的dp[j]"(即dp[i-1][j]),更新后变为"当前行的dp[j]"(即dp[i][j])

图解过程

网格:
  0  1  2
0[1, 3, 1]
1[1, 5, 1]
2[4, 2, 1]

初始:dp = [1, 4, 5] (第一行)

处理第二行(i=1):
j=0: dp[0] = grid[1][0] + dp[0] = 1+1 = 2
     dp = [2, 4, 5]
j=1: dp[1] = grid[1][1] + min(dp[0], dp[1]) = 5+min(2,4) = 7
     dp = [2, 7, 5]
j=2: dp[2] = grid[1][2] + min(dp[1], dp[2]) = 1+min(7,5) = 6
     dp = [2, 7, 6]

处理第三行(i=2):
j=0: dp[0] = 4+2 = 6 → dp = [6, 7, 6]
j=1: dp[1] = 2+min(6,7) = 8 → dp = [6, 8, 6]
j=2: dp[2] = 1+min(8,6) = 7 → dp = [6, 8, 7] ← 答案

Python代码

def minPathSumOptimized(grid: List[List[int]]) -> int:
    """
    解法二:一维DP滚动数组
    思路:用一维数组滚动更新,dp[j]复用为"上一行的j"和"当前行的j"
    """
    if not grid or not grid[0]:
        return 0

    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] = grid[i][j] + min(dp[j], dp[j - 1])
            #               上方来 ↑   左方来 ←

    return dp[n - 1]


# ✅ 测试
print(minPathSumOptimized([[1,3,1],[1,5,1],[4,2,1]]))  # 期望输出:7
print(minPathSumOptimized([[1,2,3],[4,5,6]]))          # 期望输出:12

复杂度分析

  • 时间复杂度:O(m*n) — 与解法一相同,每个格子计算一次
  • 空间复杂度:O(n) — 只需要一维数组,大幅节省空间

⚡ 解法三:原地修改(极致空间优化,面试慎用)

优化思路

如果允许修改原数组,可以直接在grid上进行DP操作,空间复杂度降为O(1)。

⚠️ 注意:这种做法破坏了原始数据,在实际工程中通常不推荐,但在算法面试中可以作为"展示优化思路"的加分项。

Python代码

def minPathSumInPlace(grid: List[List[int]]) -> int:
    """
    解法三:原地修改
    思路:直接在原网格上累加,grid[i][j]最终存储从(0,0)到(i,j)的最小路径和
    """
    if not grid or not grid[0]:
        return 0

    m, n = len(grid), len(grid[0])

    # 初始化第一行
    for j in range(1, n):
        grid[0][j] += grid[0][j - 1]

    # 初始化第一列
    for i in range(1, m):
        grid[i][0] += grid[i - 1][0]

    # 填充其余格子
    for i in range(1, m):
        for j in range(1, n):
            grid[i][j] += min(grid[i - 1][j], grid[i][j - 1])

    return grid[m - 1][n - 1]


# ✅ 测试
print(minPathSumInPlace([[1,3,1],[1,5,1],[4,2,1]]))  # 期望输出:7

复杂度分析

  • 时间复杂度:O(m*n)
  • 空间复杂度:O(1) — 不使用额外空间

🐍 Pythonic 写法

利用zip和列表推导式的简洁写法:

# 使用functools.reduce进行行级滚动更新
from functools import reduce

def minPathSumPythonic(grid: List[List[int]]) -> int:
    """Pythonic写法:使用reduce进行行级更新"""
    def update_row(prev_row, curr_row):
        new_row = [prev_row[0] + curr_row[0]]
        for i in range(1, len(curr_row)):
            new_row.append(curr_row[i] + min(new_row[i-1], prev_row[i]))
        return new_row

    # 初始化第一行
    first_row = grid[0]
    for i in range(1, len(first_row)):
        first_row[i] += first_row[i-1]

    return reduce(update_row, grid[1:], first_row)[-1]

这个写法展示了函数式编程的思想,将"逐行更新"抽象为reduce操作。

⚠️ 面试建议:先写清晰版本展示思路,再提Pythonic写法展示语言功底。 面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度解法一:二维DP🏆 解法二:一维DP(最优)解法三:原地修改
时间复杂度O(m*n)O(m*n) ← 时间最优O(m*n)
空间复杂度O(m*n)O(n) ← 空间优化O(1)
代码难度简单中等简单
面试推荐⭐⭐⭐⭐⭐ ← 首选
适用场景学习标准DP面试首选允许修改原数组时

为什么解法二是最优解:

  • 时间复杂度O(m*n)已经是最优(至少要遍历所有格子一遍)
  • 空间从O(m*n)优化到O(n),在网格很大时内存节省显著(如200x200网格,从40000降到200)
  • 不破坏原数组,工程实践中更安全
  • 代码仍然清晰易懂,面试中容易写对

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道"最小路径和"问题。

:(审题30秒)好的,这道题要求从网格左上角走到右下角,每步只能向右或向下,找出路径和最小的路径。让我先想一下...

我的第一个想法是用DFS暴力枚举所有路径,时间复杂度是O(2^(m+n)),对于大规模网格会超时。

不过这是一个典型的网格DP问题。我们可以用dp[i][j]表示从起点到(i,j)的最小路径和。状态转移方程是:dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])。

时间复杂度优化到O(m*n),空间可以进一步优化到O(n)使用滚动数组。

面试官:很好,请写一下代码。

:(边写边说)

def minPathSum(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] += grid[i][0]  # 第一列只能从上方来
        for j in range(1, n):
            dp[j] = grid[i][j] + min(dp[j], dp[j-1])
            # dp[j]是上一行的值,dp[j-1]是当前行左边的值

    return dp[n-1]

面试官:测试一下?

:用示例[[1,3,1],[1,5,1],[4,2,1]]走一遍... 初始dp=[1,4,5],处理第二行后dp=[2,7,6],处理第三行后dp=[6,8,7],返回7。正确!

再测一个边界情况[[1]],返回1。结果正确。

高频追问

追问应答策略
"还有更优解吗?""时间O(mn)已经是最优(必须遍历所有格子),空间已从O(mn)优化到O(n)。如果允许修改原数组,可以做到O(1)空间,但破坏了输入数据。"
"如果网格非常大怎么办?""可以分块处理,每次加载部分数据到内存,或使用流式处理。如果网格在磁盘上,可以按行读取,只在内存中保留一行的DP数组。"
"能不能空间O(1)?""可以直接在原网格上修改,但会破坏输入数据。实际工程中不推荐,除非明确允许。"
"路径可以往回走呢?""那就不能用DP了,需要用Dijkstra最短路径算法或BFS,复杂度会上升到O(mn*log(mn))。"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:二维数组初始化 — 避免浅拷贝陷阱
dp = [[0] * n for _ in range(m)]  # ✅ 正确
dp = [[0] * n] * m  # ❌ 错误:m行共享同一个列表

# 技巧2:滚动数组原地更新 — 复用变量节省空间
for i in range(1, m):
    dp[0] += grid[i][0]  # 先更新第一个元素
    for j in range(1, n):
        dp[j] = grid[i][j] + min(dp[j], dp[j-1])
        # dp[j]还未更新时是"上一行的值",更新后变为"当前行的值"

💡 底层原理(选读)

为什么滚动数组可以工作?

在二维DP中,计算dp[i][j]时只依赖dp[i-1]j和dp[i]j-1。滚动数组利用了"从左到右更新"的顺序:

  • 当前位置j更新前,dp[j]存储的是上一行的值(即dp[i-1][j])
  • 当前位置j更新后,dp[j]存储的是当前行的值(即dp[i][j])
  • dp[j-1]已经在本轮更新过,是当前行左边的值

这种"旧值被新值覆盖但仍能及时使用"的技巧是滚动数组的核心。

算法模式卡片 📐

  • 模式名称:网格DP(Grid DP)
  • 适用条件:
    1. 在m×n网格中找最优路径/方案
    2. 每步只能向右/向下移动(单向性)
    3. 当前状态只依赖相邻格子的状态
  • 识别关键词:"网格"、"路径"、"最小/最大"、"只能向右/向下"
  • 模板代码:
# 标准网格DP模板
def gridDP(grid):
    m, n = len(grid), len(grid[0])
    dp = [[0] * n for _ in range(m)]

    # 初始化第一行和第一列
    dp[0][0] = grid[0][0]
    for i in range(1, m):
        dp[i][0] = dp[i-1][0] + grid[i][0]
    for j in range(1, n):
        dp[0][j] = dp[0][j-1] + grid[0][j]

    # 填充其余格子
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = grid[i][j] + 某个函数(dp[i-1][j], dp[i][j-1])

    return dp[m-1][n-1]

# 空间优化版(滚动数组)
def gridDPOptimized(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] += grid[i][0]
        for j in range(1, n):
            dp[j] = grid[i][j] + 某个函数(dp[j], dp[j-1])

    return dp[n-1]

易错点 ⚠️

  1. 忘记初始化第一行和第一列

    • 错误:直接从dp[1][1]开始填表
    • 解释:第一行只能从左边累加,第一列只能从上边累加,需要单独初始化
    • 正确做法:先初始化边界,再填充内部格子
  2. 滚动数组更新顺序错误

    • 错误:
    for j in range(1, n):
        dp[j] = grid[i][j] + min(dp[j], dp[j-1])
    dp[0] += grid[i][0]  # ❌ 第一列应该先更新
    
    • 解释:如果先更新j>=1的列,dp[1]会错误地使用未更新的dp[0]
    • 正确做法:每行先更新dp[0],再从左到右更新其余列
  3. 混淆"路径数"和"路径和"

    • 本题求的是"最小路径和",状态转移是min(上,左)+当前值
    • 如果是第80课的"不同路径"(求路径数),状态转移是dp[i][j] = dp[i-1][j] + dp[i][j-1]
    • 注意题目要求的是"最值"还是"计数"

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:成本优化路径规划

    • 在云计算中,数据从源节点传输到目标节点,每个中继节点都有流量成本
    • 可以建模为网格DP,找出最低成本的传输路径
  • 场景2:图像处理中的Seam Carving

    • 图像内容感知缩放算法,通过移除"能量最低"的像素缝(seam)来缩小图像
    • 从上到下找一条路径,使得路径上的能量和最小,正是网格DP的应用
  • 场景3:游戏关卡设计

    • 在策略游戏中,角色从起点移动到终点,地形有不同的移动成本
    • 用网格DP计算最优移动路径,引导玩家体验

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 62. 不同路径Medium网格DP路径计数状态转移是加法而非取min
LeetCode 63. 不同路径IIMedium网格DP+障碍处理遇到障碍格子时dp值为0
LeetCode 174. 地下城游戏Hard逆向网格DP从终点倒推,维护"最低健康值"
LeetCode 931. 下降路径最小和Medium网格DP三方向可以向左下/正下/右下移动
LeetCode 120. 三角形最小路径和Medium变形网格DP不是矩形而是三角形,状态转移类似

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:如果网格中每个格子还有一个"通行证"要求(0或1),只有持有通行证才能通过某些格子。你最多可以获得K张通行证。求从左上到右下的最小路径和。

输入:grid = [[1,3,1],[1,5,1],[4,2,1]], pass = [[0,1,0],[0,1,0],[0,0,0]], k = 1 解释:pass[i][j]=1表示该格子需要通行证

💡 提示(实在想不出来再点开)

增加一个维度:dp[i][j][p]表示到达(i,j)且已使用p张通行证的最小路径和

✅ 参考答案
def minPathSumWithPass(grid, pass_grid, k):
    """
    三维DP:dp[i][j][p]表示到(i,j)使用p张通行证的最小路径和
    """
    m, n = len(grid), len(grid[0])
    INF = float('inf')
    dp = [[[INF] * (k+2) for _ in range(n)] for _ in range(m)]

    # 初始化起点
    if pass_grid[0][0] == 0:
        dp[0][0][0] = grid[0][0]
    else:
        dp[0][0][1] = grid[0][0]

    # 填表
    for i in range(m):
        for j in range(n):
            if i == 0 and j == 0:
                continue
            for p in range(k+2):
                # 从上方来
                if i > 0:
                    if pass_grid[i][j] == 0:
                        dp[i][j][p] = min(dp[i][j][p], dp[i-1][j][p] + grid[i][j])
                    elif p > 0:
                        dp[i][j][p] = min(dp[i][j][p], dp[i-1][j][p-1] + grid[i][j])
                # 从左方来(类似逻辑)
                if j > 0:
                    if pass_grid[i][j] == 0:
                        dp[i][j][p] = min(dp[i][j][p], dp[i][j-1][p] + grid[i][j])
                    elif p > 0:
                        dp[i][j][p] = min(dp[i][j][p], dp[i][j-1][p-1] + grid[i][j])

    return min(dp[m-1][n-1])

# 测试
print(minPathSumWithPass([[1,3,1],[1,5,1],[4,2,1]],
                         [[0,1,0],[0,1,0],[0,0,0]], 1))

核心思路:增加一个维度记录"已使用的通行证数量",遇到需要通行证的格子时,从p-1状态转移到p状态。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。