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

Posted 阿宋同学

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)相关的知识,希望对你有一定的参考价值。

题目解析

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

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

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

示例 1:

  • 输入:
    [
    [0,0,0],
    [0,1,0],
    [0,0,0]
    ]
    输出: [[0,0],[0,1],[0,2],[1,2],[2,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(); 
    
;

复杂度分析

当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;
    • 之后我们用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;
    
;

复杂度分析

时间复杂度分析:

  • 第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)。

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

总结

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

程序员面试金典-面试题 16.08. 整数的英语表示

题目:

给定一个整数,打印该整数的英文描述。

示例 1:

输入: 123
输出: "One Hundred Twenty Three"
示例 2:

输入: 12345
输出: "Twelve Thousand Three Hundred Forty Five"
示例 3:

输入: 1234567
输出: "One Million Two Hundred Thirty Four Thousand Five Hundred Sixty Seven"
示例 4:

输入: 1234567891
输出: "One Billion Two Hundred Thirty Four Million Five Hundred Sixty Seven Thousand Eight Hundred Ninety One"

分析:

没想到什么太好的办法,就是按个千百万十亿统计,再将字符串拼接起来。

程序:

class Solution {
    public String numberToWords(int num) {
        if(num == 0)
            return "Zero";
        List<String> res = new ArrayList<>();
        int hundred = 0;
        int thousand = 0;
        int million = 0;
        int billion = 0;
        if(num < 0){
            res.add("Negative");
            num = -num;    
        }
        if(num > 0){
            hundred = num % 1000;
            num /= 1000;
            if(num > 0){
                thousand = num % 1000;
                num /= 1000;
                if(num > 0){
                    million = num % 1000;
                    num /= 1000;
                    if(num > 0){
                        billion = num % 1000;
                    }
                }
            }
        }
        if(billion > 0){
            for(String str:change(billion))
                res.add(str);
            res.add("Billion");
        }
        if(million > 0){
            for(String str:change(million))
                res.add(str);
            res.add("Million");
        }
        if(thousand > 0){
            for(String str:change(thousand))
                res.add(str);
            res.add("Thousand");
        }
        if(hundred > 0){
            for(String str:change(hundred))
                res.add(str);
        }
        StringBuilder ans = new StringBuilder();
        for(String s:res){
            if(ans.length() == 0)
                ans.append(s);
            else{
                ans.append(" " + s);
            }
        }
        return ans.toString();
    }
    private LinkedList<String> change(int num){
        LinkedList<String> list = new LinkedList<>();
        int n = num % 100;
        if(0 < n && n < 20){
            list.addFirst(num0_19[n]);
        }else if(n >= 20){
            int m = n % 10;
            int mm = n / 10;
            if(m > 0)
                list.addFirst(num0_19[m]);
            list.addFirst(num0_90[mm]);
        }
        if(num >= 100){
            list.addFirst("Hundred");
            list.addFirst(num0_19[num / 100]);
        }
        return list;
    }
    private String[] num0_19 = new String[]{ "Zero","One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine","Ten","Eleven","Twelve","Thirteen","Fourteen","Fifteen","Sixteen","Seventeen","Eighteen","Nineteen" };
    private String[] num0_90 = new String[]{ "Zero","Ten","Twenty","Thirty", "Forty", "Fifty", "Sixty",  "Seventy", "Eighty", "Ninety" };
}

 

以上是关于《程序员面试金典(第六版)》面试题 08.02. 迷路的机器人(动态规划,回溯法)的主要内容,如果未能解决你的问题,请参考以下文章

程序员面试金典-面试题 10.09. 排序矩阵查找

程序员面试金典-面试题 16.06. 最小差

程序员面试金典面试题 03.05. 栈排序

程序员面试金典面试题 08.11. 硬币

程序员面试金典面试题 01.08. 零矩阵

程序员面试金典面试题 01.08. 零矩阵