每日算法 — 使用java实现宾果游戏:卡方检验与蒙特卡洛模拟最优卡片设计

引言

宾果游戏(Bingo)是一种广受欢迎的概率游戏,玩家持有印有数字的卡片,主持人随机抽取数字,率先在卡片上连成特定图案(如一条直线)的玩家获胜。一个有趣的问题是:如何设计宾果卡片,使得游戏的公平性得到保证,同时每位玩家的获胜概率分布尽可能均匀?

本文将用Java实现宾果卡片设计的完整分析流程,包括卡方检验(Chi-Square Test)评估卡片均匀性,以及蒙特卡洛模拟评估不同设计方案的获胜概率分布。

宾果游戏规则与建模

标准75球宾果

在标准75球宾果中:
– 数字分为5列:B(1-15)、I(16-30)、N(31-45)、G(46-60)、O(61-75)
– 每张卡片为5×5矩阵,中心格为免费格(FREE)
– 获胜条件:任意行、列或对角线的5个数字全部被抽中

卡片设计问题

若卡片上的数字分布不均匀(例如某列数字过于集中),会导致某些玩家天然拥有更高胜率。我们需要:
1. 用卡方检验检测数字分布的均匀性
2. 用蒙特卡洛模拟估计不同卡片配置的获胜概率

Java实现

import java.util.*;

/**
 * 宾果卡片类
 */
class BingoCard {
    private final int[][] grid;  // 5x5网格,-1表示FREE格
    private final String playerName;
    private final boolean[][] marked;

    public BingoCard(String playerName, Random random) {
        this.playerName = playerName;
        this.grid = new int[5][5];
        this.marked = new boolean[5][5];
        generateCard(random);
    }

    /**
     * 生成标准宾果卡片
     * B列: 1-15, I列: 16-30, N列: 31-45, G列: 46-60, O列: 61-75
     * 中心格为FREE
     */
    private void generateCard(Random random) {
        int[][] ranges = {
            {1, 15}, {16, 30}, {31, 45}, {46, 60}, {61, 75}
        };
        for (int col = 0; col < 5; col++) {
            List<Integer> numbers = new ArrayList<>();
            for (int i = ranges[col][0]; i <= ranges[col][1]; i++) {
                numbers.add(i);
            }
            Collections.shuffle(numbers, random);
            for (int row = 0; row < 5; row++) {
                if (row == 2 && col == 2) {
                    grid[row][col] = -1;  // FREE格
                    marked[row][col] = true;
                } else {
                    grid[row][col] = numbers.get(row);
                    marked[row][col] = false;
                }
            }
        }
    }

    public void markNumber(int number) {
        for (int row = 0; row < 5; row++) {
            for (int col = 0; col < 5; col++) {
                if (grid[row][col] == number) {
                    marked[row][col] = true;
                }
            }
        }
    }

    /**
     * 检查是否已获胜(任意行、列、对角线全满)
     */
    public boolean hasWon() {
        // 检查行
        for (int row = 0; row < 5; row++) {
            boolean complete = true;
            for (int col = 0; col < 5; col++) {
                if (!marked[row][col]) { complete = false; break; }
            }
            if (complete) return true;
        }
        // 检查列
        for (int col = 0; col < 5; col++) {
            boolean complete = true;
            for (int row = 0; row < 5; row++) {
                if (!marked[row][col]) { complete = false; break; }
            }
            if (complete) return true;
        }
        // 检查对角线
        boolean diag1 = true, diag2 = true;
        for (int i = 0; i < 5; i++) {
            if (!marked[i][i]) diag1 = false;
            if (!marked[i][4 - i]) diag2 = false;
        }
        return diag1 || diag2;
    }

    public int[][] getGrid() { return grid; }
    public String getPlayerName() { return playerName; }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("  B   I   N   G   O\n");
        for (int row = 0; row < 5; row++) {
            for (int col = 0; col < 5; col++) {
                if (grid[row][col] == -1) sb.append(" FR ");
                else sb.append(String.format("%3d ", grid[row][col]));
            }
            sb.append("\n");
        }
        return sb.toString();
    }
}

/**
 * 卡方检验工具类
 */
