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

> 1% 掉落率挖一百次，約 37% 的人一個都拿不到，P95 要挖到 299 次。用幾何分布看玩家實際等多久，再實作累積權重加二分搜尋與 alias method，比較兩者的建表與抽樣成本。

原文：https://sheng.page/posts/drop-rates-and-the-geometric-distribution/ · 發布：2026-10-10 · 作者：Sheng

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

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

「用數學描述遊戲世界」系列到了第三篇，採礦遊戲開始有了隨機性。前兩篇〈[朝向不等於位置](/posts/game-math-vectors-and-coordinate-spaces/)〉和〈[同一艘船在 30 FPS 和 144 FPS 開起來不一樣](/posts/frame-rate-and-game-physics-integration/)〉處理的是空間與時間，接下來要算的是稀有礦等多久才挖得到。

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

延續前面的太空採礦遊戲：每次鑽探有 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% 的人還沒拿到。幾何分布的分位數有封閉解，下面另外跑模擬，對照算出的結果：

```python
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)}")
```

```text
== 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 次，規則不同，長尾和那端玩家的感受也會不同，下一篇會算出這些差異。

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

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

| 結果 | 權重 | 機率 |
|---|---|---|
| stone | 7000 | 70.0% |
| iron | 2200 | 22.0% |
| copper | 600 | 6.0% |
| crystal | 190 | 1.9% |
| void ore | 10 | 0.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)，但只要一個權重改變，就得整張重建。

```python
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 的執行結果：

```text
== 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](https://en.wikipedia.org/wiki/Geometric_distribution)〉：機率質量函數、平均數與無記憶性，百科條目，用來對照公式。
- Wikipedia〈[Alias method](https://en.wikipedia.org/wiki/Alias_method)〉：Walker 1974 年的原始方法、Vose 1991 年的線性時間建表與浮點誤差處理，百科條目，原始論文列在條目的參考文獻。
- 公視新聞網〈[「轉蛋法」通過！遊戲業者須揭露中獎率 違者最高罰 50 萬](https://news.pts.org.tw/article/590448)〉：2022-07-15 修正《網路連線遊戲服務定型化契約應記載及不得記載事項》的報導，屬新聞報導，條文以主管機關公告為準。
- Python〈[bisect](https://docs.python.org/3/library/bisect.html)〉與〈[random](https://docs.python.org/3/library/random.html)〉：`bisect_right` 與 `randrange` 的行為，官方文件。
- Adams、Dormans《Game Mechanics: Advanced Game Design》第 6 章：隨機性在內部經濟裡的作用，遊戲設計教科書。

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