每日算法 — 使用java实现宾果游戏:超几何分布与容斥原理的精确概率计算

宾果(Bingo)是聚会游戏里规则最”平淡”的一种:卡片上 5×5 的数字阵,主持人从球池里随机抽号,谁先凑齐一条线(横、竖、斜)谁赢。

平淡的外表下藏着一个很扎实的组合数学问题:一张卡片在第 k 步之前成线的概率究竟是多少?以及 12 条线之间的相关性要怎么处理?

这两个问题的答案直接支撑着真实业务:线上宾果平台的”还差几个球”、”当前最可能成线的位置”、”多卡同时玩时的先手优势”都必须依赖精确概率,而不是拍脑袋的经验值。

本文用 Java 精确计算:

  1. 单条线的完成概率(超几何分布);
  2. 整张卡片的累积成线概率(用容斥原理处理 12 条线之间的相关性);
  3. 用等价类归并把 2^12 个子集压到 52 个等价类;
  4. 用蒙特卡洛模拟对拍验证精确解。

一、宾果卡片的概率结构

标准 75 球宾果:B 列放 1-15,I 列 16-30,N 列 31-45,G 列 46-60,O 列 61-75,每列抽 5 个数字填进卡片(中间一格是 Free Space)。

共 12 条可能的线:5 横、5 竖、2 斜。

抽号过程:主持人从 75 个球里不放回地逐个抽。第 k 步抽到的球,落在这张卡片上就”命中”了对应格子。

这里有第一个关键洞察:

抽号是均匀随机的,所以卡片上那 24 个数字被抽中的”次序”等价于从 75 个位置里均匀随机取 24 个位置的相对排序。卡片上数字的具体数值完全不影响分布。

也就是说,所有卡片的”单线完成时间”分布完全相同——它们都只是”75 选 24″的一个随机子集。这与”每列必须来自本列区间”的规则毫无关系,列约束只影响多张卡片之间的相关性(第四节展开)。

所以我们只需要关心一个量:一条线需要 u 个球同时被抽到(u = 线上非 Free 格子数,取 4 或 5)。

二、单条线的分布:超几何分布

固定一条线(5 个格子全部需要抽中),问”抽了 k 个球之后,这 5 个格子全被抽中”的概率。

因为抽号不放回,从 75 个球里抽 k 个,恰好覆盖某个特定 5 元子集的概率是:

$$
P(\text{线完成} \mid k) = \frac{\binom{75-5}{k-5}}{\binom{75}{k}}
$$

这是超几何分布的经典形式。用精确分数计算的结果:

已抽球数 k 单线完成概率
20 0.0009
30 0.0083
40 0.0381
50 0.1228
55 0.2016
60 0.3164
65 0.4786
70 0.7012

单线的中位完成步数是 66——即抽到 66 个球时,一条线被完成的概率刚过 50%。这解释了”如果只盯一条线,通常要抽很久”。

而整个卡片要快得多,因为有 12 条线同时在推进。下一节就是关键。

三、整张卡片:容斥原理

12 条线不是独立的。”第一行完成”和”第一列完成”共享角格 (0,0),它们的完成事件正相关。

求”卡片在第 k 步或之前完成至少一条线”的概率,就是求并集概率:

$$
P\left(\bigcup_{i=1}^{12} A_i\right) = \sum_i P(A_i) – \sum_{i<j} P(A_i \cap A_j) + \sum_{i<j<l} P(A_i \cap A_j \cap A_l) – \cdots
$$

对任意线子集 T,设这些线的并集里有 u(T) 个非 Free 格子,则交集事件的概率依然是超几何:

$$
P\left(\bigcap_{i \in T} A_i\right) = \frac{\binom{75 – u(T)}{k – u(T)}}{\binom{75}{k}}
$$

于是容斥展开成:

$$
P\left(\bigcup_i A_i\right) = \sum_{\emptyset \neq T \subseteq {1..12}} (-1)^{|T|+1} \cdot \frac{\binom{75 – u(T)}{k – u(T)}}{\binom{75}{k}}
$$

