每日算法 — 使用java实现抽奖模拟器:概率分布与保底机制设计

抽奖系统是游戏运营中常见的用户激励机制,其核心在于通过精心设计的概率分布与保底机制,在控制成本的同时维持玩家的参与热情。一个合理的抽奖系统需要平衡随机性与确定性:纯随机可能导致极端的”非洲玩家”体验,而过度保底又会削弱随机乐趣。本文将用Java实现一个支持多稀有度、概率权重与保底机制的抽奖模拟器,并分析其数学原理。

抽奖系统的核心设计要素

概率权重分配

现代抽奖系统通常采用权重分配模型。设总权重为W,各奖项权重为w_i,则第i项的中奖概率P_i = w_i / W。这种模型灵活支持任意概率配置,如SSR(1%)、SR(10%)、R(89%)的经典手游设定。

保底机制类型

  1. 软保底(Pity System):连续N次未获得高稀有度物品后,逐步提升其概率直至100%
  2. 硬保底(Hard Pity):累计抽取达到固定次数时强制获得高稀有度物品
  3. 水位继承:保底计数跨卡池继承,保护玩家历史投入

伪随机分布(PRD)

Dota 2率先采用的PRD机制用公式P(N) = C × N动态调整第N次尝试的概率,相比纯独立事件能更平滑地控制方差。常数C满足方程Σ(1-P(i))×P(i) = 目标概率,通常需要数值方法求解。

Java实现

import java.util.*;

/**
 * 抽奖模拟器完整实现
 * 支持权重概率、软保底、硬保底与PRD伪随机分布
 */
public class GachaSimulator {

    /**
     * 奖项定义
     */
    static class Prize {
        final String name;
        final String rarity; // SSR, SR, R等
        final int weight;    // 基础权重

        Prize(String name, String rarity, int weight) {
            this.name = name;
            this.rarity = rarity;
            this.weight = weight;
        }

        @Override
        public String toString() {
            return String.format("%s[%s]", name, rarity);
        }
    }

    /**
     * 保底配置
     */
    static class PityConfig {
        final String targetRarity;   // 目标稀有度
        final int softPityStart;     // 软保底起始次数
        final double softPityBonus;  // 每次额外增加的概率
        final int hardPity;          // 硬保底次数
        final boolean resetOnHit;    // 中奖后是否重置计数

        PityConfig(String targetRarity, int softPityStart, 
                   double softPityBonus, int hardPity, boolean resetOnHit) {
            this.targetRarity = targetRarity;
            this.softPityStart = softPityStart;
            this.softPityBonus = softPityBonus;
            this.hardPity = hardPity;
            this.resetOnHit = resetOnHit;
        }
    }

    private final List<Prize> prizes;
    private final Map<String, PityConfig> pityConfigs;
    private final Map<String, Integer> pityCounters; // 各稀有度保底计数
    private final Random random;

    // 统计信息
    private final Map<String, Integer> drawStats;
    private int totalDraws;

    public GachaSimulator(List<Prize> prizes, List<PityConfig> configs) {
        this.prizes = new ArrayList<>(prizes);
        this.pityConfigs = new HashMap<>();
        this.pityCounters = new HashMap<>();
        this.random = new Random();
        this.drawStats = new HashMap<>();
        this.totalDraws = 0;

        for (PityConfig config : configs) {
            pityConfigs.put(config.targetRarity, config);
            pityCounters.put(config.targetRarity, 0);
        }
    }

    /**
     * 计算当前有效权重(考虑保底加成)
     */
    private Map<Prize, Double> calculateEffectiveWeights() {
        Map<Prize, Double> weights = new HashMap<>();
        double totalBaseWeight = prizes.stream().mapToInt(p -> p.weight).sum();

        for (Prize prize : prizes) {
            weights.put(prize, (double) prize.weight);
        }

        // 应用软保底加成
        for (String rarity : pityConfigs.keySet()) {
            PityConfig config = pityConfigs.get(rarity);
            int counter = pityCounters.getOrDefault(rarity, 0);

            if (counter >= config.softPityStart) {
                double bonus = config.softPityBonus * (counter - config.softPityStart + 1);

                // 将加成平均分配给目标稀有度的所有奖项
                List<Prize> targetPrizes = new ArrayList<>();
                for (Prize p : prizes) {
                    if (p.rarity.equals(rarity)) {
                        targetPrizes.add(p);
                    }
                }

                if (!targetPrizes.isEmpty()) {
                    double bonusPerPrize = bonus * totalBaseWeight / targetPrizes.size();
                    for (Prize p : targetPrizes) {
                        weights.put(p, weights.get(p) + bonusPerPrize);
                    }
                }
            }
        }

        return weights;
    }

    /**
     * 执行单次抽取
     * @return 抽中的奖项
     */
    public Prize draw() {
        totalDraws++;

        // 检查硬保底
        for (String rarity : pityConfigs.keySet()) {
            PityConfig config = pityConfigs.get(rarity);
            int counter = pityCounters.getOrDefault(rarity, 0);

            if (counter + 1 >= config.hardPity) {
                // 触发硬保底
                List<Prize> targetPrizes = new ArrayList<>();
                for (Prize p : prizes) {
                    if (p.rarity.equals(rarity)) {
                        targetPrizes.add(p);
                    }
                }
                if (!targetPrizes.isEmpty()) {
                    Prize prize = targetPrizes.get(random.nextInt(targetPrizes.size()));
                    updateCounters(prize);
                    recordStat(prize);
                    return prize;
                }
            }
        }

        // 权重抽奖
        Map<Prize, Double> weights = calculateEffectiveWeights();
        double totalWeight = weights.values().stream().mapToDouble(Double::doubleValue).sum();
        double roll = random.nextDouble() * totalWeight;

        double cumulative = 0;
        Prize selected = prizes.get(0);
        for (Prize prize : prizes) {
            cumulative += weights.get(prize);
            if (roll <= cumulative) {
                selected = prize;
                break;
            }
        }

        updateCounters(selected);
        recordStat(selected);
        return selected;
    }

