每日算法 — 使用java实现双色球:组合数学概率计算与蒙特卡洛期望模拟

问题描述

双色球是中国福利彩票的经典玩法:从1-33中选取6个红球(不重复),再从1-16中选取1个蓝球,组成一注号码。开奖时随机摇出6个红球和1个蓝球,根据命中数量确定奖级。

本文将用Java实现双色球的完整模拟系统,包括组合数计算、各奖级概率精确求解,以及蒙特卡洛方法验证理论概率。

核心算法思路

组合数计算

从 $n$ 个元素中选取 $k$ 个的组合数为:

$$C(n,k) = \frac{n!}{k!(n-k)!}$$

双色球一等奖(6+1)的中奖概率为:

$$P = \frac{1}{C(33,6) \times 16} = \frac{1}{17,721,088}$$

各奖级概率模型

奖级 红球命中 蓝球命中 组合数计算
一等奖 6 1 $C(6,6) \times C(27,0) \times C(1,1)$
二等奖 6 0 $C(6,6) \times C(27,0) \times C(15,1)$
三等奖 5 1 $C(6,5) \times C(27,1) \times C(1,1)$
四等奖 5/4 0/1 分别计算
五等奖 4/3 0/1 分别计算
六等奖 2/1/0 1 分别计算

蒙特卡洛模拟

通过大量随机模拟(如1000万次)统计各奖级出现频率,验证理论计算的正确性。

Java实现

import java.util.*;

/**
 * 双色球概率分析与蒙特卡洛模拟
 * 精确计算各奖级中奖概率并通过大数定律验证
 */
public class DoubleColorBall {

    // 红球池大小、选取数量,蓝球池大小
    private static final int RED_POOL = 33;
    private static final int RED_SELECT = 6;
    private static final int BLUE_POOL = 16;

    // 奖级名称
    private static final String[] PRIZE_NAMES = {
        "一等奖", "二等奖", "三等奖", "四等奖", "五等奖", "六等奖", "未中奖"
    };

    private Random random;

    public DoubleColorBall() {
        this.random = new Random();
    }

    public DoubleColorBall(long seed) {
        this.random = new Random(seed);
    }

    /**
     * 计算组合数 C(n, k)
     * 使用递推避免大数阶乘溢出
     */
    public long combination(int n, int k) {
        if (k < 0 || k > n) return 0;
        if (k == 0 || k == n) return 1;
        if (k > n - k) k = n - k; // 对称性优化

        long result = 1;
        for (int i = 1; i <= k; i++) {
            result = result * (n - k + i) / i;
        }
        return result;
    }

    /**
     * 计算理论总注数
     */
    public long totalCombinations() {
        return combination(RED_POOL, RED_SELECT) * BLUE_POOL;
    }

    /**
     * 计算特定奖级的理论注数
     * @param redHit 红球命中数
     * @param blueHit 蓝球命中数 (0或1)
     * @return 该奖级的组合数
     */
    public long calculatePrizeCombinations(int redHit, int blueHit) {
        // 从6个中奖红球中命中redHit个
        long redWays = combination(RED_SELECT, redHit);
        // 从27个未中奖红球中选出剩余的红球
        long redMissWays = combination(RED_POOL - RED_SELECT, RED_SELECT - redHit);
        // 蓝球命中方式
        long blueWays = blueHit == 1 ? 1 : (BLUE_POOL - 1);

        return redWays * redMissWays * blueWays;
    }

    /**
     * 判定奖级
     * @param redHit 红球命中数
     * @param blueHit 蓝球是否命中
     * @return 奖级索引 (0-5为中奖, 6为未中奖)
     */
    public int determinePrize(int redHit, boolean blueHit) {
        if (redHit == 6 && blueHit) return 0;  // 一等奖
        if (redHit == 6 && !blueHit) return 1; // 二等奖
        if (redHit == 5 && blueHit) return 2;  // 三等奖
        if ((redHit == 5 && !blueHit) || (redHit == 4 && blueHit)) return 3; // 四等奖
        if ((redHit == 4 && !blueHit) || (redHit == 3 && blueHit)) return 4; // 五等奖
        if (blueHit) return 5; // 六等奖 (2+1, 1+1, 0+1)
        return 6; // 未中奖
    }

    /**
     * 打印理论概率表
     */
    public void printTheoreticalProbabilities() {
        long total = totalCombinations();
        System.out.println("=== 双色球理论概率分析 ===");
        System.out.printf("总注数: %,d%n", total);
        System.out.println();

        // 各奖级组合数
        long[] prizeCounts = new long[7];
        prizeCounts[0] = calculatePrizeCombinations(6, 1);
        prizeCounts[1] = calculatePrizeCombinations(6, 0);
        prizeCounts[2] = calculatePrizeCombinations(5, 1);
        prizeCounts[3] = calculatePrizeCombinations(5, 0) + calculatePrizeCombinations(4, 1);
        prizeCounts[4] = calculatePrizeCombinations(4, 0) + calculatePrizeCombinations(3, 1);
        prizeCounts[5] = calculatePrizeCombinations(2, 1) + calculatePrizeCombinations(1, 1)
                       + calculatePrizeCombinations(0, 1);

        long sum = 0;
        for (int i = 0; i < 6; i++) sum += prizeCounts[i];
        prizeCounts[6] = total - sum;

        System.out.printf("%-8s %-15s %-18s %-15s%n", "奖级", "命中条件", "组合数", "概率");
        System.out.println("-".repeat(65));

        for (int i = 0; i < 7; i++) {
            double prob = (double) prizeCounts[i] / total;
            String condition;
            switch (i) {
                case 0: condition = "6+1"; break;
                case 1: condition = "6+0"; break;
                case 2: condition = "5+1"; break;
                case 3: condition = "5+0/4+1"; break;
                case 4: condition = "4+0/3+1"; break;
                case 5: condition = "2+1/1+1/0+1"; break;
                default: condition = "其他"; break;
            }
            System.out.printf("%-8s %-15s %,15d  %.8f%% (%s)%n",
                PRIZE_NAMES[i], condition, prizeCounts[i],
                prob * 100, formatOdds(prob));
        }

        double winProb = 0;
        for (int i = 0; i < 6; i++) winProb += (double) prizeCounts[i] / total;
        System.out.println("-".repeat(65));
        System.out.printf("总中奖概率: %.4f%% (约 %.2f 分之一)%n", winProb * 100, 1.0 / winProb);
    }

