设想有个机器人坐在一个网格的左上角,网格 r 行 c 列。机器人只能向下或向右移动,但不能走到一些被禁止的网格(有障碍物)。设计一种算法,寻找机器人从左上角移动到右下角的路径。
网格中的障碍物和空位置分别用 1 和 0 来表示。
返回一条可行的路径,路径由经过的网格的行号和列号组成。左上角为 0 行 0 列。如果没有可行的路径,返回空数组。
示例 1:
解释:
说明:r 和 c 的值均不超过 100。
这道题是一道经典的网格迷宫问题,我们可以利用回溯法去解决。
这道题我们只需要找到一条可以返回的路径就完全ok了。所以我们先要想想我们需要在什么地方去返回。首先,你碰到了石头,就要返回把。其次,超出了边界,是不是也要返回?到达了终点是不是更要返回?
还有,我们需要一个二维数组去确定自己走过的路径,走过的路径不可能再走一遍,所以也要返回。我们也需要用一个标志位,代表我已经找到了一条路径,找到了就把标志位变位,这样再遇到其他路径的时候就直接不走,也算是一种返回吧。这个其实就是回溯算法中的剪枝思想。
把所有的边界条件想清楚后,这道题,也就不是一道难题了,十分好理解的。我们通过不断地尝试新的路径,并通过回溯的方式来回到之前的状态。就完全可以解出这道题了。
具体的代码如下:
class Solution {
public:
vector<vector<int>> result; //最终的结果集
vector<vector<int>> pathWithObstacles(vector<vector<int>>& obstacleGrid) {
int row = obstacleGrid.size();
int cal = obstacleGrid[0].size();
if(obstacleGrid[0][0] == 1 || obstacleGrid[row-1][cal-1] == 1) return {};
vector<vector<bool>> flag (row,vector<bool>(cal,false)); //记录走过的路径
bool path = false;//找到一条路径就可以返回了
backtracking(obstacleGrid,flag,row,cal,0,0,path);
return result;
}
void backtracking(vector<vector<int>>& obstacleGrid,vector<vector<bool>>& flag, int row,int cal,int i, int j,bool& path){
if(i >= row || j >= cal || obstacleGrid[i][j] == 1 || path || flag[i][j] )
return ;
if(i == row -1 && j == cal -1){
result.push_back({i,j});
path = true;
return;
}
flag[i][j] = true;
result.push_back({i,j});
backtracking(obstacleGrid,flag,row,cal,i+1,j,path);
backtracking(obstacleGrid,flag,row,cal,i,j+1,path);
if(!path) result.pop_back();
}
};
当obstacleGrid的大小为m行n列时,该代码的时间复杂度和空间复杂度如下:
时间复杂度:O(mn * 2^(m+n) )。其中,m*n是obstacleGrid的大小,2^(m+n)是所有可能的路径数量。在backtracking函数中,每个格子有两个方向可以探索(向下和向右),因此,每个格子的搜索分支最多有两个。对于每一个分支,都需要遍历整个obstacleGrid,并递归探索下一个格子。因此,时间复杂度是O(mn * 2^(m+n))。
空间复杂度:O(mn + 2^(m+n) )。其中,mn是flag数组的大小,2(m+n)是结果数组result的大小。在backtracking函数中,需要使用一个二维的flag数组来记录已经访问过的格子,因此,空间复杂度是O(mn)。同时,结果数组result也需要记录所有的路径,因此空间复杂度也是O(2(m+n))。因此,总的空间复杂度是O(mn + 2^(m+n))。
需要注意的是,由于时间复杂度和空间复杂度都与obstacleGrid的大小呈指数关系,因此,当obstacleGrid的大小比较大时,该算法的时间复杂度和空间复杂度都会变得非常高,导致算法的性能下降。因此,需要对算法进行优化,以降低时间复杂度和空间复杂度。
用动态规划的方法与用回溯法的方法,在某些地方是一样的。比如第一开始的条件判断。若起点终点有障碍物,我们就直接返回。
不一样的点在于,回溯法,是一步一步的去试探,这道道路能不能走到终点,如果走不到,我们就原路返回,换条路再走走看。
动态规划法,是检测有多少条路径能够到达终点,如果大于0,就直接构造路径。如果等于0就返回空数组。
而有关动态规划的题,它是有特定的解题步骤的。
我们就用动态规划的五部曲,来去分析一下这道题,我们是怎么做的:
第一步:确定dp数组的下标以及含义
dp[i][j]
:i是纵坐标,j是横坐标,dp[i][j]代表的是到达坐标为 (i,j)
这个格子的路径数量第二步:确定(递推)推导公式
由题意可知,机器人只能向下或向右移动,因此到达坐标(i,j)的格子的路径数量只可能是从(i-1,j)或(i,j-1)格子的状态转移而来。因此,我们可以得到递推公式:
dp[i][j] = dp[i-1][j] + dp[i][j-1];
特别需要注意的是
,在obstacleGrid[i][j]的值为1
的情况下(也就是这个格子上有障碍物),dp[i][j]的值要为0(也就是表示没有一条路径可以到达这个格子)。所以,最终的递推公式应该长这样:
dp[i][j] = obstacleGrid[i][j] == 1 ? 0 : dp[i-1][j] + dp[i][j-1];
第三步:初始化dp数组
由于机器人只能选择向下走或向右走,除第一行与第一列外的格子,需要由第一行与第一列的dp值去推导出来,所以我们要去初始化dp数组的第一行与第一列。因为现在起点是没有石头的,所以dp[0][0] = 1
。
在推导的时候需要注意的是,dp值由两个要素决定,一个是你obstacleGrid数组中对应的值是是否为0(为0代表这个格子没有障碍物),第二个是你前一个dp值是否为1(为1就是有路径可以到达这个格子),这两个条件缺一不可。
知道了这些前提条件后,我们就可以用for循环来初始化第一行与第一列啦。具体代码如下:
for(int i = 1;i < row; ++i)
if(obstacleGrid[i][0] == 0 && dp[i-1][0] == 1)
dp[i][j] = 1
for(int j = 1;j < cal; ++i)
if(obstacleGrid[0][j] == 0 && dp[0][j-1] == 1)
dp[i][j] = 1
第四步,确定变量顺序
具体的代码长这样:
for(int i = 1; i < row; ++i)
for(int j = 1; j < cal; ++j)
if(obstacleGrid[i][j] == 0)
dp[i][j] = dp[i-1][j]%10000000 + dp[i][j-1]%10000000;
第五步,举例推导dp数组
至此,动态规划的部分结束了。动态规划部分最重要的是推断出是否有路径能从左上角到达右下角。那如果dp[i][j] = 0,则其实就代表没有路径可以到达终点,我们返回空数组就可以了。
那其他情况,就不管有几条路径可以到达终点,我们自己构造一条路径不就好了嘛?
从终点往起点去推,先设置两个变量,将终点的坐标表示出来:
int i = row - 1, j = cal - 1;
--i ,--j
。那如果i先减到0了,那就代表我们已经回到了第一行,该--j
了。如果j先减到0了,那就代表我们已经回到了第一列,该--i
了。当i与j都小于0时,就代表着循环该结束了
。由此,我们就构建出了一条由终点指向起点的路径。我们再reverse一下,不就得到了一条由起点指向终点的路径嘛?
但我们不能从起点往终点去构建路径,因为我发现,如果这么去构建路径了话,会出现遇到石头,但它不停下的情况。
整个题解的具体代码如下:
class Solution {
public:
vector<vector<int>> result; //最终的结果集
vector<vector<int>> pathWithObstacles(vector<vector<int>>& obstacleGrid) {
int row = obstacleGrid.size();
int cal = obstacleGrid[0].size();
if(obstacleGrid[0][0] == 1 || obstacleGrid[row-1][cal-1] == 1) return {};
vector<vector<long long>> dp(row,vector<long long>(cal,0)); // 记录到达每个格子的路径数量
dp[0][0] = 1;
// 初始化第一行和第一列
for(int i = 1; i < row; i++){
if(obstacleGrid[i][0] == 0 && dp[i-1][0] == 1) dp[i][0] = 1;
}
for(int j = 1; j < cal; j++){
if(obstacleGrid[0][j] == 0 && dp[0][j-1] == 1) dp[0][j] = 1;
}
// 动态规划
for(int i = 1; i < row; i++){
for(int j = 1; j < cal; j++){
if(obstacleGrid[i][j] == 0){
dp[i][j] = dp[i-1][j]%10000000 + dp[i][j-1]%10000000;
}
}
}
// 如果到达终点的路径数量为0,则无法到达终点
if(dp[row-1][cal-1] == 0) return {};
// 构造路径
int i = row - 1, j = cal - 1;
while(i >= 0 && j >= 0){
result.push_back({i,j});
if(i == 0) j--;
else if(j == 0) i--;
else{
if(dp[i-1][j] > 0) i--;
else j--;
}
}
reverse(result.begin(), result.end());
return result;
}
};
时间复杂度分析:
空间复杂度分析:
因此,该算法的时间复杂度为 O(row * cal),空间复杂度为 O(row * cal)。
比起回溯法,动态规划的时间复杂度与空间复杂度已经降了很低很低了。
这道题是一道用来练习动态规划与回溯法的好题。你可以仔细去揣摩里面的每一个步骤,它为什么要这么做?会导致什么后果。做完这道题我受益良多。