记一次活动抽奖(Lucky Draw🎁)

在各种营销活动种,抽奖是常用的手段之一,无论是在游戏还是购物上,均能看到其身影。功能对于使用者来说很简单,测一测人品就完事了,运气好就抽到了。对于像笔者这样还比较年轻的人,说到这个很容易想到国内某企鹅手下的众多游戏了,抽奖属实是被它整明白了,玩的那叫一个666。那么它到底是怎么运作的呢?真的就那么"公平"吗🤨?

🎯需求

笔者所在项目是一个电商类的网站,此次抽奖活动目的为消耗用户账户的一些积分,同时顺带一些宣传作用。

1、用户可以使用300积分抽一次奖,但是此种方式每人每天限制使用三次

2、用户购物后可以通过评论的方式来获得一次抽奖机会,每个评论加一次,此种方式不限制次数

3、用户可以通过分享自己的评论至社交网站上来获得一次机会,此种方式不限制次数

📐设计

需求挺简单的,但笔者不是一个只追求满足当前需求的人,考虑到各种节假日设置各种抽奖活动的可能性,打算做一个通用的抽奖活动的模块。这里需要说明一下,做成通用模块需要的时间肯定比只满足当前需求的一次性的需要的时间长,这需要根据自身接收到的要求来衡量。

这就开始吧,在需求中,可以看出,什么积分、评论、机会,其实都是建立在抽奖的前提下,所以我们先实现它。那么首先得知道抽奖这个流程由哪些东西构成。不难想到,首先你得有一个活动,这是用户参加的一个入口,其次你需要把你的奖品搬上台面,告诉用户,TA通过你的这个活动有可能获得什么,所以我们还需要一些奖品,最后,如何获得通过你这个活动获得你的奖品,这是关键,指的是活动与奖品的对应关系及获得方式,这里称之为绑定关系。因此我们最起码需要这三个对象:活动奖品绑定关系

👨‍💻那还等什么,开整吧。

yuanxingtu

不一会儿,我们的脑海中形成了这样一个结构图,很好,它很简单,有点像RBAC(Role-Based Access Control )的原型,但它很好的满足了我们的要求,既可以配置多个活动,奖品也可以灵活的绑定在对应的活动上。尽管我没有很具体的把每个对象该包含什么属性写上去,但当你有了新的属性想要添加时,你会很清楚的知道该添加到哪个对象上,比如中奖方式,就可以添加到绑定关系上。

🧱实现

有了目标的三个对象就开始板砖整活吧,左手右手一个慢动作CRUD都整起来。不一会儿我们完事了,可是总感觉差了点什么。没错,就是抽奖这个动作,基本条件都有了,得让它活起来,那才叫抽奖。可是咱也没啥经验啊,赶紧请教万能的度娘,不一会便有了参考资料,复制粘贴一把梭,果然面向百度编程就是So easy。但不了解原理盲目使用是很危险的,再者万一贵重东西中多了,那我可就要倒霉了😓。使不得使不得,于是总结一下常用的三种抽奖算法。

假定我们当前几个奖品的中奖概率分别为:25%,20%,10%,5%,40%

直接取样

构造一个容量为100(或其他)的数组array,将各个概率相应数量的元素填入进去,然后在1到100之间取随机数rand,取到的array[rand]对应的值,即为随机到的类型。这种方法实现简单,时间复杂度是O(1),但是占用空间大,尤其在类型很多的时候。

离散取样

通过概率构建一个累加概率分布列表:[0.25,0.45,0.55, 0.60, 1.0],后面的值就是前面依次累加的概率之和,之后产生一个随机数(0-1),假设为0.7,那么在列表中找到第一个大于0.7的数为1.0,对应的类别是第5类。每次取样复杂度为O(K),使用二分搜索之后,降为O(logK)。

Alias Method离散分布取样

这一块复杂一点,但是最为准确及合适。每次随机取样的复杂度为O(1)。示例内容来自:Alias Method离散分布随机取样

