每日算法 — 使用java实现宾果游戏:概率分布与最优卡生成策略

宾果(Bingo)是一款风靡全球的经典概率游戏。每位玩家持有一张 $5 \times 5$ 的数字卡片,主持人依次随机喊出数字,玩家标记匹配项。率先在横行、竖列或对角线上连成5个标记的玩家喊出”Bingo”获胜。本文用Java实现完整的宾果游戏引擎,讲解概率分布建模、最优卡片生成策略与多玩家胜率模拟,包含完整代码与复杂度分析。

游戏规则与数学建模

标准宾果卡片结构:
– 5列分别对应字母 B(1-15)、I(16-30)、N(31-45)、G(46-60)、O(61-75)
– 中心格为”免费格”(始终视为已标记)
– 获胜条件:任意行、列或两条主对角线全部标记

从概率论视角,游戏可建模为:
– 总体空间:从75个数字中无放回抽取,直到某张卡片满足获胜条件
– 随机变量 $X$:某张卡片达成Bingo所需的抽取次数
– 目标:分析 $X$ 的分布,并设计使期望步数最大化的卡片

核心概率分析

单张卡片的获胜概率

对于固定的抽取序列,某张卡片在第 $k$ 步获胜的概率取决于:
1. 该卡片上有多少数字在前 $k$ 个被抽中的数字中
2. 这些数字的分布是否构成完整的行/列/对角线

精确计算需要枚举所有 $\binom{75}{24}$ 种可能的卡片配置,计算量巨大。实践中采用蒙特卡洛模拟估计。

最优卡片设计

理论分析表明,使各行列数字分布尽量均匀的卡片具有更好的鲁棒性。具体策略:
– 避免单行列中数字过度集中于某一段区间
– 利用免费格的优势,优先强化包含中心格的行、列和对角线

完整Java实现

import java.util.*;

/**
 * 宾果卡片表示与验证
 */
public class BingoCard {

    private static final int SIZE = 5;
    private static final int[][] RANGES = {
        {1, 15}, {16, 30}, {31, 45}, {46, 60}, {61, 75}
    };

    private int[][] grid;           // 卡片数字,0表示免费格
    private boolean[][] marked;     // 标记状态

    public BingoCard() {
        this.grid = new int[SIZE][SIZE];
        this.marked = new boolean[SIZE][SIZE];
        generateRandomCard();
    }

    /**
     * 生成随机宾果卡片
     * 每列从对应范围随机选取5个不重复数字,中心格设为免费格
     */
    private void generateRandomCard() {
        Random rand = new Random();
        for (int col = 0; col < SIZE; col++) {
            List<Integer> pool = new ArrayList<>();
            for (int n = RANGES[col][0]; n <= RANGES[col][1]; n++) {
                pool.add(n);
            }
            Collections.shuffle(pool, rand);
            for (int row = 0; row < SIZE; row++) {
                if (row == 2 && col == 2) {
                    grid[row][col] = 0;  // 免费格
                    marked[row][col] = true;
                } else {
                    grid[row][col] = pool.get(row);
                }
            }
        }
    }

    /**
     * 生成"优化"卡片 - 尽量使各行列数字分散均匀
     */
    public static BingoCard generateOptimizedCard() {
        BingoCard card = new BingoCard();
        Random rand = new Random();

        for (int col = 0; col < SIZE; col++) {
            List<Integer> pool = new ArrayList<>();
            for (int n = RANGES[col][0]; n <= RANGES[col][1]; n++) pool.add(n);
            Collections.shuffle(pool, rand);

            // 尝试均匀分布:将数字按大小排序后交替放置
            List<Integer> selected = pool.subList(0, SIZE);
            Collections.sort(selected);

            for (int row = 0; row < SIZE; row++) {
                if (row == 2 && col == 2) {
                    card.grid[row][col] = 0;
                    card.marked[row][col] = true;
                } else {
                    // 交替放置:大-小-中-大-小 的模式
                    int idx = (row % 2 == 0) ? row / 2 : SIZE - 1 - row / 2;
                    card.grid[row][col] = selected.get(idx);
                }
            }
        }
        return card;
    }

