掉落率 1%,為什麼挖一百次還是沒有:幾何分布與加權抽樣

掉落率 1%,平均挖 100 次會出一個,但挖滿 100 次還沒出的人約有 37%。只看平均值,很容易忽略有人要等多久:一半的人 69 次內就拿到,最倒楣的 5% 要挖到 299 次以上,即使亂數本身完全公平也是如此。

這裡先假設亂數沒有問題,單看同一個機率落到很多玩家身上之後的分布,以及怎麼從掉落表裡正確地抽一個結果。亂數本身的品質,包括 seed、狀態和 modulo bias,另見〈洗牌只有 8,640 萬種:遊戲 RNG 認證在檢查什麼〉。

「用數學描述遊戲世界」系列到了第三篇,採礦遊戲開始有了隨機性。前兩篇〈朝向不等於位置〉和〈同一艘船在 30 FPS 和 144 FPS 開起來不一樣〉處理的是空間與時間,接下來要算的是稀有礦等多久才挖得到。

「平均一百次」和「一百次內」

延續前面的太空採礦遊戲:每次鑽探有 1% 機率挖到稀有礦。每次獨立、機率固定,第一次挖到之前要鑽幾次,就是幾何分布(geometric distribution):

  • 第 k 次才第一次挖到的機率是 (1-p)^(k-1)·p。
  • k 次內至少挖到一次的機率是 1 - (1-p)^k。
  • 平均要 1/p 次。

把 p = 0.01 代進去,100 次內至少一次是 1 - 0.99^100 ≈ 63.4%。平均要挖 100 次,並不保證在 100 次內拿到,實際上只有 63.4% 的玩家會在這之前取得。

幾何分布還有一個性質叫無記憶性:已經挖了 200 次沒出,下一次的機率還是 1%,之後平均還要再挖 100 次。玩家會覺得「這麼久了應該快了」,但先前的失敗不會改變後面的機率,下一篇談的保底才會刻意讓系統記住歷史,打破這個性質。

用分位數看玩家實際等多久

要知道玩家實際等多久,可以看分位數:中位數表示一半玩家在這之前就拿到,P95 則表示只有 5% 的人還沒拿到。幾何分布的分位數有封閉解,下面另外跑模擬,對照算出的結果:

import bisect
import math
import random
import sys
import time
from collections import Counter
from itertools import accumulate

p = 0.01   # rare ore: 1% per drill

print("== 1. what 1% means over 100 drills ==")
print(f"P(at least one in 100)   = {1 - (1 - p) ** 100:.4f}")
print(f"P(none in 100)           = {(1 - p) ** 100:.4f}")
print(f"P(none in 200)           = {(1 - p) ** 200:.4f}")
print(f"mean drills to first hit = {1 / p:.0f}")


def quantile(q):
    """Smallest k with P(first hit <= k) >= q, for a geometric distribution."""
    return math.ceil(math.log(1 - q) / math.log(1 - p))


for q in (0.5, 0.9, 0.95, 0.99, 0.999):
    print(f"P{q*100:g}: {quantile(q)} drills")

print("\n== 2. 100,000 simulated players, drills until the first rare ore ==")
rng = random.Random(20261007)
waits = []
for _ in range(100_000):
    n = 1
    while rng.random() >= p:
        n += 1
    waits.append(n)
waits.sort()
pick = lambda q: waits[int(q * len(waits)) - 1]
print(f"mean {sum(waits)/len(waits):.1f}  median {pick(0.5)}  P90 {pick(0.9)}"
      f"  P99 {pick(0.99)}  worst {waits[-1]}")
print(f"players still empty-handed after 300 drills: {sum(w > 300 for w in waits)}")
== 1. what 1% means over 100 drills ==
P(at least one in 100)   = 0.6340
P(none in 100)           = 0.3660
P(none in 200)           = 0.1340
mean drills to first hit = 100
P50: 69 drills
P90: 230 drills
P95: 299 drills
P99: 459 drills
P99.9: 688 drills

== 2. 100,000 simulated players, drills until the first rare ore ==
mean 99.6  median 69  P90 229  P99 456  worst 1142
players still empty-handed after 300 drills: 4877

