每日算法 — 使用java实现盲盒:概率模型与背包优化策略

引言

盲盒经济近年来风靡全球,其核心魅力在于不确定性的惊喜感。从算法角度看,盲盒系统涉及概率分布设计、库存管理、用户心理建模等复杂问题。本文用 Java 实现一个完整的盲盒抽取系统,深入讲解带权随机抽样、背包优化与期望价值计算,帮助读者理解电商与游戏行业中概率系统的工程实现。

核心算法思路

概率模型设计

盲盒的核心是隐藏款与普通款的比例控制:
– 设隐藏款概率为 p,普通款概率为 1-p
– 为避免用户连续抽不到隐藏款,引入保底机制(Pity System)
– 保底机制本质是条件概率:P(隐藏款 | 已连续n次未中) 随n递增

背包优化

用户收集多款盲盒后,如何以最小成本集齐全套?这是一个经典的优惠券收集问题(Coupon Collector’s Problem)变种,可转化为期望计算与动态规划。

库存约束抽样

实际系统中,每款盲盒有固定库存。当某款售罄后,需要动态调整剩余款式的概率分布,确保总概率恒为1。

完整 Java 实现

import java.util.*;
import java.util.concurrent.ThreadLocalRandom;

/**
 * 盲盒抽取系统
 * 核心算法:带权随机抽样 + 保底机制 + 背包优化
 */
public class BlindBoxSystem {

    // 盲盒款式
    static class Style {
        String name;
        double baseProbability;  // 基础概率
        int rarity;              // 稀有度 1-5
        int stock;               // 剩余库存
        int totalSold;           // 总售出数
        boolean isHidden;        // 是否隐藏款

        Style(String name, double baseProbability, int rarity, int stock, boolean isHidden) {
            this.name = name;
            this.baseProbability = baseProbability;
            this.rarity = rarity;
            this.stock = stock;
            this.totalSold = 0;
            this.isHidden = isHidden;
        }
    }

    // 用户收集状态
    static class Collector {
        String userId;
        Map<String, Integer> owned = new HashMap<>();  // 款式 -> 数量
        int consecutiveMiss;                           // 连续未中隐藏款次数
        int totalSpent;                                // 总花费

        Collector(String userId) {
            this.userId = userId;
        }

        boolean hasCompleteSet(List<Style> allStyles) {
            for (Style s : allStyles) {
                if (!owned.containsKey(s.name) || owned.get(s.name) == 0) {
                    return false;
                }
            }
            return true;
        }
    }

    private final List<Style> styles = new ArrayList<>();
    private final Random random = ThreadLocalRandom.current();
    private final int pityThreshold;      // 保底阈值
    private final double pityBoost;       // 每次未中的概率增量

    public BlindBoxSystem(int pityThreshold, double pityBoost) {
        this.pityThreshold = pityThreshold;
        this.pityBoost = pityBoost;
    }

    // 初始化盲盒系列
    public void initSeries() {
        styles.add(new Style("经典款A", 0.25, 1, 1000, false));
        styles.add(new Style("经典款B", 0.20, 1, 1000, false));
        styles.add(new Style("经典款C", 0.20, 1, 1000, false));
        styles.add(new Style("稀有款D", 0.15, 2, 500, false));
        styles.add(new Style("稀有款E", 0.12, 2, 500, false));
        styles.add(new Style("史诗款F", 0.06, 3, 200, false));
        styles.add(new Style("传说款G", 0.015, 4, 50, true));   // 隐藏款
        styles.add(new Style("神话款H", 0.005, 5, 10, true));   // 超隐藏款
    }

    /**
     * 计算动态概率(考虑库存与保底)
     */
    public double[] computeDynamicProbabilities(Collector collector) {
        double[] probs = new double[styles.size()];
        double total = 0.0;

        for (int i = 0; i < styles.size(); i++) {
            Style s = styles.get(i);
            if (s.stock <= 0) {
                probs[i] = 0;
                continue;
            }

            double p = s.baseProbability;

            // 保底机制:连续未中隐藏款时提升概率
            if (s.isHidden && collector.consecutiveMiss >= pityThreshold) {
                p += pityBoost * (collector.consecutiveMiss - pityThreshold + 1);
            }

            // 库存越少,相对权重越高(防止滞销款积压)
            p *= (1.0 + 0.1 * (1.0 - s.stock / 1000.0));

            probs[i] = p;
            total += p;
        }

        // 归一化,确保总和为1
        if (total > 0) {
            for (int i = 0; i < probs.length; i++) {
                probs[i] /= total;
            }
        }
        return probs;
    }

    /**
     * 带权随机抽样(别名方法优化版)
     */
    public int aliasMethodSample(double[] probs) {
        int n = probs.length;
        int[] alias = new int[n];
        double[] prob = new double[n];
        Deque<Integer> small = new ArrayDeque<>();
        Deque<Integer> large = new ArrayDeque<>();
        double[] scaled = new double[n];

        for (int i = 0; i < n; i++) {
            scaled[i] = probs[i] * n;
            if (scaled[i] < 1.0) small.add(i);
            else large.add(i);
        }

        while (!small.isEmpty() && !large.isEmpty()) {
            int l = small.poll();
            int g = large.poll();
            prob[l] = scaled[l];
            alias[l] = g;
            scaled[g] = scaled[g] + scaled[l] - 1.0;
            if (scaled[g] < 1.0) small.add(g);
            else large.add(g);
        }

        while (!large.isEmpty()) prob[large.poll()] = 1.0;
        while (!small.isEmpty()) prob[small.poll()] = 1.0;

        int col = random.nextInt(n);
        return random.nextDouble() < prob[col] ? col : alias[col];
    }