    /**
     * 标记数字(如果卡片上有该数字)
     * @return 是否成功标记
     */
    public boolean markNumber(int number) {
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                if (grid[r][c] == number) {
                    marked[r][c] = true;
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * 检查是否已达成Bingo
     */
    public boolean hasBingo() {
        // 检查行
        for (int r = 0; r < SIZE; r++) {
            if (isLineComplete(marked[r])) return true;
        }
        // 检查列
        for (int c = 0; c < SIZE; c++) {
            boolean complete = true;
            for (int r = 0; r < SIZE; r++) {
                if (!marked[r][c]) { complete = false; break; }
            }
            if (complete) return true;
        }
        // 检查主对角线
        boolean diag1 = true, diag2 = true;
        for (int i = 0; i < SIZE; i++) {
            if (!marked[i][i]) diag1 = false;
            if (!marked[i][SIZE - 1 - i]) diag2 = false;
        }
        return diag1 || diag2;
    }

    private boolean isLineComplete(boolean[] line) {
        for (boolean b : line) if (!b) return false;
        return true;
    }

    /**
     * 统计当前卡片上已标记的数字数量
     */
    public int getMarkedCount() {
        int count = 0;
        for (boolean[] row : marked) {
            for (boolean b : row) if (b) count++;
        }
        return count;
    }

    /**
     * 获取卡片上的数字(用于显示)
     */
    public int[][] getGrid() { return grid; }
    public boolean[][] getMarked() { return marked; }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("  B   I   N   G   O\n");
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                if (r == 2 && c == 2) {
                    sb.append(" FREE");
                } else {
                    sb.append(String.format(" %3d", grid[r][c]));
                }
            }
            sb.append("\n");
        }
        return sb.toString();
    }
}

/**
 * 宾果游戏主控与模拟器
 */
public class BingoGame {

    private List<BingoCard> players;
    private List<Integer> drawPool;
    private List<Integer> drawnNumbers;
    private Random random;

    public BingoGame(int numPlayers) {
        this.players = new ArrayList<>();
        for (int i = 0; i < numPlayers; i++) {
            players.add(new BingoCard());
        }
        this.drawPool = new ArrayList<>();
        for (int i = 1; i <= 75; i++) drawPool.add(i);
        this.drawnNumbers = new ArrayList<>();
        this.random = new Random();
    }

    /**
     * 进行一局游戏,返回各玩家的获胜步数(未获胜则为-1)
     */
    public int[] playGame() {
        List<Integer> pool = new ArrayList<>(drawPool);
        Collections.shuffle(pool, random);
        int[] winSteps = new int[players.size()];
        Arrays.fill(winSteps, -1);

        for (int step = 0; step < pool.size(); step++) {
            int number = pool.get(step);
            drawnNumbers.add(number);

            for (int p = 0; p < players.size(); p++) {
                if (winSteps[p] != -1) continue;  // 已获胜
                players.get(p).markNumber(number);
                if (players.get(p).hasBingo()) {
                    winSteps[p] = step + 1;
                }
            }

            // 检查是否全部获胜或数字用完
            boolean allDecided = true;
            for (int w : winSteps) if (w == -1) allDecided = false;
            if (allDecided) break;
        }
        return winSteps;
    }

    /**
     * 蒙特卡洛模拟:运行多局游戏统计胜率分布
     * @param trials 模拟次数
     * @return 各步数区间内的获胜频率
     */
    public Map<String, Double> simulateWinDistribution(int trials) {
        int[] stepHistogram = new int[76];  // stepHistogram[k] = 第k步首次获胜的次数
        int[] playerWins = new int[players.size()];

        for (int t = 0; t < trials; t++) {
            // 重置所有卡片
            for (BingoCard card : players) {
                card.marked = new boolean[5][5];
                card.marked[2][2] = true;  // 重新标记免费格
            }
            drawnNumbers.clear();

            int[] results = playGame();
            int minStep = Integer.MAX_VALUE;
            List<Integer> winners = new ArrayList<>();
            for (int p = 0; p < results.length; p++) {
                if (results[p] != -1 && results[p] < minStep) {
                    minStep = results[p];
                    winners.clear();
                    winners.add(p);
                } else if (results[p] == minStep) {
                    winners.add(p);
                }
            }

            if (minStep != Integer.MAX_VALUE) {
                stepHistogram[minStep]++;
                for (int w : winners) playerWins[w]++;
            }
        }

        Map<String, Double> distribution = new LinkedHashMap<>();
        for (int k = 5; k <= 75; k++) {
            if (stepHistogram[k] > 0) {
                distribution.put("Step_" + k, (double) stepHistogram[k] / trials);
            }
        }
        return distribution;
    }

