每日算法 — 使用java实现刮刮乐:概率分布与期望值计算

引言

刮刮乐(Scratch Card)是一种即开型彩票游戏,玩家刮开涂层即可知道是否中奖。从算法角度看,刮刮乐的设计是一个精妙的概率工程问题:如何在保证庄家盈利的同时,让玩家感受到”差点就中”的刺激?这涉及到概率分布设计、期望值控制、以及心理学层面的”近失效应”建模。

本文将用Java实现一个完整的刮刮乐模拟系统,包括奖池结构设计、期望值计算、概率分布可视化,以及基于大数定律的统计分析。通过这个看似简单的小游戏,读者将深入理解离散型概率分布在商业系统设计中的应用。

刮刮乐的概率模型

奖池结构设计

典型的刮刮乐包含以下奖项层级:

奖项等级 中奖金额 设计概率 期望贡献
头奖 100,000 0.0001 10
二等奖 10,000 0.001 10
三等奖 1,000 0.01 10
四等奖 100 0.1 10
五等奖 10 0.2 2
未中奖 0 0.6889 0

核心公式

期望收益:

E = Σ (probability_i × prize_i)

庄家优势:

House Edge = (ticketPrice - E) / ticketPrice × 100%

返奖率(RTP, Return to Player):

RTP = E / ticketPrice × 100%

合法的即开型彩票通常将返奖率控制在 50%-75% 之间,庄家优势约为 25%-50%。

核心算法设计

1. 离散概率采样

使用别名方法(Alias Method)或轮盘赌选择(Roulette Wheel Selection)在 O(1) 或 O(log N) 时间内根据概率分布采样奖项。

2. 奖池预生成

为保证实际返奖率与理论一致,可以预先生成一个固定大小的奖池(如100万张),其中各奖项数量严格按照概率比例分配。玩家从中随机抽取一张。

3. 近失效应模拟

近失(Near Miss)是指结果非常接近大奖但未中的情况。心理学研究表明,近失会增强玩家的继续游戏意愿。可以通过调整非中奖卡片的图案分布来增加”差点就中”的体验。

Java实现

import java.util.*;

/**
 * 刮刮乐彩票模拟系统
 * 包含奖池设计、概率采样、期望值计算与统计分析
 */
public class ScratchCardSimulator {

    /**
     * 奖项定义
     */
    static class Prize {
        final String name;       // 奖项名称
        final double amount;     // 中奖金额
        final double probability; // 理论中奖概率
        final String symbol;     // 刮开显示符号

        public Prize(String name, double amount, double probability, String symbol) {
            this.name = name;
            this.amount = amount;
            this.probability = probability;
            this.symbol = symbol;
        }

        @Override
        public String toString() {
            return String.format("%s(%s元, P=%.4f)", name, amount, probability);
        }
    }

    /**
     * 单张刮刮乐卡片
     */
    static class ScratchCard {
        final Prize prize;
        final boolean isNearMiss;  // 是否为近失卡片
        final String[] displayedSymbols;  // 刮开区显示的3x3符号矩阵

        public ScratchCard(Prize prize, boolean isNearMiss, String[] symbols) {
            this.prize = prize;
            this.isNearMiss = isNearMiss;
            this.displayedSymbols = symbols;
        }
    }

    private final List<Prize> prizes;
    private final double ticketPrice;
    private final Random random;

    // 别名方法数据结构(用于O(1)概率采样)
    private final double[] aliasProb;
    private final int[] aliasTable;

    // 奖池模式(预生成固定数量卡片)
    private List<ScratchCard> prizePool;
    private int poolIndex = 0;

    // 统计
    private int totalScratched = 0;
    private double totalWon = 0;
    private final Map<String, Integer> winCounts = new HashMap<>();
    private int nearMissCount = 0;

    public ScratchCardSimulator(List<Prize> prizes, double ticketPrice, Random random) {
        this.prizes = new ArrayList<>(prizes);
        this.ticketPrice = ticketPrice;
        this.random = random;

        // 验证概率总和为1
        double totalProb = prizes.stream().mapToDouble(p -> p.probability).sum();
        if (Math.abs(totalProb - 1.0) > 1e-6) {
            throw new IllegalArgumentException("概率总和必须等于1.0,当前=" + totalProb);
        }

        // 构建别名方法
        double[] probs = prizes.stream().mapToDouble(p -> p.probability).toArray();
        this.aliasProb = new double[probs.length];
        this.aliasTable = new int[probs.length];
        buildAliasMethod(probs);

        // 初始化统计
        for (Prize p : prizes) {
            winCounts.put(p.name, 0);
        }
    }

