📖 第80课:不同路径

3 阅读14分钟

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

📖 第80课:不同路径

模块:动态规划 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/un… 前置知识:第71课(爬楼梯)、第72课(杨辉三角) 预计学习时间:20分钟


🎯 题目描述

机器人位于一个 m × n 网格的左上角,每次只能向下或向右移动一步,试问到达右下角总共有多少条不同的路径。

示例:

输入:m = 3, n = 7
输出:28
解释:从左上角到右下角共有28条不同路径

约束条件:

  • 1 <= m, n <= 100
  • 机器人只能向下或向右移动

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小网格m=1, n=11起点即终点
单行m=1, n=71只能一直向右
单列m=3, n=11只能一直向下
正方形m=3, n=36基本功能
大规模m=100, n=100性能边界

💡 思路引导

生活化比喻

想象你在一个街区的西北角,要走到东南角的家,街区是棋盘状的,你每次只能向东或向南走一个街区。

🐌 笨办法:枚举所有可能的路线,数一数有多少条。假如是10×10的街区,可能的路线数是组合数 C(18,9) ≈ 48620,全部枚举会很慢。

🚀 聪明办法:站在某个路口时,到达这个路口的方法数 = "从上面来"的方法数 + "从左边来"的方法数。从起点开始,逐步推算出每个路口的到达方法数,最后得到终点的答案。

关键洞察

到达 (i, j) 的路径数 = 到达 (i-1, j) 的路径数 + 到达 (i, j-1) 的路径数


🧠 解题思维链

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

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

  • 输入:网格大小 m(行数)、n(列数),范围 1~100
  • 输出:整数,表示不同路径的总数
  • 限制:只能向下或向右移动

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

用递归枚举所有路径:从 (0,0) 出发,每次选择"向下"或"向右",直到到达 (m-1, n-1)。

  • 时间复杂度:O(2^(m+n)) — 每个位置两种选择,总共需要 m+n-2 步
  • 瓶颈在哪:指数级的枚举,存在大量重复计算

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

递归中存在重复子问题。例如到达 (2,3) 的路径,可能从 (1,3) 或 (2,2) 到达,而这两个位置的路径数都会被重复计算多次。

  • 核心问题:每次都要重新计算"到达某个位置的路径数"
  • 优化思路:能不能记住"到达每个位置的路径数",避免重复计算?

Step 4:选择武器

  • 选用:网格动态规划
  • 理由:这是典型的"在网格中计数路径"问题,状态转移清晰(当前格子依赖上方和左方)

🔑 模式识别提示:当题目出现"网格中从起点到终点的路径计数/最值",优先考虑"网格DP"


🔑 解法一:递归+记忆化(朴素法)

思路

用递归计算到达每个位置的路径数,用哈希表缓存已计算的结果,避免重复计算。

图解过程

网格 3×3:
(0,0) → → → (0,2)
  ↓         ↓
(1,0) → → → (1,2)
  ↓         ↓
(2,0) → → → (2,2)

递归树(省略重复节点):
            paths(2,2)
           /          \
      paths(1,2)    paths(2,1)
       /     \        /     \
  paths(0,2) ...  paths(1,1) ...

Python代码

def uniquePaths_memo(m: int, n: int) -> int:
    """
    解法一:递归+记忆化
    思路:递归计算每个位置的路径数,用哈希表缓存
    """
    memo = {}

    def dfs(i: int, j: int) -> int:
        # 边界:到达第一行或第一列,只有1条路径
        if i == 0 or j == 0:
            return 1

        # 查缓存
        if (i, j) in memo:
            return memo[(i, j)]

        # 状态转移:当前位置 = 上方 + 左方
        result = dfs(i - 1, j) + dfs(i, j - 1)
        memo[(i, j)] = result
        return result

    return dfs(m - 1, n - 1)


# ✅ 测试
print(uniquePaths_memo(3, 7))  # 期望输出:28
print(uniquePaths_memo(3, 2))  # 期望输出:3
print(uniquePaths_memo(1, 1))  # 期望输出:1

