加權隨機抽獎是電商促銷、遊戲掉寶、廣告投放的核心邏輯。本文從數學原理推導到面向物件的生產級 PHP 實作,涵蓋邊界條件、擴展性與效能優化。
獎品權重:[A:5, B:6, C:7, D:2],總權重 N = 20
M ∈ [1, N]時間複雜度:建立區間 O(n)、查找 O(n) 或 O(log n) (二分查找)
id = $id;
$this->weight = max(0, $weight);
$this->name = $name ?: "Prize #$id";
}
public function setRange(int $start, int $end): void {
$this->rangeStart = $start;
$this->rangeEnd = $end;
}
public function inRange(int $num): bool {
return $num >= $this->rangeStart && $num <= $this->rangeEnd;
}
public function getId(): int { return $this->id; }
public function getWeight(): int { return $this->weight; }
public function getName(): string { return $this->name; }
}
/**
* 獎品池:管理獎品集合、支援動態增刪改
*/
class PrizePool implements IteratorAggregate, Countable {
private array $items = [];
public function add(Prize $prize): self {
$this->items[$prize->getId()] = $prize;
return $this;
}
public function remove(int $id): self {
unset($this->items[$id]);
return $this;
}
public function update(Prize $prize): self {
if (isset($this->items[$prize->getId()])) {
$this->items[$prize->getId()] = $prize;
}
return $this;
}
public function getVisible(): array {
return array_filter($this->items, fn($p) => $p->getWeight() > 0);
}
public function count(): int { return count($this->items); }
public function getIterator(): Traversable { return new ArrayIterator($this->items); }
}
/**
* 抽獎引擎:核心算法封裝
*/
class LotteryEngine {
private PrizePool $pool;
public function __construct(PrizePool $pool) {
$this->pool = $pool;
}
public function draw(): ?Prize {
$items = $this->pool->getVisible();
if (empty($items)) throw new RuntimeException('No visible prizes');
// 建立累積區間
$sum = 0;
foreach ($items as $prize) {
$start = $sum + 1;
$sum += $prize->getWeight();
$prize->setRange($start, $sum);
}
$rand = $this->randomInt(1, $sum);
// 線性查找 (獎品數少時足夠)
foreach ($items as $prize) {
if ($prize->inRange($rand)) return $prize;
}
return null; // 理論不會到達
}
// 可替換為 random_int (PHP 7+) 或 openssl_random_pseudo_bytes
protected function randomInt(int $min, int $max): int {
return random_int($min, $max);
}
}
try {
$pool = new PrizePool();
$pool->add(new Prize(1, 5, '一等獎'))
->add(new Prize(2, 10, '二等獎'))
->add(new Prize(3, 20, '三等獎'))
->add(new Prize(4, 65, '參與獎')); // 總權重 100
$engine = new LotteryEngine($pool);
// 模擬 10000 次驗證機率
$stats = [];
for ($i = 0; $i < 10000; $i++) {
$prize = $engine->draw();
$stats[$prize->getId()] = ($stats[$prize->getId()] ?? 0) + 1;
}
print_r($stats);
// 輸出接近: [1=>500, 2=>1000, 3=>2000, 4=>6500]
} catch (Exception $e) {
echo "Error: " . $e->getMessage();
}
獎品數超過 100 時,線性查找成瓶頸。改用累積權重陣列 + 二分查找:
class OptimizedLotteryEngine extends LotteryEngine {
public function draw(): ?Prize {
$items = $this->pool->getVisible();
if (empty($items)) return null;
$cumulative = [];
$sum = 0;
foreach ($items as $prize) {
$sum += $prize->getWeight();
$cumulative[] = [$sum, $prize]; // [累積權重, 獎品物件]
}
$rand = $this->randomInt(1, $sum);
// 二分查找第一個累積權重 >= rand 的索引
$low = 0;
$high = count($cumulative) - 1;
while ($low < $high) {
$mid = (($low + $high) >> 1);
if ($cumulative[$mid][0] >= $rand) {
$high = $mid;
} else {
$low = $mid + 1;
}
}
return $cumulative[$low][1];
}
}
class PityLotteryEngine extends OptimizedLotteryEngine {
private array $pityCounters = []; // user_id => [prize_id => count]
private array $pityRules = []; // prize_id => max_attempts
public function setPityRule(int $prizeId, int $maxAttempts): void {
$this->pityRules[$prizeId] = $maxAttempts;
}
public function draw(int $userId): Prize {
// 檢查保底
foreach ($this->pityRules as $pid => $max) {
$count = $this->pityCounters[$userId][$pid] ?? 0;
if ($count >= $max - 1) {
// 必中
$this->pityCounters[$userId][$pid] = 0;
return $this->pool->getVisible()[$pid] ?? $this->draw($userId);
}
}
$prize = parent::draw();
// 更新計數器
if ($prize) {
$this->pityCounters[$userId][$prize->getId()] =
($this->pityCounters[$userId][$prize->getId()] ?? 0) + 1;
}
return $prize;
}
}
// Redis 實現分散式限流
$redis->eval("
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call('INCR', key)
if current == 1 then redis.call('EXPIRE', key, 86400) end
return current
", 1, "lottery:user:$userId:daily", 10); // 每日 10 次
// 統計檢定:卡方檢驗驗證機率分布
function chiSquareTest(array $observed, array $expected): float {
$chi2 = 0;
foreach ($observed as $k => $o) {
$e = $expected[$k];
$chi2 += (($o - $e) ** 2) / $e;
}
return $chi2;
}
// 100萬次模擬,p-value > 0.05 表示分布符合預期
$engine = new OptimizedLotteryEngine($pool);
$results = [];
for ($i = 0; $i < 1000000; $i++) {
$results[$engine->draw()->getId()]++;
}
// 驗證...
| 項目 | 標準 |
|---|---|
| 隨機數來源 | random_int() (PHP 7+) / openssl_random_pseudo_bytes() |
| 權重總和溢位 | 使用 bcmath 或 gmp 處理大數 |
| 併發安全 | Redis Lua 腳本 / 資料庫樂觀鎖 |
| 審計日誌 | 每次抽獎記錄 user_id, prize_id, rand, timestamp |
| 可驗證公平性 | 種子公開、鏈上驗證 (區塊鏈場景) |
從簡單的區間法到企業級抽獎引擎,核心算法不變,關鍵在於邊界處理、擴展性設計、可審計性。掌握這套模式,應對各類隨機分發場景遊刃有餘。