引言
跳房子(Hopscotch)是起源于古罗马的经典儿童户外游戏,玩家在画有连续方格的场地上,通过单脚跳跃或双脚并跳的方式按数字顺序前进。传统玩法要求玩家投掷石子到指定格子后,绕过该格完成剩余路径。本文将这一经典游戏抽象为图论与动态规划问题,用Java实现一个智能跳房子路径规划系统,核心讲解BFS(广度优先搜索)计算最短可达路径,以及动态规划求解在体力限制下的最优跳跃序列。
游戏规则与数学建模
经典规则抽象
假设有一条包含N个格子的直线场地,编号从1到N。玩家从起点(0号位置)出发,每次可以选择:
- 单脚跳:向前跳1格或2格,消耗体力1单位
- 双脚并跳:向前跳3格,消耗体力2单位
- 长跳:向前跳4格,消耗体力3单位
某些格子被标记为”障碍格”,玩家不能落在上面。当玩家投掷石子到格子K后,格子K变为”禁跳格”,必须跳过。目标是到达终点(N+1号位置),且总消耗体力最少。
图论建模
将每个格子视为图中的一个节点,从节点i可以到达的节点集合由跳跃规则决定:
- 从格子i,可达 {i+1, i+2, i+3, i+4} 中的非障碍、非禁跳格子
- 边权为对应的体力消耗:1, 1, 2, 3
这是一个典型的带权有向图最短路径问题。但由于图的边具有规律结构(只向前连接),我们可以用动态规划而非Dijkstra算法来高效求解。
动态规划状态设计
定义 dp[i] 为到达格子i所需的最小体力消耗。状态转移方程:
dp[i] = min(
dp[i-1] + 1, // 从i-1单脚跳1格
dp[i-2] + 1, // 从i-2单脚跳2格
dp[i-3] + 2, // 从i-3双脚并跳3格
dp[i-4] + 3 // 从i-4长跳4格
)
其中,如果某个前驱格子是障碍或禁跳,则对应转移不合法。
初始条件:dp[0] = 0(起点体力消耗为0)。
BFS计算可达性
在求解最优路径之前,我们需要先判断终点是否可达。由于边权不均匀(1,1,2,3),标准的无权图BFS需要适配。这里我们采用0-1 BFS思想,使用双端队列处理不同权重的边。
0-1 BFS可达性检测
/**
* 0-1 BFS判断终点是否可达
* 使用双端队列,体力消耗为1的边放队首,消耗为2或3的边放队尾
*/
public boolean isReachable(int n, boolean[] blocked, int forbidden) {
boolean[] visited = new boolean[n + 2];
Deque<int[]> deque = new ArrayDeque<>(); // [position, cost]
deque.offerFirst(new int[]{0, 0});
visited[0] = true;
while (!deque.isEmpty()) {
int[] curr = deque.pollFirst();
int pos = curr[0];
if (pos == n + 1) return true; // 到达终点
// 四种跳跃方式
int[][] moves = {{1, 1}, {2, 1}, {3, 2}, {4, 3}};
for (int[] move : moves) {
int next = pos + move[0];
int cost = move[1];
if (next > n + 1) continue;
if (next == forbidden) continue; // 禁跳格
if (next <= n && blocked[next]) continue; // 障碍格
if (visited[next]) continue;
visited[next] = true;
if (cost == 1) {
deque.offerFirst(new int[]{next, cost});
} else {
deque.offerLast(new int[]{next, cost});
}
}
}
return false;
}
时间复杂度:O(N),每个节点最多入队一次。
空间复杂度:O(N)。
动态规划求解最优跳跃序列
核心算法实现
import java.util.*;
public class HopscotchSolver {
// 跳跃规则:{步数, 体力消耗}
private static final int[][] JUMP_RULES = {{1, 1}, {2, 1}, {3, 2}, {4, 3}};
/**
* 求解最小体力消耗及最优跳跃序列
* @param n 场地格子总数
* @param blocked 障碍格标记数组,blocked[i]=true表示格子i是障碍
* @param forbidden 禁跳格编号(石子投掷位置)
* @return Result对象,包含最小体力、路径数组和跳跃序列
*/
public Result solve(int n, boolean[] blocked, int forbidden) {
int target = n + 1; // 终点位置
int INF = Integer.MAX_VALUE / 2;
int[] dp = new int[target + 1];
int[] prev = new int[target + 1]; // 记录前驱节点
int[] jumpType = new int[target + 1]; // 记录跳跃类型
Arrays.fill(dp, INF);
Arrays.fill(prev, -1);
dp[0] = 0;
for (int i = 1; i <= target; i++) {
// 障碍格和禁跳格不可达
if (i <= n && blocked[i]) continue;
if (i == forbidden) continue;
for (int[] rule : JUMP_RULES) {
int step = rule[0];
int cost = rule[1];
int from = i - step;
if (from < 0) continue;
if (dp[from] == INF) continue; // 前驱不可达
if (dp[from] + cost < dp[i]) {
dp[i] = dp[from] + cost;
prev[i] = from;
jumpType[i] = step;
}
}
}
// 回溯构造路径
List<Integer> path = new ArrayList<>();
List<Integer> jumps = new ArrayList<>();
if (dp[target] != INF) {
int curr = target;
while (curr != -1) {
path.add(curr);
if (curr != 0) {
jumps.add(jumpType[curr]);
}
curr = prev[curr];
}
Collections.reverse(path);
Collections.reverse(jumps);
}
return new Result(dp[target] == INF ? -1 : dp[target], path, jumps);
}
/**
* 打印跳跃序列的可视化表示
*/
public void printPathVisualization(int n, Result result, int forbidden) {
if (result.minEnergy == -1) {
System.out.println("无可达路径!");
return;
}
System.out.println("最优路径(最小体力: " + result.minEnergy + "):");
boolean[] onPath = new boolean[n + 2];
for (int pos : result.path) {
if (pos <= n + 1) onPath[pos] = true;
}
for (int i = 0; i <= n + 1; i++) {
if (i == forbidden) {
System.out.print("[X] "); // 禁跳格
} else if (onPath[i]) {
System.out.print("[" + i + "] "); // 路径上的格子
} else {
System.out.print(" _ "); // 普通格子
}
}
System.out.println();
System.out.print("跳跃序列: ");
for (int j : result.jumps) {
String type = j == 1 ? "单脚跳1格" : j == 2 ? "单脚跳2格" :
j == 3 ? "双脚跳3格" : "长跳4格";
System.out.print(type + " -> ");
}
System.out.println("到达终点");
}
static class Result {
int minEnergy;
List<Integer> path;
List<Integer> jumps;
Result(int minEnergy, List<Integer> path, List<Integer> jumps) {
this.minEnergy = minEnergy;
this.path = path;
this.jumps = jumps;
}
}
public static void main(String[] args) {
HopscotchSolver solver = new HopscotchSolver();
// 测试用例1:无障碍
int n1 = 10;
boolean[] blocked1 = new boolean[n1 + 1];
Result r1 = solver.solve(n1, blocked1, -1);
solver.printPathVisualization(n1, r1, -1);
// 测试用例2:有障碍和禁跳格
int n2 = 15;
boolean[] blocked2 = new boolean[n2 + 1];
blocked2[5] = true;
blocked2[8] = true;
blocked2[12] = true;
Result r2 = solver.solve(n2, blocked2, 7);
solver.printPathVisualization(n2, r2, 7);
// 测试用例3:不可达情况
int n3 = 5;
boolean[] blocked3 = new boolean[n3 + 1];
blocked3[1] = true;
blocked3[2] = true;
blocked3[3] = true;
blocked3[4] = true;
Result r3 = solver.solve(n3, blocked3, -1);
solver.printPathVisualization(n3, r3, -1);
}
}
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(N × K) | N为格子数,K为跳跃规则数(此处K=4) |
| 空间复杂度 | O(N) | dp数组、prev数组、jumpType数组 |
| 路径回溯 | O(N) | 最坏情况下路径包含所有格子 |
由于K是常数4,实际时间复杂度为O(N),对于N ≤ 10^5 的场景可以毫秒级响应。
扩展:多石子禁跳与状态压缩
当游戏允许多次投掷石子,产生多个禁跳格时,问题变为多约束最短路径。若禁跳格数量M较小(M ≤ 20),可以用状态压缩动态规划:
/**
* 多禁跳格状态压缩DP
* dp[i][mask] 表示到达格子i,且已激活禁跳格状态为mask时的最小体力
*/
public int solveMultiForbidden(int n, boolean[] blocked, int[] forbiddenList) {
int m = forbiddenList.length;
int target = n + 1;
int INF = Integer.MAX_VALUE / 2;
int[][] dp = new int[target + 1][1 << m];
for (int[] row : dp) Arrays.fill(row, INF);
dp[0][0] = 0;
for (int i = 1; i <= target; i++) {
if (i <= n && blocked[i]) continue;
for (int mask = 0; mask < (1 << m); mask++) {
// 检查当前格子是否是某个禁跳格
boolean isForbidden = false;
for (int j = 0; j < m; j++) {
if (forbiddenList[j] == i && (mask & (1 << j)) == 0) {
isForbidden = true; // 该禁跳格尚未被"跳过"
break;
}
}
if (isForbidden) continue;
// 状态转移(更新mask逻辑省略,视具体规则而定)
// ...
}
}
// 返回所有mask状态下的最小值
return Arrays.stream(dp[target]).min().orElse(-1);
}
总结
本文将跳房子游戏抽象为带约束的最短路径问题,通过BFS进行可达性预检查,再用动态规划在O(N)时间内求解最优跳跃序列。核心技巧包括:
- 0-1 BFS:利用双端队列优化带权图的可达性检测
- 前驱数组回溯:在DP过程中同步记录路径信息,实现结果还原
- 多约束扩展:通过状态压缩处理多个禁跳格的复杂场景
读者可以在此基础上扩展:加入风向随机扰动(概率DP)、支持二维平面跳房子(网格BFS+DP)、或设计多玩家竞争的最优策略(博弈论)。