    /**
     * 构建别名方法(Alias Method)
     * 预处理O(N),每次采样O(1)
     */
    private void buildAliasMethod(double[] probs) {
        int n = probs.length;
        double[] scaled = new double[n];
        for (int i = 0; i < n; i++) {
            scaled[i] = probs[i] * n;
        }

        int[] small = new int[n];
        int[] large = new int[n];
        int ns = 0, nl = 0;

        for (int i = 0; i < n; i++) {
            if (scaled[i] < 1.0) {
                small[ns++] = i;
            } else {
                large[nl++] = i;
            }
        }

        while (ns > 0 && nl > 0) {
            int l = small[--ns];
            int g = large[--nl];
            aliasProb[l] = scaled[l];
            aliasTable[l] = g;
            scaled[g] = scaled[g] + scaled[l] - 1.0;
            if (scaled[g] < 1.0) {
                small[ns++] = g;
            } else {
                large[nl++] = g;
            }
        }

        while (nl > 0) {
            aliasProb[large[--nl]] = 1.0;
        }
        while (ns > 0) {
            aliasProb[small[--ns]] = 1.0;
        }
    }

    /**
     * 使用别名方法进行O(1)概率采样
     */
    private Prize samplePrize() {
        int n = prizes.size();
        int idx = random.nextInt(n);
        double r = random.nextDouble();
        return prizes.get(r < aliasProb[idx] ? idx : aliasTable[idx]);
    }

    /**
     * 生成一张刮刮乐卡片
     */
    public ScratchCard generateCard() {
        Prize prize = samplePrize();
        boolean isNearMiss = false;
        String[] symbols = generateSymbols(prize);

        // 如果未中奖,有一定概率生成近失效果
        if (prize.amount == 0 && random.nextDouble() < 0.3) {
            isNearMiss = true;
            symbols = generateNearMissSymbols();
        }

        return new ScratchCard(prize, isNearMiss, symbols);
    }

    /**
     * 生成刮开区的显示符号
     * 中奖卡片会显示匹配的中奖符号
     */
    private String[] generateSymbols(Prize prize) {
        String[] symbols = new String[9];
        String base = prize.amount > 0 ? prize.symbol : "X";

        for (int i = 0; i < 9; i++) {
            if (prize.amount > 0 && random.nextDouble() < 0.4) {
                symbols[i] = base;
            } else {
                symbols[i] = getRandomSymbol(base);
            }
        }
        return symbols;
    }

    /**
     * 生成近失效果符号(两个大奖符号加一个不同符号)
     */
    private String[] generateNearMissSymbols() {
        String[] symbols = new String[9];
        Arrays.fill(symbols, "O");

        // 随机放两个大奖符号
        int pos1 = random.nextInt(9);
        int pos2;
        do { pos2 = random.nextInt(9); } while (pos2 == pos1);

        symbols[pos1] = "★";
        symbols[pos2] = "★";

        return symbols;
    }

    private String getRandomSymbol(String exclude) {
        String[] pool = {"O", "X", "△", "□", "◇"};
        String s;
        do {
            s = pool[random.nextInt(pool.length)];
        } while (s.equals(exclude));
        return s;
    }

    /**
     * 刮开一张卡片
     */
    public ScratchCard scratch() {
        ScratchCard card = generateCard();

        totalScratched++;
        totalWon += card.prize.amount;
        winCounts.put(card.prize.name, winCounts.get(card.prize.name) + 1);
        if (card.isNearMiss) nearMissCount++;

        return card;
    }

    /**
     * 计算理论期望收益
     */
    public double theoreticalExpectedValue() {
        double expected = 0;
        for (Prize p : prizes) {
            expected += p.probability * p.amount;
        }
        return expected;
    }

