一个简单抽奖算法的实现以及如何预防超中
需求

每个用户每天有3次抽奖机会;
抽奖奖池一共分为6档内容:现金红包1元,2元,3元,5元,iphone6s,谢谢参与;
支持每天调整和配置抽奖的获奖概率;

算法介绍
每种奖品都有一个权重 对应一个区间 若落入该区间就表示中奖 调整区间大小就可改变获奖概率 即调整权重值即可

奖品

权重

区间

1元

5000

[0,5000)

2元

1000

[5000,6000)

3元

500

[6000,6500)

5元

100

[6500, 6600)

iphone6s

1

[6600, 6601)

未中奖

59409

[6601,66010)

假设设定抽10次中一次, 未中奖权重 = 抽检概率导数奖品数-奖品数 = 106601-6601 = 59409

抽奖的时候 先生成一个随机值

randNum = new Random().nextInt(totalWeight);  // totalWeight = 上面权重列之和

判断该随机值在哪一个区间 如

randNum = 8944 落在未中奖区间 未中奖 
randNum = 944 落在1元区间 中了一元

如果想增大中iphone6s的概率 调整权重值即可 如将权重改为1000, 则区间变为[6600,7600)同时会为每种奖品设置库存 如

日期

奖品

库存

3.1

一元

5000

中奖后 会减库存 但假如库存只剩1个了 有10个用户同时落入一元区间 如何避免1-10=-9的情况呢?
解决方法

update award_stock set stock = stock - 1 where award_id = ? and stock > 0;

即是否中奖除了落入区间外 还需判断减库存是否成功
如果减库存失败 仍当做未中奖

一旦一种奖品库存为0 下次计算区间的时候 将它排除 如一元奖品库存已为0 这时各奖品的区间变化为

奖品

权重

区间

2元

1000

[0,1000)

3元

500

[1000,1500)

5元

100

[1500, 1600)

iphone6s

1

[1600, 1601)

未中奖

59409

[1601,61010)

61010/1601=38 此时中奖概率变小了 相当于抽38次中一次

验证上述算法
看是否能抽完所有奖品 如某天的奖品配置如下 (权重默认等于库存)

日期

奖品

权重

库存

3.1

1元

5000

5000

3.1

2元

1000

1000

3.1

3元

500

500

3.1

5元

100

100

3.1

iphone6s

1

1

3.1

未中奖

59409

59409

假设日活用户数为3万 每个用户可抽3次
java代码

final Map<String, Integer> awardStockMap = new ConcurrentHashMap<>(); // 奖品 <--> 奖品库存
awardStockMap.put("1", 5000);
awardStockMap.put("2", 1000);
awardStockMap.put("3", 500);
awardStockMap.put("5", 100);
awardStockMap.put("iphone", 1);
awardStockMap.put("未中奖", 59409); //6601*10 -6601
//权重默认等于库存      
final Map<String, Integer> awardWeightMap = new ConcurrentHashMap<>(awardStockMap); // 奖品 <--> 奖品权重
 
int userNum = 30000; // 日活用户数
int drawNum = userNum * 3; // 每天抽奖次数 = 日活数*抽奖次数
Map<String, Integer> dailyWinCountMap = new ConcurrentHashMap<>(); // 每天实际中奖计数
for(int j=0; j<drawNum; j++){ // 模拟每次抽奖
    //排除掉库存为0的奖品
    Map<String, Integer> awardWeightHaveStockMap = awardWeightMap.entrySet().stream().filter(e->awardStockMap.get(e.getKey())>0).collect(Collectors.toMap(e->e.getKey(), e->e.getValue()));
    int totalWeight = (int) awardWeightHaveStockMap.values().stream().collect(Collectors.summarizingInt(i->i)).getSum();
    int randNum = new Random().nextInt(totalWeight); //生成一个随机数
    int prev = 0;
    String choosedAward = null;
    // 按照权重计算中奖区间
    for(Entry<String,Integer> e : awardWeightHaveStockMap.entrySet() ){
        if(randNum>=prev && randNum<prev+e.getValue()){
            choosedAward = e.getKey(); //落入该奖品区间
            break;
        }
        prev = prev+e.getValue();
    }
    dailyWinCountMap.compute(choosedAward, (k,v)->v==null?1:v+1); //中奖计数 
    if(!"未中奖".equals(choosedAward)){ //未中奖不用减库存 
        awardStockMap.compute(choosedAward, (k,v)->v-1); //奖品库存一
        if(awardStockMap.get(choosedAward)==0){
            System.out.printf("奖品:%s 库存为空%n",choosedAward); //记录库存为空的顺序
        }
    }
 
}
System.out.println("各奖品中奖计数: "+dailyWinCountMap); //每日各奖品中奖计数