    /**
     * 计算单张卡片的理论期望获胜步数(通过模拟)
     */
    public static double expectedWinSteps(BingoCard card, int trials) {
        Random rand = new Random();
        long totalSteps = 0;

        for (int t = 0; t < trials; t++) {
            // 重置卡片
            card.marked = new boolean[5][5];
            card.marked[2][2] = true;

            List<Integer> pool = new ArrayList<>();
            for (int i = 1; i <= 75; i++) pool.add(i);
            Collections.shuffle(pool, rand);

            for (int step = 0; step < pool.size(); step++) {
                card.markNumber(pool.get(step));
                if (card.hasBingo()) {
                    totalSteps += (step + 1);
                    break;
                }
            }
        }
        return (double) totalSteps / trials;
    }

    public List<BingoCard> getPlayers() { return players; }

    public static void main(String[] args) {
        // 演示:对比随机卡片与优化卡片的期望获胜步数
        System.out.println("=== 宾果卡片性能对比 ===\n");

        int trials = 10000;

        BingoCard randomCard = new BingoCard();
        BingoCard optimizedCard = BingoCard.generateOptimizedCard();

        System.out.println("随机卡片:");
        System.out.println(randomCard);
        double randomExp = expectedWinSteps(randomCard, trials);
        System.out.printf("期望获胜步数: %.2f\n\n", randomExp);

        System.out.println("优化卡片:");
        System.out.println(optimizedCard);
        double optExp = expectedWinSteps(optimizedCard, trials);
        System.out.printf("期望获胜步数: %.2f\n\n", optExp);

        // 多人模拟
        System.out.println("=== 10人局模拟 ===");
        BingoGame game = new BingoGame(10);
        Map<String, Double> dist = game.simulateWinDistribution(1000);
        System.out.println("获胜步数分布(前10个非零区间):");
        dist.entrySet().stream().limit(10).forEach(e -> 
            System.out.printf("  %s: %.3f%%\n", e.getKey(), e.getValue() * 100));
    }
}

复杂度分析

卡片生成:每列从15个数中选5个,时间复杂度 $O(5 \times 5) = O(1)$(常数时间)。

标记操作:遍历 $5 \times 5$ 网格查找数字,时间复杂度 $O(25) = O(1)$。

Bingo检测:检查5行+5列+2对角线,时间复杂度 $O(5 \times 5) = O(1)$。

单局模拟:最多抽取75个数字,每步对 $P$ 张卡片执行标记和检测,复杂度 $O(75 \cdot P)$。

蒙特卡洛模拟:$T$ 次试验的总复杂度 $O(T \cdot 75 \cdot P)$。

最优策略分析

理论研究表明,在标准 $5 \times 5$ 宾果中:
– 最少需要4次抽取才能获胜(不考虑免费格则为5次)
– 平均获胜步数约为43-45步
– 卡片设计对胜率影响有限(差异通常在1-2步以内),因为总体随机性占主导

但以下策略仍具有实践价值:
1. 对角线强化:利用免费格位于中心的优势,优先使两条对角线数字分布更易达成
2. 列均衡:确保每列数字不全部集中在范围的一端,降低被连续”跳过”的风险
3. 边缘覆盖:中间行和列有更高概率被较早完成,可适当关注

扩展方向

  1. 变体规则:支持”四角Bingo”、”黑板Bingo”、”图案Bingo”等获胜条件
  2. 多轮次抽奖:引入累积奖池机制,未在指定步数内获胜则奖池累积
  3. 卡片协同优化:多张卡片间的数字互补策略,最大化至少一张获胜的概率

总结

宾果游戏将概率论与组合设计巧妙结合。本文通过Java实现了完整的卡片生成、游戏模拟与蒙特卡洛分析框架,验证了不同卡片设计策略的性能差异。虽然宾果本质上是一个运气主导的游戏,但理解其概率结构有助于设计更公平的竞技规则与更有趣的变体玩法。