题目描述

✅ 499. 迷宫 III

题意分析

小球选定方向后会一直滚动,直到墙前才能再次选择方向;途中经过洞则立即落入。求滚动总格数最少的入洞路线,同距离时返回字典序最小的方向字符串,无法入洞则返回 "impossible"。字符串长度是滚动次数,不是经过的格数。

解法:Dijkstra + 路径字典序

核心思路

[!blue]

把起点以及滚动后能停下的位置看成图节点。从一个位置选方向滚动到另一个位置,构成一条边,边权是实际经过的格数;洞可能在途中,因此模拟滚动时一到洞就停止。这些边的长度不同,普通 BFS 按操作次数分层不能保证滚动距离最短,应使用 Dijkstra。

每个位置保存一对最优信息:dist[x][y] 是最短距离,bestPath[x][y] 是达到该距离时字典序最小的方向序列。最小堆也按这两个条件排序,先比较距离,相同再比较路径。起点的距离为零、路径为空,其他位置距离为 INF。

从堆中取出一个有效状态后,分别模拟四个方向。若一格也不能移动,就不存在有效操作,不追加方向字符;否则把滚动格数加到距离上,把本次方向追加到路径末尾。新距离更小,或者距离相同而路径更小时,都需要更新目标位置并入堆。

只保留同一位置的最小路径不会丢失更优后续:较短的到达距离接上相同后续仍然较短;等距离的两条不同路径不可能互为严格前缀,因为同一方向序列确定同一滚动过程,额外走一段回到相同位置必然增加正距离。因此等距离时,字典序先后的差别发生在已有字符内,追加相同后缀不会改变优劣。

有效边权都为正,到达某位置的更短前驱会先被堆处理,所以取出的当前最优状态可以用于后续扩展。堆里可能还保留该位置此前的旧记录,弹出时要同时核对距离和路径,任一已经过期就跳过。搜索结束后,洞位置保存的就是距离优先、字典序次之的最佳答案。

解题步骤

  1. 初始化距离表、路径表与按双关键字排序的最小堆,将起点入堆。
  2. 取出状态,跳过不再等于当前最优记录的旧项。
  3. 沿四个方向滚动到墙前或洞内,计算经过格数,跳过原地不动的方向。
  4. 对得到的停点按距离和字典序进行松弛,改进时更新记录并入堆。
  5. 全部候选处理完后,返回洞的路径;没有找到入洞路径则返回 "impossible"。

代码实现

class Solution {
    private static final int INF = 1_000_000_000;
    private static final int[] DX = {
        1,
        0,
        0,
        -1,
    };
    private static final int[] DY = {
        0,
        -1,
        1,
        0,
    };
    private static final String[] DIR = {
        "d",
        "l",
        "r",
        "u",
    };

    private static class State {
        int x;
        int y;
        int dist;
        String path;

        State(int x, int y, int dist, String path) {
            this.x = x;
            this.y = y;
            this.dist = dist;
            this.path = path;
        }
    }

    public String findShortestWay(int[][] maze, int[] ball, int[] hole) {
        int m = maze.length;
        int n = maze[0].length;

        int[][] dist = new int[m][n];
        String[][] bestPath = new String[m][n];

        for (int i = 0; i < m; i++) {
            Arrays.fill(dist[i], INF);

            for (int j = 0; j < n; j++) {
                bestPath[i][j] = "";
            }
        }

        PriorityQueue<State> pq =
                new PriorityQueue<>(
                        (a, b) -> {
                            if (a.dist != b.dist) {
                                return a.dist - b.dist;
                            }

                            return a.path.compareTo(b.path);
                        });

        dist[ball[0]][ball[1]] = 0;
        pq.offer(new State(ball[0], ball[1], 0, ""));

        while (!pq.isEmpty()) {
            State cur = pq.poll();

            if (cur.dist != dist[cur.x][cur.y] || !cur.path.equals(bestPath[cur.x][cur.y])) {
                continue;
            }

            for (int d = 0; d < 4; d++) {
                int x = cur.x;
                int y = cur.y;
                int steps = 0;

                while (true) {
                    int nx = x + DX[d];
                    int ny = y + DY[d];

                    if (nx < 0 || nx >= m || ny < 0 || ny >= n || maze[nx][ny] == 1) {
                        break;
                    }

                    x = nx;
                    y = ny;
                    steps++;

                    // 途中进入洞就停止滚动,不必继续到墙边。
                    if (x == hole[0] && y == hole[1]) {
                        break;
                    }
                }

                // 不能移动的方向不构成有效操作,不能追加到路径中。
                if (steps == 0) {
                    continue;
                }

                int ndist = cur.dist + steps;
                String npath = cur.path + DIR[d];

                // 距离相同但方向序列更小,也必须更新并重新入堆。
                if (ndist < dist[x][y]
                        || (ndist == dist[x][y]
                                && (bestPath[x][y].isEmpty()
                                        || npath.compareTo(bestPath[x][y]) < 0))) {
                    dist[x][y] = ndist;
                    bestPath[x][y] = npath;
                    pq.offer(new State(x, y, ndist, npath));
                }
            }
        }

        String answer = bestPath[hole[0]][hole[1]];

        return answer.isEmpty() ? "impossible" : answer;
    }
}
import "container/heap"