输出

奖品:iphone 库存为空
奖品:5 库存为空
奖品:1 库存为空
奖品:2 库存为空
奖品:3 库存为空
每日各奖品中奖计数: {1=5000, 2=1000, 3=500, 5=100, iphone=1, 未中奖=83399}

可知 假如该天抽奖次数能有9万次的话 可以抽完所有的奖品 另外因是单线程未考虑减库存
失败的情况 即并发减库存的情况


抽奖算法2 存在奖品库存的前提下 保证每次中奖的概率恒定 如15% 抽100次有15次中奖

final Map<String, Integer> awardStockMap = new ConcurrentHashMap<>(); 
        awardStockMap.put("1", 3000);
        awardStockMap.put("2", 2000);
        awardStockMap.put("3", 1500);
        awardStockMap.put("5", 1000);
        awardStockMap.put("10", 100);
        awardStockMap.put("20", 10);
        awardStockMap.put("50", 5);
        awardStockMap.put("100", 2);
        // 权重默认等于库存
        final Map<String, Integer> awardWeightMap = new ConcurrentHashMap<>(awardStockMap); 
        final Map<String, Integer> initAwardStockMap = new ConcurrentHashMap<>(awardStockMap); 

        int drawNum = 50780; // 理论可以抽完所有奖品所需抽奖次数 = 奖品数×中奖概率导数 = 7617*100/15
        final int threshold = 15; //中奖概率 15%
        Map<String, Integer> dailyWinCountMap = new ConcurrentHashMap<>(); // 每天实际中奖计数

        for (int j = 0; j < drawNum; j++) { // 模拟每次抽奖
            //确定是否中奖
            int randNum = new Random().nextInt(100);
            if(randNum>threshold){
                dailyWinCountMap.compute("未中奖", (k,v)->v==null?1:v+1);
                continue; //未中奖
            }
            //中奖 确定是哪个奖品
            //排除掉库存为0的奖品
            Map<String, Integer> awardWeightHaveStockMap = awardWeightMap.entrySet().stream().filter(e->awardStockMap.get(e.getKey())>0).collect(Collectors.toMap(e->e.getKey(), e->e.getValue()));
            if(awardWeightHaveStockMap.isEmpty()){ //奖池已为空
                System.out.printf("第%d次抽奖 奖品已被抽完%n",j);
                break;
            }
            int totalWeight = (int) awardWeightHaveStockMap.values().stream().collect(Collectors.summarizingInt(i->i)).getSum();
            randNum = new Random().nextInt(totalWeight); 
            int prev=0;
            String choosedAward = null;
            for(Entry<String,Integer> e : awardWeightHaveStockMap.entrySet() ){
                if(randNum>=prev && randNum<prev+e.getValue()){
                    choosedAward = e.getKey(); //落入此区间 中奖
                    dailyWinCountMap.compute(choosedAward, (k,v)->v==null?1:v+1);
                    break;
                }
                prev = prev+e.getValue();
            }
            //减小库存
            awardStockMap.compute(choosedAward, (k,v)->v-1);
        }
        System.out.println("每日各奖品中奖计数: "); // 每日各奖品中奖计数
        dailyWinCountMap.entrySet().stream().sorted((e1,e2)->e2.getValue()-e1.getValue()).forEach(System.out::println);
        awardStockMap.forEach((k,v)->{if(v>0){
            System.out.printf("奖品:%s, 总库存: %d, 剩余库存: %d%n",k,initAwardStockMap.get(k),v);
        }});

输出

第47495次抽奖 奖品已被抽完
每日各奖品中奖计数: 
未中奖=39878
1=3000
2=2000
3=1500
5=1000
10=100
20=10
50=5
100=2

可见 实际不用到理论抽奖次数 即可抽完所有奖品