    /**
     * 执行一次抽取
     */
    public Style draw(Collector collector) {
        double[] probs = computeDynamicProbabilities(collector);
        int idx = aliasMethodSample(probs);
        Style result = styles.get(idx);

        // 更新库存与状态
        result.stock--;
        result.totalSold++;
        collector.owned.merge(result.name, 1, Integer::sum);
        collector.totalSpent++;

        if (result.isHidden) {
            collector.consecutiveMiss = 0;
        } else {
            collector.consecutiveMiss++;
        }

        return result;
    }

    /**
     * 背包优化:计算集齐全套的期望花费
     * 使用蒙特卡洛模拟
     */
    public double simulateExpectedCost(int simulations) {
        long totalCost = 0;
        for (int sim = 0; sim < simulations; sim++) {
            Collector dummy = new Collector("sim" + sim);
            int cost = 0;
            while (!dummy.hasCompleteSet(styles)) {
                // 简化为等概率抽取进行期望估算
                int idx = random.nextInt(styles.size());
                dummy.owned.merge(styles.get(idx).name, 1, Integer::sum);
                cost++;
                if (cost > 100000) break; // 防止无限循环
            }
            totalCost += cost;
        }
        return (double) totalCost / simulations;
    }

    /**
     * 优惠券收集问题精确解
     * 对于n种等概率款式,期望抽取次数 = n * H_n(调和级数)
     */
    public double couponCollectorExact(int n) {
        double harmonic = 0.0;
        for (int i = 1; i <= n; i++) {
            harmonic += 1.0 / i;
        }
        return n * harmonic;
    }

    /**
     * 动态规划:最优购买策略
     * dp[mask] 表示已收集mask状态时的最小期望花费
     */
    public double optimalStrategyDP() {
        int n = styles.size();
        int fullMask = (1 << n) - 1;
        double[] dp = new double[1 << n];
        Arrays.fill(dp, -1.0);
        dp[fullMask] = 0.0;

        return dfs(0, dp, fullMask, n);
    }

    private double dfs(int mask, double[] dp, int fullMask, int n) {
        if (dp[mask] >= 0) return dp[mask];

        double[] probs = new double[n];
        double sum = 0;
        for (int i = 0; i < n; i++) {
            probs[i] = styles.get(i).baseProbability;
            sum += probs[i];
        }
        for (int i = 0; i < n; i++) probs[i] /= sum;

        double expectation = 0.0;
        for (int i = 0; i < n; i++) {
            int newMask = mask | (1 << i);
            expectation += probs[i] * (1.0 + dfs(newMask, dp, fullMask, n));
        }

        dp[mask] = expectation;
        return expectation;
    }

    public void printStats() {
        System.out.println("=== 盲盒系列库存状态 ===");
        for (Style s : styles) {
            System.out.printf("%s | 稀有度: %d | 库存: %d | 售出: %d | 隐藏款: %b%n",
                    s.name, s.rarity, s.stock, s.totalSold, s.isHidden);
        }
    }

    public static void main(String[] args) {
        BlindBoxSystem system = new BlindBoxSystem(50, 0.02);
        system.initSeries();

        Collector alice = new Collector("Alice");
        System.out.println("=== 盲盒抽取模拟 ===");
        for (int i = 0; i < 100; i++) {
            Style s = system.draw(alice);
            if (s.isHidden) {
                System.out.printf("第%d抽: *** %s (隐藏款!) ***%n", i + 1, s.name);
            }
        }

        system.printStats();
        System.out.printf("%nAlice 已收集 %d 种款式,连续未中隐藏: %d 次%n",
                alice.owned.size(), alice.consecutiveMiss);

        double expected = system.simulateExpectedCost(1000);
        System.out.printf("蒙特卡洛模拟集齐全套期望花费: %.2f 抽%n", expected);
        System.out.printf("优惠券收集精确解(等概率%d款): %.2f 抽%n",
                system.styles.size(), system.couponCollectorExact(system.styles.size()));
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度
动态概率计算 O(n) O(n)
别名方法抽样 O(n) 预处理, O(1) 抽样 O(n)
蒙特卡洛模拟 O(k * n) k为模拟次数 O(n)
DP最优策略 O(2^n) O(2^n)

扩展思考

  1. 库存预警算法:当某款库存低于阈值时,自动提升其概率或触发补货。可使用指数平滑预测销量趋势。
  2. 用户画像建模:根据用户历史抽取行为,使用协同过滤推荐其可能喜欢的系列。
  3. 防沉迷概率调控:对高消费用户逐步降低隐藏款概率,可用多臂老虎机(Multi-Armed Bandit)的UCB算法平衡收益与用户体验。

总结

盲盒系统的核心挑战在于概率的公平性、库存的动态平衡与用户体验的可持续性。本文实现的带权随机抽样、保底机制与背包优化,不仅适用于盲盒,也是游戏抽奖、推荐系统排序、负载均衡等场景的通用解决方案。