十萬個模擬玩家裡,中位數 69、平均 99.6,最倒楣的那個挖了 1,142 次。挖了 300 次還兩手空空的有 4,877 人,約 4.9%,和公式算出的 P95 = 299 吻合。如果遊戲有一百萬個活躍玩家,就有大約五萬人挖了 300 次還沒拿到,這是正常機率下就會出現的情況。

亂數公平屬於工程與認證的問題,玩家能不能接受這樣的等待時間,則要回到設計上討論。同樣平均 100 次,規則不同,長尾和那端玩家的感受也會不同,下一篇會算出這些差異。

從掉落表抽一個結果:累積權重加二分搜尋

實際的掉落很少是單一機率,通常是一張表:

結果權重機率
stone700070.0%
iron220022.0%
copper6006.0%
crystal1901.9%
void ore100.1%

最常見的寫法是把權重累加成 [7000, 9200, 9800, 9990, 10000],抽一個 0 到 9999 的整數,再用二分搜尋找它落在哪一格。建表是 O(n),每次抽是 O(log n)。權重用整數、亂數也用整數,可以避開浮點數在區間邊界上的誤差;整數亂數本身要沒有 modulo bias,Python 的 randrange 已經處理了,其他語言要確認(RNG 那篇有實測)。

另一種做法是 alias method,Alastair Walker 在 1974 年發表,Michael Vose 1991 年提出線性時間的建表方法。它把每一格補成剛好一樣高,不足的部分「借」另一格來填,每次抽樣只要擲兩次:選一格,再決定用這格本身還是它的 alias。建表 O(n),抽樣 O(1),但只要一個權重改變,就得整張重建。

print("\n== 3. weighted drop table: cumulative weights + binary search ==")
TABLE = [("stone", 7000), ("iron", 2200), ("copper", 600), ("crystal", 190), ("void ore", 10)]
names = [n for n, _ in TABLE]
cum = list(accumulate(w for _, w in TABLE))          # 7000, 9200, 9800, 9990, 10000
TOTAL = cum[-1]


def draw_bisect(r):
    x = r.randrange(TOTAL)                            # integer: no float edge cases
    return names[bisect.bisect_right(cum, x)]


def build_alias(weights):
    """Vose's alias method with integer arithmetic. Setup O(n), draw O(1)."""
    n, total = len(weights), sum(weights)
    scaled = [w * n for w in weights]                 # compare against total
    prob, alias = [0] * n, [0] * n
    small = [i for i, s in enumerate(scaled) if s < total]
    large = [i for i, s in enumerate(scaled) if s >= total]
    while small and large:
        s, l = small.pop(), large.pop()
        prob[s], alias[s] = scaled[s], l
        scaled[l] -= total - scaled[s]
        (small if scaled[l] < total else large).append(l)
    for i in small + large:
        prob[i] = total
    return prob, alias, total


prob, alias, tot = build_alias([w for _, w in TABLE])


def draw_alias(r):
    i = r.randrange(len(prob))
    return names[i] if r.randrange(tot) < prob[i] else names[alias[i]]


N = 1_000_000
for label, fn in (("bisect", draw_bisect), ("alias", draw_alias)):
    r = random.Random(42)
    t0 = time.perf_counter()
    c = Counter(fn(r) for _ in range(N))
    dt = time.perf_counter() - t0
    print(f"{label:6} {dt*1000:5.0f} ms  " + "  ".join(
        f"{n} {c[n]/N*100:.3f}%" for n in names))
print("expected     " + "  ".join(f"{n} {w/TOTAL*100:.3f}%" for n, w in TABLE))

print("\n== 4. a 100,000-entry table (e.g. per-player loot rolls), 200,000 draws ==")
r = random.Random(7)
big = [r.randint(1, 1000) for _ in range(100_000)]
t0 = time.perf_counter()
big_cum = list(accumulate(big))
t1 = time.perf_counter()
bp, ba, bt = build_alias(big)
t2 = time.perf_counter()
rb, ra = random.Random(1), random.Random(1)
for _ in range(200_000):
    bisect.bisect_right(big_cum, rb.randrange(big_cum[-1]))
t3 = time.perf_counter()
for _ in range(200_000):
    i = ra.randrange(len(bp))
    _ = i if ra.randrange(bt) < bp[i] else ba[i]
