每日算法 — 使用java实现扭蛋机:概率分布建模与保底机制期望值计算

引言

扭蛋机(Gacha)是现代游戏中最常见的付费机制之一,玩家通过抽取随机获得不同稀有度的角色或道具。为了平衡玩家体验与商业收益,扭蛋系统通常设计复杂的概率模型和保底机制。本文将用Java实现一个完整的扭蛋机模拟器,深入讲解概率分布建模、伪随机数生成、保底机制设计,以及期望值和方差的计算方法,帮助你理解游戏经济学背后的数学原理。

一、扭蛋机的概率模型

1.1 基础概率设计

典型的扭蛋系统包含多个稀有度等级,例如:
– SSR(超稀有):1%
– SR(稀有):10%
– R(普通):89%

1.2 保底机制类型

  1. 软保底(Pity System):连续N次未出SSR后,每次抽取SSR概率递增
  2. 硬保底(Guarantee):最多N次必出SSR
  3. 保底继承:当前卡池的保底计数可继承到下一个卡池
  4. 大保底:小保底(50%概率)失败后,下一次必出目标角色

二、Java核心实现

import java.util.*;

/**
 * 扭蛋物品类
 * 表示扭蛋池中的一种奖励
 */
class GachaItem {
    String name;
    String rarity; // SSR, SR, R
    double baseProbability; // 基础概率
    boolean isUp; // 是否当前UP角色

    public GachaItem(String name, String rarity, double baseProbability, boolean isUp) {
        this.name = name;
        this.rarity = rarity;
        this.baseProbability = baseProbability;
        this.isUp = isUp;
    }

    @Override
    public String toString() {
        return name + "(" + rarity + ")";
    }
}

/**
 * 扭蛋结果类
 */
class GachaResult {
    GachaItem item;
    int pullNumber; // 第几抽
    boolean isPity; // 是否由保底触发

    public GachaResult(GachaItem item, int pullNumber, boolean isPity) {
        this.item = item;
        this.pullNumber = pullNumber;
        this.isPity = isPity;
    }
}

/**
 * 扭蛋机核心类
 * 实现完整的概率模型、保底机制和统计计算
 */
public class GachaMachine {
    private final List<GachaItem> itemPool;
    private final Random random;

    // 保底参数
    private final int softPityStart; // 软保底开始抽数
    private final double softPityIncrement; // 每次递增概率
    private final int hardPity; // 硬保底抽数
    private final boolean hasBigPity; // 是否有大保底

    // 状态跟踪
    private int ssrPityCounter; // 距离上次SSR的抽数
    private boolean lastSsrWasUp; // 上次SSR是否是UP角色

    // 统计
    private int totalPulls;
    private int ssrCount;
    private int srCount;
    private int rCount;
    private int pityTriggered;
    private List<Integer> ssrIntervals; // 每次SSR间隔的抽数

    public GachaMachine(List<GachaItem> pool, int softPityStart, 
                        double softPityIncrement, int hardPity, boolean hasBigPity) {
        this.itemPool = new ArrayList<>(pool);
        this.random = new Random();
        this.softPityStart = softPityStart;
        this.softPityIncrement = softPityIncrement;
        this.hardPity = hardPity;
        this.hasBigPity = hasBigPity;

        this.ssrPityCounter = 0;
        this.lastSsrWasUp = true; // 初始假设上次是UP,这样第一次小保底正常触发
        this.totalPulls = 0;
        this.ssrCount = 0;
        this.srCount = 0;
        this.rCount = 0;
        this.pityTriggered = 0;
        this.ssrIntervals = new ArrayList<>();
    }

    /**
     * 计算当前SSR概率(考虑软保底)
     */
    public double getCurrentSsrProbability() {
        double baseSsrProb = 0.0;
        for (GachaItem item : itemPool) {
            if ("SSR".equals(item.rarity)) {
                baseSsrProb += item.baseProbability;
            }
        }

        if (ssrPityCounter < softPityStart) {
            return baseSsrProb;
        }

        // 软保底:概率线性递增
        double extraProb = (ssrPityCounter - softPityStart + 1) * softPityIncrement;
        return Math.min(baseSsrProb + extraProb, 1.0);
    }

