题目描述

✅ 1654. 到家的最少跳跃次数

image-20260928230819315

image-20260928230819316

题意分析

从数轴位置零出发,每次向前跳 a 或向后跳 b,求到达位置 x 的最少跳跃次数。不能落在负数位置或 forbidden 中,也不能连续两次向后跳。

只限制落点,允许跳过禁区位置,也允许先越过目标再跳回来。起点就是目标时需要零跳;没有合法路线时返回 -1。

解法:带方向状态的 BFS

核心思路

[!blue]

每次跳跃的代价都是一步,因此用 BFS 按跳数逐层搜索。状态不能只有位置,还要记录 jumpedBack:零表示上一步不是后跳、允许下一步后跳;一表示刚后跳过、下一步只能前跳。

同一个位置的这两种状态允许的下一步不同,不能共用访问标记。前跳到 pos + a 后状态变为零;只有当前状态为零,才允许后跳到 pos - b,后跳后状态变为一。落点合法且该状态未访问时入队,并立即标记,避免重复扩展。

数轴没有天然右界,需要证明可以只搜索到 limit = max(x, maxForbidden) + a + b。令 M = max(x, maxForbidden)。若 a >= b,位置超过 x + b 后,即使先后跳一次也仍在目标右侧;此后每次后跳之前必须前跳,合计位移 a-b 非负,不可能再回到目标。因此不需要进入更远位置。

若 a < b,考虑一条合法路线中超过 M+a+b 的最高位置 p。到达它的最后一步只能前跳;前一步也必须是前跳,否则那次后跳的出发点为 p-a+b>p,与最高位置矛盾。离开最高点只能后跳,所以局部一定出现“前、前、后”。

将这三步改为“前、后、前”,起止位置与步数都不变,也不会出现连续后跳。唯一新增落点是 p-a-b>M,高于所有禁区且非负,其余落点仍合法,同时避开原最高点。不断替换过高峰值,就能把任意可行路线变成步数相同、不过此上界的路线,所以截断不会丢掉最短解。

有界后最多只有 2 * (limit + 1) 种状态。每层固定处理进入该层时的队列长度,新状态留到下一层;第一次取出目标的层数就是最少跳数。

解题步骤

  1. 将禁区放入集合,计算最大禁区及搜索上界。
  2. 从状态 (0, 0) 开始,标记并入队,层数为零。
  3. 每层取固定数量的状态;当前位置等于目标时返回层数。
  4. 尝试上界以内的前跳;当前允许后跳时,再尝试非负的后跳。
  5. 只将不在禁区且未访问的完整状态入队,处理完一层后跳数加一。
  6. 队列耗尽仍未到目标则返回 -1。

代码实现