复杂度分析

  • 时间复杂度:O(m × n) — 每个格子计算一次,共 m×n 个格子
    • 具体地说:如果 m=3, n=7,大约需要 21 次计算
  • 空间复杂度:O(m × n) — 递归栈 + 哈希表

优缺点

  • ✅ 递归思路直观,易于理解状态转移
  • ❌ 空间开销较大(递归栈+哈希表),可以优化

🏆 解法二:二维DP(最优解)

优化思路

用二维数组 dp[i][j] 记录到达每个位置的路径数,从左上角开始逐行/逐列填表,最后返回右下角的值。

💡 关键想法:定义 dp[i][j] 为"到达位置 (i, j) 的路径数"

图解过程

网格 3×7,初始化第一行和第一列为1:

  0  1  2  3  4  5  6
0 1  1  1  1  1  1  1  ← 第一行只能一直向右
1 1  ?  ?  ?  ?  ?  ?
2 1  ?  ?  ?  ?  ?  ?
↑ 第一列只能一直向下

填表过程:
dp[1][1] = dp[0][1] + dp[1][0] = 1 + 1 = 2
dp[1][2] = dp[0][2] + dp[1][1] = 1 + 2 = 3
dp[1][3] = dp[0][3] + dp[1][2] = 1 + 3 = 4
...

最终结果:
  0  1  2  3  4  5  6
0 1  1  1  1  1  1  1
1 1  2  3  4  5  6  7
2 1  3  6 10 15 21 28  ← dp[2][6] = 28

Python代码

def uniquePaths(m: int, n: int) -> int:
    """
    解法二:二维DP(最优解)
    思路:dp[i][j] = 到达位置 (i,j) 的路径数
    """
    # 创建 m×n 的DP表
    dp = [[0] * n for _ in range(m)]

    # 初始化第一行和第一列
    for i in range(m):
        dp[i][0] = 1  # 第一列只有1条路径(一直向下)
    for j in range(n):
        dp[0][j] = 1  # 第一行只有1条路径(一直向右)

    # 填表:dp[i][j] = dp[i-1][j] + dp[i][j-1]
    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]


# ✅ 测试
print(uniquePaths(3, 7))  # 期望输出:28
print(uniquePaths(3, 2))  # 期望输出:3
print(uniquePaths(1, 1))  # 期望输出:1

复杂度分析

  • 时间复杂度:O(m × n) — 填满整个DP表
  • 空间复杂度:O(m × n) — DP表的大小

⚡ 解法三:一维DP空间优化(进阶)

优化思路

观察状态转移方程 dp[i][j] = dp[i-1][j] + dp[i][j-1],当前行只依赖上一行和当前行左边的值,可以用滚动数组优化空间到 O(n)。

图解过程

用一维数组 dp[],每次更新代表当前行:

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

更新第2行:
  dp[1] = dp[1] + dp[0] = 1 + 1 = 2
  dp[2] = dp[2] + dp[1] = 1 + 2 = 3
  dp[3] = dp[3] + dp[2] = 1 + 3 = 4
  ...
  结果:dp = [1, 2, 3, 4, 5, 6, 7]

更新第3行:
  dp[1] = dp[1] + dp[0] = 2 + 1 = 3
  dp[2] = dp[2] + dp[1] = 3 + 3 = 6
  ...
  结果:dp = [1, 3, 6, 10, 15, 21, 28]

Python代码

def uniquePaths_optimized(m: int, n: int) -> int:
    """
    解法三:一维DP空间优化
    思路:用滚动数组,每次更新代表当前行
    """
    # 初始化为第一行,全部为1
    dp = [1] * n

    # 逐行更新
    for i in range(1, m):
        for j in range(1, n):
            dp[j] = dp[j] + dp[j - 1]
            # dp[j] 原值是上一行的值(对应 dp[i-1][j])
            # dp[j-1] 是当前行左边的值(对应 dp[i][j-1])

    return dp[n - 1]


# ✅ 测试
print(uniquePaths_optimized(3, 7))  # 期望输出:28
print(uniquePaths_optimized(3, 2))  # 期望输出:3

