🗺️ 路径DP — 最小路径和与不同路径

网格上的DP——从左上到右下,每一步都在做最优决策

📖 网格路径DP概览

网格路径DP是最直观的DP模型:在m*n的网格中从左上角走到右下角,每步只能向右或向下。核心状态定义:dp[i][j] = 到达位置(i,j)的某个属性(最小和、路径数等)。

网格路径DP模型: +---+---+---+---+---+---+---+ | S -> -> -> -> -> -> | S=起点 +---+---+---+---+---+---+ E=终点 | v | v | v | v | v | v | ->=向右 +---+---+---+---+---+---+ v=向下 | v | v | v | v | v | E | +---+---+---+---+---+---+ 三大模型: +-- 路径计数: dp[i][j] = dp[i-1][j] + dp[i][j-1] +-- 最短/最小路径: dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + cost +-- 障碍处理: 障碍位置dp=0或inf

1. 网格DP的通用框架

def grid_dp(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] = f(dp[i-1][j], dp[i][j-1]) + grid[i][j]
    return dp[m-1][n-1]

🔬 题目一:最小路径和(LC 64)

给定m*n的非负整数网格,找到从左上到右下的路径,使路径上数字之和最小。

思路分析

最小路径和示例: grid: dp: [1, 3, 1] [1, 4, 5] [1, 5, 1] --> [2, 7, 6] [4, 2, 1] [6, 8, 7] <-- 答案=7 dp[1][2] = min(dp[0][2], dp[1][1]) + 1 = min(5, 7) + 1 = 6
DP分析:
1. 状态:dp[i][j] = 从(0,0)到(i,j)的最小路径和
2. 转移:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
3. 初始:第一行只能从左来,第一列只能从上来
4. 结果:dp[m-1][n-1]

代码实现

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        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] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
        return dp[m-1][n-1]

# 原地修改版本 O(1)空间
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        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]

复杂度分析

时间: O(m * n) 空间: O(m * n),原地修改O(1)

🔬 题目二:不同路径(LC 62)

一个m*n的网格,从左上到右下有多少条不同路径?

思路分析

不同路径计数: m=3, n=7 的dp表: 1 1 1 1 1 1 1 1 2 3 4 5 6 7 1 3 6 10 15 21 28 dp[i][j] = dp[i-1][j] + dp[i][j-1] 到达(i,j) = 从上面来 + 从左边来 本质: 组合数学 C(m+n-2, m-1) 但面试推荐DP写法,更通用

代码实现

class Solution:
    def uniquePaths(self, 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]

# 组合数学版本
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        from math import comb
        return comb(m + n - 2, m - 1)

复杂度分析

时间: O(m * n) 空间: O(m * n),可优化到O(n)

🔬 题目三:不同路径II(LC 63)

网格中有障碍物,求从左上到右下的不同路径数。

思路分析

障碍处理策略:
1. 障碍位置的dp值=0(无法到达)
2. 初始化第一行/列时:障碍位置及其后续全为0
3. 填表时:若grid[i][j]为障碍,dp[i][j]=0;否则正常转移
4. 关键:一旦某行/列出现障碍,其后的位置都无法通过该行/列到达

代码实现

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        m, n = len(obstacleGrid), len(obstacleGrid[0])
        dp = [[0] * n for _ in range(m)]
        dp[0][0] = 1 if obstacleGrid[0][0] == 0 else 0
        for j in range(1, n):
            dp[0][j] = dp[0][j-1] if obstacleGrid[0][j] == 0 else 0
        for i in range(1, m):
            dp[i][0] = dp[i-1][0] if obstacleGrid[i][0] == 0 else 0
        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]
        return dp[m-1][n-1]

复杂度分析

时间: O(m * n) 空间: O(m * n)

网格DP的扩展方向:允许8方向移动用BFS;有权重/负数用Dijkstra;三角形网格(LC 120);多起点多终点。核心都是定义好状态和转移。

面试实战技巧

在实际面试中,算法题不仅仅是写代码,更考察沟通、分析和解决问题的能力。以下是一些实战技巧:

1. 解题步骤模板

5分钟分析:理解题意 - 确认边界 - 想暴力解 - 优化 - 写代码

1. 先复述题目,确认理解无误
2. 问清边界条件:空输入?负数?溢出?
3. 先说暴力解法(证明你理解问题)
4. 分析瓶颈,提出优化思路
5. 边写代码边解释思路