    /**
     * 单次抽取
     * @return 抽取结果
     */
    public GachaResult pull() {
        totalPulls++;
        ssrPityCounter++;

        boolean isPityTrigger = false;
        GachaItem result = null;

        // 检查硬保底
        if (ssrPityCounter >= hardPity) {
            result = drawSsrWithPity();
            isPityTrigger = true;
            pityTriggered++;
        } else {
            // 计算当前各稀有度概率
            double ssrProb = getCurrentSsrProbability();
            double roll = random.nextDouble();

            if (roll < ssrProb) {
                result = drawSsrNormal();
            } else {
                result = drawLowerRarity(roll - ssrProb);
            }
        }

        // 更新状态
        if ("SSR".equals(result.rarity)) {
            ssrCount++;
            ssrIntervals.add(ssrPityCounter);
            ssrPityCounter = 0;
            lastSsrWasUp = result.isUp;
        } else if ("SR".equals(result.rarity)) {
            srCount++;
        } else {
            rCount++;
        }

        return new GachaResult(result, totalPulls, isPityTrigger);
    }

    /**
     * 通过保底抽取SSR
     */
    private GachaItem drawSsrWithPity() {
        // 如果有大保底机制且上次SSR非UP,本次必出UP
        if (hasBigPity && !lastSsrWasUp) {
            for (GachaItem item : itemPool) {
                if ("SSR".equals(item.rarity) && item.isUp) {
                    return item;
                }
            }
        }
        return drawSsrNormal();
    }

    /**
     * 正常抽取SSR(按UP概率分配)
     */
    private GachaItem drawSsrNormal() {
        List<GachaItem> ssrs = new ArrayList<>();
        double totalWeight = 0;
        for (GachaItem item : itemPool) {
            if ("SSR".equals(item.rarity)) {
                ssrs.add(item);
                totalWeight += item.baseProbability;
            }
        }

        double roll = random.nextDouble() * totalWeight;
        double cumsum = 0;
        for (GachaItem item : ssrs) {
            cumsum += item.baseProbability;
            if (roll < cumsum) {
                return item;
            }
        }
        return ssrs.get(ssrs.size() - 1);
    }

    /**
     * 抽取SR或R
     */
    private GachaItem drawLowerRarity(double normalizedRoll) {
        List<GachaItem> srs = new ArrayList<>();
        List<GachaItem> rs = new ArrayList<>();
        double srTotal = 0, rTotal = 0;

        for (GachaItem item : itemPool) {
            if ("SR".equals(item.rarity)) {
                srs.add(item);
                srTotal += item.baseProbability;
            } else if ("R".equals(item.rarity)) {
                rs.add(item);
                rTotal += item.baseProbability;
            }
        }

        double srProb = srTotal / (srTotal + rTotal);
        if (normalizedRoll < srProb) {
            return drawFromPool(srs, srTotal);
        } else {
            return drawFromPool(rs, rTotal);
        }
    }

    private GachaItem drawFromPool(List<GachaItem> pool, double totalWeight) {
        double roll = random.nextDouble() * totalWeight;
        double cumsum = 0;
        for (GachaItem item : pool) {
            cumsum += item.baseProbability;
            if (roll < cumsum) {
                return item;
            }
        }
        return pool.get(pool.size() - 1);
    }