复杂度分析

  • 时间复杂度:O(m × n) — 与二维DP相同
  • 空间复杂度:O(n) — 只需一维数组

🐍 Pythonic 写法

利用组合数学公式,这道题本质是求组合数 C(m+n-2, m-1):

from math import comb

def uniquePaths_math(m: int, n: int) -> int:
    """数学法:组合数 C(m+n-2, m-1)"""
    return comb(m + n - 2, m - 1)

解释:从 (0,0) 到 (m-1,n-1) 需要向下 m-1 步,向右 n-1 步,总共 m+n-2 步。问题等价于"从 m+n-2 步中选 m-1 步向下",即组合数 C(m+n-2, m-1)。

⚠️ 面试建议:先写DP展示思路,再提数学解法展示知识广度。 面试官更看重你的DP思维,而非公式记忆。


📊 解法对比

维度解法一:递归记忆化🏆 解法二:二维DP(最优)解法三:一维DP优化
时间复杂度O(m × n)O(m × n)O(m × n)
空间复杂度O(m × n)O(m × n)O(n) ← 最优
代码难度中等简单 ← 最清晰中等
面试推荐⭐⭐⭐⭐⭐ ← 首选⭐⭐
适用场景理解状态转移面试首选,代码清晰进阶优化,展示优化能力

为什么解法二是最优解:

  • 代码最清晰易懂,DP表直观体现状态转移
  • 时间复杂度 O(m×n) 已经是理论最优(至少要填满整个网格)
  • 虽然空间可以优化到 O(n),但二维版本更容易理解和扩展

面试建议:

  1. 先用30秒口述递归思路,表明你理解状态转移
  2. 立即写出🏆最优解(二维DP),展示DP建表能力
  3. 重点讲解核心思想:"每个格子的路径数 = 上方格子 + 左方格子"
  4. 强调边界初始化:第一行和第一列都是1
  5. 如果时间充裕,提一维DP优化展示进阶能力

🎤 面试现场

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

面试官:请你解决一下这道题。

:(审题30秒)好的,这道题要求计算从左上角到右下角的不同路径数,每次只能向下或向右移动。

我的第一个想法是用递归:paths(i, j) = paths(i-1, j) + paths(i, j-1),边界是第一行和第一列都是1。但递归会有重复计算,时间复杂度是指数级。

优化方法是用动态规划,建一个 m×n 的DP表,dp[i][j] 表示到达位置 (i,j) 的路径数。状态转移方程是 dp[i][j] = dp[i-1][j] + dp[i][j-1],时间复杂度是 O(m×n)。

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

:(边写边说)

def uniquePaths(m, n):
    # 创建DP表
    dp = [[0] * n for _ in range(m)]

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

    # 填表:当前格子 = 上方 + 左方
    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]

面试官:能优化空间吗?

:可以!因为每行只依赖上一行,可以用一维数组滚动更新。初始化 dp=[1]*n,然后逐行更新,dp[j] = dp[j] + dp[j-1],空间复杂度降到 O(n)。

面试官:测试一下?

:用示例 m=3, n=3 走一遍:

  • 初始化第一行:[1,1,1]
  • 更新第二行:[1,2,3]
  • 更新第三行:[1,3,6] ✅ 结果是6,符合预期。

高频追问

追问应答策略
"还有更优解吗?"时间 O(m×n) 已经是最优,空间可以优化到 O(min(m,n)),选择较短的维度作为滚动数组
"如果有障碍物呢?"遇到障碍物时 dp[i][j]=0,其他逻辑不变(参考 LeetCode 63)
"能用数学公式吗?"可以用组合数 C(m+n-2, m-1),但面试更看重DP思维
"实际工程中怎么用?"游戏中计算路径数、网络路由计数、电路设计中的布线方案计数

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:二维列表初始化 — 注意不能用 [[0]*n]*m
dp = [[0] * n for _ in range(m)]  # 正确:每行独立
# dp = [[0] * n] * m  # 错误:所有行指向同一对象

# 技巧2:一维滚动数组 — 原地更新节省空间
dp[j] = dp[j] + dp[j - 1]  # dp[j]原值是上一行,dp[j-1]是当前行左边