class ChiSquareTest {
    /**
     * 对卡片进行列内数字分布的卡方检验
     * 检验每列中数字是否均匀分布在对应区间内
     */
    public static double testColumnUniformity(List<BingoCard> cards) {
        // 统计每列中各数字的出现频率
        int[][] ranges = {{1, 15}, {16, 30}, {31, 45}, {46, 60}, {61, 75}};
        double totalChiSquare = 0;

        for (int col = 0; col < 5; col++) {
            int rangeSize = ranges[col][1] - ranges[col][0] + 1;
            int[] observed = new int[rangeSize + 1];
            int totalNumbers = 0;

            for (BingoCard card : cards) {
                int[][] grid = card.getGrid();
                for (int row = 0; row < 5; row++) {
                    if (row == 2 && col == 2) continue;  // 跳过FREE格
                    int num = grid[row][col];
                    observed[num - ranges[col][0]]++;
                    totalNumbers++;
                }
            }

            // 期望频率:每个数字出现次数相同
            double expected = (double) totalNumbers / rangeSize;
            double chiSquare = 0;
            for (int i = 0; i < rangeSize; i++) {
                chiSquare += Math.pow(observed[i] - expected, 2) / expected;
            }
            totalChiSquare += chiSquare;
        }

        return totalChiSquare;
    }

    /**
     * 检验行内数字的离散程度(方差分析)
     */
    public static double testRowVariance(List<BingoCard> cards) {
        double totalVariance = 0;
        for (BingoCard card : cards) {
            int[][] grid = card.getGrid();
            for (int row = 0; row < 5; row++) {
                double sum = 0, sumSq = 0;
                int count = 0;
                for (int col = 0; col < 5; col++) {
                    if (grid[row][col] == -1) continue;
                    sum += grid[row][col];
                    sumSq += grid[row][col] * grid[row][col];
                    count++;
                }
                double mean = sum / count;
                double variance = sumSq / count - mean * mean;
                totalVariance += variance;
            }
        }
        return totalVariance / (cards.size() * 5);
    }
}

/**
 * 蒙特卡洛模拟引擎
 */
class MonteCarloBingo {
    private final Random random;
    private final int numSimulations;

    public MonteCarloBingo(int numSimulations, long seed) {
        this.numSimulations = numSimulations;
        this.random = new Random(seed);
    }

    /**
     * 模拟单局游戏,返回各卡片的获胜轮次(未获胜返回-1)
     */
    public int[] simulateSingleGame(List<BingoCard> cards) {
        // 重置所有卡片
        for (BingoCard card : cards) {
            // 重新创建标记状态(通过深拷贝或重置标记)
            // 简化处理:创建新卡片副本进行模拟
        }
        // 为了效率,我们直接操作卡片标记
        List<BingoCard> simCards = new ArrayList<>();
        for (BingoCard card : cards) {
            simCards.add(createCopy(card));
        }

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

        int[] winRound = new int[cards.size()];
        Arrays.fill(winRound, -1);
        int winners = 0;

        for (int round = 0; round < 75 && winners == 0; round++) {
            int ball = balls.get(round);
            for (int i = 0; i < simCards.size(); i++) {
                if (winRound[i] == -1) {
                    simCards.get(i).markNumber(ball);
                    if (simCards.get(i).hasWon()) {
                        winRound[i] = round + 1;
                        winners++;
                    }
                }
            }
        }
        return winRound;
    }

    private BingoCard createCopy(BingoCard original) {
        // 简化:重新生成一张同配置卡片
        return new BingoCard(original.getPlayerName(), random);
    }

    /**
     * 运行大规模蒙特卡洛模拟
     */
    public SimulationResult runSimulation(int numCards) {
        int[] winCounts = new int[numCards];
        int[] winRoundSum = new int[numCards];
        int[] winRoundCount = new int[numCards];
        int totalGamesWithWinner = 0;

        for (int sim = 0; sim < numSimulations; sim++) {
            List<BingoCard> cards = new ArrayList<>();
            for (int i = 0; i < numCards; i++) {
                cards.add(new BingoCard("Player" + i, random));
            }

            int[] result = simulateSingleGame(cards);
            boolean hasWinner = false;
            for (int i = 0; i < numCards; i++) {
                if (result[i] != -1) {
                    winCounts[i]++;
                    winRoundSum[i] += result[i];
                    winRoundCount[i]++;
                    hasWinner = true;
                }
            }
            if (hasWinner) totalGamesWithWinner++;
        }

        return new SimulationResult(winCounts, winRoundSum, winRoundCount, totalGamesWithWinner);
    }
}

/**
 * 模拟结果统计
 */