    /**
     * 十连抽
     */
    public List<GachaResult> pullTen() {
        List<GachaResult> results = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            results.add(pull());
        }
        return results;
    }

    /**
     * 计算抽取到目标物品的期望抽数
     * 使用蒙特卡洛模拟
     */
    public double simulateExpectedPulls(String targetName, int simulations) {
        long totalPullsSum = 0;
        int successCount = 0;

        for (int sim = 0; sim < simulations; sim++) {
            GachaMachine simMachine = new GachaMachine(itemPool, softPityStart, 
                                                        softPityIncrement, hardPity, hasBigPity);
            int pulls = 0;
            boolean found = false;

            while (pulls < hardPity * 3 && !found) { // 设置上限防止无限循环
                GachaResult result = simMachine.pull();
                pulls++;
                if (result.item.name.equals(targetName)) {
                    found = true;
                }
            }

            if (found) {
                totalPullsSum += pulls;
                successCount++;
            }
        }

        return successCount > 0 ? (double) totalPullsSum / successCount : -1;
    }

    /**
     * 计算指定抽数内获得SSR的概率(累积分布)
     */
    public double simulateCumulativeSsrProbability(int maxPulls, int simulations) {
        int successCount = 0;

        for (int sim = 0; sim < simulations; sim++) {
            GachaMachine simMachine = new GachaMachine(itemPool, softPityStart,
                                                        softPityIncrement, hardPity, hasBigPity);
            boolean gotSsr = false;

            for (int i = 0; i < maxPulls && !gotSsr; i++) {
                GachaResult result = simMachine.pull();
                if ("SSR".equals(result.item.rarity)) {
                    gotSsr = true;
                }
            }

            if (gotSsr) successCount++;
        }

        return (double) successCount / simulations;
    }

    /**
     * 打印统计报告
     */
    public void printStatistics() {
        System.out.println("===== 扭蛋统计报告 =====");
        System.out.println("总抽数: " + totalPulls);
        System.out.println("SSR数: " + ssrCount + " (" + String.format("%.2f%%", 100.0 * ssrCount / totalPulls) + ")");
        System.out.println("SR数: " + srCount + " (" + String.format("%.2f%%", 100.0 * srCount / totalPulls) + ")");
        System.out.println("R数: " + rCount + " (" + String.format("%.2f%%", 100.0 * rCount / totalPulls) + ")");
        System.out.println("保底触发次数: " + pityTriggered);

        if (!ssrIntervals.isEmpty()) {
            double avgInterval = ssrIntervals.stream().mapToInt(Integer::intValue).average().orElse(0);
            int maxInterval = ssrIntervals.stream().mapToInt(Integer::intValue).max().orElse(0);
            System.out.println("SSR平均间隔: " + String.format("%.2f", avgInterval) + "抽");
            System.out.println("SSR最大间隔: " + maxInterval + "抽");
        }
    }

    // Getters
    public int getTotalPulls() { return totalPulls; }
    public int getSsrCount() { return ssrCount; }
}

三、期望值与方差的数学推导

/**
 * 扭蛋期望值计算器
 * 基于概率论计算理论期望和方差
 */
public class GachaExpectationCalculator {

    /**
     * 计算硬保底机制下的期望抽数
     * 公式:E = sum(k * P(第k抽首次出)) for k=1..N
     * 其中P(第k抽首次出) = P(前k-1抽不出) * P(第k抽出)
     */
    public static double calculateHardPityExpectation(double baseProb, int hardPity) {
        double expectation = 0;
        double cumulativeNoSsr = 1.0; // P(前k-1抽都不出)

        for (int k = 1; k <= hardPity; k++) {
            double probK; // 第k抽出的概率
            if (k < hardPity) {
                probK = baseProb;
            } else {
                probK = 1.0; // 硬保底必出
            }

            double probFirstAtK = cumulativeNoSsr * probK;
            expectation += k * probFirstAtK;

            if (k < hardPity) {
                cumulativeNoSsr *= (1 - baseProb);
            }
        }

        return expectation;
    }

    /**
     * 计算软保底+硬保底组合机制下的期望
     * 使用数值方法近似
     */
    public static double calculateSoftHardPityExpectation(
            double baseProb, int softStart, double increment, int hardPity) {
        double expectation = 0;
        double cumulativeNoSsr = 1.0;

        for (int k = 1; k <= hardPity; k++) {
            double currentProb;
            if (k >= hardPity) {
                currentProb = 1.0;
            } else if (k >= softStart) {
                currentProb = Math.min(baseProb + (k - softStart + 1) * increment, 1.0);
            } else {
                currentProb = baseProb;
            }

            double probFirstAtK = cumulativeNoSsr * currentProb;
            expectation += k * probFirstAtK;
            cumulativeNoSsr *= (1 - currentProb);
        }

        return expectation;
    }

    /**
     * 计算方差:Var(X) = E[X²] - (E[X])²
     */
    public static double calculateVariance(double baseProb, int hardPity) {
        double expectation = 0;
        double expectationSquared = 0;
        double cumulativeNoSsr = 1.0;

        for (int k = 1; k <= hardPity; k++) {
            double probK = (k < hardPity) ? baseProb : 1.0;
            double probFirstAtK = cumulativeNoSsr * probK;

            expectation += k * probFirstAtK;
            expectationSquared += k * k * probFirstAtK;

            if (k < hardPity) {
                cumulativeNoSsr *= (1 - baseProb);
            }
        }

        return expectationSquared - expectation * expectation;
    }

