最近修改了网站的抽奖算法,使得抽奖看起来更加『公平』,为此我整理了下,谈谈在抽奖系统设计中的『坑』。
抽奖分为两种:
知道总人数
不知道总人数
举栗子
1. 已知人数
14 个奖品分给 500 个人:
奖品分为一等奖、二等奖、三等奖;
总人数 500 人。
奖品 | 数量 |
---|---|
一等奖 | 1 |
二等奖 | 3 |
三等奖 | 10 |
设计思路:
为 500 人设计序号,1 - 500;
生成中奖序列(伪代码)
// 中奖序号, 大奖为最后一个
awardIds = List()
// random 出序号
while (awardIds.size() < 14) {
rand = Math.ceil(random() * 500); // 取整
// 限不限制都可以,概率太低
awardIds.contains(rand) ? awardIds.push(rand);
}
这种设计下,每个人的中间概率都是 1/500 之一。
这样就选出了中奖列表了,然而有『坑』,后面再说这个问题。
2. 开放人数
需求栗子
设计一个简单的开放人数抽奖系统。
奖品分为一等奖、二等奖、三等奖;
保证每个人的抽奖概率一致。
奖品 | 数量 | 概率 |
---|---|---|
一等奖 | 1 | 0.001 |
二等奖 | 3 | 0.005 |
三等奖 | 10 | 0.01 |
设计思路
利用程序自带的 random
函数,很容易生成一个 0 ~ 1
之间的随机数,可以直接这样设计:
奖品 | 中奖区间 |
---|---|
一等奖 | [0, 0.001) |
二等奖 | [0.001, 0.006) |
三等奖 | [0.006, 0.016) |
只要 random()
落在了哪个区间,就中了几等奖。
这种情况下因为是对区间取值,所以每次抽奖的概率都是一样的。
说完栗子,下面来说说 random
函数。
random 函数
大部分语言都自带 random 函数,然而都只是伪随机,是由可确定的函数(常用线性同余),通过一个种子( 常用当前时间),产生的伪随机数。
这意味着:如果知道了种子(seed),或者已经产生的随机数,都可能获得接下来随机数序列的信息。即可以预测。
种子
由于各个语言实现 random 的方式不一样,所以栗子 1、2 会有不同的问题存在。
栗子 1 中可能会出现种子没有更改的情况,这种情况下生成的序列可以预测,设计没有问题,而结果变成了『坑』。而如果种子时间相关就可以避免这个问题。
栗子 2 中如果同一时间有多个用户同时开始抽奖,这个时候时间相关的种子又会带来问题,即:只要这几个用户有一个中奖,就意味着这个时间点的用户都中奖了。
真随机
程序并不能产生真正的随机数,但是可以产生理论上的真随机数。
如 UNIX 中的 /dev/random
可以看成是一个真随机的生成器。
具体来讲就是生成器有一个容纳噪声数的熵池,在读取时,/dev/random
设备会返回小于熵池噪声总数的随机字节,比如任何硬件的状态变化,IO 响应时间、磁盘读写速度、中断、网络变化等等,都会作为熵反馈给生成器,所以理论上就产生了不可预测性。
如果程序中 random 并不能满足随机要求,可以为种子设计一个熵,将不可预知的事件收集起来,作为种子产生随机数。
重新设计栗子 2
现在我们有了真随机函数,然后栗子 2 依然不是一个可用的抽奖系统,依然有一些问题。
问题
重复中奖
-
同一个用户重复中一种奖品?
必然要限制,不然很容易被认为有 py 交易。
-
同一个用户重复中多个奖品?
限制,虚拟奖品(积分等)无所谓,实体奖品限制。
奖品放出
-
抽奖开始就放出所有奖品?
由于无法预估人数,概率很难调整。
-
抽奖开始后奖品一段时间一段时间的放出。
防止大量的人都在放出的点抽取,而送光奖品。
区间清理
-
如果某个奖品被抽光了,是否剔除该奖品的区间?
剔除不剔除都没关系,为了简化处理(偷懒),实际上是可以不用理会的。
重新设计
在保持核心中奖逻辑不变(概率区间)的情况下,为栗子 2 添加限制。
唯一中奖
设计了奖品(award)之后,再添加一张 具体奖品(cdkey),使用 user 表示中奖用户,默认值 null。
字段 | 类型 | 默认值 |
---|---|---|
... | ... | ... |
user | int、UNIQUE | null |
如果使用 MySQL ,MySQL 的 UNIQUE 限制并不校验 null 值,所以在限制唯一中奖上并不用单独处理。
如果不做 UNIQUE 限制,那么就需要手动查询了:
# SQL 示例, 一次选出两条,检查用户是否中奖过
SELECT * FROM ckdey WHERE award = ? AND (user = ? OR user = null) LIMIT 2;
奖品放出时间
为了限制奖品的放出时间,可以在 cdkey 上再添加 ready 表示放出时间:
奖品 | ready |
---|---|
一等奖 1 | 08:00:00 |
二等奖 1 | 08:00:00 |
二等奖 2 | 12:00:00 |
二等奖 3 | 16:00:00 |
三等奖 1 | 08:00:00 |
... | ... |
这种方法在奖品数少的时候特别好使,而且还可以指定开奖时间,然而当奖品数很多的时候,就没那么好使了。
当不需要使用指定时间时,完全可以利用 random 的特性来随机生成时间,不需要写入数据库。
在 award 上增加 count(总数) 和 remain (剩余数量)
以 remain 的线性函数作为随机数种子,则每次随机数的序列都是一样的。
# 等分抽奖时间
average = Math.ceil((expirationTime - startTime) / count);
# 相同的种子得到生成器
rand = seedrand(transform(remain))
# 获取奖品放出时间
readyTime = expirationTime + Math.ceil(average * rand()) - average * remain;
这样对于每个用户,每次放出的时间都是相同的(不看源码也不知道是什么时候),每次放出一个奖品,第一段时间没送出去,就累积到第二段时间,依次后推。
PS: 这种设计下,中奖的人真的是运气爆表,万年非洲人表示很忧伤!!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。