按照奖品概率分布抽奖的实现

原文:https://www.fanhaobai.com/2017/05/draw-by-prob.html

需求:首先用户通过以一定方式(好友点赞等)开启抽奖资格,然后按照用户 100% 中奖概率进行抽奖,且系统的发放奖品需要按照各个奖品整体的期望中奖比例来进行分布,最后用户抽中奖品调用第三方发放接口发放奖品并记录保存,另有些奖品存在发放数量限制。

问题分析

整个抽奖过程是同步进行,由于前置了开启抽奖资格保护,会避免用户集中进行抽奖,故系统并发量并不会太高。突出的问题主要有以下几个:

1)由于同步调用第三方接口发放奖品,奖品可能发放失败;
2)有一些奖品存在数量限制,可能已经发放完;
3)系统要求用户 100% 抽中奖品;
4)系统要求各个奖品总的发放情况符合预期的比例分布;

解决方案

针对以上突出问题,给出针对的解决办法。

  • 问题1:采用带有次数限制的重试机制,降低奖品发放接口发放失败情况,同时捕获异常来应对接口返回异常信息。重试机制失败则自动重新进行一轮按概率抽奖,依次类推并做重发次数限制;
  • 问题2:奖品数量在奖品发放端进行限制。因为系统存在数量限制的奖品期望发放比例较低,每轮抽中这些奖品概率也较低,所以可以采用若奖品已发放完,则自动重新进行一轮按概率抽奖,依次类推并做重发次数限制;
  • 问题3:尽管有发放接口的重试机制和自动多轮按概率抽奖机制,也可能存在抽取奖品失败的情况,这里采用一种特定奖品作为兜底的办法,当然兜底奖品也有重试机制,使用户抽中概率接近 100%;
  • 问题4:因为重试机制失败或者抽取到已经发送完毕的奖品时,会自动重新进行下一轮抽奖,由于规则也是按照概率抽奖,所以不影响各个奖品总的比例分布情况;

编码

按概率抽奖

核心思想是采用随机函数 mt_rand() 来模拟用户抽奖。

奖品信息如下:

//所有奖品信息
$allPrizes = [
  'jd'    => ['name' => '京东券', 'probability' => 30],
  'film'  => ['name' => '电影票', 'probability' => 10],
  'tb'    => ['name' => '淘宝券', 'probability' => 60],
]

方式一

这是一个比较中规中矩的方式,主要思想 是:将所有奖品按照期望比例分布,一段一段小区间分布到 1~100 这个区间,然后随机一个 1~100 的随机数,如果这个随机数落在某段区间,则表示抽取对应区间的奖品。

1            30     10                    60
1|-----------|------|----------------------|100
     京东券    电影票          淘宝券       

代码如下:

/**
 * 按照概率抽取一个奖品, 返回奖品
 * @param   array      $prizes     所有奖品的probability概率总和应该为100
 * @return  mixed
 */
private function randPrize(array $prizes)
{
    //总概率基数
    $totalProbability = array_sum(array_column(array_values($prizes), 'probability'));
    if (100 !== $totalProbability) {
        throw new Exception('invalid probability config');
    }
    $rand = mt_rand(1, 100);
    $cursor = 0;
    $id = '';
    while(list($key, $item) = each($prizes)) {
        if ($rand > $cursor && $rand <= $cursor + $item['probability']) {
            $id = $key;
            break;
        }
        $cursor += $item['probability'];
    }
    unset($prizes[$id]['probability']);

    return $prizes[$id] + ['id' => $id];
}

方式二

该方式如果直接看代码比较难理解。主要思想:按照给定顺序(按照奖品配置顺序),先后一个一个抽取奖品,直到抽中一个奖品为止, 抽中后续奖品的概率的前提是没有抽中当前奖品,多次抽取概率应该相乘。

例如:

次数       奖品       概率    基数        中奖概率                     未中奖概率
 1        京东券      30     100         30/100                      70/100
 2        电影票      10      70      (70/100)*(10/70)           (70/100)*(60/70)
 3        淘宝券      60      60     (70/100)*(60/70)*(1)       1-(70/100)*(60/70)*(1)
/**
 * 按照概率抽取一个奖品, 返回奖品, 
 * @param   array    $prizes    参与抽奖的奖品信息, 所有奖品的probability概率总和应该为100
 * @return  array
 */