💡 底层原理(选读)

为什么一维DP要从左到右更新?

在滚动数组优化中,dp[j] = dp[j] + dp[j-1]:

  • dp[j] 原值代表上一行的值(对应二维的 dp[i-1][j])
  • dp[j-1] 已经被更新,代表当前行左边的值(对应二维的 dp[i][j-1])

从左到右更新确保:

  • 更新 dp[j] 时,dp[j-1] 已经是当前行的值
  • dp[j] 的原值还保留着上一行的值

杨辉三角与本题的联系: 如果画出DP表,会发现数字排列类似杨辉三角(帕斯卡三角形)。 本质原因:组合数递推公式 C(n,k) = C(n-1,k) + C(n-1,k-1) 与本题状态转移方程一致。

算法模式卡片 📐

  • 模式名称:网格DP(路径计数)
  • 适用条件:在二维网格中从起点到终点的路径计数/最值问题
  • 识别关键词:"网格中移动"、"只能向下/向右"、"路径数/最小路径和"
  • 模板代码:
def grid_dp(m, n):
    dp = [[0] * n for _ in range(m)]
    # 初始化第一行和第一列
    for i in range(m):
        dp[i][0] = 1  # 或其他初值
    for j in range(n):
        dp[0][j] = 1
    # 填表
    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. 二维列表初始化错误:用 [[0]*n]*m 导致所有行指向同一对象

    • 错误:dp = [[0] * n] * m
    • 正确:dp = [[0] * n for _ in range(m)]
  2. 边界初始化遗漏:忘记初始化第一行和第一列

    • 错误:直接从 (1,1) 开始填表,导致第一行第一列全是0
    • 正确:先初始化 dp[i][0]=1dp[0][j]=1
  3. 返回值下标错误:返回 dp[m][n] 而非 dp[m-1][n-1]

    • 错误:return dp[m][n] (数组越界)
    • 正确:return dp[m-1][n-1] (右下角坐标)

🏗️ 工程实战(选读)

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

  • 场景1:游戏关卡设计 — 计算迷宫中从起点到终点的可能路线数
  • 场景2:物流路径规划 — 在城市网格中计算配送路线的多样性
  • 场景3:芯片设计 — 计算电路板上从输入端到输出端的布线方案数

🏋️ 举一反三

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

题目难度相关知识点提示
LeetCode 63. 不同路径IIMedium网格DP+障碍物遇到障碍物时 dp[i][j]=0
LeetCode 64. 最小路径和Medium网格DP求最值状态转移改为取 min
LeetCode 120. 三角形最小路径和Medium变形网格DP每行长度不同,注意边界

📝 课后小测

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

题目:在 m×n 网格中,如果某些格子有障碍物(标记为1),机器人无法通过,其他格子标记为0。计算从左上角到右下角的不同路径数。例如 grid=[[0,0,0],[0,1,0],[0,0,0]],返回2。

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

遇到障碍物时,该位置的路径数为0。状态转移方程变为:如果 grid[i][j]==1,则 dp[i][j]=0;否则 dp[i][j] = dp[i-1][j] + dp[i][j-1]。

✅ 参考答案
def uniquePathsWithObstacles(obstacleGrid):
    m, n = len(obstacleGrid), len(obstacleGrid[0])
    if obstacleGrid[0][0] == 1 or obstacleGrid[m-1][n-1] == 1:
        return 0  # 起点或终点有障碍

    dp = [[0] * n for _ in range(m)]
    dp[0][0] = 1

    # 初始化第一行
    for j in range(1, n):
        dp[0][j] = 0 if obstacleGrid[0][j] == 1 else dp[0][j-1]

    # 初始化第一列
    for i in range(1, m):
        dp[i][0] = 0 if obstacleGrid[i][0] == 1 else dp[i-1][0]

    # 填表
    for i in range(1, m):
        for j in range(1, n):
            if obstacleGrid[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基础上增加障碍物判断,遇到障碍物时路径数为0,其他逻辑不变。注意初始化时也要检查障碍物。


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