每日算法 — 使用java实现蛇梯棋:BFS最短路径与概率期望计算

蛇梯棋(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实现能帮助你在经典游戏中理解图搜索与概率模型的精妙结合。