    /**
     * 更新保底计数器
     */
    private void updateCounters(Prize hitPrize) {
        for (String rarity : pityConfigs.keySet()) {
            PityConfig config = pityConfigs.get(rarity);
            int current = pityCounters.getOrDefault(rarity, 0);

            if (hitPrize.rarity.equals(rarity) && config.resetOnHit) {
                pityCounters.put(rarity, 0);
            } else {
                pityCounters.put(rarity, current + 1);
            }
        }
    }

    private void recordStat(Prize prize) {
        drawStats.put(prize.rarity, drawStats.getOrDefault(prize.rarity, 0) + 1);
    }

    /**
     * 批量抽取
     */
    public List<Prize> drawMulti(int count) {
        List<Prize> results = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            results.add(draw());
        }
        return results;
    }

    /**
     * 计算PRD常数C(数值方法)
     * 满足:目标概率 = Σ(Π(1-C*j) * C*i) for i=1 to N
     */
    public static double calculatePRDConstant(double targetProb, int maxAttempts) {
        double low = 0, high = 1;
        for (int iter = 0; iter < 100; iter++) {
            double mid = (low + high) / 2;
            double prob = 0;
            double noHitProduct = 1;

            for (int i = 1; i <= maxAttempts; i++) {
                double pThis = Math.min(mid * i, 1.0);
                prob += noHitProduct * pThis;
                noHitProduct *= (1 - pThis);
            }

            if (prob < targetProb) {
                low = mid;
            } else {
                high = mid;
            }
        }
        return (low + high) / 2;
    }

    /**
     * 使用PRD进行抽取
     */
    public static boolean prdDraw(double targetProb, int maxAttempts, Random random) {
        double c = calculatePRDConstant(targetProb, maxAttempts);
        for (int i = 1; i <= maxAttempts; i++) {
            double p = Math.min(c * i, 1.0);
            if (random.nextDouble() < p) {
                return true;
            }
        }
        return false;
    }

    public Map<String, Integer> getStats() { return new HashMap<>(drawStats); }
    public int getTotalDraws() { return totalDraws; }
    public Map<String, Integer> getPityCounters() { return new HashMap<>(pityCounters); }

    public static void main(String[] args) {
        // 配置奖项池
        List<Prize> prizes = Arrays.asList(
            new Prize("传说之剑", "SSR", 10),
            new Prize("史诗护甲", "SR", 100),
            new Prize("精良戒指", "R", 500),
            new Prize("普通药水", "N", 1390)
        );

        // 配置保底:SSR在80抽开始软保底,每抽+5%,90抽硬保底
        List<PityConfig> configs = Arrays.asList(
            new PityConfig("SSR", 80, 0.05, 90, true)
        );

        GachaSimulator simulator = new GachaSimulator(prizes, configs);

        // 模拟10000次十连抽
        int tenDrawCount = 10000;
        Map<String, Long> rarityCount = new HashMap<>();
        int ssrObtained = 0;
        List<Integer> ssrIntervals = new ArrayList<>();
        int lastSSRDraw = 0;

        for (int i = 0; i < tenDrawCount * 10; i++) {
            Prize p = simulator.draw();
            rarityCount.merge(p.rarity, 1L, Long::sum);

            if (p.rarity.equals("SSR")) {
                ssrObtained++;
                ssrIntervals.add(i - lastSSRDraw);
                lastSSRDraw = i;
            }
        }

        System.out.println("=== 抽奖模拟统计 ===");
        System.out.println("总抽取次数: " + simulator.getTotalDraws());
        System.out.println("\n稀有度分布:");
        rarityCount.forEach((rarity, count) -> 
            System.out.printf("  %s: %,d (%.3f%%)%n", rarity, count, count * 100.0 / simulator.getTotalDraws()));

        System.out.println("\nSSR获取统计:");
        System.out.println("  总获得数: " + ssrObtained);
        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.printf("  平均间隔: %.2f抽%n", avgInterval);
            System.out.println("  最大间隔: " + maxInterval + "抽");
        }

        // PRD演示
        System.out.println("\n=== PRD伪随机演示 ===");
        double target = 0.20; // 20%目标概率
        double c = calculatePRDConstant(target, 10);
        System.out.printf("目标概率 %.0f%% 的PRD常数C = %.6f%n", target * 100, c);

        Random rand = new Random(42);
        int prdSuccess = 0;
        for (int i = 0; i < 100000; i++) {
            if (prdDraw(target, 10, rand)) {
                prdSuccess++;
            }
        }
        System.out.printf("PRD 10万次模拟实际成功率: %.4f%n", prdSuccess / 100000.0);
    }
}

复杂度分析

  • 单次抽取:权重计算O(R)(R为奖项数量),抽奖选择O(R),总体O(R)
  • 批量抽取N次:O(N × R)
  • PRD常数计算:二分搜索100次 × maxAttempts,O(maxAttempts)
  • 空间复杂度:O(R + C)(C为保底配置数量)

设计要点总结

抽奖系统的核心挑战在于方差控制。纯独立事件下,SSR的理论获取间隔为1/P_SSR抽,但标准差同样巨大。软保底通过动态概率有效压缩方差上界,硬保底则彻底消除极端情况。PRD机制以数学优雅的方式实现更平滑的概率曲线,是现代游戏设计的推荐方案。开发者应根据业务目标在”刺激感”与”公平感”之间寻找最佳平衡点。