# 踩地雷無猜測盤面的生成：首點規則與 solver 驗證

> 踩地雷無猜測盤面可用 solver 從指定起點篩選，首點安全仍可能留下必須猜的局面。比較首點開區域的機率與簡單 solver 的通過率，再決定如何重試或擾動盤面。

原文：https://sheng.page/posts/minesweeper-no-guess-board-generation/ · 發布：2026-10-24 · 作者：Sheng

踩地雷即使保證第一下開出一片區域，後面仍可能只剩猜的份。生成無猜測盤面，一個直接的做法是讓 solver 從玩家選的起點解完整盤，只留下能解完的候選盤。Simon Tatham 的 Mines 再加上擾動器，修改 solver 解不下去的盤面，改完重新驗證。

純隨機佈雷與排除首點後均勻佈雷，開區域的機率都有閉式解，可以用 Monte Carlo 核對。整盤則用只有兩條規則的 single-point solver 篩選，Expert 盤面幾乎全被拒絕。

## 三種「第一下」規則

純隨機佈雷最直接，在所有格子中均勻選出地雷位置，玩家第一下就可能踩雷。若想保證首點安全，可以等玩家選好起點，先把那格排除，再從剩下的格子均勻佈雷。

舊版 Windows 的[首點規則](https://minesweepergame.com/strategy/first-click.php)是先佈雷，若第一下踩到雷，就把那顆雷搬到最上排從左數第一個空格。這會讓最上排左側的雷密度略高，分布與「排除首點後均勻佈雷」不同，要另外計算機率。Vista 起改成保證首點開出區域。

「開出區域」是指首點顯示 0 並自動展開，以有完整八鄰格的中央格來說，首點本身與周圍 8 格都得無雷，光是點到安全的數字格還不算。

## 首點開出區域的機率

設盤面共有 N 格、M 顆雷，首點在中央，`C(N, M)` 表示從 N 格選出 M 格的組合數。純隨機盤共有 `C(N, M)` 種佈雷方式，要讓中央的 9 格全都安全，地雷就只能放在其餘 N − 9 格，機率是 `C(N - 9, M) / C(N, M)`。

先排除首點時，所有佈雷方式只剩 `C(N - 1, M)` 種，符合條件的佈雷方式仍是 `C(N - 9, M)` 種，因此機率改為 `C(N - 9, M) / C(N - 1, M)`。這也等於在純隨機模型中，條件設為「首點安全」後，周圍 8 格都無雷的機率，是超幾何分布的一個特例。

`first_click.py` 對每種盤面與規則各抽樣 200,000 次，seed 固定為 42。`exact` 是公式結果，`MC` 是抽樣比例，`CI95` 是以 `1.96 * sqrt(p * (1 - p) / T)` 計算的近似 95% 信賴區間半寬，`delta` 則是 `MC - exact`。

```python
# Probability that the first click (on the centre cell) opens a region, i.e. shows a zero.
# Two placement rules give two closed forms; both are checked against Monte Carlo (seed 42, 95% CI).
#   random board:            P(9 cells clear) = C(N-9, M) / C(N, M)
#   first click excluded,    P(8 neighbours clear | click cell safe) = C(N-9, M) / C(N-1, M)
#   mines uniform elsewhere:
# Windows' "move the mine to the top-left free cell" rule is a third distribution and is not modelled here.
import math, random

random.seed(42)
boards = {"Beginner 9x9, 10 mines": (9, 9, 10), "Intermediate 16x16, 40": (16, 16, 40), "Expert 30x16, 99": (30, 16, 99)}
T = 200_000
print(f"{'board':24s} {'rule':22s} {'exact':>7} {'MC':>7} {'CI95':>7} {'delta':>7}")
for name, (w, h, m) in boards.items():
    n = w * h
    cx, cy = w // 2, h // 2
    click = cx + cy * w
    nb = {(cx + dx) + (cy + dy) * w for dx in (-1, 0, 1) for dy in (-1, 0, 1)} - {click}
    for rule, exact in (("random board", math.comb(n - 9, m) / math.comb(n, m)), ("click excluded", math.comb(n - 9, m) / math.comb(n - 1, m))):
        hits = 0
        for _ in range(T):
            pool = range(n) if rule == "random board" else [i for i in range(n) if i != click]
            mines = set(random.sample(pool, m))
            hits += click not in mines and not (mines & nb)
        p = hits / T
        ci = 1.96 * math.sqrt(p * (1 - p) / T)
        print(f"{name:24s} {rule:22s} {exact:7.4f} {p:7.4f} {ci:7.4f} {p - exact:+7.4f}")
```

```text
board                    rule                     exact      MC    CI95   delta
Beginner 9x9, 10 mines   random board            0.2855  0.2852  0.0020 -0.0002
Beginner 9x9, 10 mines   click excluded          0.3257  0.3270  0.0021 +0.0013
Intermediate 16x16, 40   random board            0.2110  0.2089  0.0018 -0.0021
Intermediate 16x16, 40   click excluded          0.2501  0.2495  0.0019 -0.0006
Expert 30x16, 99         random board            0.1226  0.1226  0.0014 +0.0000
Expert 30x16, 99         click excluded          0.1545  0.1533  0.0016 -0.0012
```

Expert 的中央首點，在純隨機盤開區域的機率約 12.3%，先排除首點後約 15.4%。

Intermediate 純隨機盤的 `delta` 是 −0.0021，絕對值超過 `CI95` 的 0.0018，約差 2.3 個標準誤。95% 信賴區間仍可能漏掉精確值，六組比較中出現一次並不奇怪。

[Kostka 的開區域機率](https://minesweepergame.com/strategy/first-click.php)隨點擊位置而變，Beginner 介於 0.19 到 0.60，Intermediate 介於 0.21 到 0.60，Expert 則介於 0.12 到 0.50。邊角需要同時無雷的鄰格較少，比中央格更容易開出區域。

## 首點規則怎麼選

第一下開出區域後，其他地方仍可能出現 50/50：兩格之中有一顆雷，現有線索無法分辨位置，也沒有其他線索可用。

只要求第一下安全，就把點擊格排除在佈雷範圍外；要讓第一下展開區域，連它的鄰格也一起排除。

## Tatham 的 solver 加擾動器

[Tatham 的生成器](https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/mines-solver/)等玩家點了第一下才開始佈雷，同一張盤面換個起點，可解性可能不同。

solver 只用玩家看得到的線索推理，解不下去時，擾動器通常在未開區域搬雷，讓推理能繼續；接近結束、沒有其他未開格可放時，也可能把雷移進已開區域。修改可能破壞先前用過的線索，即使這輪解完，仍要清空解題狀態，從同一起點重跑。完全不靠擾動就解完，盤面才能交給玩家。Tatham 用每輪需要的擾動次數判斷進展，次數減少就繼續，反而增加就放棄這張盤面、重新生成。

這是啟發式做法，沒有保證每次生成都會成功。Tatham 的預設雷密度超過 Windows 標準兩倍，仍能生成可解的盤面。

只要推理規則正確，弱 solver 通過的盤面也確實能不猜解完；加強 solver 可以讓更多盤面通過，但不代表玩家更容易看出解法。除錯時也要能重現同一張盤面，seed 的管理見〈[遊戲 RNG 正式環境要猜不到、測試要能重播：在 Go 裡把兩件事切開](/posts/rng-unpredictable-in-prod-replayable-in-tests/)〉。

## 一個簡單 solver 能解完多少

`single_point_solver.py` 只看單一數字周圍的格子，反覆套用兩條規則：

- 數字扣掉已插旗數，剛好等於未開且未插旗的鄰格數，這些格子就全是雷，全插旗。
- 已插旗數等於數字，其餘未開且未插旗的鄰格就全安全，全開。

每種難度測 2,000 張盤面，seed 固定為 42。每張盤都從中央開始，地雷均勻放在首點周圍 3×3 安全區之外，遇到 0 就展開，兩條規則都無法再推進時停止，只有開完所有安全格才算成功。

```python
# How often can a simple "single point" solver finish a board without guessing?
# Boards: Beginner/Intermediate/Expert, first click on the centre, mines placed uniformly
# outside the 3x3 block around the click (so the first click always opens a region).
# Solver rules, applied until nothing changes:
#   - a revealed number whose hidden neighbours all must be mines -> flag them
#   - a revealed number whose flagged neighbours already satisfy it -> open the rest
# This measures what THIS solver can finish, not whether a board is logically guess-free.
import random

random.seed(42)

def neighbours(i, w, h):
    x, y = i % w, i // w
    return [(x + dx) + (y + dy) * w for dx in (-1, 0, 1) for dy in (-1, 0, 1)
            if (dx or dy) and 0 <= x + dx < w and 0 <= y + dy < h]

def solve(w, h, m, click):
    n = w * h
    safe_zone = set(neighbours(click, w, h)) | {click}
    mines = set(random.sample([i for i in range(n) if i not in safe_zone], m))
    count = [sum(j in mines for j in neighbours(i, w, h)) for i in range(n)]
    opened, flagged = set(), set()
    def open_cell(i):  # flood fill through zeros
        stack = [i]
        while stack:
            c = stack.pop()
            if c in opened: continue
            opened.add(c)
            if count[c] == 0:
                stack.extend(j for j in neighbours(c, w, h) if j not in opened)
    open_cell(click)
    changed = True
    while changed:
        changed = False
        for i in list(opened):
            if count[i] == 0: continue
            hidden = [j for j in neighbours(i, w, h) if j not in opened and j not in flagged]
            flags = sum(j in flagged for j in neighbours(i, w, h))
            if hidden and flags + len(hidden) == count[i]:
                flagged.update(hidden); changed = True
            elif hidden and flags == count[i]:
                for j in hidden: open_cell(j)
                changed = True
    return len(opened) == n - m

T = 2000
for name, (w, h, m) in {"Beginner 9x9/10": (9, 9, 10), "Intermediate 16x16/40": (16, 16, 40), "Expert 30x16/99": (30, 16, 99)}.items():
    done = sum(solve(w, h, m, (w // 2) + (h // 2) * w) for _ in range(T))
    p = done / T
    print(f"{name:24s} single-point solver finishes without guessing: {p:.3f} +/- {1.96 * (p * (1 - p) / T) ** 0.5:.3f}  (n={T})")
```

```text
Beginner 9x9/10          single-point solver finishes without guessing: 0.637 +/- 0.021  (n=2000)
Intermediate 16x16/40    single-point solver finishes without guessing: 0.241 +/- 0.019  (n=2000)
Expert 30x16/99          single-point solver finishes without guessing: 0.001 +/- 0.001  (n=2000)
```

Beginner 約 64% 能解完，Intermediate 約 24%，Expert 約 0.1%。輸出的 `+/-` 沿用常態近似公式，但 Expert 成功次數太少，95% 區間的下限在四捨五入前已落到零以下，應改用 Wilson 區間估計。

若線索指出兩組未開格需要相同的雷數，而其中一組完全包含於另一組，多出來的格子就能判定安全。single-point solver 不會合併線索做這種子集推理，也沒有利用全域雷數約束或列舉可能的佈雷，有些還能推進的盤面就這樣被放棄了，所以它的失敗比例不等於必須猜測的比例。

實作生成器可以先用隨機重試。這份測量程式在解題函式裡佈雷，最後只回傳是否成功；要接成篩選步驟，就把佈雷搬到外層，保留候選盤，再交給 solver，通過才回傳同一張盤面。失敗就重新抽樣，達到嘗試次數或等待時間上限便回報生成失敗。Expert 的候選盤大多會被這個 solver 拒絕，先量單次驗證耗時與取得可用盤面的等待時間；等得太久，再比較加強 solver 或加入 Tatham 擾動器後的完整生成時間。

## NP-complete 對 solver 的影響

Kaye 在 2000 年的《Mathematical Intelligencer》22（2），第 9 到 15 頁，[證明 Minesweeper Consistency Problem 是 NP-complete](https://web.mat.bham.ac.uk/R.W.Kaye/minesw/ordmsw.htm)：給定局部盤面與數字，判斷是否存在與線索一致的佈雷。P vs NP 是 Clay 數學研究所的百萬美元獎問題之一。

這個問題允許任意大小與形狀的一般化輸入，solver 可以列舉所有可能的佈雷，代價是最壞情況會很慢。某張盤面留下無法區分的 50/50，則是現有線索不足，就算把可能的佈雷全查過，仍無法確定該開哪格。

## 補充筆記

- 選首點公式時，先確認佈雷規則：純隨機盤用 `C(N - 9, M) / C(N, M)`，排除首點後均勻佈雷用 `C(N - 9, M) / C(N - 1, M)`，Expert 的中央開區域機率約為 12.3% 與 15.4%。踩雷後搬到固定位置要另算分布。
- 採用 Tatham 的擾動方式後，要從玩家選的同一起點完整重跑 solver，通過才交出盤面。
- single-point solver 在 Beginner 約 64%、Intermediate 約 24%、Expert 約 0.1% 能解完。用它篩 Expert 盤面時，先量取得可用盤面的等待時間，再決定是否加強推理或加入擾動。
- 判斷局面是否必須猜，要檢查現有線索能否確定安全格；Kaye 的 NP-complete 結論處理一般化輸入的 consistency problem。

## 延伸閱讀

- Simon Tatham〈[Writing a soluble-grid generator for Mines](https://www.chiark.greenend.org.uk/~sgtatham/quasiblog/mines-solver/)〉：solver 與擾動器的設計，以及修改盤面後重跑驗證的原因。
- [Minesweeper first click](https://minesweepergame.com/strategy/first-click.php)：首點策略短文，記載舊版 Windows 搬雷規則、Vista 的改變，並引用 Kostka 的開區域機率範圍。
- Richard Kaye〈[Minesweeper and NP-completeness](https://web.mat.bham.ac.uk/R.W.Kaye/minesw/ordmsw.htm)〉：Kaye 對 Minesweeper 與 NP-completeness 的說明，附原論文出處。

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