    private String formatOdds(double prob) {
        if (prob <= 0) return "不可能";
        double odds = 1.0 / prob;
        if (odds > 10000000) return String.format("约 %.0f万分之一", odds / 10000);
        if (odds > 10000) return String.format("约 %.0f千分之一", odds / 1000);
        return String.format("约 %.0f分之一", odds);
    }

    /**
     * 生成一注随机号码
     */
    public Bet generateRandomBet() {
        Set<Integer> reds = new TreeSet<>();
        while (reds.size() < RED_SELECT) {
            reds.add(random.nextInt(RED_POOL) + 1);
        }
        int blue = random.nextInt(BLUE_POOL) + 1;
        return new Bet(reds, blue);
    }

    /**
     * 计算两注号码的匹配情况
     */
    public MatchResult match(Bet bet, Bet winning) {
        int redHit = 0;
        for (int r : bet.redBalls) {
            if (winning.redBalls.contains(r)) redHit++;
        }
        boolean blueHit = (bet.blueBall == winning.blueBall);
        int prize = determinePrize(redHit, blueHit);
        return new MatchResult(redHit, blueHit, prize);
    }

    /**
     * 蒙特卡洛模拟
     * @param iterations 模拟次数
     */
    public void monteCarloSimulation(long iterations) {
        System.out.printf("\n=== 蒙特卡洛模拟 (%,d 次) ===%n", iterations);

        Bet winningBet = generateRandomBet();
        long[] prizeCounts = new long[7];

        for (long i = 0; i < iterations; i++) {
            Bet randomBet = generateRandomBet();
            MatchResult result = match(randomBet, winningBet);
            prizeCounts[result.prizeLevel]++;
        }

        System.out.printf("%-8s %-15s %-15s %-15s%n", "奖级", "命中条件", "模拟次数", "模拟概率");
        System.out.println("-".repeat(60));

        for (int i = 0; i < 7; i++) {
            double prob = (double) prizeCounts[i] / iterations;
            String condition;
            switch (i) {
                case 0: condition = "6+1"; break;
                case 1: condition = "6+0"; break;
                case 2: condition = "5+1"; break;
                case 3: condition = "5+0/4+1"; break;
                case 4: condition = "4+0/3+1"; break;
                case 5: condition = "2+1/1+1/0+1"; break;
                default: condition = "其他"; break;
            }
            System.out.printf("%-8s %-15s %,12d    %.6f%%%n",
                PRIZE_NAMES[i], condition, prizeCounts[i], prob * 100);
        }
    }

    // 数据类
    public static class Bet {
        public final Set<Integer> redBalls;
        public final int blueBall;

        public Bet(Set<Integer> redBalls, int blueBall) {
            this.redBalls = redBalls;
            this.blueBall = blueBall;
        }

        @Override
        public String toString() {
            return "红球: " + redBalls + " 蓝球: " + blueBall;
        }
    }

    public static class MatchResult {
        public final int redHits;
        public final boolean blueHit;
        public final int prizeLevel;

        public MatchResult(int redHits, boolean blueHit, int prizeLevel) {
            this.redHits = redHits;
            this.blueHit = blueHit;
            this.prizeLevel = prizeLevel;
        }
    }

    public static void main(String[] args) {
        DoubleColorBall dcb = new DoubleColorBall(42);
        dcb.printTheoreticalProbabilities();

        // 蒙特卡洛验证
        dcb.monteCarloSimulation(1_000_000);

        // 生成一注随机号码
        System.out.println("\n随机一注: " + dcb.generateRandomBet());
    }
}

算法复杂度分析

  • 组合数计算:$O(k)$,其中 $k \leq 6$ 为选取数量,通过对称性优化后最多计算6次乘除。
  • 理论概率求解:$O(1)$,所有奖级均可通过闭式公式直接计算。
  • 蒙特卡洛模拟:$O(n \times k \log k)$,$n$ 为模拟次数,每次需要对比6个红球。

扩展思考

  1. 大数定律:当蒙特卡洛模拟次数趋于无穷时,模拟频率收敛于理论概率。通常100万次模拟即可得到3位有效数字的精度。
  2. 期望值计算:根据各奖级奖金,可计算单注彩票的期望收益。以现行规则计算,期望收益率约为50%左右。
  3. 复式投注:若一次选取7个红球,组合数变为 $C(7,6) \times 16 = 112$ 注,可用相同方法计算成本与覆盖率。

总结

双色球问题完美展示了组合数学在实际中的应用。通过精确计算 $C(33,6)$ 种红球组合与16种蓝球的笛卡尔积,可以严格推导出各奖级的理论概率。蒙特卡洛模拟则作为验证手段,直观展示了大数定律的收敛过程。