class SimulationResult {
    public final int[] winCounts;
    public final int[] winRoundSum;
    public final int[] winRoundCount;
    public final int totalGamesWithWinner;

    public SimulationResult(int[] winCounts, int[] winRoundSum, int[] winRoundCount, int totalGamesWithWinner) {
        this.winCounts = winCounts;
        this.winRoundSum = winRoundSum;
        this.winRoundCount = winRoundCount;
        this.totalGamesWithWinner = totalGamesWithWinner;
    }

    public void printStatistics(int numSimulations) {
        System.out.println("=== 蒙特卡洛模拟结果 ===");
        System.out.println("总模拟次数: " + numSimulations);
        System.out.println("有获胜者的局数: " + totalGamesWithWinner);

        double[] winRates = new double[winCounts.length];
        for (int i = 0; i < winCounts.length; i++) {
            winRates[i] = (double) winCounts[i] / numSimulations;
        }

        double meanWinRate = Arrays.stream(winRates).average().orElse(0);
        double variance = 0;
        for (double rate : winRates) {
            variance += Math.pow(rate - meanWinRate, 2);
        }
        variance /= winRates.length;

        System.out.printf("平均胜率: %.4f%n", meanWinRate);
        System.out.printf("胜率方差: %.6f%n", variance);
        System.out.printf("胜率标准差: %.6f%n", Math.sqrt(variance));

        System.out.println("\n各玩家胜率:");
        for (int i = 0; i < Math.min(10, winRates.length); i++) {
            System.out.printf("  Player%d: %.4f", i, winRates[i]);
            if (winRoundCount[i] > 0) {
                System.out.printf(" (平均获胜轮次: %.1f)", (double) winRoundSum[i] / winRoundCount[i]);
            }
            System.out.println();
        }
        if (winRates.length > 10) {
            System.out.println("  ... (共 " + winRates.length + " 张卡片)");
        }
    }
}

/**
 * 主程序入口
 */
public class BingoSimulationDemo {
    public static void main(String[] args) {
        Random random = new Random(12345);

        // 生成100张卡片进行卡方检验
        System.out.println("=== 生成测试卡片 ===");
        List<BingoCard> testCards = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            testCards.add(new BingoCard("Player" + i, random));
        }

        // 卡方检验
        double chiSquare = ChiSquareTest.testColumnUniformity(testCards);
        System.out.printf("卡方统计量: %.4f%n", chiSquare);
        System.out.println("(值越小表示分布越均匀)\n");

        // 蒙特卡洛模拟
        System.out.println("=== 开始蒙特卡洛模拟 ===");
        MonteCarloBingo mc = new MonteCarloBingo(10000, 99999);
        SimulationResult result = mc.runSimulation(50);
        result.printStatistics(10000);

        // 展示一张示例卡片
        System.out.println("\n=== 示例卡片 ===");
        BingoCard example = new BingoCard("Example", random);
        System.out.println(example);
    }
}

算法分析

卡方检验原理

卡方检验统计量计算公式为:

$$\chi^2 = \sum_{i=1}^{k} \frac{(O_i – E_i)^2}{E_i}$$

其中 $O_i$ 为观测频数,$E_i$ 为期望频数。在均匀分布假设下,每个数字的期望出现次数相等。当 $\chi^2$ 值过大时,拒绝均匀性假设。

蒙特卡洛收敛性

蒙特卡洛模拟的估计误差以 $O(1/\sqrt{N})$ 的速度收敛,其中N为模拟次数。要将估计精度提高一位小数,需要将模拟次数增加100倍。

时间复杂度

  • 单局宾果模拟:O(75 × K),K为卡片数
  • N次蒙特卡洛模拟:O(N × 75 × K)

扩展思考

  1. 卡片相关性分析:实际游戏中玩家可能选择关联卡片(如包含相同数字),可用Jaccard相似度度量卡片间相关性。
  2. 多胜者概率:修改获胜条件,允许多名玩家同时获胜,分析平局概率。
  3. 非标准图案:除直线外,可模拟”X形”、”方形”等复杂图案的获胜概率。

总结

通过卡方检验和蒙特卡洛模拟的组合分析,我们能够量化评估宾果卡片设计的质量。卡方检验确保数字分布的统计均匀性,而蒙特卡洛模拟提供了在实际游戏规则下的胜率预测。这两种方法的结合,为概率游戏的设计与优化提供了系统化的分析框架。