t4 = time.perf_counter()
print(f"bisect: build {(t1-t0)*1000:5.1f} ms, draws {(t3-t2)*1000:5.0f} ms")
print(f"alias : build {(t2-t1)*1000:5.1f} ms, draws {(t4-t3)*1000:5.0f} ms")
print(f"\nPython {sys.version.split()[0]}")

以下是 Python 3.13.16 的執行結果:

== 3. weighted drop table: cumulative weights + binary search ==
bisect   430 ms  stone 70.002%  iron 21.977%  copper 5.992%  crystal 1.925%  void ore 0.105%
alias    676 ms  stone 70.028%  iron 22.017%  copper 5.976%  crystal 1.880%  void ore 0.099%
expected     stone 70.000%  iron 22.000%  copper 6.000%  crystal 1.900%  void ore 0.100%

== 4. a 100,000-entry table (e.g. per-player loot rolls), 200,000 draws ==
bisect: build   3.7 ms, draws   151 ms
alias : build  34.8 ms, draws   227 ms

Python 3.13.16

兩種方法抽一百萬次的比例都貼近設定值,正確性沒問題,速度卻跟教科書給人的印象有些落差。五格的小表和十萬格的大表都是二分搜尋比較快,alias 的建表還多花了十倍時間。比較可能的原因(沒有分開量測)是 Python 的 bisect 用 C 實作,log₂(100,000) 也才 17 次比較,而 alias 每次要呼叫兩次 randrange,呼叫本身的成本蓋過了省下的比較。

這組數字只代表 CPython 上的情況,每次計時也會有些起伏。在編譯語言、GPU 或每秒要抽上億次的場合,O(1) 的優勢才比較容易顯現,換成 alias 前仍要在自己的環境量測。一般遊戲的掉落表只有幾格到幾十格,又常隨活動調整權重,累積權重加二分搜尋通常就夠了,別人也比較容易讀懂程式、核對結果。

公告的機率要從同一份設定產生

掉落表寫在程式裡,公告寫在網頁上,兩邊分開維護遲早會對不上。台灣 2022 年 7 月修正《網路連線遊戲服務定型化契約應記載及不得記載事項》,公視報導指出,付費取得的機率型商品要在官網、登入頁、購買頁等處以數字百分比公告機率,起因之一是當年《天堂M》的機率爭議。RNG 那篇也提過,亂數通過認證,不代表公告的數字是對的。

公告可以直接從正式環境使用的權重表產生,表格裡的百分比用 weight / total 計算,省去人工抄寫;正式環境再按二項分布監控實際掉落率有沒有偏離設定。有了這些資料,玩家在論壇上貼出挖了 400 次沒出的截圖時,就能核對設定與實際掉落率,說明這是 P98 左右的正常長尾。

補充筆記

  • 1% 挖 100 次,至少一次的機率約 63.4%,不能把平均 100 次當成期限。同一個分布的中位數是 69 次。
  • 每次獨立且機率固定,首次命中次數就符合幾何分布,先前失敗不會影響後面;P95 是 299 次,P99 是 459 次。
  • 工程與認證要確認亂數公平,設計則要判斷玩家能否接受長尾,這兩件事得分開討論。
  • 整數權重的累積和加二分搜尋能正確抽樣,也容易讀懂和對帳;alias method 雖然是 O(1),權重改了就得重建。在 CPython 實測,五格和十萬格的表都是二分搜尋較快,選演算法仍要看自己的量測結果。
  • 公告機率由正式環境的同一份設定產生,實際掉落率也要持續監控。

延伸閱讀

  • Wikipedia〈Geometric distribution〉:機率質量函數、平均數與無記憶性,百科條目,用來對照公式。
  • Wikipedia〈Alias method〉:Walker 1974 年的原始方法、Vose 1991 年的線性時間建表與浮點誤差處理,百科條目,原始論文列在條目的參考文獻。
  • 公視新聞網〈「轉蛋法」通過!遊戲業者須揭露中獎率 違者最高罰 50 萬〉:2022-07-15 修正《網路連線遊戲服務定型化契約應記載及不得記載事項》的報導,屬新聞報導,條文以主管機關公告為準。
  • Python〈bisect〉與〈random〉:bisect_right 與 randrange 的行為,官方文件。
  • Adams、Dormans《Game Mechanics: Advanced Game Design》第 6 章:隨機性在內部經濟裡的作用,遊戲設計教科書。

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。