按照獎品概率分佈抽獎的實現

樊浩柏發表於2019-02-16

首發於 樊浩柏科學院

需求:首先使用者通過以一定方式(好友點贊等)開啟抽獎資格,然後按照使用者 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"
}

相關文章