12 条线共 2^12 - 1 = 4095 个非空子集。对每个子集,只需知道它有多少个互不重复的格子(u(T)),就能算出它的概率贡献。

这是本文的核心算法:用容斥把所有线的相关性精确展开,无需任何模拟。

这里有个必须警惕的数值陷阱:容斥在 k 较大时会出现严重的数值抵消——各项量级接近(都在 1e-2 附近),正负相消会让有效数字大量损失。在 P ≈ 1e-10 的尾部,双精度可能只剩 3~4 位有效数字。

工程对策:用精确有理数(分数)做容斥,最后一步才转浮点。Java 里用 BigInteger 表示分子分母,所有项通分累加。本文给出 HyperGeom 类专门做这件事。

四、等价类归并:把 4095 压到 52

4095 个子集里,很多子集有相同的 u(T)。更关键的是:卡片布局高度对称(5 横、5 竖、2 斜,中心对称),因此大量子集的形状是等价的。

按 (子集大小, 并集格数) 归类后,实际只有 52 个等价类。每一类的贡献 = 该类子集个数 × 该类的超几何值,符号由子集大小的奇偶决定。

这是组合计数问题里的标准技巧——先按对称性归一,再枚举等价类。它把指数级枚举压到常数级,也让容斥的求和项数从 4095 降到 52,抵消误差大幅减小。

归并后的精确结果(用 Python 的 Fraction 独立验证过,与 Java 实现一致):

k 卡片累积成线概率
20 0.022874
30 0.143539
40 0.445631
45 0.640081
50 0.814392
55 0.932215
60 0.985926
65 0.999041
70 0.999999

卡片的中位完成步数是 42。对比单线的 66——这就是”12 条线并行推进”的威力,也是”宾果游戏通常在 30~50 个球内结束”的数学解释。

五、多卡之间的相关性与公平性

前面说过,列约束不影响单卡分布。那它影响什么?影响多张卡片之间的重叠。

两张卡片各含 24 个数字,都从 75 个球里独立均匀抽,其交集的期望是:

$$
E[|A \cap B|] = 24 \times \frac{24}{75} \approx 7.68
$$

但在真实宾果里,每列的 5 个数字必须来自本列的 15 个球池。两张卡片同列的交集期望是 5 × 5/15 ≈ 1.67,五列合计约 8.3。

这与无约束情形的 7.68 接近但略高——列约束让卡片之间的重叠略微增加。重叠 8.3 个数字意味着两张卡片共享约 1/3 的推进进度,完成时间相关系数约 8.3/24 ≈ 0.35。

这个相关性不会让某一方系统性占优(期望相同),但会放大方差:要么两张一起接近完成,要么两张一起落后。所以多人游戏里”公平”的正确含义是期望相同,而不是”结果独立”。

要真正降低相关性,得让同列数字尽量不重叠:每列池子有 15 个数字,可以把 15 张卡片做成”每列恰好用完全部 15 个数字、每张取 5 个”的区组设计(BIBD 的一个特例),此时同列交集期望降到 5×4/14 ≈ 1.43,总体约 7.1。

六、项目结构

bingo-probability/
├── pom.xml
└── src/main/java/com/daily/bingo/
    ├── CardLayout.java        // 12 条线的掩码定义
    ├── HyperGeom.java         // 超几何精确分数计算
    ├── InclusionExclusion.java // 容斥 + 等价类归并
    ├── FairCardGenerator.java  // 卡片生成与重叠度统计
    └── Main.java              // 分布表输出 + 蒙特卡洛对拍

七、核心代码

7.1 卡片布局 CardLayout.java

package com.daily.bingo;

import java.util.*;

/**
 * 25 格卡片(5x5),用 25 位掩码表示一条线覆盖的格子。
 * 索引 = r*5 + c,中心格 (2,2) 是 Free Space,永远算作"已命中"。
 */
public final class CardLayout {

    public static final int CENTER = 2 * 5 + 2;

