《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)

题目解析

设想有个机器人坐在一个网格的左上角,网格 r 行 c 列。机器人只能向下或向右移动,但不能走到一些被禁止的网格(有障碍物)。设计一种算法,寻找机器人从左上角移动到右下角的路径。

《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)_第1张图片

网格中的障碍物和空位置分别用 1 和 0 来表示。

返回一条可行的路径,路径由经过的网格的行号和列号组成。左上角为 0 行 0 列。如果没有可行的路径,返回空数组。

示例 1:

  • 输入:
    [
    [0,0,0],
    [0,1,0],
    [0,0,0]
    ]
    输出: [[0,0],[0,1],[0,2],[1,2],[2,2]]

《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)_第2张图片

解释:

  • 输入中标粗的位置即为输出表示的路径,即
    0行0列(左上角) -> 0行1列 -> 0行2列 -> 1行2列 -> 2行2列(右下角)

说明: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(); 
    }
};

《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)_第3张图片

复杂度分析

当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数组

《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)_第4张图片

至此,动态规划的部分结束了。动态规划部分最重要的是推断出是否有路径能从左上角到达右下角。那如果dp[i][j] = 0,则其实就代表没有路径可以到达终点,我们返回空数组就可以了。

构造路径

  • 那其他情况,就不管有几条路径可以到达终点,我们自己构造一条路径不就好了嘛?

  • 从终点往起点去推,先设置两个变量,将终点的坐标表示出来:

    • int i = row - 1, j = cal - 1;
    • 之后我们用while循环,如果说dp[i][j] > 0 ,就代表着这个坐标我们可以通过,就直接加入结果集就好,然后 --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;
    }
};

《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)_第5张图片

复杂度分析

时间复杂度分析:

  • 第1个 for 循环的时间复杂度为 O(row),第2个 for 循环的时间复杂度为 O(cal),第3个嵌套循环的时间复杂度为 O(row * cal)。因此,总的时间复杂度为 O(row * cal)。

空间复杂度分析:

  • 使用了一个大小为 rowcal 的 dp 数组,因此空间复杂度为 O(row * cal)。除此之外,使用了一些辅助变量,但其空间占用很小,因此可以忽略不计。

因此,该算法的时间复杂度为 O(row * cal),空间复杂度为 O(row * cal)。

比起回溯法,动态规划的时间复杂度与空间复杂度已经降了很低很低了。

总结

这道题是一道用来练习动态规划与回溯法的好题。你可以仔细去揣摩里面的每一个步骤,它为什么要这么做?会导致什么后果。做完这道题我受益良多。

你可能感兴趣的:(#,算法题解析与个人做题技巧总结,面试,动态规划,回溯算法)