首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 抽獎概率算法實現:從刮刮卡到大轉盤的通用加權隨機方案

     編輯:PHP 相關知識

核心問題:如何按權重隨機選取獎項?

抽獎系統的核心需求是:按照預設概率分佈,高效、公平地選出中獎項目。經典的「概率區間法」時間複雜度 O(n),適合獎項較少(<20)的場景;大規模或高併發下可優化為「前綴和 + 二分查找」O(log n)。

算法一:經典概率區間法(O(n))

/**
 * 加權隨機選取
 * @param array $probMap ['prize_id' => weight, ...] 權重為整數
 * @return string|int 中獎鍵值
 */
function weightedRandom(array $probMap): mixed {
    $totalWeight = array_sum($probMap);
    $rand = mt_rand(1, $totalWeight);
    
    foreach ($probMap as $key => $weight) {
        $rand -= $weight;
        if ($rand <= 0) {
            return $key;
        }
    }
    // 理論不可達,防御性返回
    return array_key_first($probMap);
}

執行流程圖解

假設獎池:['A'=>10, 'B'=>30, 'C'=>60],總權重 100。

  1. 隨機數 rand ∈ [1,100],假設 rand=45。
  2. 遍歷 A:45-10=35 > 0 繼續。
  3. 遍歷 B:35-30=5 > 0 繼續。
  4. 遍歷 C:5-60=-55 ≤ 0 → 中獎 C。

數學期望完全符合權重比例。

算法二:前綴和 + 二分查找(O(log n),適合大獎池)

class WeightedLottery {
    private array $prefixSum = [];
    private int $totalWeight = 0;
    
    public function __construct(array $weightMap) {
        foreach ($weightMap as $key => $weight) {
            $this->totalWeight += $weight;
            $this->prefixSum[$key] = $this->totalWeight;
        }
    }
    
    public function draw(): mixed {
        $rand = mt_rand(1, $this->totalWeight);
        // 二分查找首個 prefixSum >= rand 的鍵
        $keys = array_keys($this->prefixSum);
        $low = 0; $high = count($keys) - 1;
        while ($low < $high) {
            $mid = ($low + $high) >> 1;
            if ($this->prefixSum[$keys[$mid]] < $rand) {
                $low = $mid + 1;
            } else {
                $high = $mid;
            }
        }
        return $keys[$low];
    }
}

完整抽獎系統實現(含庫存、去重、日誌)

class LotteryService {
    private WeightedLottery $lottery;
    private array $prizes = []; // ['id' => ['name','stock','weight',...]]
    private string $logFile;
    
    public function __construct(array $prizeConfig, string $logFile = 'lottery.log') {
        $this->prizes = $prizeConfig;
        $weightMap = [];
        foreach ($prizeConfig as $id => $cfg) {
            if (($cfg['stock'] ?? -1) > 0) { // 有庫存限制
                $weightMap[$id] = $cfg['weight'];
            }
        }
        $this->lottery = new WeightedLottery($weightMap);
        $this->logFile = $logFile;
    }
    
    public function draw(string $userId): array {
        // 1. 抽獎
        $prizeId = $this->lottery->draw();
        $prize = $this->prizes[$prizeId];
        
        // 2. 扣減庫存(Redis 原子操作或文件鎖)
        if (isset($prize['stock'])) {
            $this->decreaseStock($prizeId);
            if ($this->getStock($prizeId) <= 0) {
                $this->lottery->remove($prizeId); // 動態調整權重
            }
        }
        
        // 3. 記錄日誌
        $this->log([
            'time' => date('c'),
            'user' => $userId,
            'prize' => $prizeId,
            'name' => $prize['name']
        ]);
        
        return ['code' => 0, 'data' => $prize];
    }
    
    private function decreaseStock(string $id): void { /* Redis decr 或 flock+file */ }
    private function getStock(string $id): int { /* 讀取剩餘庫存 */ }
    private function log(array $data): void { /* file_put_contents JSON Lines */ }
}

獎品配置範例

$prizes = [
    'grand'     => ['name' => 'iPhone 15',      'weight' => 1,   'stock' => 5],
    'first'     => ['name' => 'AirPods Pro',    'weight' => 5,   'stock' => 20],
    'second'    => ['name' => '充電寶',         'weight' => 50,  'stock' => 200],
    'third'     => ['name' => '優惠券 10元',    'weight' => 200, 'stock' => 1000],
    'consolation'=> ['name' => '再接再厲',      'weight' => 744, 'stock' => -1], // 無限
];

Java 版本參考實現(雲端服務常用)

public class LotteryDraw {
    private final TreeMap prefixMap = new TreeMap<>();
    private long totalWeight = 0;
    
    public LotteryDraw(Map weights) {
        for (var e : weights.entrySet()) {
            totalWeight += e.getValue();
            prefixMap.put(totalWeight, e.getKey());
        }
    }
    
    public String draw() {
        long rand = ThreadLocalRandom.current().nextLong(1, totalWeight + 1);
        return prefixMap.ceilingEntry(rand).getValue(); // O(log n)
    }
}

進階議題與最佳實踐

議題解決方案
高併發庫存扣減Redis Lua 腳本 / Redis SETNX 分佈式鎖 / 數據庫樂觀鎖
防刷/風控IP/用戶頻率限制、設備指紋、行為分析、驗證碼
概率動態調整後台管理介面修改權重,熱更新 WeightedLottery 實例
大獎兜底/保底機制記錄用戶抽獎次數,達閾值必中大獎(修改權重或單獨邏輯)
可驗證公平性種子哈希鏈、區塊鏈高度、第三方公證、前端可驗證算法
分布式一致性中央化抽獎服務(gRPC/HTTP),避免各節點獨立隨機導致概率偏差

常見錯誤避坑

  • 浮點數權重:必須轉為整數(乘以 100/10000),避免精度誤差。
  • 權重總和為 0:需檢查並拋出異常,防止 mt_rand(1,0) 報錯。
  • 未處理庫存歸零:中獎後必須原子扣減,歸零時從權重池移除,否則會出現「中獎但無庫存」的嚴重體驗問題。
  • 偽隨機種子:PHP mt_rand 已足夠,嚴格場景可用 random_int()(CSPRNG)。

總結

掌握「加權隨機」核心算法,結合庫存管理、並發控制、風控策略、可觀測日誌,即可構建生產級抽獎系統。小規模用 O(n) 區間法簡單可靠;大規模、高併發建議 O(log n) 前綴和法,並將抽獎邏輯封裝為獨立服務。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved