蛇梯棋(Snakes and Ladders)是一款源自古印度的经典棋盘游戏,距今已有两千多年历史。玩家在1到100的线性棋盘上掷骰子前进,遇到梯子可以”一飞冲天”,遭遇蛇则会”一落千丈”。看似全靠运气的游戏背后,隐藏着BFS最短路径搜索与概率动态规划两大核心算法问题。本文将用Java完整实现蛇梯棋的引擎,从图论建模到期望计算逐层剖析。
一、问题建模与棋盘数据结构
1.1 蛇梯棋规则抽象
标准蛇梯棋棋盘为 $1 \times N$ 的线性格子序列(通常 $N=100$)。核心规则可抽象为:
- 掷骰前进:玩家掷一枚六面骰,点数 $d \in [1,6]$,从当前格子 $i$ 移动到 $i+d$
- 梯子跳跃:若落点 $i+d$ 是梯子底部,则直接跳到梯子顶部
- 蛇滑行:若落点 $i+d$ 是蛇头,则直接滑落到蛇尾
- 胜利条件:首次到达或超过格子 $N$ 者获胜
从算法视角看,棋盘是一个有向图:每个格子是一个节点,掷骰子可达的格子之间有一条边(边的权重取决于分析目标)。
1.2 棋盘状态类设计
import java.util.*;
/**
* 蛇梯棋棋盘
* 维护 N 个格子的跳跃映射关系
*/
class SnakesAndLaddersBoard {
// 棋盘总格子数,默认100
final int n;
// jumps[i] 表示从格子 i 出发的最终落点
// 若 i 有梯子或蛇,则 jumps[i] != i
// 若 i 是普通格子,则 jumps[i] = i
final int[] jumps;
/**
* 构造空棋盘(无蛇无梯子)
* @param n 棋盘格子总数
*/
SnakesAndLaddersBoard(int n) {
this.n = n;
this.jumps = new int[n + 1]; // 索引1~n,0号位置废弃
for (int i = 0; i <= n; i++) {
jumps[i] = i;
}
}
/**
* 添加一条蛇:从蛇头滑落到蛇尾
* @param head 蛇头位置(高位)
* @param tail 蛇尾位置(低位)
*/
void addSnake(int head, int tail) {
if (head <= tail) {
throw new IllegalArgumentException("蛇头必须高于蛇尾");
}
jumps[head] = tail;
}
/**
* 添加一个梯子:从底部跳到顶部
* @param bottom 梯子底部(低位)
* @param top 梯子顶部(高位)
*/
void addLadder(int bottom, int top) {
if (bottom >= top) {
throw new IllegalArgumentException("梯子顶部必须高于底部");
}
jumps[bottom] = top;
}
/**
* 计算从某个格子掷骰子后的最终落点
* 核心逻辑:先走d步,再应用蛇/梯子跳跃
* @param pos 当前位置
* @param dice 骰子点数 1~6
* @return 最终落点,若超出n则返回n(表示已到达终点)
*/
int move(int pos, int dice) {
int next = pos + dice;
if (next > n) {
// 超出终点视为到达终点(不同规则变体)
return n;
}
// 应用跳跃:蛇或梯子
return jumps[next];
}
/**
* 判断是否已经到达终点
*/
boolean isGoal(int pos) {
return pos >= n;
}
/**
* 获取从位置pos掷一次骰子所有可能的落点
* @return 6个可能的落点数组
*/
int[] possibleMoves(int pos) {
int[] moves = new int[6];
for (int d = 1; d <= 6; d++) {
moves[d - 1] = move(pos, d);
}
return moves;
}
}
二、核心算法一:BFS最短路径
2.1 问题定义
若将棋盘视为有向图,每条边(掷一次骰子)的权重为1,则最少掷骰次数到达终点的问题等价于从节点1到节点N的无权最短路径问题,天然适合BFS求解。
2.2 BFS实现
/**
* BFS求解蛇梯棋最短路径
* 返回从起点到终点的最少掷骰次数,以及具体路径
*/
class ShortestPathSolver {
/**
* BFS计算最少掷骰次数
* @param board 蛇梯棋棋盘
* @return 最少掷骰次数,不可达返回-1(正常棋盘总是可达)
*/
static int minThrowsBFS(SnakesAndLaddersBoard board) {
int n = board.n;
// 记录每个格子是否访问过
boolean[] visited = new boolean[n + 1];
// BFS队列:存储 {当前位置, 已掷骰次数}
Queue<int[]> queue = new LinkedList<>();
queue.offer(new int[]{1, 0});
visited[1] = true;
while (!queue.isEmpty()) {
int[] cur = queue.poll();
int pos = cur[0];
int throwsCount = cur[1];
// 到达终点
if (board.isGoal(pos)) {
return throwsCount;
}
// 尝试6种骰子点数
for (int dice = 1; dice <= 6; dice++) {
int next = board.move(pos, dice);
// 注意:必须基于跳跃前的格子判断访问状态
// 因为跳跃后的格子可能是被蛇拉下来的,从其他路径到达那里仍需考虑
// 但 next 是最终落点,我们用 next 做访问标记即可
if (next <= n && !visited[next]) {
visited[next] = true;
queue.offer(new int[]{next, throwsCount + 1});
}
}
}
return -1; // 不可达
}
/**
* BFS同时记录最短路径的具体走法
* @param board 蛇梯棋棋盘
* @return 路径列表,每个元素为 [当前位置, 骰子点数, 落点]
*/
static List<int[]> shortestPathWithTrace(SnakesAndLaddersBoard board) {
int n = board.n;
boolean[] visited = new boolean[n + 1];
// parent[pos] 记录到达pos的前一个位置
int[] parent = new int[n + 1];
// diceUsed[pos] 记录从parent[pos]到pos使用的骰子点数
int[] diceUsed = new int[n + 1];
Arrays.fill(parent, -1);
Queue<Integer> queue = new LinkedList<>();
queue.offer(1);
visited[1] = true;
while (!queue.isEmpty()) {
int pos = queue.poll();
if (board.isGoal(pos)) {
// 回溯路径
return reconstructPath(parent, diceUsed, pos);
}
for (int dice = 1; dice <= 6; dice++) {
int next = board.move(pos, dice);
if (next <= n && !visited[next]) {
visited[next] = true;
parent[next] = pos;
diceUsed[next] = dice;
queue.offer(next);
}
}
}
return Collections.emptyList();
}
/**
* 根据parent数组回溯路径
*/
private static List<int[]> reconstructPath(int[] parent, int[] diceUsed, int goal) {
List<int[]> path = new ArrayList<>();
int cur = goal;
while (parent[cur] != -1) {
path.add(new int[]{parent[cur], diceUsed[cur], cur});
cur = parent[cur];
}
Collections.reverse(path);
return path;
}
}
2.3 复杂度分析
- 时间复杂度:$O(N)$,每个格子最多入队一次,每次处理6条边
- 空间复杂度:$O(N)$,visited数组和队列的空间
三、核心算法二:概率动态规划求期望步数
3.1 问题定义
BFS解决的是”运气最好时需要几步”,而实际游戏中骰子是随机的。更贴近现实的问题是:从任意格子 $i$ 出发,期望需要掷多少次骰子才能到达终点?
设 $E[i]$ 为从格子 $i$ 到达终点的期望掷骰次数,则有递推关系:
$$E[i] = 1 + \frac{1}{6} \sum_{d=1}^{6} E[\text{move}(i, d)]$$
边界条件:$E[N] = 0$(已在终点,无需再掷)。
注意:若 $\text{move}(i, d) > N$,则视为 $E[N] = 0$。
3.2 线性方程组与迭代法
由于蛇和梯子的存在,$\text{move}(i, d)$ 可能跳跃到任意位置,导致递推关系形成循环依赖。例如从格子98掷出3到达101(超出终点),但掷出2可能到达某条蛇的头部被拉回低处,再从低处又可能掷骰返回。因此直接递推无法从后向前求解。
解决方案:高斯-赛德尔迭代法。从初始猜测出发,反复用最新值更新每个 $E[i]$,直到收敛。
/**
* 概率动态规划求解期望步数
*/
class ExpectedMovesSolver {
/**
* 使用高斯-赛德尔迭代法计算每个格子的期望掷骰次数
* @param board 蛇梯棋棋盘
* @param epsilon 收敛阈值
* @param maxIter 最大迭代次数
* @return 期望次数数组,result[i]表示从格子i到终点的期望掷骰次数
*/
static double[] expectedMoves(SnakesAndLaddersBoard board, double epsilon, int maxIter) {
int n = board.n;
double[] E = new double[n + 1];
// E[n] = 0 已经满足
for (int iter = 0; iter < maxIter; iter++) {
double maxDiff = 0.0;
// 从 n-1 到 1 倒序更新(高斯-赛德尔:使用最新值)
for (int i = n - 1; i >= 1; i--) {
double sum = 0.0;
int validMoves = 0;
for (int dice = 1; dice <= 6; dice++) {
int next = board.move(i, dice);
// next 不会小于1,move方法已保证
sum += E[next];
validMoves++;
}
double newValue = 1.0 + sum / validMoves;
maxDiff = Math.max(maxDiff, Math.abs(newValue - E[i]));
E[i] = newValue;
}
if (maxDiff < epsilon) {
System.out.println("迭代收敛于第 " + (iter + 1) + " 轮");
return E;
}
}
System.out.println("警告:达到最大迭代次数,结果可能未完全收敛");
return E;
}
/**
* 简化版本:使用默认参数
*/
static double[] expectedMoves(SnakesAndLaddersBoard board) {
return expectedMoves(board, 1e-9, 10000);
}
}
3.3 收敛性说明
高斯-赛德尔迭代对蛇梯棋模型保证收敛,原因如下:
- 状态转移矩阵是随机矩阵(每行和为1),且系统存在吸收态(终点N)
- 蛇梯棋图中从任意节点出发都存在正概率在有限步内到达终点
- 该问题本质上是吸收马尔可夫链的基本矩阵求解,迭代法必然收敛
四、核心算法三:蒙特卡洛模拟验证
4.1 模拟思路
通过大量随机对局模拟,统计平均每局掷骰次数,与概率DP的理论值进行交叉验证。
import java.util.Random;
/**
* 蒙特卡洛模拟器
*/
class MonteCarloSimulator {
private static final Random random = new Random(42); // 固定种子保证可复现
/**
* 模拟单局游戏
* @param board 蛇梯棋棋盘
* @return 本局掷骰次数
*/
static int simulateSingleGame(SnakesAndLaddersBoard board) {
int pos = 1;
int throwsCount = 0;
while (!board.isGoal(pos)) {
int dice = random.nextInt(6) + 1; // 1~6
pos = board.move(pos, dice);
throwsCount++;
// 安全检查:防止异常循环
if (throwsCount > 100000) {
throw new RuntimeException("模拟步数异常,可能存在循环");
}
}
return throwsCount;
}
/**
* 批量模拟并返回平均掷骰次数
* @param board 蛇梯棋棋盘
* @param trials 模拟局数
* @return 平均掷骰次数
*/
static double simulateAverage(SnakesAndLaddersBoard board, int trials) {
long totalThrows = 0;
for (int i = 0; i < trials; i++) {
totalThrows += simulateSingleGame(board);
}
return (double) totalThrows / trials;
}
/**
* 模拟并统计从每个格子出发的平均步数
* 方法:大量对局中记录每个起始位置对应的步数(仅适用于空棋盘无蛇梯)
*/
static double[] simulateFromEachPosition(SnakesAndLaddersBoard board, int trialsPerPos) {
int n = board.n;
double[] avg = new double[n + 1];
for (int start = 1; start < n; start++) {
long total = 0;
for (int t = 0; t < trialsPerPos; t++) {
int pos = start;
int steps = 0;
while (!board.isGoal(pos)) {
pos = board.move(pos, random.nextInt(6) + 1);
steps++;
if (steps > 100000) break;
}
total += steps;
}
avg[start] = (double) total / trialsPerPos;
}
return avg;
}
}
五、完整运行示例
public class SnakesAndLaddersGame {
public static void main(String[] args) {
// 构造标准100格蛇梯棋棋盘
SnakesAndLaddersBoard board = new SnakesAndLaddersBoard(100);
// 添加经典梯子
board.addLadder(2, 38);
board.addLadder(4, 14);
board.addLadder(9, 31);
board.addLadder(21, 42);
board.addLadder(28, 84);
board.addLadder(36, 44);
board.addLadder(51, 67);
board.addLadder(71, 91);
board.addLadder(80, 100);
// 添加经典蛇
board.addSnake(16, 6);
board.addSnake(48, 26);
board.addSnake(49, 11);
board.addSnake(56, 53);
board.addSnake(62, 19);
board.addSnake(64, 60);
board.addSnake(87, 24);
board.addSnake(93, 73);
board.addSnake(95, 75);
board.addSnake(98, 78);
System.out.println("===== 蛇梯棋算法分析 =====\n");
// 1. BFS最短路径
int minThrows = ShortestPathSolver.minThrowsBFS(board);
System.out.println("BFS最短路径:最少需要 " + minThrows + " 次掷骰");
List<int[]> path = ShortestPathSolver.shortestPathWithTrace(board);
System.out.println("最优路径详情:");
for (int[] step : path) {
System.out.printf(" 位置 %d → 掷 %d → 位置 %d%n", step[0], step[1], step[2]);
}
System.out.println();
// 2. 概率DP期望步数
double[] expected = ExpectedMovesSolver.expectedMoves(board);
System.out.printf("概率DP:从起点到达终点的期望掷骰次数 = %.4f%n", expected[1]);
System.out.printf(" 从位置50到达终点的期望次数 = %.4f%n%n", expected[50]);
// 3. 蒙特卡洛模拟验证
int trials = 100000;
double mcAvg = MonteCarloSimulator.simulateAverage(board, trials);
System.out.printf("蒙特卡洛模拟(%d局):平均掷骰次数 = %.4f%n", trials, mcAvg);
System.out.printf("理论值与模拟值差异 = %.4f%n", Math.abs(expected[1] - mcAvg));
}
}
六、算法扩展与变体
6.1 双人对抗变体
标准蛇梯棋是单人/纯运气游戏。若改为双人轮流掷骰,可先到达终点者获胜,则问题转化为马尔可夫决策过程,可用动态规划计算先手胜率:
$$P[i][j] = \frac{1}{6} \sum_{d=1}^{6} (1 – P[j][\text{move}(i, d)])$$
其中 $P[i][j]$ 表示当前玩家在位置 $i$、对手在位置 $j$ 时的获胜概率。
6.2 最优蛇梯布局设计
给定固定数量的蛇和梯子,如何布局使期望步数最大化或最小化?这是一个组合优化问题,可用模拟退火或遗传算法搜索近似最优布局。
6.3 带决策的骰子选择
若允许玩家在某些位置选择使用1~3点的小骰子或4~6点的大骰子,则游戏变为有限阶段随机博弈,可用逆向归纳法求解最优策略。
七、总结
蛇梯棋是一个看似简单却内涵丰富的算法载体:
- BFS最短路径回答了”运气最好时的最优路线”
- 概率动态规划给出了”长期平均需要多少步”的理论值
- 蒙特卡洛模拟提供了实验验证手段
三种方法相互印证,构成从确定性到随机性、从理论到实践的完整分析链条。希望本文的Java实现能帮助你在经典游戏中理解图搜索与概率模型的精妙结合。