type State struct {
    x    int
    y    int
    dist int
    path string
}

type MinHeap []State

func (h MinHeap) Len() int { return len(h) }

func (h MinHeap) Less(i, j int) bool {
    if h[i].dist != h[j].dist {
        return h[i].dist < h[j].dist
    }
    return h[i].path < h[j].path
}

func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

func (h *MinHeap) Push(x any) { *h = append(*h, x.(State)) }

func (h *MinHeap) Pop() any {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[:n-1]
    return x
}

func findShortestWay(maze [][]int, ball []int, hole []int) string {
    m := len(maze)
    n := len(maze[0])

    const inf = int(1e9)
    dist := make([][]int, m)
    best := make([][]string, m)
    for i := 0; i < m; i++ {
        dist[i] = make([]int, n)
        best[i] = make([]string, n)
        for j := 0; j < n; j++ {
            dist[i][j] = inf
            best[i][j] = ""
        }
    }

    dx := []int{
        1,
        0,
        0,
        -1,
    }
    dy := []int{
        0,
        -1,
        1,
        0,
    }
    dir := []string{
        "d",
        "l",
        "r",
        "u",
    }

    dist[ball[0]][ball[1]] = 0
    best[ball[0]][ball[1]] = ""

    h := &MinHeap{}
    heap.Init(h)
    heap.Push(h, State{x: ball[0], y: ball[1], dist: 0, path: ""})

    for h.Len() > 0 {
        cur := heap.Pop(h).(State)
        if cur.dist != dist[cur.x][cur.y] || cur.path != best[cur.x][cur.y] {
            continue
        }

        for d := 0; d < 4; d++ {
            x, y := cur.x, cur.y
            steps := 0
            for {
                nx, ny := x+dx[d], y+dy[d]
                if nx < 0 || nx >= m || ny < 0 || ny >= n || maze[nx][ny] == 1 {
                    break
                }
                x, y = nx, ny
                steps++
                // 途中进入洞就停止滚动,不必继续到墙边。
                if x == hole[0] && y == hole[1] {
                    break
                }
            }
            // 不能移动的方向不构成有效操作,不能追加到路径中。
            if steps == 0 {
                continue
            }

            ndist := cur.dist + steps
            npath := cur.path + dir[d]
            // 距离相同但方向序列更小,也必须更新并重新入堆。
            if ndist < dist[x][y] || (ndist == dist[x][y] && (best[x][y] == "" || npath < best[x][y])) {
                dist[x][y] = ndist
                best[x][y] = npath
                heap.Push(h, State{x: x, y: y, dist: ndist, path: npath})
            }
        }
    }

    if best[hole[0]][hole[1]] == "" {
        return "impossible"
    }
    return best[hole[0]][hole[1]]
}

复杂度分析

  • 时间复杂度:设网格格数为 V = mn,一次滚动最多经过 D 格,候选路径最大长度为 L,上界为 $O(V(D+L\log(V+1)))$。每个停点最多有四条出边,滚动需要逐格模拟,堆比较与路径构造还涉及字符串成本。
  • 空间复杂度:$O(VL)$,用于距离、最佳路径以及携带路径字符串的堆状态。

关键点总结

[!green]

  • 图的边权是实际滚动距离,不能把一次选方向当作距离一。
  • 距离相同而路径更小,同样是必须传播的改进。
  • 入洞要在滚动过程中识别,不能只检查靠墙停点。

易错点总结

[!yellow]

  • 只在距离减少时更新,会保留字典序更大的等距路线。
  • 堆按字典序排在距离之前,会把目标变成先优化字符串而非距离。
  • 只检查过期距离,不检查过期路径,会重复扩展已经被同距更小路径替代的记录。
  • 不能移动时仍追加字符,会产生题目不允许的原地操作,也破坏正边权的依据。

相似题目

题目 难度 关联与区别
505. 迷宫 II 中等 同样按滚动距离做最短路,本题遇洞提前停且同距离时需比较路径字符串。
490. 迷宫 中等 原题只判断可达,本题还需要最短距离与字典序两个排序条件。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/57538385
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!