目录

题目描述

面试题 08.02. 迷路的机器人

题意分析

给定一个 m × n 的网格,0 表示可以通过、1 表示障碍。机器人从左上角 (0, 0) 出发,每一步只能向右或向下,要求返回一条到达右下角 (m - 1, n - 1) 的路径,路径以坐标列表的形式给出,起点和终点都要包含在内;若不存在这样的路径,返回空列表。

注意这题要的是任意一条可行路径,不是最短路径、不是路径数量、也不是所有路径。只要找到一条就可以立刻收工,这决定了整个算法可以「一旦成功就短路返回」。

「只能向右或向下」这个限制信息量极大:行号与列号都只增不减,机器人永远不可能回到走过的格子,也不可能绕圈。换句话说,格子之间的可达关系构成一张有向无环图,这直接排除了成环的顾虑,也让状态标记不必在回退时清除。

更进一步的推论是无后效性:一个格子能不能走到终点,只取决于它自己和它右下方的格局,与「机器人是怎么走到这个格子的」毫无关系。因此某个格子一旦被判定为「走不通」,这个结论就永久成立,任何路径再次经过它都不必重新验证。这是把指数级搜索压成线性的关键。

数据规模上,网格边长在百的量级,路径条数最多可达组合数 $C_{m+n}^{n}$ 那么多,逐条枚举必然超时;但格子总数只有 m × n,围绕格子而不是围绕路径来做文章才有出路。

边界:起点或终点本身是障碍时无解,返回空列表;网格为空或首行为空时同样返回空列表。

解法:回溯加失败位置剪枝

核心思路

最直接的想法是枚举所有由「右」「下」组成的走法,每条走完再看是不是合法。路径总数是组合数级别的,m = n = 20 时就已经上亿,完全不可行。

瓶颈在于同一个格子会被无数条不同的前缀路径重复展开,而每次展开得到的结论完全相同。上面已经论证过:由于只能右下移动,「从某格出发能否到终点」与前缀无关。既然结论可复用,那就把它记下来。

于是引入 visited 数组,但它的语义不是通常意义上的「这个格子来过」,而是——visited[r][c] == true 表示已经以该格为起点搜索过,并且确认它走不到终点。之所以能这样解释,是因为一旦某次搜索成功,函数会一路 return true 直接返回到最外层,绝不会再有第二次查询发生;只有失败的格子才会被后续分支撞见。有了这个标记,每个格子至多被真正展开一次,总工作量降到 $O(mn)$。

路径的维护采用「进入时压入、失败时弹出、成功时保留」的回溯模式,不变量是:path 在任意时刻都恰好保存着从起点走到当前格子的一条合法路径(全是 0 格,且每步都是向右或向下)。进入一个格子先把它压进 path,如果它就是终点,那么此刻 path 正好是一条完整答案,直接返回 true 并且不再弹出任何元素;如果两个方向都失败,说明这一格无法通向终点,就把它弹出、打上失败标记,恢复到进入前的状态。

搜索方向固定为「先下后右」,这只影响最终返回的是哪一条路径,不影响是否有解——题目接受任意一条,所以两种顺序都对。

顺便说一句,这题也能用「从右下往左上推」的动态规划求出每格是否可达,再顺着可达标记回溯出路径;但本解法的写法更短,且 visited 剪枝已经让复杂度与动态规划持平。

解题步骤

  • 处理空输入:网格为 null、行数为 0 或列数为 0 时直接返回空列表,避免后续取 grid[0].length 时越界。
  • 准备路径列表与失败标记数组path 收集答案坐标,visited 与网格同形,初值全 false
  • 从起点发起搜索:调用 dfs(grid, 0, 0, visited, path),成功返回 path,失败返回一个新的空列表。起点是障碍的情况不需要特判,会被 dfs 内部的障碍判断吃掉。
  • 四个失败条件合并成一行row >= 行数col >= 列数grid[row][col] == 1visited[row][col],任一成立就返回 false。因为行列只增不减,不可能出现负下标,所以不必判断 row < 0;但越界判断必须排在读取 grid[row][col] 之前,否则会数组越界。
  • 压入当前格并打标记path.add(...)visited[row][col] = true。这里先打标记不会误伤自己,因为向右向下永远不会绕回当前格。
  • 命中终点立即返回 true:此时 path 的最后一个元素正是终点,整条路径已经完整,必须在这里返回,否则会继续往界外试探并最终把终点弹出去。
  • 依次尝试向下、向右dfs(row + 1, col) || dfs(row, col + 1),短路求值保证一旦向下成功就不再尝试向右。任一成功就 return true 把成功信号一路传上去,path 原样保留。
  • 两个方向都失败则弹出当前格path.remove(path.size() - 1) 恢复现场,返回 false。此时 visited 标记保留不清除——这正是剪枝生效的地方,也是本题与普通网格回溯(如单词搜索)最大的写法差异。

