今天是两道路径相关的DP题目,之前接触过,所以做起来比较容易。两道题虽然原理和实现都比较容易,但想优化成用一行数组代替矩阵的实现方法还是有一些坑的。第1道题比较经典。从中学习到了3种完全不同的,时间复杂度由高到低的方法,以及计算组合数的数论模板方法。从第2道题学习补充了一些容易疏忽的漏洞。
第1题(LeetCode 62. 不同路径)比较简单,自己的思路是把dp[i][j]定义为到达迷宫的第i行第j列可能的路径数。而由于只能向下或向右走,那么对于第i行第j列,它的来源只可能是它的上方或左方,也就是第i - 1行第j列,或者第i行第j - 1列。那么把这两者的对应路径数相加,就能得到当前点的路径数。所以状态转移方程为dp[i][j] = dp[i][j - 1] + dp[i - 1][j]。初始化方面,由于第0行的所有点都是只能由出发点向右走的,第0列所有点都是只能由出发点向下走的,所以第0行和第0列的值都应该是1。于是就可以从第1行开始便利到第m - 1行。每行从第1列开始遍历到第n - 1列。最终dp矩阵右下角的数值就是答案。
又因为更新每一行时,只会用到上一行和当前位置左边的数字,所以并不需要一个矩阵的空间,仅需要一行的空间就可以了。于是优化后的代码如下:
class Solution {
public:
int uniquePaths(int m, int n) {
vector<int> dp(n, 1);
for (int i = 1; i < m; ++i) {
for (int j = 1; j < n; ++j) {
dp[j] = dp[j - 1] + dp[j];
}
}
return dp[n - 1];
}
};
于是这种方法的时间复杂度是O(mn),空间复杂度是O(n)。
题解中,首先介绍的是DFS方法。如果使用搜索的方法,那么搜索树的深度就是m + n - 1(深度按从1开始计算),二叉树的节点数就是2^(m+n-1)了,对应时间复杂度O( 2^(m+n-1) ),所以会超时。所以这里的代码也只是提供下思路。
class Solution {
private:
int dfs(int i, int j, int m, int n) {
if (i > m || j > n) return 0; // 越界
if (i == m && j == n) return 1; // 找到一种路径
return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n);
}
public:
int uniquePaths(int m, int n) {
return dfs(1, 1, m, n);
}
};
题解中还给出一种数论的解法。从起点到终点总共是需要走(m - 1) + (n - 1) = m + n - 2步的,其中每一步都有可能向下,或者向右。其中向下的总步数为m - 1是确定的,只是具体是哪一步并不确定。所以问题就相当于总共有m + n - 2个不同的数,需要从中取m - 1个数,问总共有多少种取法?也就是求\(C_{m+n-2}^{m-1}\)。计算\(C_{a}^{b}\)需要令分子为a * (a - 1) * (a - 2) * ··· * (a - b + 1),项数为b,分母为1 * 2 * 3 * ··· * b,项数也为b,分子与分母相除就得到了答案。
而在实践中如果先计算分母,那可能就会因为数值太大而溢出。而因为分母与分子的项数都是b,都需要进行b次循环,那么就可以在循环的时候同时计算分母和分子,并在每次计算时就将分母与分子相除,从而避免溢出。
class Solution {
public:
int uniquePaths(int m, int n) {
int times = m - 1;
long long upBegin = m + n - 2;
long long up = 1, down = m - 1;
while(times--) {
up *= upBegin--;
while (down != 0 && up % down == 0) {
up /= down;
--down;
}
}
return up;
}
};
这种方法计算组合数的代码还是不容易理解的,可以多熟悉理解下当做以数论方式计算组合数的模板。
二刷:忘记数论方法和它对应的计算组合数方法。
第2题(LeetCode 63. 不同路径 II)相比第1题的不同只是增加了障碍物。所以状态转移矩阵方面相比上一题的改变就是,当遇到障碍物时,因为没有路径能走到障碍物上,所以将dp[i][j]的数值设置为0。初始化方面,因为在第0行一旦遇到一个障碍物,那它和它右边所有的点都是不可达的。第0列也同样如此,如果中途遇到一个障碍物,那它和它下边所有的点也都是不可达的。所以对于这些不可达的点,应该在初始化时就将其值设置为0,而不是原本的1。其他方面都与上一题一致。
同样地,这道题也可以用一行数组来代替矩阵。需要对应额外做的是,在遍历每一行时,都判断下该行的首个位置是否是障碍物,如果是的话,就要将之后每一行dp数组的第0列也设置为0。具体的实践方法可以是设定一个flag。在每行都将flag值赋值给dp的首列元素。一旦遇到障碍物就将flag设置为零。这里容易疏忽一个点,就是flag的初始化。如果惯性思维将其初始化为1,那对于“第0行的第0个元素就是障碍物,但其他行的第0列元素并没有障碍物”的情况,由于是从第1行开始遍历的,那就会错误地将其他行的第0列元素设置为1。所以应该将flag初始化为“已经初始化好的dp的第0列”。
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size(), n = obstacleGrid[0].size();
vector<int> dp(n, 0);
for (int j = 0; j < n; ++j) {
if (obstacleGrid[0][j] == 1) {
break;
}
dp[j] = 1;
}
int flag = dp[0];
for (int i = 1; i < m; ++i) {
if (obstacleGrid[i][0] == 1) {
flag = 0;
}
dp[0] = flag;
for (int j = 1; j < n; ++j) {
if (obstacleGrid[i][j] == 1) {
dp[j] = 0;
}
else {
dp[j] = dp[j - 1] + dp[j];
}
}
}
return dp[n - 1];
}
};
在初始化部分,自己刚开始实现的方法是将dp数组全部初始化为1。然后遍历dp数组,在遇到障碍物的时候,再将其和之后的位置都设置为0。但可以使用相反的思维,使得代码更简洁。将dp数组全部初始化为0,然后遍历dp数组,不断地将当前值赋值为1,直到遇到障碍物为止。
二刷:忘记节省空间写法。