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

首發於 樊浩柏科學院

需求:首先用戶經過以必定方式(好友點贊等)開啓抽獎資格,而後按照用戶 100% 中獎機率進行抽獎,且系統的發放獎品須要按照各個獎品總體的指望中獎比例來進行分佈,最後用戶抽中獎品調用第三方發放接口發放獎品並記錄保存,另有些獎品存在發放數量限制。html

問題分析

整個抽獎過程是同步進行,因爲前置了開啓抽獎資格保護,會避免用戶集中進行抽獎,故系統併發量並不會過高。突出的問題主要有如下幾個:併發

1)因爲同步調用第三方接口發放獎品,獎品可能發放失敗;
2)有一些獎品存在數量限制,可能已經發放完;
3)系統要求用戶 100% 抽中獎品;
4)系統要求各個獎品總的發放狀況符合預期的比例分佈;函數

解決方案

針對以上突出問題,給出針對的解決辦法。this

  • 問題1:採用帶有次數限制的重試機制,下降獎品發放接口發放失敗狀況,同時捕獲異常來應對接口返回異常信息。重試機制失敗則自動從新進行一輪按機率抽獎,依次類推並作重發次數限制;
  • 問題2:獎品數量在獎品發放端進行限制。由於系統存在數量限制的獎品指望發放比例較低,每輪抽中這些獎品機率也較低,因此能夠採用若獎品已發放完,則自動從新進行一輪按機率抽獎,依次類推並作重發次數限制;
  • 問題3:儘管有發放接口的重試機制和自動多輪按機率抽獎機制,也可能存在抽取獎品失敗的狀況,這裏採用一種特定獎品做爲兜底的辦法,固然兜底獎品也有重試機制,使用戶抽中機率接近 100%;
  • 問題4:由於重試機制失敗或者抽取到已經發送完畢的獎品時,會自動從新進行下一輪抽獎,因爲規則也是按照機率抽獎,因此不影響各個獎品總的比例分佈狀況;

編碼

按機率抽獎

核心思想是採用隨機函數 mt_rand() 來模擬用戶抽獎。編碼

獎品信息以下:code

//全部獎品信息
$allPrizes = [
  'jd'    => ['name' => '京東券', 'probability' => 30],
  'film'  => ['name' => '電影票', 'probability' => 10],
  'tb'    => ['name' => '淘寶券', 'probability' => 60],
]

方式一 htm

這是一個比較中規中矩的方式,主要思想 是:將全部獎品按照指望比例分佈,一段一段小區間分佈到 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];
}

方式二get

該方式若是直接看代碼比較難理解。主要思想:按照給定順序(按照獎品配置順序),前後一個一個抽取獎品,直到抽中一個獎品爲止, 抽中後續獎品的機率的前提是沒有抽中當前獎品,屢次抽取機率應該相乘。

例如:

次數       獎品       機率    基數        中獎機率                     未中獎機率
 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"
}
相關文章
相關標籤/搜索