一、定义奖品实体类
@Data
@Builder
public class LuckDrawRules {
/**
* id
*/
private Long id;
/**
* 奖品名称
*/
private String title;
/**
* 中奖概率
*/
private Double rate;
/**
* 奖品图片
*/
private JSONArray image;
}
二、实现类
public interface LuckDrawService {
/**
* 抽奖
* @return
*/
LuckDrawRules luckDraw();
}
@Service
public class LuckDrawServiceImpl implements LuckDrawService {
// 构造奖品列表数据,自定义中奖概率,可根据业务将奖品添加到数据库
private List<LuckDrawRules> buildRule() {
List<LuckDrawRules> rules = new ArrayList<>();
rules.add(LuckDrawRules.builder().id(1L).rate(0.022).title("永久英雄*李白").build());
rules.add(LuckDrawRules.builder().id(2L).rate(0.317).title("皮肤碎片*5").build());
rules.add(LuckDrawRules.builder().id(3L).rate(0.355).title("铭文碎片*10").build());
rules.add(LuckDrawRules.builder().id(4L).rate(0.159).title("钻石*10").build());
rules.add(LuckDrawRules.builder().id(5L).rate(0.139).title("金币*200").build());
rules.add(LuckDrawRules.builder().id(6L).rate(0.008).title("荣耀水晶").build());
rules.add(LuckDrawRules.builder().id(7L).rate(0.011).title("永久皮肤*死神来了").build());
rules.add(LuckDrawRules.builder().id(8L).rate(0.011).title("永久皮肤*冰冠公主").build());
rules.add(LuckDrawRules.builder().id(9L).rate(0.5).title("亲密玫瑰*5").build());
rules.add(LuckDrawRules.builder().id(10L).rate(0.5).title("战令币*50").build());
return rules;
}
/**
* 抽奖
* @return
*/
@Override
public LuckDrawRules luckDraw() {
List<LuckDrawRules> luckDrawRules = buildRule();
List<Double> originalRates = new ArrayList<>(luckDrawRules.size());
for (LuckDrawRules rule : luckDrawRules) {
double probability = rule.getRate() < 0 ? 0 : rule.getRate();
originalRates.add(probability);
}
int originalIndex = lottery(originalRates);
return luckDrawRules.get(originalIndex);
}
private int lottery(List<Double> originalRates) {
if (CollUtil.isEmpty(originalRates)) {
return -1;
}
// 计算总概率,这样可以保证不一定总概率是1
double sumRate = 0d;
for (double rate : originalRates) {
sumRate += rate;
}
// 计算每个物品在总概率的基础下的概率情况
List<Double> sortOriginalRates = new ArrayList<>(originalRates.size());
double tempSumRate = 0d;
for (double rate : originalRates) {
tempSumRate += rate;
sortOriginalRates.add(tempSumRate / sumRate);
}
// 根据区块值来获取抽取到的物品索引
double nextDouble = Math.random();
sortOriginalRates.add(nextDouble);
Collections.sort(sortOriginalRates);
return sortOriginalRates.indexOf(nextDouble);
}
}
三、Controller
可根据参数count来进行单抽或五连抽
抽到重复奖品不去重,如下:
@RestController
public class LuckDrawController {
@Resource
private LuckDrawService luckDrawService;
/**
* 不去重
* @param count 抽奖次数
* @return
*/
@GetMapping("/luckDraw")
public List<LuckDrawRules> luckDraw(Integer count) {
List<LuckDrawRules> list = new ArrayList<>();
for (int i = 0; i < count ; i++) {
LuckDrawRules luckDraw = luckDrawService.luckDraw();
list.add(luckDraw);
}
return list;
}
}
结果:
如果需要奖品重复,数量叠加的话,如下:
@RestController
public class LuckDrawController {
@Resource
private LuckDrawService luckDrawService;
/**
* 去重,计算重复的数量
* @param count 抽奖次数
* @return
*/
@GetMapping("/luckDrawDuplicate")
public Map<String, Long> luckDrawDuplicate(Integer count) {
List<LuckDrawRules> list = new ArrayList<>();
for (int i = 0; i < count ; i++) {
LuckDrawRules luckDraw = luckDrawService.luckDraw();
list.add(luckDraw);
}
//查询重复出现的元素及个数
Map<String, Long> map = list.stream().collect(Collectors.groupingBy(LuckDrawRules::getTitle, Collectors.counting()));
// 根据value排序
return map.entrySet().stream()
// 根据键排序 comparingByKey()
// 根据中奖次数排序
.sorted(Map.Entry.comparingByValue())
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(oldVal, newVal) -> oldVal,
LinkedHashMap::new
));
}
}
结果:
以上抽奖核心代码的基本原理如下:
- 根据输入的中奖概率建立奖励规则列表
- 计算每个奖品对应的中奖概率和总概率
- 将总概率转换为[0,1]区间内的概率值
- 生成一个随机数,映射到[0,1]区间内
- 根据随机数落入的区间对应到对应的奖品得主
可以发现,这个抽奖算法的核心是将中奖概率映射到[0,1]区间上,并在该区间内产生随机数。通过将总概率分段映射到[0,1]上,在[0,1]上生成的随机数可以直接对应到对应的奖品中,从而确定获奖者。此外,还考虑了输入数据的异常情况(例如中奖概率小于0),也代码中进行了处理。
详细图解:
当概率给定为P1、P2、P3时,把概率映射到[0,1]区间上的过程可以表示为:
|<--- P1 --->|<--- P2 --->|<--- P3 --->|
0 x y 1
其中,x = P1 / (P1+P2+P3),y = (P1+P2) / (P1+P2+P3)。
生成一个在 [0,1] 区间内的随机数,如下图所示:
|<--- P1 --->|<--- P2 --->|<--- P3 --->|
0 R y 1
根据随机数R所在的区间可以得到相应的奖品编号。
注意,这里的 sortOriginalRates 实际上是每个奖品的累积概率,因此将它与随机数 R 进行比较来确定落入哪个区间,并返回对应的奖励规则。
如果还不理解,那再进一步解释一下:
这段代码的主要思路是:根据每个奖品获奖概率的大小,把[0,1]区间分成多个小区间,并把它们的长度与每个奖品的概率对应起来。然后,生成一个0~1之间的随机数r,根据r所对应的小区间的编号,得到获奖的奖品。
假设有三个奖品,他们的中奖概率分别为P1, P2, P3,其和为S=P1+P2+P3。我们将[0,1]区间等分成n份,每个奖品获奖的区间长度就是它的中奖概率除以总概率S,并将它们相加,得到对应的序列sortOriginalRates。这个序列的最后一位是1,用来处理随机数r刚好落在最后一个区间的特殊情况。
接着,我们生成一个0~1之间的随机数R。我们将R插入到sortOriginalRates中,排序后,R所在的位置即为获奖的奖品对应的序号。
这种方法的好处是不需要事先对奖品的概率进行归一化,可以直接使用原始值进行计算。同时,计算简单、速度快,适合处理中小规模的抽奖活动。