12

最近修改了网站的抽奖算法,使得抽奖看起来更加『公平』,为此我整理了下,谈谈在抽奖系统设计中的『坑』。

抽奖分为两种:

  1. 知道总人数

  2. 不知道总人数

举栗子

1. 已知人数

14 个奖品分给 500 个人:

  1. 奖品分为一等奖、二等奖、三等奖;

  2. 总人数 500 人。

奖品 数量
一等奖 1
二等奖 3
三等奖 10

设计思路:

  1. 为 500 人设计序号,1 - 500;

  2. 生成中奖序列(伪代码)

// 中奖序号, 大奖为最后一个
awardIds = List()
// random 出序号 
while (awardIds.size() < 14) {
  rand = Math.ceil(random() * 500); // 取整
  // 限不限制都可以,概率太低
  awardIds.contains(rand) ? awardIds.push(rand);
}

这种设计下,每个人的中间概率都是 1/500 之一。

这样就选出了中奖列表了,然而有『坑』,后面再说这个问题。

2. 开放人数

需求栗子

设计一个简单的开放人数抽奖系统。

  1. 奖品分为一等奖、二等奖、三等奖;

  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 依然不是一个可用的抽奖系统,依然有一些问题。

问题

重复中奖

  1. 同一个用户重复中一种奖品?

    必然要限制,不然很容易被认为有 py 交易。

  2. 同一个用户重复中多个奖品?

    限制,虚拟奖品(积分等)无所谓,实体奖品限制。

奖品放出

  1. 抽奖开始就放出所有奖品?

    由于无法预估人数,概率很难调整。

  2. 抽奖开始后奖品一段时间一段时间的放出。

    防止大量的人都在放出的点抽取,而送光奖品。

区间清理

  1. 如果某个奖品被抽光了,是否剔除该奖品的区间?

    剔除不剔除都没关系,为了简化处理(偷懒),实际上是可以不用理会的。

重新设计

在保持核心中奖逻辑不变(概率区间)的情况下,为栗子 2 添加限制。

唯一中奖

设计了奖品(award)之后,再添加一张 具体奖品(cdkey),使用 user 表示中奖用户,默认值 null。

字段 类型 默认值
... ... ...
user int、UNIQUE null
  1. 如果使用 MySQL ,MySQL 的 UNIQUE 限制并不校验 null 值,所以在限制唯一中奖上并不用单独处理。

  2. 如果不做 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 的特性来随机生成时间,不需要写入数据库。

  1. 在 award 上增加 count(总数) 和 remain (剩余数量)

  2. 以 remain 的线性函数作为随机数种子,则每次随机数的序列都是一样的。

# 等分抽奖时间
average = Math.ceil((expirationTime - startTime) / count);
# 相同的种子得到生成器
rand = seedrand(transform(remain))
# 获取奖品放出时间
readyTime = expirationTime + Math.ceil(average * rand()) - average * remain;

这样对于每个用户,每次放出的时间都是相同的(不看源码也不知道是什么时候),每次放出一个奖品,第一段时间没送出去,就累积到第二段时间,依次后推。

PS: 这种设计下,中奖的人真的是运气爆表,万年非洲人表示很忧伤!!


猫与麦子
413 声望12 粉丝

膝盖中箭


引用和评论

0 条评论