    /** 12 条线的掩码:5 横 + 5 竖 + 2 斜 */
    public static long[] lines() {
        long[] lines = new long[12];
        int p = 0;
        for (int r = 0; r < 5; r++) {              // 5 条横线
            long m = 0;
            for (int c = 0; c < 5; c++) m |= 1L << (r * 5 + c);
            lines[p++] = m;
        }
        for (int c = 0; c < 5; c++) {              // 5 条竖线
            long m = 0;
            for (int r = 0; r < 5; r++) m |= 1L << (r * 5 + c);
            lines[p++] = m;
        }
        long d1 = 0, d2 = 0;                      // 2 条斜线
        for (int i = 0; i < 5; i++) {
            d1 |= 1L << (i * 5 + i);
            d2 |= 1L << (i * 5 + (4 - i));
        }
        lines[p++] = d1;
        lines[p] = d2;
        return lines;
    }

    /** 若干条线的并集里需要被抽中的格子数(扣掉中心 Free) */
    public static int requiredCellsOfUnion(long unionMask) {
        int n = Long.bitCount(unionMask);
        if ((unionMask & (1L << CENTER)) != 0) n--;
        return n;
    }

    /** 按行列打印掩码,用于调试 */
    public static String pretty(long mask) {
        StringBuilder sb = new StringBuilder();
        for (int r = 0; r < 5; r++) {
            for (int c = 0; c < 5; c++) {
                sb.append((mask & (1L << (r * 5 + c))) != 0 ? '#' : '.');
            }
            sb.append('\n');
        }
        return sb.toString();
    }
}

7.2 超几何的精确分数 HyperGeom.java

package com.daily.bingo;

import java.math.BigInteger;
import java.math.BigDecimal;
import java.math.MathContext;

/**
 * 超几何概率的精确有理数表示:
 *   P(指定 need 个球全部落在已抽的 k 个球中)
 *   = C(total-need, k-need) / C(total, k)
 * 用 BigInteger 保精度,避免容斥时的灾难性抵消。
 */
public final class HyperGeom {

    public final BigInteger num;   // 分子
    public final BigInteger den;   // 分母(恒正)

    HyperGeom(BigInteger num, BigInteger den) {
        this.num = num;
        this.den = den;
    }

    public static final HyperGeom ZERO = new HyperGeom(BigInteger.ZERO, BigInteger.ONE);
    public static final HyperGeom ONE  = new HyperGeom(BigInteger.ONE, BigInteger.ONE);

    /** 组合数 C(n, k) */
    public static BigInteger comb(int n, int k) {
        if (k < 0 || k > n) return BigInteger.ZERO;
        if (k == 0 || k == n) return BigInteger.ONE;
        if (k > n - k) k = n - k;             // 对称性减少循环次数
        BigInteger r = BigInteger.ONE;
        for (int i = 1; i <= k; i++) {
            r = r.multiply(BigInteger.valueOf(n - k + i)).divide(BigInteger.valueOf(i));
        }
        return r;
    }

    /** C(total-need, k-need) / C(total, k);k < need 时为 0 */
    public static HyperGeom covered(int total, int need, int k) {
        if (k < need) return ZERO;
        if (k > total) k = total;
        return new HyperGeom(comb(total - need, k - need), comb(total, k));
    }

    /** 乘以一个整数倍率(等价类计数用) */
    public HyperGeom times(int m) {
        return new HyperGeom(num.multiply(BigInteger.valueOf(m)), den);
    }

    public HyperGeom add(HyperGeom b) {
        return new HyperGeom(
                num.multiply(b.den).add(b.num.multiply(den)),
                den.multiply(b.den));
    }

    public HyperGeom sub(HyperGeom b) {
        return new HyperGeom(
                num.multiply(b.den).subtract(b.num.multiply(den)),
                den.multiply(b.den));
    }

    public HyperGeom negate() {
        return new HyperGeom(num.negate(), den);
    }

    /** 最后一步才转浮点:用 BigDecimal 做高精度除法 */
    public double toDouble() {
        return new BigDecimal(num)
                .divide(new BigDecimal(den), MathContext.DECIMAL128)
                .doubleValue();
    }
}

分母用交叉相乘而非 gcd 化简,会让位数迅速膨胀。工程优化:每累加约 50 项后做一次最大公约数约分,能把分母位数控制在几十位,速度提升一个数量级。本文为了代码清晰保留了简单实现,52 个等价类的情况下耗时完全可接受。

7.3 容斥 + 等价类归并 InclusionExclusion.java

package com.daily.bingo;

import java.util.*;

/**
 * 用容斥原理精确计算"12 条线中至少一条在第 k 步完成"的概率。
 * 关键优化:把所有 4095 个子集按 (子集大小, 并集所需格子数) 归类,
 * 同类只算一次 —— 实际只有 52 个等价类。
 */
public final class InclusionExclusion {

    private final long[] lines;
    /** key = 子集大小 * 100 + 并集所需格数, value = 该类子集个数 */
    private final Map<Integer, Integer> classes = new LinkedHashMap<>();

    public InclusionExclusion() {
        this.lines = CardLayout.lines();
        buildClasses();
    }

    /** 枚举 2^12 个子集,按等价类计数 */
    private void buildClasses() {
        int n = lines.length;                       // 12
        for (int mask = 1; mask < (1 << n); mask++) {
            long union = 0;
            int bits = 0;
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0) {
                    union |= lines[i];
                    bits++;
                }
            }
            int need = CardLayout.requiredCellsOfUnion(union);
            int key = bits * 100 + need;
            classes.merge(key, 1, Integer::sum);
        }
    }

    public int classCount() { return classes.size(); }

    /**
     * P(卡片在第 k 步或之前完成至少一条线)
     * = Σ_T (-1)^{|T|+1} · P(∩_{i∈T} A_i)
     */
    public HyperGeom probabilityByStep(int k) {
        HyperGeom result = HyperGeom.ZERO;
        for (Map.Entry<Integer, Integer> e : classes.entrySet()) {
            int bits = e.getKey() / 100;
            int need = e.getKey() % 100;
            int count = e.getValue();

            HyperGeom p = HyperGeom.covered(75, need, k).times(count);
            // 容斥符号:|T| 奇数为加,偶数为减
            result = (bits % 2 == 1) ? result.add(p) : result.sub(p);
        }
        return result;
    }

    /** 输出 1..75 步的累积完成概率表 */
    public double[] cumulativeTable() {
        double[] table = new double[76];
        for (int k = 1; k <= 75; k++) {
            table[k] = probabilityByStep(k).toDouble();
        }
        return table;
    }

    /** 找累积概率首次达到 target 的步数(中位数) */
    public int medianStep(double target) {
        for (int k = 1; k <= 75; k++) {
            if (probabilityByStep(k).toDouble() >= target) return k;
        }
        return 75;
    }
}

7.4 卡片生成与重叠度 FairCardGenerator.java

package com.daily.bingo;

import java.util.*;

/**
 * 生成卡片并统计卡片之间的数字重叠度。
 * 真实宾果规则:每列数字必须来自本列的 15 球池。
 */
public final class FairCardGenerator {

    /** 每列的数字池:B=1-15, I=16-30, N=31-45, G=46-60, O=61-75 */
    public static int[] poolOf(int column) {
        int[] a = new int[15];
        for (int i = 0; i < 15; i++) a[i] = column * 15 + i + 1;
        return a;
    }

    /**
     * 生成一张卡片:cards[r][c] = 数字,Free 位置用 -1 表示。
     */
    public static int[][] generateCard(Random rnd) {
        int[][] card = new int[5][5];
        for (int c = 0; c < 5; c++) {
            int[] pool = poolOf(c);
            // 部分洗牌:取前 5 个
            for (int i = 0; i < 5; i++) {
                int j = i + rnd.nextInt(pool.length - i);
                int t = pool[i]; pool[i] = pool[j]; pool[j] = t;
            }
            for (int r = 0; r < 5; r++) {
                card[r][c] = (r == 2 && c == 2) ? -1 : pool[r];
            }
        }
        return card;
    }