    /**
     * 计算返奖率(RTP)
     */
    public double getRTP() {
        return (theoreticalExpectedValue() / ticketPrice) * 100;
    }

    /**
     * 计算庄家优势
     */
    public double getHouseEdge() {
        return ((ticketPrice - theoreticalExpectedValue()) / ticketPrice) * 100;
    }

    /**
     * 打印统计报告
     */
    public void printStatistics() {
        System.out.println("\n=== 刮刮乐统计报告 ===");
        System.out.println("单张票价:" + ticketPrice + "元");
        System.out.printf("理论期望收益:%.2f元%n", theoreticalExpectedValue());
        System.out.printf("返奖率(RTP):%.2f%%%n", getRTP());
        System.out.printf("庄家优势:%.2f%%%n", getHouseEdge());
        System.out.println("\n总刮开次数:" + totalScratched);
        System.out.printf("总奖金支出:%.2f元%n", totalWon);
        System.out.printf("玩家净盈亏:%.2f元%n", totalWon - totalScratched * ticketPrice);
        System.out.println("近失次数:" + nearMissCount);

        System.out.println("\n各奖项统计:");
        System.out.println("奖项\t\t理论概率\t实际频率\t中奖次数\t总奖金");
        for (Prize p : prizes) {
            int count = winCounts.get(p.name);
            double actualFreq = totalScratched > 0 ? (double) count / totalScratched : 0;
            System.out.printf("%-10s\t%.4f\t\t%.4f\t\t%d\t\t%.0f%n",
                p.name, p.probability, actualFreq, count, count * p.amount);
        }
    }

    /**
     * 预生成奖池
     * @param poolSize 奖池大小(如1,000,000张)
     */
    public void generatePrizePool(int poolSize) {
        prizePool = new ArrayList<>(poolSize);

        // 按概率比例生成各奖项卡片
        for (Prize p : prizes) {
            int count = (int) Math.round(poolSize * p.probability);
            for (int i = 0; i < count; i++) {
                prizePool.add(new ScratchCard(p, false, generateSymbols(p)));
            }
        }

        // 打乱顺序
        Collections.shuffle(prizePool, random);
        poolIndex = 0;

        System.out.println("奖池已生成,共 " + prizePool.size() + " 张卡片");
    }

    /**
     * 从预生成奖池中抽取
     */
    public ScratchCard scratchFromPool() {
        if (prizePool == null || poolIndex >= prizePool.size()) {
            return scratch();  // 回退到随机生成
        }
        ScratchCard card = prizePool.get(poolIndex++);

        totalScratched++;
        totalWon += card.prize.amount;
        winCounts.put(card.prize.name, winCounts.get(card.prize.name) + 1);

        return card;
    }

    /**
     * 模拟玩家连续购买的行为模式
     * @param numPlayers 玩家数量
     * @param maxCardsPerPlayer 每个玩家最多购买的卡片数
     */
    public void simulatePlayerBehavior(int numPlayers, int maxCardsPerPlayer) {
        System.out.println("\n=== 玩家行为模拟 ===");

        double totalRevenue = 0;
        double totalPayout = 0;
        int bankruptPlayers = 0;

        for (int player = 0; player < numPlayers; player++) {
            double budget = 1000;  // 初始预算
            int cardsBought = 0;
            double playerWon = 0;

            while (budget >= ticketPrice && cardsBought < maxCardsPerPlayer) {
                budget -= ticketPrice;
                cardsBought++;

                ScratchCard card = scratch();
                playerWon += card.prize.amount;
                budget += card.prize.amount;

                // 近失效应:如果近失,玩家有更高概率继续
                if (card.isNearMiss && random.nextDouble() < 0.6) {
                    continue;  // 继续购买
                }

                // 小奖激励继续
                if (card.prize.amount > 0 && card.prize.amount < ticketPrice * 2) {
                    if (random.nextDouble() < 0.4) {
                        continue;
                    }
                }

                // 自然停止概率
                if (random.nextDouble() < 0.15) {
                    break;
                }
            }

            totalRevenue += cardsBought * ticketPrice;
            totalPayout += playerWon;
            if (budget <= 0) bankruptPlayers++;
        }

        System.out.println("模拟玩家数:" + numPlayers);
        System.out.println("总营收:" + totalRevenue);
        System.out.println("总派奖:" + totalPayout);
        System.out.printf("实际利润率:%.2f%%%n", ((totalRevenue - totalPayout) / totalRevenue) * 100);
        System.out.println("破产玩家数:" + bankruptPlayers);
    }