假设概率分布为: 1/2,1/3,1/12,1/12。

  1. 初始概率分布: 类别数目K=4,以颜色表示不同的类别

  2. 每个类别概率乘以K=4,使得总和为4. 这样分为两类,大于1:第一列与第二列; 小于1: 第三列与第四列。

  3. 下面通过拼凑,使得每一列的和都为1,但是每一列中,最多只能是两种类型的拼凑,就是每一列最多两种颜色存在

    • 将第一列拿出2/3给最后一列,使其变为1,如下:(棕色表示空缺)

    • 将第一列拿出2/3给第三列,使之变为1,如下:

    • 最后一次,第二列给第一列1/3, 最后每一列都是1,且每一列最多两种类型,其中下面一层表示原类的概率,上面层表示另外一种类型的概率,如只有一种比如第二列,那么第二层就是NULL:

  4. 写出两个数组:

    • Probability table:落在原类型的概率,每一列第一层的概率,即:[2/3,1,1/3,1/3]

    • Alias table:每一列第二层的类型(颜色),这里用下标表示: [2,null,1,1]

  5. 采样过程: 随机取某一列k(即[1,4]的随机整数),再随机产生一个[0-1]的小数c,如果Prob[k]大于 c,那么采样结果就是k,反之则为Alias[k]。比如随机取得第1列, 随机产生的小数为0.5<2/3,那么采样的结果就是第一类。 如果随机产生的小数为0.8>2/3,那么采样结果就是第一列的第二层的类别,也就是Alias[1]=2(紫色对应的类别: 第二列)。

    再来验证一下各列的概率是否符合原始概率

    • 第一列:1/4*2/3+1/4*2/3+1/4*2/3=1/2
    • 第二列:1/4*1/3+1/4*1=1/3
    • 第三列:1/4*1/3=1/12
    • 第四列:1/4*1/3=1/12
  6. 有了上面的论证,再来实现一下Alias Method,我用的是C#

     public class AliasMethod
        {
            /// <summary>
            /// Alias table
            /// </summary>
            private readonly int[] _alias;
    
            /// <summary>
            /// Probability table
            /// </summary>
            private readonly double[] _probability;
    
            public AliasMethod(IList<double> probabilities)
            {
                //分配空间
                _probability = new double[probabilities.Count];
                _alias = new int[probabilities.Count];
    
                double average = 1.0 / probabilities.Count;
    
                var small = new Stack<int>();
                var large = new Stack<int>();
    
                for (int i = 0; i < probabilities.Count; ++i)
                {
                    if (probabilities[i] >= average)
                        large.Push(i);
                    else
                        small.Push(i);
                }
    
                while (small.Count > 0 && large.Count > 0)
                {
                    //获取索引
                    int less = small.Pop();
                    int more = large.Pop();
    
                    _probability[less] = probabilities[less] * probabilities.Count;
                    _alias[less] = more;
    
                    //适当降低大概率的数量
                    probabilities[more] = (probabilities[more] + probabilities[less] - average);
    
                    if (probabilities[more] >= average)
                        large.Push(more);
                    else
                        small.Push(more);
                }
    
                while (small.Count > 0)
                    _probability[small.Pop()] = 1.0;
                while (large.Count > 0)
                    _probability[large.Pop()] = 1.0;
            }
    
            /// <summary>
            /// 获取随机命中到的索引值
            /// </summary>
            /// <returns></returns>
            public int Next()
            {
                long tick = DateTime.Now.Ticks;
                var seed = ((int)(tick & 0xffffffffL) | (int)(tick >> 32));
                unchecked
                {
                    seed = (seed + Guid.NewGuid().GetHashCode() + new Random().Next(0, 100));
                }
                var random = new Random(seed);
                int column = random.Next(_probability.Length);
    
                //确定选项
                bool coinToss = random.NextDouble() < _probability[column];
    
                return coinToss ? column : _alias[column];
            }
        }
    

    试验一千万次的随机取样,查看是否符合概率分布:

其他

至此最重要的核心已经完成了,关机,下班。有人可能喊,qio托吗dei,你的三条需求还没满足呢。淡定,其实仔细看一看,剩下的只需要堆时间即可。这种关系让我联想到了Abp框架中所阐述的DDD思想的一部分,一种叫领域逻辑和应用逻辑的东西,什么意思呢,按照我的理解,就是大家公用且重要的逻辑已经完成了,剩下的都是个性化的应用逻辑。三条具体的需求指的就是个性化的应用逻辑,而公用且重要的领域逻辑指的就是抽奖,为什么呢,可能这次我是评论商品抽一次、积分抽一次什么的,下次我别的活动可能要花RMB才能抽,也就是说,怎么才能去抽奖是个性化的,确定不了的,但无论怎么变化,始终是离不开抽奖这个环节。

最后说点实际的抽奖,抽奖的过程实际是把握奖池内容的过程,概率是最后决定你会命中哪个奖品,更多的是控制奖池的内容,哪些奖品会在哪些条件下入场以及离场,这个是由你的需求所决定的。比如笔者这次加入了一个逢百抽奖,即总抽奖次数为100的倍数时才会把大奖放入奖池,这几率可想而知有多低了。由于奖池中所有奖项的概率加起来不总是百分之百,因此在总概率不是百分之百的情况下,采用的是离散取样,反之则采用Alias Method离散分布取样。如果有更好的方法,也希望能分享一下,多多学习。

参考资料

Alias Method——高效的离散分布采样算法

Darts, Dice, and Coins: Sampling from a Discrete Distribution

抽奖算法-指定概率的随机

Alias Method离散分布随机取样

WuMiaoMiao 2021.12.18

posted @ 2021-12-18 17:39  WuMiaoMiao  阅读(440)  评论(0编辑  收藏  举报