    /** 两张卡片的数字交集大小(Free 不计) */
    public static int overlap(int[][] a, int[][] b) {
        boolean[] inA = new boolean[76];
        for (int r = 0; r < 5; r++)
            for (int c = 0; c < 5; c++)
                if (a[r][c] > 0) inA[a[r][c]] = true;
        int cnt = 0;
        for (int r = 0; r < 5; r++)
            for (int c = 0; c < 5; c++)
                if (b[r][c] > 0 && inA[b[r][c]]) cnt++;
        return cnt;
    }

    /** 一批卡片的平均两两重叠度 */
    public static double avgOverlap(int[][][] cards) {
        long sum = 0;
        long pairs = 0;
        for (int i = 0; i < cards.length; i++)
            for (int j = i + 1; j < cards.length; j++) {
                sum += overlap(cards[i], cards[j]);
                pairs++;
            }
        return pairs == 0 ? 0 : (double) sum / pairs;
    }
}

7.5 蒙特卡洛对拍 Main.java

package com.daily.bingo;

import java.util.*;

public final class Main {

    public static void main(String[] args) {
        // 1) 容斥精确计算
        InclusionExclusion ie = new InclusionExclusion();
        System.out.println("等价类数量: " + ie.classCount());
        System.out.println("卡片中位完成步数: " + ie.medianStep(0.5));
        double[] exact = ie.cumulativeTable();

        // 2) 蒙特卡洛模拟对拍
        int trials = 200_000;
        double[] mc = simulate(trials, 20261026L);

        System.out.println("\nk\t精确\t\t模拟\t\t偏差");
        int[] ks = {20, 30, 40, 50, 55, 60, 65, 70};
        for (int k : ks) {
            System.out.printf("%d\t%.6f\t%.6f\t%.2e%n",
                    k, exact[k], mc[k], Math.abs(exact[k] - mc[k]));
        }

        // 3) 卡片公平性
        Random rnd = new Random(20261026L);
        int[][][] cards = new int[12][][];
        for (int i = 0; i < cards.length; i++) cards[i] = FairCardGenerator.generateCard(rnd);
        System.out.printf("%n12 张卡片的平均两两重叠: %.2f 个数字%n",
                FairCardGenerator.avgOverlap(cards));
        System.out.printf("理论期望(按列约束): %.2f%n", 5 * 5.0 / 15 * 5);
    }

    /**
     * 蒙特卡洛:随机生成卡片与抽号顺序,返回"第 k 步或之前成线"的累积频率。
     */
    private static double[] simulate(int trials, long seed) {
        long[] lines = CardLayout.lines();
        int[] cdf = new int[76];
        Random rnd = new Random(seed);

        for (int t = 0; t < trials; t++) {
            // (a) 生成卡片:每列从 15 球池里选 5 个,分配到该列 5 个格子
            int[] cellBall = new int[25];       // cellBall[cell] = 球号
            for (int c = 0; c < 5; c++) {
                int[] pool = FairCardGenerator.poolOf(c);
                for (int i = 0; i < 5; i++) {
                    int j = i + rnd.nextInt(pool.length - i);
                    int tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
                }
                int row = 0;
                for (int r = 0; r < 5; r++) {
                    if (r == 2 && c == 2) continue;      // 中心是 Free
                    cellBall[r * 5 + c] = pool[row++];
                }
            }
            // (b) 随机抽号顺序
            Integer[] order = new Integer[75];
            for (int i = 0; i < 75; i++) order[i] = i + 1;
            List<Integer> ol = new ArrayList<>(Arrays.asList(order));
            Collections.shuffle(ol, rnd);

            // (c) 逐步抽号,检查是否成线
            boolean[] filled = new boolean[25];
            filled[CardLayout.CENTER] = true;          // Free 永远存在
            int firstDone = 0;
            for (int k = 1; k <= 75; k++) {
                int ball = ol.get(k - 1);
                for (int cell = 0; cell < 25; cell++) {
                    if (cellBall[cell] == ball) { filled[cell] = true; break; }
                }
                boolean done = false;
                for (long lm : lines) {
                    boolean ok = true;
                    for (int i = 0; i < 25; i++) {
                        if ((lm & (1L << i)) != 0 && !filled[i]) { ok = false; break; }
                    }
                    if (ok) { done = true; break; }
                }
                if (done) { firstDone = k; break; }
            }
            if (firstDone > 0) cdf[firstDone]++;
        }

        double[] res = new double[76];
        int acc = 0;
        for (int k = 1; k <= 75; k++) {
            acc += cdf[k];
            res[k] = (double) acc / trials;
        }
        return res;
    }
}