2. 常见陷阱

通用陷阱:
1. 索引越界:特别注意循环范围和数组边界
2. 初始化遗漏:DP的初始条件是否正确?
3. 遍历方向:依赖关系决定遍历顺序
4. 整数溢出:Python不溢出但其他语言注意
5. 边界条件:空数组、单元素、全相同

3. 代码模板

# 通用解题模板
class Solution:
    def solve(self, input_data):
        # 1. 边界检查
        if not input_data:
            return default_value
        # 2. 初始化
        result = initial_value
        state = initial_state
        # 3. 主循环
        for item in input_data:
            state = transition(state, item)
            result = max/min/update(result, state)
        # 4. 返回结果
        return result
面试中的代码不是越短越好,而是越清晰越好。适当的注释、合理的变量名、分步骤的实现,比一行式解法更能展示工程素养。
成就解锁:路径规划师 — 掌握网格路径DP的三大模型
LeetCode AC验证:LC 64 Minimum Path Sum ✅ | LC 62 Unique Paths ✅ | LC 63 Unique Paths II ✅

📝 课后练习

  1. LC 120 三角形最小路径和
  2. LC 931 下降路径最小和
  3. LC 174 地下城游戏(反向DP)
  4. LC 1289 下降路径最小和II

🔑 本课要点回顾

📚 扩展阅读

思考题:LC 174地下城游戏为什么需要从右下往左上反向DP?正向DP为什么不行?

⚡ 进阶专题:常见变体与扩展

每道经典题目都有丰富的变体。掌握核心模板后,灵活应对变体才是面试的真正挑战。以下列举本课相关的高频变体和扩展思路。

1. 常见题目变体

条件变化型:在原题基础上增加约束条件,如:数组中有负数、元素有上限、需要考虑溢出等。核心思路:不改变DP框架,只在转移时增加条件判断。

维度扩展型:从一维DP扩展到二维或三维。如:从线性到环形(LC 213)、从单串到双串(LCS)、从无约束到有约束。核心思路:增加状态维度来编码额外信息。

目标变化型:从求最值变为求方案数、求具体方案、判定可行性。核心思路:dp存的值从max/min变为count/boolean,转移从取最值变为累加。

2. 面试中的沟通技巧

# 面试沟通模板
# 1. 先说思路(1-2分钟)
"这道题我想到两种解法,先说更直观的一种..."

# 2. 分析复杂度(30秒)
"时间复杂度O(n^2),空间可以优化到O(n)"

# 3. 边写边说
"这里我定义dp[i]表示...,转移方程是..."

# 4. 主动验证
"我用示例走一遍:dp[0]=..., dp[1]=..."

# 5. 讨论优化
"空间还可以进一步优化,因为dp[i]只依赖..."

3. 代码调试检查清单

检查项常见错误
初始条件dp[0]是否正确?空输入是否处理?
遍历范围循环从0还是1开始?是否越界?
状态转移依赖的状态是否已计算?
返回值是dp[n]还是max(dp)?
边界情况单元素、全相同、空数组
面试中主动验证是最被低估的技巧。用示例手动走一遍DP表,既能发现bug,又能展示你的严谨性。很多面试官就是看你能否主动发现和修正错误。

⚡ 路径DP变形大全

网格路径DP的变形非常丰富,以下是最常见的变体。

变体题目DP定义特殊处理
三角形网格LC 120dp[i][j]=到(i,j)最小和每行长度不同
3方向下降LC 931dp[i][j]=到(i,j)最小和从左上/正上/右上来
反向DPLC 174dp[i][j]=从(i,j)到终点最少血量从右下往左上
障碍网格LC 63dp[i][j]=路径数障碍位=0
必须经过所有空格LC 980bitmask+位置状态压缩

为什么LC 174需要反向DP?

正向DP的问题:dp[i][j]表示到达(i,j)时的最大血量,但无法保证后续路径不会死掉。因为"当前血量最多"不等于"后续能活下来"。

反向DP的思路:dp[i][j]表示从(i,j)到终点所需的最少初始血量。这样每个位置的需求是由后续路径决定的,不存在"不知道未来"的问题。

核心洞察:当问题需要"保证全程不违反约束"时,反向DP往往比正向DP更合适。