题目描述
一个机器人位于一个 m x n 网格的左上角(起始点标记为 "Start")。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(标记为 "Finish")。问总共有多少条不同的路径?
示例
示例 1:
输入:m = 3, n = 7 输出:28
示例 2:
输入:m = 3, n = 2 输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
示例 3:
输入:m = 7, n = 3 输出:28
示例 4:
输入:m = 3, n = 3 输出:6
约束:1 <= m, n <= 100
答案保证小于等于 2 * 10^9
解题思路总览
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 | 备注 |
|---|---|---|---|---|
| 暴力搜索 | 递归搜索所有可能的路径 | O(2^(m+n)) | O(m+n) | 会超时 |
| 记忆化搜索 | 暴力 + 剪枝(备忘录) | O(m*n) | O(m*n) | 避免重复计算 |
| 动态规划 | 状态转移方程 | O(m*n) | O(m*n) | 最常用 |
| 空间优化 | 一维数组滚动 | O(m*n) | O(n) | 面试最优 |
| 数学公式 | 组合数 C(m+n-2, m-1) | O(min(m,n)) | O(1) | 理论最优 |
一、暴力搜索(会超时,仅作思路参考)
思路
机器人只能向右或向下,所以到达终点的问题可以分解为:
- 先向右走一步 + 递归解决子问题
- 或先向下走一步 + 递归解决子问题
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// 从起点 (0,0) 到终点 (m-1, n-1)
return dfs(0, 0, m, n);
}
private:
int dfs(int i, int j, int m, int n) {
// 到达终点,计数 +1
if (i == m - 1 && j == n - 1) return 1;
int ans = 0;
// 只能向右走
if (i < m - 1) ans += dfs(i + 1, j, m, n);
// 只能向下走
if (j < n - 1) ans += dfs(i, j + 1, m, n);
return ans;
}
};
问题分析
假设 m=3, n=3,路径展开是一棵二叉树:
(0,0)
/ \
(1,0) (0,1)
/ \ / \
(2,0) (1,1) (1,1) (0,2)
\ /\ /\ /
\ / \ / \
... ... ...
从 (0,0) 到 (m-1,n-1) 最多走 (m-1)+(n-1) = m+n-2 步。每一步都有两种选择,所以总路径数是 2^(m+n-2),当 m=n=100 时,这个数字是 2^198,溢出。
时间复杂度:O(2^(m+n)),空间复杂度:O(m+n)
二、记忆化搜索(递归 + 备忘录)
思路
暴力搜索中存在大量重复计算。例如:
- 从 (0,0) 到 (2,2) 有多条路径经过 (1,1)
- (1,1) 被计算了多次,浪费
用 memo 数组记录每个格子到终点的路径数,避免重复计算。
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// memo[i][j] 记录从 (i,j) 到终点的路径数
vector<vector<int>> memo(m, vector<int>(n, -1));
return dfs(0, 0, m, n, memo);
}
private:
int dfs(int i, int j, int m, int n, vector<vector<int>>& memo) {
// 到达终点
if (i == m - 1 && j == n - 1) return 1;
// 之前计算过,直接返回
if (memo[i][j] != -1) return memo[i][j];
int ans = 0;
if (i < m - 1) ans += dfs(i + 1, j, m, n, memo);
if (j < n - 1) ans += dfs(i, j + 1, m, n, memo);
memo[i][j] = ans; // 记忆化
return ans;
}
};
图解记忆化
以 m=3, n=3 为例:
memo 初始化为 -1(表示未计算):
j:0 1 2
+---+---+---+
i:0 | -1 | -1 | -1 |
+---+---+---+
i:1 | -1 | -1 | -1 |
+---+---+---+
i:2 | -1 | -1 | 1 | <- 终点 memo[2][2] = 1
+---+---+---+
递归计算过程:
dfs(0,0) -> dfs(1,0) -> dfs(2,0) -> dfs(2,1) -> dfs(2,2) = 1
-> dfs(1,1) -> ... -> memo[1][1] = 2
-> dfs(1,1) -> memo[1][1] 已计算 = 2
-> dfs(0,1) -> ... -> memo[0][1] = 3
memo[0][0] = memo[1][0] + memo[0][1] = 3 + 3 = 6
最终 memo:
j:0 1 2
+---+---+---+
i:0 | 6 | 3 | 1 |
+---+---+---+
i:1 | 3 | 2 | 1 |
+---+---+---+
i:2 | 1 | 1 | 1 |
+---+---+---+
时间复杂度:O(m*n),每个格子最多计算一次
空间复杂度:O(m*n)
三、动态规划(最常用)
思路
将记忆化搜索改写为自底向上 的填表方式。
定义 dp[i][j] = 从 (0,0) 到 (i,j) 的不同路径数。
状态转移方程:
- 机器人只能从上方 (i-1,j) 或左方 (i,j-1) 到达 (i,j)
dp[i][j] = dp[i-1][j] + dp[i][j-1]
边界条件:
- 第一列
dp[i][0] = 1(只能从上往下走) - 第一行
dp[0][j] = 1(只能从左往右走)
完整代码
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// 定义二维 dp 数组
vector<vector<int>> dp(m, vector<int>(n, 0));
// 初始化:第一列全部为 1(只能从上往下走)
for (int i = 0; i < m; i++) {
dp[i][0] = 1;
}
// 初始化:第一行全部为 1(只能从左往右走)
for (int j = 1; j < n; j++) {
dp[0][j] = 1;
}
// 填表:对于其他位置,到达它的路径数 = 从上方来的 + 从左边来的
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};
算法流程图
以 m = 3, n = 7 为例:
Step 1: 初始化 dp 数组(全部置 0)
j:0 1 2 3 4 5 6
+---+---+---+---+---+---+---+---+
i:0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
i:1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
i:2 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
Step 2: 初始化第一列 dp[i][0] = 1(只能从上往下走)
j:0 1 2 3 4 5 6
+---+---+---+---+---+---+---+---+
i:0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
i:1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
i:2 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
Step 3: 初始化第一行 dp[0][j] = 1(只能从左往右走)
j:0 1 2 3 4 5 6
+---+---+---+---+---+---+---+---+
i:0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
+---+---+---+---+---+---+---+---+
i:1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
i:2 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---+---+---+---+---+---+---+---+
Step 4: 按行填表 dp[i][j] = dp[i-1][j] + dp[i][j-1]
j:0 1 2 3 4 5 6
+---+---+---+---+---+---+---+---+
i:0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | <- 第一行 (j=0 已初始化)
+---+---+---+---+---+---+---+---+
i:1 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | <- dp[1][1] = 1+1 = 2
+---+---+---+---+---+---+---+---+ dp[1][2] = 1+2 = 3
i:2 | 1 | 3 | 6 |10 |15 |21 |28 | | <- dp[2][6] = 7+21 = 28
+---+---+---+---+---+---+---+---+
答案
====>>> 28
四、空间优化(面试最优解)
思路
观察 DP 表可以发现,第 i 行只依赖第 i-1 行。所以不需要完整的二维数组,一维就够了。
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
vector<int> dp(n, 1); // 初始化为 1,第一行全是 1
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
// dp[j] : 上一行的值(上方格子)
// dp[j-1] : 本行已更新的值(左边格子)
dp[j] += dp[j - 1];
}
}
return dp[n - 1];
}
};
图解空间优化
初始 dp = [1, 1, 1, 1, 1, 1, 1] (n=7)
处理 i=1(第二行):
j=1: dp[1] = dp[1] + dp[0] = 1 + 1 = 2
j=2: dp[2] = dp[2] + dp[1] = 1 + 2 = 3
j=3: dp[3] = dp[3] + dp[2] = 1 + 3 = 4
...
dp = [1, 2, 3, 4, 5, 6, 7]
处理 i=2(第三行):
j=1: dp[1] = dp[1] + dp[0] = 2 + 1 = 3
j=2: dp[2] = dp[2] + dp[1] = 3 + 3 = 6
j=3: dp[3] = dp[3] + dp[2] = 4 + 6 = 10
...
dp = [1, 3, 6, 10, 15, 21, 28]
答案:dp[6] = 28 OK
时间复杂度:O(m*n)
空间复杂度:O(n)
五、数学公式(组合数)
思路
机器人从左上角到右下角,总共走 (m-1)+(n-1) = m+n-2 步。
其中,必须走 m-1 步向下,n-1 步向右(顺序可任意)。
所以答案 = 从 m+n-2 步中选 m-1 步向下的组合数:
C(m+n-2, m-1) = (m+n-2)! / ((m-1)! * (n-1)!)
代码实现
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// 需要计算 C(m+n-2, m-1)
// 使用 long long 防止溢出
long long ans = 1;
int k = min(m - 1, n - 1); // 取较小的,减少循环次数
for (int i = 1; i <= k; i++) {
ans = ans * (n - 1 + i) / i;
}
return (int)ans;
}
};
验证示例
m=3, n=7:
C(3+7-2, 3-1) = C(8, 2) = 8*7/2 = 28 OK
m=3, n=2:
C(3+2-2, 3-1) = C(3, 2) = 3*2/2 = 3 OK
m=3, n=3:
C(3+3-2, 3-1) = C(4, 2) = 4*3/2 = 6 OK
逐行解析(对照原题代码)
cpp
class Solution {
public:
int uniquePaths(int m, int n) {
// 创建 m 行 n 列的二维数组,初始值全为 0
// dp[i][j] 表示从起点到达 (i, j) 的不同路径数
vector<vector<int>> dp(m, vector<int>(n, 0));
// 初始化第一列:对于第一列的每个位置,只能从上方下来
// 所以 dp[i][0] = 1 (i = 0, 1, 2, ..., m-1)
for (int i = 0; i < m; i++)
dp[i][0] = 1;
// 初始化第一行:对于第一行的每个位置,只能从左边过来
// 所以 dp[0][j] = 1 (j = 1, 2, ..., n-1)
// 注意:j 从 1 开始,因为 dp[0][0] 已在前面的循环中设置
for (int i = 1; i < n; i++)
dp[0][i] = 1;
// 填表:对于其他位置 (i, j),机器人可以从上方 (i-1, j) 或
// 左边 (i, j-1) 到达,所以路径数是两者之和
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
// 返回终点的路径数
return dp[m - 1][n - 1];
}
};
复杂度分析总结
| 方法 | 时间复杂度 | 空间复杂度 | 评价 |
|---|---|---|---|
| 暴力搜索 | O(2^(m+n)) | O(m+n) | 会超时,不可取 |
| 记忆化搜索 | O(m*n) | O(m*n) | 避免重复计算 |
| 动态规划 | O(m*n) | O(m*n) | 最常用,清晰 |
| 空间优化 | O(m*n) | O(n) | 面试最优 |
| 组合数 | O(min(m,n)) | O(1) | 数学 trick |
面试追问 FAQ
| 问题 | 回答要点 |
|---|---|
| Q: 为什么可以用动态规划? | 因为机器人只能向右或向下移动,每个位置的路径数只依赖于上方和左方的位置,满足最优子结构和无后效性 |
| Q: 空间还能进一步优化吗? | 可以,从一维数组优化到只用一个变量(需数学推导),但面试中 O(n) 已足够 |
| Q: 如果有障碍物怎么办? | 在初始化和状态转移时,如果该位置有障碍,直接置 dp[i][j] = 0,表示不可达 |
| Q: 组合数解法的原理? | 机器人共走 m+n-2 步,只需选 m-1 步向下(或 n-1 步向右),答案是两者的组合数 |
| Q: 结果会溢出吗? | 题目保证答案 <= 210^9,在 int 范围内(2^31-1 约等于 2.110^9),但计算过程中可能超出,需用 long long |
| Q: 能否用 BFS? | BFS 也可以,从起点开始向四周扩展,遇到终点就计数,但需要维护访问集合,复杂度和 DP 相当 |
相关题目
| 题目编号 | 题目名称 | 难度 | 核心差异 |
|---|---|---|---|
| 62 | 不同路径 | 中等 | 基础题,无障碍 |
| 63 | 不同路径 II | 中等 | 有障碍物,障碍处 dp = 0 |
| 64 | 最小路径和 | 中等 | 不仅计数,还要求路径和最小 |
| 120 | 三角形最小路径和 | 中等 | 三角形网格,从顶到底 |
| 174 | 地下城游戏 | 困难 | 从右下向左上 DP,求最小生命值 |
| 931 | 下降路径最小和 | 中等 | 矩阵,可以向左下/正下/右下走 |
总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 动态规划,分解子问题,每个位置由上方和左方转移而来 |
| 状态定义 | dpij = 到达位置 (i, j) 的不同路径数 |
| 转移方程 | dpij = dpi-1j + dpij-1 |
| 初始化 | 第一行和第一列全部初始化为 1 |
| 边界条件 | 机器人只能向右或向下,不会回退 |
| 空间优化 | 滚动数组,从二维 O(m*n) 优化到一维 O(n) |
这是一道经典的动态规划入门题,掌握后可以轻松扩展到带障碍物、带权重、路径求和等变种问题。面试中推荐使用空间优化版,既高效又体现对算法的理解深度。