    /**
     * 大保底机制下获得UP角色的期望
     * 小保底50%概率出UP,失败则大保底必出UP
     */
    public static double calculateBigPityExpectation(double baseProb, int hardPity, double upRate) {
        // 先计算获得一个SSR的期望抽数
        double ssrExpectation = calculateHardPityExpectation(baseProb, hardPity);

        // 获得UP的期望SSR数 = 1/upRate(几何分布期望)
        double expectedSsrsForUp = 1.0 / upRate;

        // 但大保底机制下,最多2个SSR必得UP
        // 实际期望更复杂,使用数值模拟更精确
        return ssrExpectation * expectedSsrsForUp;
    }
}

四、完整运行示例

/**
 * 扭蛋机模拟器主程序
 */
public class GachaSimulator {
    public static void main(String[] args) {
        // 构建扭蛋池
        List<GachaItem> pool = Arrays.asList(
            new GachaItem("传说英雄A", "SSR", 0.005, true),   // UP SSR 0.5%
            new GachaItem("传说英雄B", "SSR", 0.005, false),  // 非UP SSR 0.5%
            new GachaItem("稀有角色C", "SR", 0.05, false),
            new GachaItem("稀有角色D", "SR", 0.05, false),
            new GachaItem("普通物品E", "R", 0.445, false),
            new GachaItem("普通物品F", "R", 0.445, false)
        );

        // 创建扭蛋机:软保底从74抽开始,每次+6%,90抽硬保底,启用大保底
        GachaMachine machine = new GachaMachine(pool, 74, 0.06, 90, true);

        System.out.println("=== 扭蛋模拟开始 ===\n");

        // 模拟1000抽
        System.out.println("执行1000次单抽...");
        for (int i = 0; i < 1000; i++) {
            GachaResult result = machine.pull();
            if ("SSR".equals(result.item.rarity)) {
                System.out.println("第" + result.pullNumber + "抽获得 " + result.item + 
                                 (result.isPity ? " [保底]" : ""));
            }
        }

        machine.printStatistics();

        // 计算理论期望值
        System.out.println("\n===== 理论期望值 =====");
        double hardExpectation = GachaExpectationCalculator.calculateHardPityExpectation(0.01, 90);
        System.out.println("纯硬保底期望抽数: " + String.format("%.2f", hardExpectation));

        double softHardExpectation = GachaExpectationCalculator.calculateSoftHardPityExpectation(
            0.01, 74, 0.06, 90);
        System.out.println("软+硬保底期望抽数: " + String.format("%.2f", softHardExpectation));

        double variance = GachaExpectationCalculator.calculateVariance(0.01, 90);
        System.out.println("方差: " + String.format("%.2f", variance));
        System.out.println("标准差: " + String.format("%.2f", Math.sqrt(variance)));

        // 蒙特卡洛模拟验证
        System.out.println("\n===== 蒙特卡洛模拟(10万次) =====");
        double mcExpectation = machine.simulateExpectedPulls("传说英雄A", 100000);
        System.out.println("获得UP角色期望抽数: " + String.format("%.2f", mcExpectation));

        double prob90 = machine.simulateCumulativeSsrProbability(90, 100000);
        System.out.println("90抽内获得SSR概率: " + String.format("%.4f", prob90));

        double prob180 = machine.simulateCumulativeSsrProbability(180, 100000);
        System.out.println("180抽内获得SSR概率: " + String.format("%.4f", prob180));
    }
}

五、概率模型分析

机制类型 期望抽数 方差 玩家体验
纯随机(1%) 100.00 9900 极差,非酋可能永远不出
硬保底(90抽) 55.21 较大 较好,有明确上限
软+硬保底(74+6%,90) ~52.3 中等 优秀,渐进式保护
大保底(50%UP) ~104.6 中等 UP角色获取稳定

六、总结与扩展

本文实现了完整的扭蛋机概率模型,涵盖基础随机抽取、软保底递增、硬保底上限和大保底机制。通过蒙特卡洛模拟和理论推导,可以精确计算玩家的期望投入和概率分布。

扩展方向:
1. 保底继承机制:实现跨卡池的保底计数器继承
2. 多物品UP:实现双UP或自选UP的概率分配
3. 综合概率分析:结合SR保底、十连保底等更复杂的规则
4. 收益模型:基于期望抽数计算玩家平均消费金额