这道题也是老生常谈,非常经典的一道题目了,围绕着此题能学到各种各样的算法思想。本文主要梳理从暴力递归到动态规划、再到数学炫技的求解过程演变。
题目如下:
一个机器人位于
m
,
n
m, n
m,n网格左上角,它每次只能向右或者向下移动一格,问它到达图中右下角有多少种不同的路径?
暴力递归
首先很容易想到的就是递归写法,枚举并统计所有可以到达右下角的路径。画一下递归树,如下图所示,算法复杂度为
O
(
2
m
n
)
O(2^{mn})
O(2mn),空间复杂度为O(mn),因为创建了二维数组。
代码如下
int ans;
int m, n;
int dx[3] = {0, 1, 0};
vector<vector<int>> grid;
void dfs(int x, int y){
if(x == m-1 && y == n-1){
ans++;
return;
}
for(int i = 0;i < 2;i++){
int nx = x + dx[i];
int ny = y + dx[i+1];
if(nx >= m || ny >= n || nx < 0 || ny < 0 || grid[nx][ny] != 0) continue;
grid[nx][ny] = 1;
dfs(nx, ny);
grid[nx][ny] = 0;
}
}
int uniquePaths(int m, int n) {
ans = 0;
this->m = m;
this->n = n;
grid = vector<vector<int>>(m, vector<int>(n, 0));
grid[0][0] = 1;
dfs(0, 0);
return ans;
}
不过这样的代码直接提交会报TLE超时,因为在递归的过程中极易出现重复计算。例如下图所示,在计算到达C的所有情况总数时,在dfs中会把到达B的情况数重新算一遍。
所以想优化就需要对递归树进行减支,那么较为简单的做法就是用一个dict来存已经计算过的路径总数啦。写出的代码如下图
int ans;
int m, n;
int dx[3] = {0, 1, 0};
vector<vector<int>> grid;
vector<vector<int>> memo; // 记录算过的路径情况数
int dfs(int x, int y){
int res = 0;
if(x == m-1 && y == n-1) return 1;
if(memo[x][y] != -1) return memo[x][y];
// int tmp = grid[x][y];
for(int i = 0;i < 2;i++){
int nx = x + dx[i];
int ny = y + dx[i+1];
if(nx >= m || ny >= n || nx < 0 || ny < 0 || grid[nx][ny] != 0) continue;
grid[nx][ny] = 1;
res += dfs(nx, ny);
grid[nx][ny] = 0;
}
return memo[x][y] = res;
}
int uniquePaths(int m, int n) {
ans = 0;
this->m = m;
this->n = n;
grid = vector<vector<int>>(m, vector<int>(n, 0));
memo = vector<vector<int>>(m, vector<int>(n, -1));
grid[0][0] = 1;
return dfs(0, 0);
}
这种解法有个高端的名字,叫做记忆化递归。顾名思义,就是在递归过程中记录已经求过的解,来达到优化的目的。这样的解就能通过leetcode的刁难了。可对于这个题,它还能进一步优化,那就要上老生常谈的动态规划了。动态规划解法是这样考虑问题的,对于每一个位置 ( i , j ) (i, j) (i,j),到达该位置的路径总数恰好等于到达 ( i , j − 1 ) (i, j-1) (i,j−1)的情况总数加上 ( i − 1 , j ) (i-1, j) (i−1,j)的情况总数。这是由题目的条件所决定的,每次只能向右或者向下移动。那我们就找到当前位置和上一次位置的解有哪些练习,就能够想到这样的递推式了。对于第一行和第一列,显然到达这些位置的路径只有一条,这就是dp的初始条件。于是自然而然的得到以下的实现
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m, vector<int>(n, 0));
for(int i = 0;i < n;i++){
dp[0][i] = 1;
}
for(int i = 0;i < m;i++){
dp[i][0] = 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(mn),空间复杂度也为O(mn),但比暴力递归不知道强了多少倍。当然这样的解法还可以优化,由于dp的状态转移过程仅从 ( i , j ) (i,j) (i,j)的左侧或者上方而来,因此只需要每次存储更新这两个位置的值就可以。可以简化成如下时间复杂度不变,空间复杂度转为O(n)的解法
int uniquePaths(int m, int n) {
int left = 1;
int cur;
if(m == 1) return 1;
vector<int> top(n, 0); // 表示列
for(int i = 0;i < n;i++){
top[i] = 1;
}
for(int i = 1;i < m;i++){
left = 1;
for(int j = 1;j < n;j++){
cur = left + top[j];
left = cur;
top[j] = cur;
}
}
return cur;
}
最后再提供一种网上看到的比较优秀的数学解法。由于从Start到Finish肯定要横着向右走n-1次,再竖着向下走m-1次,一共要走m+n-2步。那么到达 ( m − 1 , n − 1 ) (m-1, n-1) (m−1,n−1)的解等于 C m + n − 2 m − 1 C m − 1 m − 1 C_{m+n-2}^{m-1}C_{m-1}^{m-1} Cm+n−2m−1Cm−1m−1或 C m + n − 2 n − 1 C n − 1 n − 1 C_{m+n-2}^{n-1}C_{n-1}^{n-1} Cm+n−2n−1Cn−1n−1,这两个求解的结果肯定是一样的的。相当于从总数不变的一共m+n-2步中,先选出竖着走/横着走的可能情况,再走剩下的。代码如下
int uniquePaths(int m, int n) {
int N = n + m - 2;
double res = 1;
for (int i = 1; i < m; i++)
res = res * (N - (m - 1) + i) / i;
return int(res);
}
相比于上面的做法更加简短,但每个数的阶乘和组合数都可以进行预先计算,在频繁求解的场景下,要比动态规划算法更快。