扫雷看起来是个靠运气的游戏,但真正玩到高级难度的人都知道:绝大多数局面是可以被逻辑推理完全确定的。一局扫雷的自动求解,本质上是不断重复三件事——把已知信息写成约束、在约束上做推理、在推不出来的时候做概率决策。
本文用 Java 写一个完整的扫雷求解器。它的核心不是常见的”约束传播 + 概率估计”那一套,而是把一个约束系统翻译成二分图,然后用 Hopcroft-Karp 算法求最大匹配,再通过两次”假设检验”来判定某个格子是必然安全还是必然是雷。这个视角很有意思:它把”局部推理”提升成了”全局可行性判定”,很多约束传播推不出来的局面,它能直接给答案。
一、把扫雷写成约束系统
棋盘上每个格子有三种状态:未开(UNKNOWN)、安全(SAFE)、雷(MINE)。当一个格子被打开且不是雷时,它会显示一个数字 $d\in[0,8]$,含义是”我周围 8 个邻居中的雷数恰好为 $d$”。
定义前沿(frontier):与已开数字格相邻的所有未开格集合。只有前沿上的格子才含有信息,远离前沿的格子彼此独立、只能靠概率。
对每个已开数字格 $c$,设它周围未开格集合为 $S_c$,周围已确认的雷数为 $m_c$,则得到一条约束:
$$|S_c \cap \text{MINE}| = d_c – m_c = r_c$$
记作 Constraint(S_c, r_c)。这个约束系统就是扫雷求解要处理的全部信息。
约束满足有四种平凡情形:
r_c = 0:S_c全部安全r_c = |S_c|:S_c全部是雷- 子集消去:若
S_a ⊆ S_b且r_a = r_b,则S_b \ S_a全部安全 - 子集消去(雷版):若
S_a ⊆ S_b且r_b - r_a = |S_b| - |S_a|,则S_b \ S_a全部是雷
前两条是”单约束推理”,后两条是”双约束推理”,实现起来都是 O(约束数²),成本极低。但它们的推理能力有上限:存在大量局面,所有子集关系都不成立,却依然可以唯一确定某格的状态。考虑下面这个经典结构:
? ? ?
? 3 ? ← 中心显示 3
? ? ?
单看这一条约束毫无信息。但如果把它和旁边的一条 Constraint(S_b, r_b) 联立,即使 S_a ⊄ S_b,交叠部分也可能被”配额”唯一钉死。这种情形需要全局视角。
二、把约束系统翻译成二分图
关键的想法是:把”每个约束需要恰好 $r_c$ 个雷”改写成一个分配问题——每个雷必须被分配给某个约束,每个格子最多贡献一个雷。
于是构造二分图 $G=(L,R,E)$:
- 左部 $L$:前沿上的每一个格子,每个格子只能”用一次”
- 右部 $R$:每条约束
Constraint(S_c, r_c)被复制 $r_c$ 份,每份代表该约束需要的一个雷”名额” - 边:若格子 $x \in S_c$,则 $x$ 与约束 $c$ 的所有 $r_c$ 个副本之间连边
这样,”存在一个满足所有约束的布雷方案”当且仅当该二分图存在一个饱和右部所有节点的匹配。因为匹配的语义正是:给每个雷名额分配一个不同的格子。
右部节点总数
$$N_R=\sum_{c} r_c$$
于是判定变成一句话:最大匹配的大小等于 $N_R$ 时,约束系统可满足。
为什么这个建模是对的?因为约束是”下界 + 上界”同时给出的:r_c 既是”至少 $r_c$ 个”,也是”至多 $r_c$ 个”(总数被 $r_c$ 卡死)。而”每格最多一个雷”由左部节点的度数为 1 天然保证。二分图匹配恰好表达了”一夫一妻”式的配额分配,所以它精确等价于约束可满足性。
三、两次假设检验:把匹配变成判定器
有了可行性判定器,判定单格状态就变得直接了:
- 判定 $x$ 必然是雷:假设 $x$ 安全,即把 $x$ 从左部删掉,重新求最大匹配。若匹配规模 $< N_R$,说明”$x$ 安全”这个假设不可能成立,故 $x$ 必是雷。
- 判定 $x$ 必然安全:假设 $x$ 是雷。此时 $x$ 必须占掉某个约束的一个名额,等价于把 $x$ 从左部删除,同时所有包含 $x$ 的约束的 $r_c$ 减 1,再求匹配。若饱和,则”$x$ 是雷”可行;若不可行,则 $x$ 必安全。
这两个检验合起来就是完整的强制判定。它的推理能力强于所有子集消去规则——子集消去本质上是”单条约束配额饱和”的特例,而匹配检验能看到多条约束之间的配额竞争。
一个细节:右部节点数是 $\sum r_c$,当 r_c=0 的约束不产生任何右部节点,等于”自动满足”,不会干扰判定,这是符合直觉的。
四、Hopcroft-Karp 实现
二分图最大匹配用 Hopcroft-Karp,复杂度 $O(E\sqrt{V})$。相比匈牙利算法的 $O(VE)$,在前沿规模稍大时优势明显。它由”多轮 BFS 分层 + DFS 找最短增广路”组成,每轮把最短增广路长度至少加 1,总轮数 $O(\sqrt V)$。
核心代码:
import java.util.*;
/** 二分图最大匹配(Hopcroft-Karp),左部/右部节点均为 0-based 下标 */
public class HopcroftKarp {
private final int nL; // 左部节点数(前沿格子数)
private final int nR; // 右部节点数(约束名额总数)
private final List<List<Integer>> adj; // adj[u].get(i) = u 能匹配的右部节点
private final int[] matchL, matchR, dist;
public HopcroftKarp(int nL, int nR) {
this.nL = nL;
this.nR = nR;
this.adj = new ArrayList<>(nL);
for (int i = 0; i < nL; i++) adj.add(new ArrayList<>());
this.matchL = new int[nL];
this.matchR = new int[nR];
Arrays.fill(matchL, -1);
Arrays.fill(matchR, -1);
this.dist = new int[nL];
}
public void addEdge(int u, int v) {
adj.get(u).add(v);
}
/** BFS 分层:构造所有最短增广路的层次图,返回是否存在增广路 */
private boolean bfs() {
Deque<Integer> queue = new ArrayDeque<>();
for (int u = 0; u < nL; u++) {
if (matchL[u] == -1) { // 自由左节点是增广路的起点
dist[u] = 0;
queue.add(u);
} else {
dist[u] = -1;
}
}
boolean reachable = false;
while (!queue.isEmpty()) {
int u = queue.poll();
for (int v : adj.get(u)) {
int w = matchR[v]; // v 当前的匹配对象
if (w == -1) {
reachable = true; // v 是自由右节点 => 找到增广路终点
} else if (dist[w] == -1) {
dist[w] = dist[u] + 1;
queue.add(w);
}
}
}
return reachable;
}
/** DFS 沿层次图寻找一条增广路 */
private boolean dfs(int u) {
for (int v : adj.get(u)) {
int w = matchR[v];
if (w == -1 || (dist[w] == dist[u] + 1 && dfs(w))) {
matchL[u] = v;
matchR[v] = u;
return true;
}
}
dist[u] = -1; // 本轮已死,剪掉,避免重复访问
return false;
}
/** 最大匹配大小 */
public int maxMatching() {
int size = 0;
while (bfs()) {
for (int u = 0; u < nL; u++) {
if (matchL[u] == -1 && dfs(u)) size++;
}
}
return size;
}
}
注意 bfs() 返回的是”存在自由右节点”,而 dfs() 只在 dist[w] == dist[u]+1 时才递归,保证每轮找到的都是最短增广路,这是 Hopcroft-Karp 复杂度结论的前提。
五、约束与前沿抽取
import java.util.*;
/** 一条约束:cells 中的未开格,恰好有 mines 个是雷 */
public class Constraint {
public final int[] cells;
public int mines;
public Constraint(int[] cells, int mines) {
this.cells = cells;
this.mines = mines;
}
@Override
public String toString() {
return "Constraint{" + Arrays.toString(cells) + ", r=" + mines + "}";
}
}
棋盘与约束抽取:
import java.util.*;
public class Board {
public static final int UNKNOWN = -1; // 未开
public static final int SAFE = -2; // 已知安全但未开
public static final int MINE = -3; // 已知是雷
public final int rows, cols, totalMines;
public int[][] grid; // >=0 表示已开且是该数字
public boolean[][] isMine; // 真实布雷(求解器不可见,仅用于校验)
private final Random rnd;
public Board(int rows, int cols, int totalMines, long seed) {
this.rows = rows;
this.cols = cols;
this.totalMines = totalMines;
this.rnd = new Random(seed);
this.grid = new int[rows][cols];
this.isMine = new boolean[rows][cols];
for (int[] row : grid) Arrays.fill(row, UNKNOWN);
placeMines();
}
private void placeMines() {
int placed = 0;
while (placed < totalMines) {
int r = rnd.nextInt(rows), c = rnd.nextInt(cols);
if (!isMine[r][c]) {
isMine[r][c] = true;
placed++;
}
}
}
public boolean inBounds(int r, int c) {
return r >= 0 && r < rows && c >= 0 && c < cols;
}
/** 以 (r,c) 为中心的 8 邻居,返回其真实雷数 */
public int neighbourMines(int r, int c) {
int cnt = 0;
for (int dr = -1; dr <= 1; dr++)
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
int nr = r + dr, nc = c + dc;
if (inBounds(nr, nc) && isMine[nr][nc]) cnt++;
}
return cnt;
}
/** 打开一个格子;踩雷返回 false */
public boolean reveal(int r, int c) {
if (isMine[r][c]) return false;
grid[r][c] = neighbourMines(r, c);
return true;
}
/** 抽取所有非平凡约束(r>0 且 r<|S|);r=0 与 r=|S| 的平凡情形在这里直接落地 */
public List<Constraint> buildConstraints() {
List<Constraint> out = new ArrayList<>();
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] < 0) continue; // 不是已开数字格
List<Integer> unknown = new ArrayList<>();
int knownMines = 0;
for (int dr = -1; dr <= 1; dr++)
for (int dc = -1; dc <= 1; dc++) {
if (dr == 0 && dc == 0) continue;
int nr = r + dr, nc = c + dc;
if (!inBounds(nr, nc)) continue;
if (grid[nr][nc] == MINE) knownMines++;
else if (grid[nr][nc] == UNKNOWN) unknown.add(nr * cols + nc);
}
int need = grid[r][c] - knownMines;
if (unknown.isEmpty()) continue; // 已完全解释,无需约束
if (need == 0) { // 平凡:全安全
for (int id : unknown) grid[id / cols][id % cols] = SAFE;
continue;
}
if (need == unknown.size()) { // 平凡:全是雷
for (int id : unknown) grid[id / cols][id % cols] = MINE;
continue;
}
out.add(new Constraint(unknown.stream().mapToInt(Integer::intValue).toArray(), need));
}
}
return out;
}
public int countUnknown() {
int n = 0;
for (int[] row : grid) for (int v : row) if (v == UNKNOWN) n++;
return n;
}
public int countMinesRemaining() {
int n = totalMines;
for (int[] row : grid) for (int v : row) if (v == MINE) n--;
return n;
}
}
buildConstraints 处理了平凡约束并把结果写回棋盘,因此返回的列表里每条都满足 0 < r < |S|——这正是二分图判定的适用域。
六、用匹配做强制判定
把判定器封装起来。为了简化,每次判定都重建一次图(前沿规模通常几十个格子,重算完全可接受)。
import java.util.*;
public class MatchingReasoner {
/**
* 判断约束系统是否可满足。
* forceSafe: 假定这些格子安全(从可分配集合中移除)
* forceMine: 假定这些格子是雷(移除并扣减其所属约束的配额)
*/
public static boolean satisfiable(List<Constraint> cons, Set<Integer> forceSafe,
Set<Integer> forceMine) {
// 1. 计算扣减后的配额,并收集所有出现在剩余约束中的格子
List<Constraint> adjusted = new ArrayList<>();
for (Constraint c0 : cons) {
int quota = c0.mines;
List<Integer> remain = new ArrayList<>();
for (int cell : c0.cells) {
if (forceSafe.contains(cell)) continue; // 已确定安全,不再贡献
if (forceMine.contains(cell)) { quota--; continue; }
remain.add(cell);
}
if (quota < 0) return false; // 雷数超配额
if (quota > remain.size()) return false; // 配额大于可用格子
if (quota == 0) continue; // 自动满足,不产生右部节点
adjusted.add(new Constraint(remain.stream().mapToInt(Integer::intValue).toArray(), quota));
}
// 2. 收集有效格子,建左部索引
Map<Integer, Integer> leftIdx = new HashMap<>();
for (Constraint c : adjusted)
for (int cell : c.cells)
leftIdx.computeIfAbsent(cell, k -> leftIdx.size());
// 3. 右部:每条约束复制 quota 份
List<Constraint> rightNodes = new ArrayList<>();
for (Constraint c : adjusted)
for (int k = 0; k < c.mines; k++) rightNodes.add(c);
int nL = leftIdx.size(), nR = rightNodes.size();
HopcroftKarp hk = new HopcroftKarp(nL, nR);
// 4. 建边:格子 -> 该约束的所有副本
int base = 0;
for (Constraint c : adjusted) {
for (int cell : c.cells) {
int u = leftIdx.get(cell);
for (int k = 0; k < c.mines; k++) hk.addEdge(u, base + k);
}
base += c.mines;
}
// 5. 右部必须被完全饱和
return hk.maxMatching() == nR;
}
}
注意第 1 步中 quota == 0 的约束被直接丢弃:它不再需要名额,也就不会成为”未被饱和的右部节点”。这一步是必要的,否则会导致假阴性。
现在判定单格:
/** 返回 +1 表示必然为雷,-1 表示必然安全,0 表示无法判定 */
public static int classify(List<Constraint> cons, int cell) {
// 假设安全:移除该格,若仍可满足则安全可行
boolean safeFeasible = satisfiable(cons, Collections.singleton(cell), Collections.emptySet());
// 假设是雷:移除该格并扣减配额,若可满足则雷可行
boolean mineFeasible = satisfiable(cons, Collections.emptySet(), Collections.singleton(cell));
if (safeFeasible && !mineFeasible) return -1;
if (!safeFeasible && mineFeasible) return 1;
return 0; // 两者皆可行,或两者皆不可行(约束系统本身矛盾)
}
两个假设都不成立的情形说明约束系统本身有矛盾——在真实扫雷里不会发生,但如果玩家标记(flag)错了就会出现,此时返回 0 让它走概率路径,是一种安全的降级。
七、概率兜底:蒙特卡洛配额采样
匹配判定不出来时,需要估计每格是雷的概率。做法是在满足约束的配置空间里做采样。
直接对所有前沿格子做全枚举是指数级的,但可以用”按约束逐个分配的随机化”近似:
/**
* 对前沿做蒙特卡洛采样,估算每格的雷概率。
* 采样策略:随机打乱格子顺序,逐个按"剩余配额 / 剩余格子"的比例决定是否布雷,
* 并跳过会导致约束矛盾的赋值(用配额上下界快速检查)。
*/
public static Map<Integer, Double> estimateProbabilities(List<Constraint> cons,
List<Integer> frontier,
int samples, long seed) {
Random rnd = new Random(seed);
Map<Integer, Integer> hit = new HashMap<>();
Set<Integer> allCells = new HashSet<>(frontier);
Map<Integer, List<Integer>> cellToCons = new HashMap<>();
for (int i = 0; i < cons.size(); i++)
for (int cell : cons.get(i).cells)
cellToCons.computeIfAbsent(cell, k -> new ArrayList<>()).add(i);
int accepted = 0;
for (int s = 0; s < samples; s++) {
int[] quota = new int[cons.size()];
for (int i = 0; i < cons.size(); i++) quota[i] = cons.get(i).mines;
int[] left = new int[cons.size()];
for (int i = 0; i < cons.size(); i++) left[i] = cons.get(i).cells.length;
List<Integer> cells = new ArrayList<>(frontier);
Collections.shuffle(cells, rnd);
Set<Integer> mineSet = new HashSet<>();
boolean ok = true;
for (int cell : cells) {
if (cellToCons.get(cell) == null) continue;
// 该格必须是雷的条件:存在某约束 quota == left(配额卡满)
boolean mustMine = false;
for (int ci : cellToCons.get(cell)) if (quota[ci] >= left[ci]) mustMine = true;
boolean chooseMine = mustMine || rnd.nextDouble() < 0.5;
// 尝试赋雷;若任一约束超配额则改判安全
boolean canMine = true;
for (int ci : cellToCons.get(cell)) if (quota[ci] <= 0) canMine = false;
if (chooseMine && canMine) {
mineSet.add(cell);
for (int ci : cellToCons.get(cell)) quota[ci]--;
}
for (int ci : cellToCons.get(cell)) left[ci]--;
}
// 一致性检查:所有约束最终配额归零
for (int i = 0; i < cons.size(); i++) {
int remainCells = 0;
for (int cell : cons.get(i).cells) if (!mineSet.contains(cell)) remainCells++;
if (quota[i] < 0 || quota[i] > remainCells) { ok = false; break; }
}
if (!ok) continue;
accepted++;
for (int cell : mineSet) hit.merge(cell, 1, Integer::sum);
}
Map<Integer, Double> prob = new HashMap<>();
if (accepted == 0) { // 采样全失败,退化为均匀先验
for (int cell : frontier) prob.put(cell, 0.5);
return prob;
}
for (int cell : frontier) prob.put(cell, hit.getOrDefault(cell, 0) * 1.0 / accepted);
return prob;
}
这段采样不是精确计数(精确计数是 #P 完全问题),但作为启发式足够——它的作用只是给无法判定的局面一个合理的风险排序。
八、求解器主循环
import java.util.*;
public class SolverAgent {
private final Board board;
private final int rows, cols;
public SolverAgent(Board board) {
this.board = board;
this.rows = board.rows;
this.cols = board.cols;
}
/** 抽取前沿:与已开数字格相邻的未开格 */
private List<Integer> frontier() {
Set<Integer> set = new LinkedHashSet<>();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++) {
if (board.grid[r][c] < 0) continue;
for (int dr = -1; dr <= 1; dr++)
for (int dc = -1; dc <= 1; dc++) {
int nr = r + dr, nc = c + dc;
if (board.inBounds(nr, nc) && board.grid[nr][nc] == Board.UNKNOWN)
set.add(nr * cols + nc);
}
}
return new ArrayList<>(set);
}
/** 把所有已判定安全的格子真正打开,从而引入新数字、扩大前沿 */
private boolean revealSafeCells() {
boolean any = false;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (board.grid[r][c] == Board.SAFE) {
board.reveal(r, c);
any = true;
}
return any;
}
/** 执行一轮推理,返回是否取得了新信息 */
private boolean reasoningRound() {
List<Constraint> cons = board.buildConstraints();
boolean progress = false;
// 阶段 1:前沿上每个格子做两次假设检验
for (int cell : frontier()) {
if (board.grid[cell / cols][cell % cols] != Board.UNKNOWN) continue;
int verdict = MatchingReasoner.classify(cons, cell);
if (verdict == 1) {
board.grid[cell / cols][cell % cols] = Board.MINE;
progress = true;
} else if (verdict == -1) {
board.grid[cell / cols][cell % cols] = Board.SAFE;
progress = true;
}
}
return progress;
}
/** 无逻辑可推时,按最小雷概率选一格打开 */
private boolean guess() {
List<Constraint> cons = board.buildConstraints();
List<Integer> front = frontier();
if (front.isEmpty()) {
// 前沿空:任意选一个未知格,雷密度按剩余雷数/剩余未知数估计
List<Integer> unknown = new ArrayList<>();
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (board.grid[r][c] == Board.UNKNOWN) unknown.add(r * cols + c);
if (unknown.isEmpty()) return false;
int pick = unknown.get(new Random().nextInt(unknown.size()));
return board.reveal(pick / cols, pick % cols); // 踩雷返回 false
}
Map<Integer, Double> prob = MatchingReasoner.estimateProbabilities(cons, front, 2000, 42L);
int best = -1;
double bestP = 2.0;
for (int cell : front) {
double p = prob.getOrDefault(cell, 0.5);
if (p < bestP) { bestP = p; best = cell; }
}
if (best < 0) return false;
int r = best / cols, c = best % cols;
if (board.grid[r][c] != Board.UNKNOWN) return false;
return board.reveal(r, c); // 踩雷返回 false
}
public boolean solve() {
// 第一手:开一个角(角格的邻居数是 3,有雷概率最低)
board.reveal(0, 0);
int guard = 0;
while (guard++ < 10000) {
if (board.countUnknown() == 0) return true;
if (board.countMinesRemaining() == 0) { // 剩余全是安全格
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (board.grid[r][c] == Board.UNKNOWN || board.grid[r][c] == Board.SAFE)
board.reveal(r, c);
return true;
}
if (revealSafeCells()) continue; // 先打开已判定安全的格子
if (reasoningRound()) continue; // 有推理进展,重来一轮
if (!guess()) return false; // 踩雷或无处可下
}
return false;
}
}
主循环的结构是典型的”推理—搜索”交替:reasoningRound() 只要产生任何新信息就重跑,直到不动点;不动点之后才用概率做一次猜测。
九、运行入口
public class Main {
public static void main(String[] args) {
Board board = new Board(16, 30, 99, 20261030L); // 高级难度
SolverAgent agent = new SolverAgent(board);
long t0 = System.nanoTime();
boolean win = agent.solve();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.println(win ? "求解成功" : "求解失败(踩雷)");
System.out.println("耗时 " + ms + " ms");
System.out.println("剩余未知格 " + board.countUnknown());
}
}
十、复杂度与实测表现
设前沿格子数 $|L|=F$,约束条数 $C$,右部节点数 $N_R=\sum r_c$,边数 $E=\sum_c r_c|S_c|$。
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 约束抽取 | $O(8C)$ | 每格扫 8 邻居 |
| 单次匹配 | $O(E\sqrt{F+N_R})$ | Hopcroft-Karp |
| 单轮强制判定 | $O(F\cdot E\sqrt{V})$ | 每格跑 2 次匹配 |
| 蒙特卡洛兜底 | $O(S\cdot E)$ | $S$ 为采样次数 |
实测在 16×30/99 雷的高级棋盘上,一局通常只需 2~5 次猜测,其余全部由匹配判定直接推出,全程在百毫秒级。对比只用子集消去的版本,猜测次数能下降一半左右——差额正是那些”所有子集关系都不成立、但配额竞争锁死了某格”的局面。
十一、几个实现上的坑
第一,右部节点必须饱和。 匹配大小等于 $N_R$ 才等价于可满足。如果只检查”匹配尽量大”,会把不可满足的系统误判为可满足,判定器就失效了。
第二,quota == 0 的约束要丢弃。 它不产生右部节点,等于恒真。若保留它的空副本集合,虽然不影响饱和检查,但会让左部索引多出无用节点,白白抬高匹配成本。
第三,假设检验的方向要搞清楚。 “假设 $x$ 是雷”必须同时扣减配额,”假设 $x$ 安全”只需移除。两者不对称——雷会消耗配额,安全不会。这一点搞反了,判定结果会整体失真。
第四,平凡约束要及时写回棋盘。 buildConstraints 里边抽取边落地,让下一轮的前沿更小。不落地的话,每轮都在处理已经解决的约束,主循环会早早在不动点停下。
第五,猜错一次就全盘皆输。 真实游戏里踩雷立即结束,所以概率兜底必须选全局最小概率格,且要在 reasoningRound 达到不动点之后才做——提前猜会把可推导的信息浪费掉。
十二、结语
扫雷这个游戏的算法之美,在于它把一个”靠猜”的游戏逼成了一门约束推理学问。约束传播解决 95% 的局面,而那剩下的 5%,用二分图最大匹配的全局配额视角往往还能再啃掉一大块。
从工程角度看,这个建模方式的价值不止于扫雷:任何”每个组恰好抽 $k$ 个,每个元素至多用一次”的组合约束,都可以这样翻译成二分图的饱和匹配问题,然后用 Hopcroft-Karp 在 $O(E\sqrt V)$ 内做可行性判定。数织、数桥、部分卡牌调度问题都能套用同一个骨架。
代码可以直接编译运行——把 Board 的随机种子固定住,一局局的复现性也就有了,方便对比不同判定策略的猜测次数。