以下面这个 3 × 3 网格走一遍,其中 (1, 1)(2, 0) 是障碍。

[[0, 0, 0], [0, 1, 0], [1, 0, 0]]

进入 (0, 0):合法,压入,path = [[0,0]],不是终点,先向下。
进入 (1, 0):合法,压入,path = [[0,0], [1,0]],不是终点,先向下。
进入 (2, 0)grid[2][0] == 1 是障碍,返回 false
回到 (1, 0) 尝试向右,进入 (1, 1):也是障碍,返回 false
(1, 0) 两个方向皆败,弹出它,path 回到 [[0,0]],并保留 visited[1][0] = true——从此这个格子被永久判死。
回到 (0, 0) 尝试向右,进入 (0, 1):合法,压入,path = [[0,0], [0,1]],先向下进入 (1, 1),障碍,失败;转向右。
进入 (0, 2):合法,压入,path = [[0,0], [0,1], [0,2]],先向下。
进入 (1, 2):合法,压入,path = [[0,0], [0,1], [0,2], [1,2]],不是终点,先向下。
进入 (2, 2):合法,压入,path = [[0,0], [0,1], [0,2], [1,2], [2,2]],命中终点,返回 true
true 一路短路上传,沿途不再执行任何弹出操作,最外层直接把 path 返回,答案就是 [[0,0], [0,1], [0,2], [1,2], [2,2]]

再看一个无解的例子 [[0, 0, 0], [0, 0, 1], [0, 1, 0]]:终点 (2, 2)(1, 2)(2, 1) 两个障碍彻底封死。搜索会依次把 (2, 0)(1, 1)(1, 0)(0, 2)(0, 1)(0, 0) 判死并逐个弹出,其中 (0, 1) 向下走到 (1, 1) 时直接命中 visited 剪枝、一次都没有重新展开。最终 dfs 返回 falsepath 也已被弹空,函数返回空列表。

代码实现

class Solution {
    public List<List<Integer>> pathWithObstacles(int[][] obstacleGrid) {
        List<List<Integer>> path = new ArrayList<>();
        if (obstacleGrid == null || obstacleGrid.length == 0 || obstacleGrid[0].length == 0) {
            return path;
        }
        boolean[][] visited = new boolean[obstacleGrid.length][obstacleGrid[0].length];
        if (dfs(obstacleGrid, 0, 0, visited, path)) {
            return path;
        }
        return new ArrayList<>();
    }

    private boolean dfs(int[][] grid, int row, int col, boolean[][] visited, List<List<Integer>> path) {
        if (row >= grid.length || col >= grid[0].length || grid[row][col] == 1 || visited[row][col]) {
            return false;
        }

        path.add(Arrays.asList(row, col));
        visited[row][col] = true;
        if (row == grid.length - 1 && col == grid[0].length - 1) {
            return true;
        }
        if (dfs(grid, row + 1, col, visited, path) || dfs(grid, row, col + 1, visited, path)) {
            return true;
        }
        path.remove(path.size() - 1);
        return false;
    }
}
func pathWithObstacles(obstacleGrid [][]int) [][]int {
    path := [][]int{}
    if len(obstacleGrid) == 0 || len(obstacleGrid[0]) == 0 {
        return path
    }

    rows, cols := len(obstacleGrid), len(obstacleGrid[0])
    visited := make([][]bool, rows)
    for i := 0; i < rows; i++ {
        visited[i] = make([]bool, cols)
    }

    var dfs func(int, int) bool
    dfs = func(row int, col int) bool {
        if row >= rows || col >= cols || obstacleGrid[row][col] == 1 || visited[row][col] {
            return false
        }
        path = append(path, []int{row, col})
        visited[row][col] = true
        if row == rows-1 && col == cols-1 {
            return true
        }
        if dfs(row+1, col) || dfs(row, col+1) {
            return true
        }
        path = path[:len(path)-1]
        return false
    }

    if dfs(0, 0) {
        return path
    }
    return [][]int{}
}

复杂度分析

  • 时间复杂度:$O(mn)$。每个格子至多被真正展开一次——成功的格子会让搜索立刻结束,失败的格子被 visited 永久标记后再也不会重新展开;每次展开只做常数次判断和两次子调用,所以总量与格子数同阶。若去掉 visited,复杂度会退化到路径条数级别的 $O(C_{m+n}^{n})$。
  • 空间复杂度:$O(mn)$,visited 数组占 $O(mn)$;递归栈深度最多是一条路径的长度 $O(m + n)$;path 最长也是 $O(m + n)$。三者之和由 visited 主导。

关键点总结

  • 先判断有没有后效性:只能右下移动 ⇒ 「能否到终点」与来路无关 ⇒ 结论可缓存。这个推理链是把回溯从指数级压到线性级的通用路径,遇到网格搜索题应当第一时间检查。
  • 想清楚标记数组的语义:这里的 visited 是「已确认走不通」,不是「来过」,所以回退时不清除。与之相对,单词搜索那类允许四方向的题必须在回退时清除标记,否则会漏解。语义决定写法,背模板最容易在这里翻车。
  • 成功与失败的收尾方式不同:失败弹出、成功保留,靠 return true 一路短路来实现「不再弹出」。凡是「找到一个解就收工」的搜索,都可以用布尔返回值 + 短路的写法,比抛异常或设全局标志位干净。
  • 合并边界判断的顺序不能乱:越界检查必须写在访问 grid[row][col] 之前,短路求值才是安全的。
  • 面试视角:这题的区分度不在能不能写出 DFS,而在能否主动说明「为什么 visited 不用回退清除」以及「不加 visited 会退化成什么复杂度」。如果面试官追问其他解法,可以答:由于只能右下走,从右下角往左上角递推每个格子是否可达是等价的动态规划,再顺着可达标记走一遍即可还原路径,复杂度同为 $O(mn)$。

易错点总结

  • 成功时也弹出路径:把 path.remove(...) 写在函数出口统一执行,返回 true 之后路径又被逐层弹空,[[0]] 这种一格网格会返回空列表而不是 [[0,0]]
  • 失败时忘记弹出路径:走不通的格子留在了 path 里,[[0,0,0],[0,1,0],[1,0,0]] 会把死胡同 (1, 0) 混进答案,输出一条根本走不通的路径。
  • 回退时把 visited 清掉:剪枝彻底失效,退化成枚举所有右下走法,100 × 100 的全 0 网格直接超时。这正是从单词搜索那类题里照搬模板最容易犯的错。
  • 到达终点后不立即返回 true:继续向下、向右试探,两个方向都越界返回 false,于是终点被当作失败格弹出,[[0]] 会返回空列表。
  • 越界判断与取值判断顺序写反:把 visited[row][col]grid[row][col] == 1 放在 row >= 行数 之前,走到边界外时直接数组越界异常。
  • 先压入路径再判断障碍:起点是障碍时 [[1,0],[0,0]] 会返回 [[0,0]] 这样一条含障碍的非法路径,正确答案是空列表。
  • 多写了向上、向左两个方向:题目只允许右和下,四方向搜索出来的路径可能先向右再向上,[[0,0,0],[0,1,0],[1,0,0]] 上会输出不满足题意的路径,并且无后效性不再成立、visited 剪枝也随之失效。
  • 误把题目当成求路径数量:写成 unique-paths 那样的递推返回计数,题目要的是具体坐标序列,答非所问。

相似题目

题目 难度 考察点
63. 不同路径 II 中等 同样是带障碍的右下移动,但求方案数量,用递推而不必还原路径
62. 不同路径 中等 无障碍版本,可直接用组合数公式,是本题的最简形态
64. 最小路径和 中等 移动方向相同,但目标从可行性变成权值最优,需比较两条来路
980. 不同路径 III 困难 四方向且必须走遍所有空格,visited 必须在回退时清除
79. 单词搜索 中等 四方向搜索的典型代表,对照理解「标记何时该清除」
剑指 Offer 13. 机器人的运动范围 中等 障碍换成数位和阈值,求可达格子总数而非某一条路径
1926. 迷宫中离入口最近的出口 中等 四方向且要求最短,必须换成广度优先搜索
200. 岛屿数量 中等 同为网格深搜,目标是统计连通块,标记一律不回退