    /**
     * 主程序:运行完整模拟
     */
    public static void main(String[] args) {
        // 定义奖项结构(示例:返奖率约60%)
        List<Prize> prizes = Arrays.asList(
            new Prize("头奖",    100000, 0.00001, "★"),
            new Prize("二等奖",  10000,  0.0001,  "☆"),
            new Prize("三等奖",  1000,   0.001,   "♦"),
            new Prize("四等奖",  100,    0.01,    "♠"),
            new Prize("五等奖",  20,     0.05,    "♣"),
            new Prize("六等奖",  5,      0.2,     "♥"),
            new Prize("未中奖",  0,      0.73889, "X")
        );

        double ticketPrice = 10.0;
        Random random = new Random(42);

        ScratchCardSimulator simulator = new ScratchCardSimulator(prizes, ticketPrice, random);

        System.out.println("=== 刮刮乐彩票系统 ===");
        System.out.println("单张票价:" + ticketPrice + "元\n");

        System.out.println("奖项配置:");
        for (Prize p : prizes) {
            System.out.println("  " + p);
        }

        System.out.printf("\n理论返奖率(RTP):%.2f%%%n", simulator.getRTP());
        System.out.printf("庄家优势:%.2f%%%n\n", simulator.getHouseEdge());

        // 批量模拟
        System.out.println("--- 批量模拟 100,000 张 ---");
        for (int i = 0; i < 100000; i++) {
            simulator.scratch();
        }
        simulator.printStatistics();

        // 玩家行为模拟
        simulator.simulatePlayerBehavior(1000, 100);
    }
}

复杂度分析

  • 别名方法预处理:O(N),N为奖项数量。
  • 单次采样:O(1) 时间,O(N) 空间。
  • 奖池生成:O(M),M为奖池大小。
  • 批量模拟:O(T),T为模拟次数。
  • 空间复杂度:O(N + M),存储别名表和奖池。

返奖率设计的数学原理

大数定律

当模拟次数 T → ∞ 时,实际返奖率收敛于理论返奖率:

lim(T→∞) (总奖金 / 总投注) = E / ticketPrice = RTP

这是庄家能够长期盈利的根本数学保障。

方差与玩家体验

高方差(大奖概率极低但金额极高)的设计让玩家很少中奖,但一旦中奖收益巨大;低方差(频繁小奖)让玩家持续获得正反馈。优秀的刮刮乐产品通常采用中高方差设计,在”刺激感”和”可持续性”之间取得平衡。

近失效应的工程化

心理学研究表明,近失体验会激活大脑的奖赏回路,其神经反应与真实获胜相似。在算法层面,可以通过以下方式实现:

  1. 符号矩阵设计:在3×3刮开区中,让大奖符号出现2个而非3个
  2. 渐进式揭示:先揭示部分区域,让玩家”看到希望”
  3. 条件概率调整:根据玩家历史行为动态调整近失概率

合规与伦理考量

在实际彩票系统设计中,以下数学约束必须满足:

  1. 返奖率公示:必须在票面上明确标注理论返奖率
  2. 概率可验证:监管机构可以独立验证概率分布的正确性
  3. 不可预测性:每张卡片的结果必须在生成时即确定,但玩家无法提前知晓
  4. 奖池守恒:预生成模式下,奖池中的大奖必须真实存在

总结

刮刮乐背后的概率工程远比表面上”随机刮开”要复杂得多。通过Java实现,我们系统掌握了离散概率分布采样、期望值计算、别名方法优化、以及基于大数定律的商业模型设计。

最核心的数学洞察是:概率分布的微小调整,在长期尺度上会产生巨大的利润差异。一个返奖率从70%降到65%的设计变更,在百万级销量的产品中意味着数十万元的额外利润。这正是为什么赌场和彩票公司愿意雇佣顶尖的数学家和统计学家来设计游戏机制——因为在概率的世界里,每一个细节都直接转化为真金白银。