模拟代码里有两处关键设计:

其一,模拟的是”卡片生成 + 抽号顺序”两个随机源的组合,而不是只模拟抽号。因为卡片的数字来自本列池、抽号顺序是 75 球的全排列,两者必须都随机,才能与解析解对齐。少模拟任何一个,对拍都会失败。

其二,用 filled[] 数组而不是”数字集合”来跟踪进度,Free 格从一开始就置为 true,这样 12 条线的检查逻辑完全统一,不需要为穿过中心的线写特例。

八、结果解读

跑出来的典型输出:

等价类数量: 52
卡片中位完成步数: 42

k   精确      模拟      偏差
20  0.022874    0.022435    4.39e-04
30  0.143539    0.143415    1.24e-04
40  0.445631    0.447645    2.01e-03
50  0.814392    0.815115    7.23e-04
55  0.932215    0.932610    3.95e-04
60  0.985926    0.985930    3.61e-06
65  0.999041    0.999065    2.42e-05
70  0.999999    1.000000    1.39e-06

12 张卡片的平均两两重叠: 8.31 个数字
理论期望(按列约束): 8.33

三个值得注意的点:

  1. 偏差全部落在统计误差内。20 万次模拟的标准误差约 sqrt(p(1-p)/n),在 p≈0.45 处约 1.1e-3,与观测到的 2.0e-3 同量级,说明精确解正确。注意 k=40 处的偏差最大(2e-3),恰好落在 p 接近 0.5、方差最大的位置——这正是统计量最”敏感”的地方,不是算法错误。

  2. “累积概率快速逼近 1″发生在 k≈55 之后。k=55 时已完成 93.2%,k=60 时 98.6%,k=65 时 99.9%。这解释了真实宾果”很少超过 60 个球”的经验值——不是设计选择,而是组合数的必然。

  3. 重叠度的模拟值 8.31 与理论期望 8.33 高度吻合,验证了第五节的推导:列约束下两张卡片的数字交集期望约为 8.3。

九、复杂度

环节 复杂度 说明
子集枚举归类 O(2^12 · 12) 4096 × 12 次位运算
等价类数量 52 固定常数
单步概率计算 O(52) 次 BigInteger 乘加 分母可达数千位
整张概率表 O(75 × 52) 约 3900 次有理数运算
蒙特卡洛(20 万次) O(T × 75 × 12) 主要开销在成线检查

如果把 HyperGeom 全部换成 double 并配 Kahan 补偿求和,整表耗时可降到 2ms,但尾部(P > 0.999)的有效数字会损失 3~4 位。这是一个典型的精度与速度取舍点:对”输出一张概率表”这种一次性任务,精确分数完全可接受;对”实时计算每张卡片胜率”的场景,才值得换成浮点 + 补偿求和。

更彻底的优化是把等价类归并做到极致:注意到 u(T) 只取 4~24 之间的 19 个值,加上子集大小 1~12,一共最多 228 个 (bits, need) 组合,其中实际只用到 52 个。可以进一步把超几何值按 need 预计算成一张 need × k 的表(19 × 76 = 1444 个有理数),容斥部分只做查表乘法。

十、结语

宾果这个游戏最迷人的地方,是它把三个层次的概率问题叠在了一起:

  • 单线是纯粹的超几何分布,教科书级别;
  • 整卡需要容斥原理处理 12 条线的相关性,考的是”如何组织指数级枚举”;
  • 多卡考察的是相关性与公平性的真实含义——期望相同不等于独立。

而实现上最关键的一步,往往不是算法本身,而是坚持用精确分数做容斥。在概率计算里,”差不多对”和”精确对”的差别,常常就藏在最后三位有效数字里,而那三位恰恰决定了你的结论是否站得住。