private function randPrize(array $prizes)
{
    //总概率基数
    $totalProbability = array_sum(array_column(array_values($prizes), 'probability'));
    if (100 !== $totalProbability) {
        throw new Exception('invalid probability config');
    }
    //可以考虑按照概率倒序排序
    /*uasort($prizes, function(array $a, array $b) {
        if ($a['probability'] == $b['probability']) return 0;
        return $a['probability'] > $b['probability'] ? -1 : 1;
    });*/
    //按照奖品顺序依次模拟抽中奖品
    $id = '';
    foreach ($prizes as $key => $item) {
        $rand = mt_rand(1, $totalProbability);    //本次抽奖的基数
        if ($rand <= $item['probability']) {      //表示抽中
            $id = $key;
            break;
        } else {
            $totalProbability -= $item['probability'];  //后续奖品基数减去抽过的概率, 因为抽中后一个奖品的前提是抽不中前一些奖品
        }
    }
    unset($prizes[$id]['probability']);
    return $prizes[$id] + ['id' => $id];
}

抽中奖品

主要包含重试机制、自动重新一轮按照概率抽奖机制、兜底机制的实现。

/**
* 抽奖
* @param   array   $allPrizes
* @return  mixed
*/
public function draw($allPrizes)
{
   $tryTimes = 0;
   $outPrize = [];
   $prize = [];

   //如果抽到有数量限制奖品且奖品也已经抽完或者抽取失败, 最多抽奖次数
   while ($tryTimes < 4) {
       $tryTimes++;
       //按照概率抽取
       $prize =  $this->randPrize($allPrizes);
       //模拟发放奖品方法
       $outPrize = $this->getOnePrize($prize['id']);
       //抽中退出
       if (!empty($outPrize)) {
           break;
       }
   }

   echo '尝试按照概率抽取次数:' , $tryTimes, PHP_EOL;

   //多次抽奖都抽中已经抽完的奖品, 则用兜底奖品兜底
   $tryTimes = 0;
   while (!$outPrize && $tryTimes < 2) {
       $tryTimes++;
   $prize = $allPrizes['default'] + ['id' => 'default'];
       $outPrize = $this->getOnePrize('default');
   }

   echo '兜底抽取次数:' , $tryTimes, PHP_EOL;

   if (!$outPrize) {
       //兜底失败, 可能是券达到上限, 或者接口down了
       return false;
   } else {
       //合并奖品信息
       $outPrize = $outPrize + $prize;
   }

   return $outPrize;
}

验证

概率分布

抽样方法

public function sample($all, $times)
{
    $out = [];
    $count = $times;
    if ($times > 1000000) return;
    while ($times) {
        $times--;
        $prize = $this->draw($all);
        if (!isset($out[$prize['id']])) {
            $out[$prize['id']] = 0;
        }
        $out[$prize['id']]++;
    }
    array_walk($out, function(&$value, $key) use ($count) {
        $value = ($value / $count * 100);
    });
 
    ksort($out);
    return $out;
}

抽样结果

//期望概率
array(3) {
  ["film"] => int(10)
  ["jd"] => int(30)
  ["tb"] => int(60)
}
//抽样2000次
array(3) {
  ["film"] => string(4) "9.8"
  ["jd"] => string(6) "31.35"
  ["tb"] => string(6) "58.85"
}

异常处理机制

尝试按照概率抽取次数: 3
兜底抽取次数: 0
抽中奖品为:array(3) {
  ["name"] => string(20) "淘宝50元消费券"
  ["content"] => string(12) "WD84-3233-21"
  ["id"] => string(2) "tb"
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351

推荐阅读更多精彩内容

  • 一般的抽奖管理功能,基本是在一个奖池中放一堆奖品,分别给它们设置不同的数量和概率,在奖品没有发完的情况下,...
    wwking阅读 10,188评论 3 16
  • 一般的抽奖管理功能,基本是在一个奖池中放一堆奖品,分别给它们设置不同的数量和概率,在奖品没有发完的情况下,概...
    wwking02阅读 3,868评论 1 4
  • 余生余心余你, 不负不离不弃。 却奈…… 我不知道. 凭心:一天是一天,何须思多年。
    理不到的解阅读 202评论 0 1
  • 每一个努力的人都值得被尊重,除了我自己
    薄荷浣熊阅读 138评论 0 0
  • 记得高中的一个夏天,暗恋了两年的男神打篮球比赛,我们班好几个女生都喜欢他给他带了水,当然也包括我,球赛结束后,他满...
    爱吃辣条00阅读 252评论 1 2