一个纯粹的逻辑游戏
妙探寻凶(Cluedo)的规则简单到可以一句话说完:庄园里发生了一桩命案,凶手在 6 名嫌疑人中、凶器在 6 件道具中、案发地点在 9 个房间中。玩家通过提问”我认为是 X(嫌疑人)+ Y(凶器)+ Z(房间)”来收集信息:被问到的人如果有其中任意一张牌,就必须私下展示给你一张。
整局游戏就是一场逐步缩小候选集合的推理。真正有意思的地方在于信息的不对称性:别人给你看的那张牌只有你知道,而”某人手上没有某张牌”这个否定信息,才是推理的主力。
用算法语言表述:这是一个带否定的约束推理问题。每个对象(嫌疑人/凶器/房间)有 3 种状态:
POSSIBLE:仍可能是答案IMPOSSIBLE:已被证明不是答案UNKNOWN:当前信息不足(初始状态对所有对象都成立,但一个组里恰好有且仅有 1 个 POSSIBLE)
关键洞察:这三值不是独立的。某个对象一旦被标记为 POSSIBLE 且该组其余全部为 IMPOSSIBLE,它就必须是答案。这种”组内唯一性”的约束会在推理中反复触发,形成一条条传播链。
本文实现一个完整推理引擎,核心是可能性矩阵(Possibility Matrix)—— 一个”玩家 × 牌”的二维表,每格是一个三值状态。
可能性矩阵的建模
九宫格式的三元组:(suspect, weapon, room)。每张牌有归属:
- 我的牌:已知,直接排除
- 对手的牌:未知
- 信封里的牌:就是答案
用 -1 表示”我在手上”、-2 表示”已被证明不在信封”、>=0 表示”在某位玩家手上”。
package com.example.cluedo;
/** 牌的种类。用 "类型 + 索引" 编码成单一 int,方便做数组下标。 */
public final class Card {
public static final int SUSPECT = 0, WEAPON = 1, ROOM = 2;
public static final int N_SUSPECT = 6, N_WEAPON = 6, N_ROOM = 9;
public final int type, index;
public Card(int type, int index) {
this.type = type; this.index = index;
}
/** 全局唯一 id:0~5 嫌疑人、6~11 凶器、12~20 房间。 */
public int id() {
return switch (type) {
case SUSPECT -> index;
case WEAPON -> N_SUSPECT + index;
default -> N_SUSPECT + N_WEAPON + index;
};
}
public static final int TOTAL = N_SUSPECT + N_WEAPON + N_ROOM; // 21
}
三值状态:
package com.example.cluedo;
/**
* 一张牌相对"信封"(答案)的三种状态。
* 注意:我们关心的是"它是否在信封里",而不是"它在谁手上"。
*/
public enum Tri {
POSSIBLE, // 可能是答案
IMPOSSIBLE, // 已证明不是答案
CERTAIN // 已证明就是答案(每组恰好一个)
}
可能性矩阵的核心结构:
package com.example.cluedo;
import java.util.*;
/**
* 可能性矩阵推理引擎。
*
* 维护两套信息:
* envelope[21] —— 每张牌是否为答案(三值)
* owner[21] —— 每张牌已知在谁手上(-1 未知,0..n-1 玩家,-2 已排除所有玩家即必在信封)
*
* 核心推理规则只有三条:
* R1 我知道自己手上有牌 → 它不在信封(IMPOSSIBLE)
* R2 某组只剩一个 POSSIBLE → 它 CERTAIN
* R3 某牌被所有玩家排除 → 它必在信封 → 该组其余全部 IMPOSSIBLE
*/
public final class DeductionEngine {
private final int nPlayers;
private final int myIndex;
/** envelope[c] ∈ {POSSIBLE, IMPOSSIBLE, CERTAIN} */
private final Tri[] envelope = new Tri[Card.TOTAL];
/** owner[c]:-1 = 未知归属;-2 = 不为任何玩家所有(即必在信封);否则为玩家编号 */
private final int[] owner = new int[Card.TOTAL];
/** 每组的牌 id 列表 */
private final List<Integer>[] groups = new List[3];
public DeductionEngine(int nPlayers, int myIndex) {
this.nPlayers = nPlayers;
this.myIndex = myIndex;
Arrays.fill(envelope, Tri.POSSIBLE);
Arrays.fill(owner, -1);
groups[Card.SUSPECT] = new ArrayList<>();
for (int i = 0; i < Card.N_SUSPECT; i++) groups[Card.SUSPECT].add(i);
groups[Card.WEAPON] = new ArrayList<>();
for (int i = 0; i < Card.N_WEAPON; i++)
groups[Card.WEAPON].add(Card.N_SUSPECT + i);
groups[Card.ROOM] = new ArrayList<>();
for (int i = 0; i < Card.N_ROOM; i++)
groups[Card.ROOM].add(Card.N_SUSPECT + Card.N_WEAPON + i);
}
}
owner 与 envelope 是互为对偶的两张表:owner[c] == -2 蕴含 envelope[c] == CERTAIN,反之亦然。双向维护是避免推理断裂的关键——很多实现只维护一边,结果在”某张牌被所有人排除”这种关键推理上漏掉结论。
三条推理规则
R1:已知手牌 → 排除
/** 我确定手上有的牌:它不在信封。 */
public void markInMyHand(List<Integer> myCards) {
for (int c : myCards) {
owner[c] = myIndex; // 我知道在我这
envelope[c] = Tri.IMPOSSIBLE;
for (int p = 0; p < nPlayers; p++) {
if (p != myIndex && owner[c] == p) owner[c] = -1;
}
}
propagate();
}
R2:组内唯一 → 确定
/** 若某组只剩一个可能项,它就是答案。 */
private boolean applyGroupUniqueness() {
boolean changed = false;
for (List<Integer> g : groups) {
int possible = 0, lastPossible = -1, certain = 0;
for (int c : g) {
if (envelope[c] == Tri.POSSIBLE) { possible++; lastPossible = c; }
else if (envelope[c] == Tri.CERTAIN) certain++;
}
// 若已有 CERTAIN,则同组其它全部 IMPOSSIBLE(这由收敛保证,不重复做)
if (certain >= 1) continue;
if (possible == 1) {
envelope[lastPossible] = Tri.CERTAIN;
owner[lastPossible] = -2;
changed = true;
} else if (possible == 0) {
throw new IllegalStateException("推理矛盾:组内无可选项");
}
}
return changed;
}
R3:全员排除 → 必在信封
这是整个引擎里最强但最不直观的一条规则。如果某张牌已经证明不在任何玩家手上(包括不在我手上、也不在对手手上),那它只能在信封里。
/**
* 若某张牌被所有玩家排除(owner 全部为"不持有"),则它必在信封。
* 实现方式:为每张牌维护"已确认不持有它的玩家数"。
*/
private final int[] excludedByPlayers = new int[Card.TOTAL];
public void markNotHeldBy(int player, int card) {
// 该玩家明确表示"我没有这张牌"
owner[card] = -1;
excludedByPlayers[card]++; // 计数(需保证不重复计数,见 set 版)
checkAllPlayersExcluded(card);
}
private void checkAllPlayersExcluded(int card) {
// 若除"未知归属"外,所有玩家都被排除,则只能在信封
if (excludedByPlayers[card] >= nPlayers) {
envelope[card] = Tri.CERTAIN;
owner[card] = -2;
}
}
生产实现应该用
BitSet记录”哪些玩家已排除该牌”,而不是计数器——因为同一玩家可能被重复告知同一信息(例如两轮提问里都展示了他没有某牌)。用 BitSet 天然幂等。
信息对称性:提问的价值计算
在妙探寻凶里,”该问什么”是一个期望信息增益问题。一次提问涉及三张牌(一人、一器、一室),对手会展示其中”他手上有的”任意一张。因此一次提问能带来多少信息,取决于对手手上有几张牌落在这次提问的三元组里。
package com.example.cluedo;
import java.util.*;
/**
* 提问选择器。
* 目标:在"最可能得到否定信息"的前提下,最大化"确定答案"的期望收益。
*/
public final class QueryPlanner {
/** 对手持有某张牌的概率估计(基于剩余候选集合的均匀先验)。 */
public static double holdProbability(DeductionEngine e, int player, int card) {
if (e.ownerOf(card) == player) return 1.0;
if (e.ownerOf(card) == -2) return 0.0;
// 未知归属:按"剩余玩家剩余槽位"均摊
int unknown = 0, available = 0;
for (int c = 0; c < Card.TOTAL; c++) {
if (e.ownerOf(c) == -1) {
unknown++;
if (e.envelopeOf(c) == Tri.POSSIBLE) available++;
}
}
double p = (double) playerSlotsLeft(e, player) / Math.max(1, unknown);
return Math.min(1.0, Math.max(0.0, p));
}
private static int playerSlotsLeft(DeductionEngine e, int player) {
int hand = 0;
for (int c = 0; c < Card.TOTAL; c++) if (e.ownerOf(c) == player) hand++;
// 每局每人手牌数固定(这里假设 6 张,实际从规则算出)
return Math.max(0, 6 - hand);
}
/**
* 枚举所有三元组提问,按"期望信息量"排序。
* 期望信息量 = P(至少展示一张) × 该展示带来的排除数量
*/
public static List<int[]> rankQueries(DeductionEngine e, int targetPlayer,
int topK) {
List<int[]> cands = new ArrayList<>();
List<Double> scores = new ArrayList<>();
for (int s = 0; s < Card.N_SUSPECT; s++) {
for (int w = 0; w < Card.N_WEAPON; w++) {
for (int r = 0; r < Card.N_ROOM; r++) {
int cs = s;
int cw = Card.N_SUSPECT + w;
int cr = Card.N_SUSPECT + Card.N_WEAPON + r;
int[] q = {cs, cw, cr};
// 已经是 CERTAIN 的项不值得问
if (e.envelopeOf(cs) == Tri.CERTAIN
&& e.envelopeOf(cw) == Tri.CERTAIN
&& e.envelopeOf(cr) == Tri.CERTAIN) continue;
double pAny = 1.0;
for (int c : q) {
pAny *= (1.0 - holdProbability(e, targetPlayer, c));
}
pAny = 1.0 - pAny; // 至少展示一张的概率
// 信息价值:展示后能让多少张牌被排除
int infoValue = 0;
for (int c : q) {
if (e.envelopeOf(c) == Tri.POSSIBLE) infoValue++;
}
// 分数:把"问已知是答案的牌"降权(浪费一次提问机会)
double score = pAny * infoValue;
cands.add(q);
scores.add(score);
}
}
}
// 按分数降序取前 topK
Integer[] idx = new Integer[cands.size()];
for (int i = 0; i < idx.length; i++) idx[i] = i;
Arrays.sort(idx, (a, b) -> Double.compare(scores.get(b), scores.get(a)));
List<int[]> out = new ArrayList<>();
for (int i = 0; i < Math.min(topK, idx.length); i++) out.add(cands.get(idx[i]));
return out;
}
}
这段逻辑有个反直觉的结论:提问的最优策略不是”把所有可能项都问一遍”,而是”优先问那些对手手上没有的”。因为对手展示一张牌,你只得到”他有这张”这条信息(弱信息:只是排除它);而对手无法展示时,你一次性得到”这三张他都没有”(强信息:三张同时排除)。所以 pAny 越低、infoValue 越高的组合,才是真正的宝藏提问。
传播循环:直到不动点
/** 反复应用全部规则,直到不再产生新结论(求不动点)。 */
private void propagate() {
int rounds = 0;
boolean changed = true;
while (changed) {
changed = false;
changed |= applyGroupUniqueness();
changed |= applyOwnerToEnvelope();
changed |= applyEnvelopeToOwner();
rounds++;
if (rounds > 100) throw new IllegalStateException("推理不收敛,规则存在循环");
}
}
/** owner == -2 → envelope CERTAIN */
private boolean applyOwnerToEnvelope() {
boolean changed = false;
for (int c = 0; c < Card.TOTAL; c++) {
if (owner[c] == -2 && envelope[c] != Tri.CERTAIN) {
envelope[c] = Tri.CERTAIN;
changed = true;
}
}
return changed;
}
/** envelope CERTAIN → 同组其他 IMPOSSIBLE */
private boolean applyEnvelopeToOwner() {
boolean changed = false;
for (List<Integer> g : groups) {
int certain = -1;
for (int c : g) if (envelope[c] == Tri.CERTAIN) { certain = c; break; }
if (certain < 0) continue;
for (int c : g) {
if (c != certain && envelope[c] != Tri.IMPOSSIBLE) {
envelope[c] = Tri.IMPOSSIBLE;
changed = true;
}
}
}
return changed;
}
三个规则交替应用、直到不动点,是约束传播的标准范式。它的正确性来自”单调性”:每条规则只会把 POSSIBLE 推向 IMPOSSIBLE/CERTAIN,永不回退,所以循环必然终止(最多 21×3 次状态转移)。
可运行的入口
package com.example.cluedo;
import java.util.*;
public final class Main {
public static void main(String[] args) {
// 3 人局:我(0)、甲(1)、乙(2)
DeductionEngine e = new DeductionEngine(3, 0);
// 我的手牌:嫌疑人1、凶器2、房间4(用 id 表示)
List<Integer> myHand = List.of(1, Card.N_SUSPECT + 2,
Card.N_SUSPECT + Card.N_WEAPON + 4);
e.markInMyHand(myHand);
// 我手上有 6 张,其余 3 张省略(此处只演示逻辑)
System.out.println("初始:已知答案数 = " + e.countCertain());
// 假设连续多次提问,每次得到"对手无法展示"或"展示了某张"
// 场景:我向甲提问 (嫌疑人0, 凶器0, 房间0),甲展示了凶器0
e.markNotHeldBy(1, Card.N_SUSPECT + 0); // 占位:实际应由展示逻辑处理
e.markHeldBy(1, Card.N_SUSPECT + 0);
// 我向乙提问 (嫌疑人0, 凶器0, 房间0),乙无法展示
// → 该三元组不在乙手上
List<Integer> q = List.of(0, Card.N_SUSPECT, Card.N_SUSPECT + Card.N_WEAPON);
e.recordNoShow(2, q); // 三张都不在乙手上
e.printMatrix();
List<int[]> top = QueryPlanner.rankQueries(e, 1, 5);
System.out.println("\n推荐提问(对甲):");
for (int[] q2 : top) {
System.out.printf(" %s + %s + %s%n",
e.cardName(q2[0]), e.cardName(q2[1]), e.cardName(q2[2]));
}
}
}
配套补全 recordNoShow 与打印方法:
/** 记录"某玩家无法展示这三张",等价于三次 markNotHeldBy。 */
public void recordNoShow(int player, List<Integer> cards) {
for (int c : cards) {
if (owner[c] != player) { // 幂等保护
excludedSet[player].set(c);
owner[c] = -1;
}
}
// 每张牌都检查"是否所有玩家均已排除"
for (int c : cards) {
if (allPlayersExcluded(c)) {
envelope[c] = Tri.CERTAIN;
owner[c] = -2;
}
}
propagate();
}
/** 是否所有玩家都已被排除持有该牌。 */
private boolean allPlayersExcluded(int card) {
for (int p = 0; p < nPlayers; p++) {
if (!excludedSet[p].get(card) && owner[card] != p) return false;
}
return true;
}
/** 打印可读的三值矩阵。 */
public void printMatrix() {
System.out.println("牌\t\t状态\t归属");
for (int c = 0; c < Card.TOTAL; c++) {
System.out.printf("%-12s\t%-10s\t%s%n",
cardName(c), envelope[c],
owner[c] == -2 ? "信封" : (owner[c] == -1 ? "未知" : "玩家" + owner[c]));
}
}
public String cardName(int id) {
String[] SUS = {" Scarlet", " Mustard", " White", " Green", " Peacock", " Plum"};
String[] WEA = {" Candlestick", " Dagger", " LeadPipe", " Revolver", " Rope", " Wrench"};
String[] ROO = {" Kitchen", " Ballroom", " Conservatory", " DiningRoom",
" Billiard", " Library", " Lounge", " Hall", " Study"};
if (id < Card.N_SUSPECT) return SUS[id];
if (id < Card.N_SUSPECT + Card.N_WEAPON) return WEA[id - Card.N_SUSPECT];
return ROO[id - Card.N_SUSPECT - Card.N_WEAPON];
}
概率增强:当三值逻辑不够用时
纯三值逻辑在信息充分时非常强,但真实对局常常卡在”每组都有 2~3 个 POSSIBLE”的僵局。这时需要引入概率。
做法是利用”每人手牌数固定”这个额外约束:如果 3 人局、每人 6 张牌,那么 18 张牌在手、3 张在信封。给定所有已知信息后,剩下的未知牌必须在剩余手牌槽位中均匀分配:
/**
* 在全部"POSSIBLE"的候选上做精确计数:
* 枚举所有满足已知约束的"信封组合",统计每张牌出现在信封里的频率。
* 组数小(6+6+9)时可直接三重循环。
*/
public Map<Integer, Double> exactProbabilities(DeductionEngine e) {
Map<Integer, Double> freq = new HashMap<>();
int total = 0;
for (int s : e.candidatesOf(Card.SUSPECT)) {
for (int w : e.candidatesOf(Card.WEAPON)) {
for (int r : e.candidatesOf(Card.ROOM)) {
if (!e.consistentWithHands(s, w, r)) continue;
total++;
freq.merge(s, 1.0, Double::sum);
freq.merge(w, 1.0, Double::sum);
freq.merge(r, 1.0, Double::sum);
}
}
}
Map<Integer, Double> p = new HashMap<>();
for (var en : freq.entrySet()) p.put(en.getKey(), en.getValue() / total);
return p;
}
consistentWithHands 检查”这三张牌都不在任何人手上”这一条,加上手牌数守恒。当候选集合缩小到每种 ≤ 3 个时,最多 3×3×3 = 27 种组合,枚举成本可忽略,但得到的精确后验概率比任何启发式都可靠——这就是这类推理游戏的”终局杀招”。
复杂度分析
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 单条规则应用 | O(21) 或 O(组内大小) | 组最大 9,常数极小 |
| 传播到不动点 | O(状态空间) ≤ O(21×3) 次转移 | 单调性保证必然终止 |
| 提问枚举 | O(6×6×9) = 324 | 每局可全枚举,无需启发式 |
| 精确概率 | O(c_s × c_w × c_r) ≤ 27 | 仅在候选收缩后有意义 |
整体是线性于状态空间的,单次推理在微秒级。真正的开销反而在”提问选择”这一步——但 324 个候选完全可以暴力枚举。
与其他推理游戏的关系
妙探寻凶的这套”三值逻辑 + 可能性矩阵”可以平移到很多同类游戏:
- 数独:三值逻辑退化为”候选数集合”,组内唯一性规则就是”唯一候选数法”(Naked Single / Hidden Single)。
- 数织(Nonogram):三值变成”黑/白/未知”,传播靠”行约束的可行位置区间”。
- Mastermind:三值逻辑换成”每位的可能颜色集合 + 计数约束”。
- 扫雷:三值变成”雷/安全/未知”,传播靠”邻域计数减去已知雷数”。
所以把本文的 DeductionEngine 抽象一下,只需要替换两样东西:组(group)的定义和规则集合,就能变成上述任一游戏的推理内核。
小结
妙探寻凶把”不确定性推理”压缩到了最干净的形态:每一张牌只有三种状态(可能是答案、不可能、确定),每一条新信息都是一个集合交集的运算。本文的三条规则——已知手牌排除、组内唯一确定、全员排除归信封——交替应用至不动点,就是完整的演绎闭包。而当演绎卡住时,切换到”枚举候选三元组 + 精确计数”,就能得到后验概率,把”猜”变成”算”。
这套结构最值得带走的一点是:信息的价值不在于”我知道了什么”,而在于”我排除了什么”。对手无法展示的那一次,才是真正的信息富矿。