class Solution {
    // 同一个位置在 "上一步是否向后跳" 两种状态下后续选择不同,访问标记必须区分这两类状态。
    public int minimumJumps(int[] forbidden, int a, int b, int x) {
        Set<Integer> blocked = new HashSet<>();
        int maxForbidden = 0;

        for (int pos : forbidden) {
            blocked.add(pos);
            maxForbidden = Math.max(maxForbidden, pos);
        }

        int limit = Math.max(x, maxForbidden) + a + b;
        boolean[][] visited = new boolean[limit + 1][2];
        Queue<int[]> queue = new ArrayDeque<>();

        queue.offer(new int[] {
            0,
            0
        });
        visited[0][0] = true;

        int steps = 0;

        while (!queue.isEmpty()) {
            // 固定本层状态数量,本轮新增状态统一留到下一跳。
            int size = queue.size();

            for (int i = 0; i < size; i++) {
                int[] cur = queue.poll();
                int pos = cur[0];
                int jumpedBack = cur[1];

                if (pos == x) {
                    return steps;
                }

                int forward = pos + a;

                if (forward <= limit && !blocked.contains(forward) && !visited[forward][0]) {
                    // 前跳恢复后跳权限,入队前标记避免重复状态。
                    visited[forward][0] = true;
                    queue.offer(new int[] {
                        forward,
                        0
                    });
                }

                // 只有上一步不是后跳,本轮才允许尝试后跳。
                if (jumpedBack == 0) {
                    int backward = pos - b;

                    if (backward >= 0 && !blocked.contains(backward) && !visited[backward][1]) {
                        // 后跳落点进入禁止连续后跳的状态。
                        visited[backward][1] = true;
                        queue.offer(new int[] {
                            backward,
                            1
                        });
                    }
                }
            }

            steps++;
        }

        return -1;
    }
}
func minimumJumps(forbidden []int, a int, b int, x int) int {
    // 同一个位置在 "上一步是否向后跳" 两种状态下后续选择不同,访问标记必须区分这两类状态。
    blocked := make(map[int]struct{})
    maxForbidden := 0
    for _, pos := range forbidden {
        blocked[pos] = struct{}{}
        if pos > maxForbidden {
            maxForbidden = pos
        }
    }

    limit := maxInt(x, maxForbidden) + a + b
    visited := make([][2]bool, limit+1)
    queue := make([][2]int, 0)
    queue = append(queue, [2]int{
        0,
        0,
    })
    visited[0][0] = true

    steps := 0
    for head := 0; head < len(queue); {
        // 固定本层状态数量,本轮新增状态统一留到下一跳。
        size := len(queue) - head
        for i := 0; i < size; i++ {
            cur := queue[head]
            head++
            pos := cur[0]
            jumpedBack := cur[1]

            if pos == x {
                return steps
            }

            forward := pos + a
            if forward <= limit && !contains(blocked, forward) && !visited[forward][0] {
                // 前跳恢复后跳权限,入队前标记避免重复状态。
                visited[forward][0] = true
                queue = append(queue, [2]int{
                    forward,
                    0,
                })
            }

            // 只有上一步不是后跳,本轮才允许尝试后跳。
            if jumpedBack == 0 {
                backward := pos - b
                if backward >= 0 && !contains(blocked, backward) && !visited[backward][1] {
                    // 后跳落点进入禁止连续后跳的状态。
                    visited[backward][1] = true
                    queue = append(queue, [2]int{
                        backward,
                        1,
                    })
                }
            }
        }
        steps++
    }

    return -1
}

func contains(blocked map[int]struct{}, pos int) bool {
    _, ok := blocked[pos]
    return ok
}

func maxInt(a int, b int) int {
    if a > b {
        return a
    }
    return b
}

复杂度分析

  • 时间复杂度:期望 $O(U+F)$,其中 $U$ 为位置上界,$F$ 为禁区数。最多处理两倍位置数的状态,每个状态尝试两种跳法,集合查询平均常数时间。
  • 空间复杂度:$O(U+F)$,用于两类访问标记、队列及禁区集合。

关键点总结

[!green]

  • 上一步方向影响后续动作,必须进入状态,而不能只记录位置。
  • 每条合法转移都花一次跳跃,BFS 的层数对应最短步数。
  • 搜索上界由可调整路径或不可回退性质保证,不能随意截在目标处。
  • 入队即标记完整状态,保证每种位置与权限组合至多入队一次。

易错点总结

[!yellow]

  • 只按位置去重,会把后跳权限不同的到达方式合并,漏掉有效路线。
  • 后跳后仍保留后跳权限,违反禁止连续后跳的规则。
  • 将搜索范围限制为 0..x,会漏掉必须先越过目标再回来的路线。
  • 只检查是否跨过禁区会增加题目没有的限制,真正需要检查的是落点。
  • 不固定每层大小就遍历新加入状态,会把不同跳数混在同一层,导致答案计数错误。

相似题目

题目 难度 关联与区别
1129. 颜色交替的最短路径 中等 同样必须把上一步类型纳入BFS状态,本题需要记是否刚向后跳,不能只按坐标去重。
1306. 跳跃游戏 III 中等 原题跳长由当前位置决定,本题前后步长固定但禁止连续后跳,状态规则不同。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2022/66443692
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!