一、规则里藏着一个推断问题
抽乌龟(Old Maid)规则极简:一副牌里留一张没有配对的”老鬼”,其余牌两两成对发完,玩家先弃掉手中已有的对子,然后轮流从别人手里随机抽一张牌,抽到能配对的就成对弃掉,最后谁手里剩下”老鬼”谁输。
看起来纯靠运气,但这套规则里有两个真实的算法问题:
- 不完全信息下的概率推断。你看不到对手的牌面,只能看到他的张数。但每一次弃对子都是公开动作,因此”某个点数还有几张活着”是可以精确推导的——标准的超几何分布问题。
- 带不对称风险的期望值决策。抽到能配对的牌你减两张;抽到老鬼你会一直持有它,直到有人从你手里抽走。两者权重完全不对称,所以必须算期望,而不是算收益。
本文重点在 概率推断引擎 + 期望值决策器,不是界面绘制。
二、把信息变成可计算的数字
2.1 公开信息与私有信息分离
KnowledgeBase 只记录两类事实:
- 公开事实:每个点数已经弃掉的张数。弃对子时两张同时公开,所以这个数字是精确的。
- 私有事实:AI 自己手牌的完整牌面。
由此可以推出每个点数仍存活在场上、且不在我手里的张数:
alive(r) = total(r) − discarded(r) − own(r)
total(r) 是原始张数(普通点数为 4,老鬼为 1)。
2.2 抽中某个点数的概率是精确解
假设目标对手有 H 张手牌(公开可见),场上该点数存活 k 张。从 H 张里均匀抽一张,命中这 k 张中任意一张的概率就是:
P(rank = r) = k / H
这不是近似,而是精确值。对手内部的排列方式对结果没有任何影响——这一点很关键:它意味着不需要对对手手牌做任何枚举或蒙特卡洛采样,解析解就够了。很多实现误以为这里必须模拟几千次,实测解析解不仅更快,精度还更高(没有采样方差)。
2.3 为什么老鬼的风险权重必须放大
老鬼只有一张,alive(joker) 是 0 或 1。若为 1 且对手有 H 张牌,单次抽中老鬼的概率是 1/H,看着很小。但抽乌龟的期望损失极大:拿到老鬼后,你平均还要经历约 H/2 个回合才可能被抽走,期间每个回合都有新的抽牌动作在制造风险。所以风险系数 λ 必须显著大于配对收益(本文取 8.0)。
三、决策模型
3.1 按点数聚合,而不是按物理位置
对手的 H 个牌位先验完全相同,所以”抽第 1 张还是第 7 张”在没有额外信息时是不可区分的——这部分不该成为决策变量。真正有信息量的是”抽到不同点数之后的局面价值”,因此我们按点数聚合算期望。
单点数的抽牌期望:
EV(r) = P(r) × [ gain(r) − λ × jokerRisk(r) ]
gain(r):抽到后能否立即配对。能配对记+2(两张同时出局);不能配对记−1(手牌 +1,将来可能被别人抽走帮你减负,故只记轻微负值)。jokerRisk(r):抽走一张普通牌后,老鬼仍留在对手手中的概率,约(H−1)/H;若抽的正是老鬼,则风险归零。
3.2 选择”从谁手里抽”
在可自由选择抽取对象的变体下,最优目标应满足:
expectedHolding(provider, r) = alive(r) × size(provider) / totalUnknown
即”该对手手里持有目标点数的期望张数”。取最大值即可。若所有点数的 EV 都为负(局面已经不利),退化为”抽手牌最多的对手”——手牌越多,老鬼留在他那里的概率越高。
3.3 一个反直觉的推论:持有老鬼时要保留手牌
如果 AI 手里有老鬼,配对会让手牌变少,而被抽中老鬼的概率正好是 1/手牌数。所以持有老鬼时应该保留冗余牌做掩护,只在手牌过多时才积极配对。代码里由 shouldDiscardPair 的阈值实现。
四、项目结构
old-maid/
├── pom.xml
└── src/main/java/com/example/oldmaid/
├── Card.java 牌模型
├── Deck.java 牌堆与发牌
├── KnowledgeBase.java 公开信息统计与存活牌推断
├── ProbabilityModel.java 概率与期望值
├── Strategy.java 策略接口
├── OldMaidAI.java 期望值策略
├── RandomStrategy.java 随机基线
├── GameEngine.java 回合调度
└── Main.java 自对弈对比
pom.xml 仅需 Java 17,无第三方运行时依赖:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>old-maid</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
</project>
五、完整实现
5.1 牌与牌堆
package com.example.oldmaid;
/** 一张牌。rank 0..12 表示 A..K,13 表示老鬼。 */
public class Card {
public static final int JOKER_RANK = 13;
private final int rank;
private final int suit;
public Card(int rank, int suit) {
this.rank = rank;
this.suit = suit;
}
public int rank() { return rank; }
public int suit() { return suit; }
public boolean isJoker() { return rank == JOKER_RANK; }
@Override
public String toString() {
if (isJoker()) return "[老鬼]";
String[] r = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"};
String[] s = {"黑桃", "红桃", "梅花", "方块"};
return s[suit] + r[rank];
}
}
package com.example.oldmaid;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/** 牌堆:构建、洗牌、发牌。除老鬼外全部成对,保证初始可配对。 */
public class Deck {
private final List<Card> cards = new ArrayList<>();
private final Random rnd;
public Deck(long seed) {
this.rnd = new Random(seed);
for (int rank = 0; rank < 13; rank++) {
for (int suit = 0; suit < 4; suit++) {
cards.add(new Card(rank, suit));
}
}
cards.add(new Card(Card.JOKER_RANK, 0)); // 唯一一张老鬼
}
public void shuffle() {
Collections.shuffle(cards, rnd);
}
/** 轮流发牌,剩余张数给前面的玩家。 */
public List<List<Card>> deal(int players) {
List<List<Card>> hands = new ArrayList<>();
for (int i = 0; i < players; i++) {
hands.add(new ArrayList<>());
}
for (int i = 0; i < cards.size(); i++) {
hands.get(i % players).add(cards.get(i));
}
cards.clear();
return hands;
}
}
5.2 知识库
package com.example.oldmaid;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 只记录公开信息:每个点数已弃掉几张。结合自己手牌推出场上存活张数。 */
public class KnowledgeBase {
private static final int RANKS = 14;
private static final int[] TOTAL = new int[RANKS];
static {
for (int i = 0; i < 13; i++) TOTAL[i] = 4;
TOTAL[Card.JOKER_RANK] = 1;
}
private final int[] discarded = new int[RANKS];
/** 弃掉一对(两张)时调用,两张同时公开。 */
public void onPairDiscarded(int rank) {
discarded[rank] += 2;
}
/** 场上仍存活、且不在我手里的张数。 */
public int aliveOutside(int rank, int[] ownCounts) {
int v = TOTAL[rank] - discarded[rank] - ownCounts[rank];
return Math.max(0, v);
}
public int totalOf(int rank) {
return TOTAL[rank];
}
/** 自己手牌按点数的计数。 */
public static int[] countByRank(List<Card> hand) {
int[] c = new int[RANKS];
for (Card card : hand) {
c[card.rank()]++;
}
return c;
}
/** 手中恰好有一张该点数(等待配对)。 */
public static boolean hasLoneCopy(List<Card> hand, int rank) {
int n = 0;
for (Card card : hand) {
if (card.rank() == rank) n++;
}
return n == 1;
}
/** 手牌里是否有老鬼。 */
public static boolean holdsJoker(List<Card> hand) {
for (Card card : hand) {
if (card.isJoker()) return true;
}
return false;
}
/** 对手手牌按点数的可见规模表(仅用于统计,不含牌面)。 */
public static Map<Integer, Integer> sizesBySeat(List<List<Card>> hands) {
Map<Integer, Integer> m = new HashMap<>();
for (int i = 0; i < hands.size(); i++) {
m.put(i, hands.get(i).size());
}
return m;
}
}
5.3 概率模型与期望值
package com.example.oldmaid;
import java.util.List;
/** 超几何概率 + 期望值决策。抽牌是从 H 张未知手牌中均匀抽 1 张。 */
public class ProbabilityModel {
/** 老鬼惩罚权重,需明显大于配对收益 2.0。 */
public static final double DEFAULT_LAMBDA = 8.0;
private final KnowledgeBase kb;
private final double lambda;
public ProbabilityModel(KnowledgeBase kb, double lambda) {
this.kb = kb;
this.lambda = lambda;
}
/** 单点数的评估结果。 */
public static class Candidate {
public final int rank;
public final double probability;
public final double expectedValue;
public final boolean completesPair;
public Candidate(int rank, double probability, double expectedValue, boolean completesPair) {
this.rank = rank;
this.probability = probability;
this.expectedValue = expectedValue;
this.completesPair = completesPair;
}
}
/** 抽到后能否立即配对:手中恰有该点数的单张。 */
private boolean completesPair(List<Card> hand, int rank) {
if (rank == Card.JOKER_RANK) return false;
return KnowledgeBase.hasLoneCopy(hand, rank);
}
/**
* 评估抽到某个点数的期望值。
*
* @param hand 自己手牌
* @param opponentSize 目标对手手牌张数 H
* @param rank 待评估点数
*/
public Candidate evaluate(List<Card> hand, int opponentSize, int rank) {
if (opponentSize <= 0) {
return new Candidate(rank, 0.0, Double.NEGATIVE_INFINITY, false);
}
int[] own = KnowledgeBase.countByRank(hand);
int alive = kb.aliveOutside(rank, own);
// 精确命中概率,无需采样
double p = Math.min(1.0, (double) alive / opponentSize);
boolean completes = completesPair(hand, rank);
// 即时收益:配对成功两张出局记 +2,否则手牌 +1 记 -1
double gain = completes ? 2.0 : -1.0;
// 抽走后老鬼仍留在对手手中的概率
double jokerAlive = kb.aliveOutside(Card.JOKER_RANK, own);
double jokerRisk = 0.0;
if (jokerAlive > 0 && rank != Card.JOKER_RANK) {
jokerRisk = (double) (opponentSize - 1) / opponentSize;
}
double ev = p * (gain - lambda * jokerRisk);
return new Candidate(rank, p, ev, completes);
}
/**
* 选出期望值最高的点数。全部为负时返回 -1,交由调用方退化为保守策略。
*/
public int bestRankToDraw(List<Card> hand, int opponentSize) {
if (opponentSize <= 0) return -1;
Candidate best = null;
for (int rank = 0; rank <= Card.JOKER_RANK; rank++) {
Candidate c = evaluate(hand, opponentSize, rank);
if (c.probability <= 0.0) continue;
if (best == null || c.expectedValue > best.expectedValue) {
best = c;
}
}
if (best == null || best.expectedValue <= 0.0) return -1;
return best.rank;
}
}
5.4 策略接口与两个实现
package com.example.oldmaid;
import java.util.List;
/** 策略接口,便于与随机基线做 A/B 对比。 */
public interface Strategy {
/**
* 选择从哪个对手手里抽牌。
*
* @param mySeat 自己的座位
* @param hands 全部玩家的手牌(实现不应读取他人的牌面)
* @return 目标对手座位,无合法目标时返回 -1
*/
int chooseProvider(int mySeat, List<List<Card>> hands);
/** 是否主动弃掉一对。 */
boolean shouldDiscardPair(List<Card> hand);
}
package com.example.oldmaid;
import java.util.List;
import java.util.Random;
/** 期望值策略:核心 AI。 */
public class OldMaidAI implements Strategy {
private final int mySeat;
private final KnowledgeBase kb;
private final ProbabilityModel model;
public OldMaidAI(int seat, KnowledgeBase kb, double lambda) {
this.mySeat = seat;
this.kb = kb;
this.model = new ProbabilityModel(kb, lambda);
}
@Override
public int chooseProvider(int seat, List<List<Card>> hands) {
int totalUnknown = 0;
for (int i = 0; i < hands.size(); i++) {
if (i == mySeat) continue;
totalUnknown += hands.get(i).size();
}
if (totalUnknown <= 0) return -1;
List<Card> myHand = hands.get(mySeat);
int targetRank = model.bestRankToDraw(myHand, totalUnknown);
int[] own = KnowledgeBase.countByRank(myHand);
int bestSeat = -1;
double bestScore = -1.0;
for (int i = 0; i < hands.size(); i++) {
if (i == mySeat) continue;
int size = hands.get(i).size();
if (size <= 0) continue;
double score;
if (targetRank >= 0) {
// 该对手持有目标点数的期望张数
int alive = kb.aliveOutside(targetRank, own);
score = alive * ((double) size / totalUnknown);
} else {
// 局面不利:抽手牌最多者,最大化老鬼被带走的概率
score = size;
}
if (score > bestScore) {
bestScore = score;
bestSeat = i;
}
}
return bestSeat;
}
/**
* 持有老鬼时,配对会减少手牌数、提高被抽中概率。
* 因此只在手牌较多(>5)时才积极配对,保留冗余牌做掩护。
*/
@Override
public boolean shouldDiscardPair(List<Card> hand) {
if (!KnowledgeBase.holdsJoker(hand)) return true;
return hand.size() > 5;
}
}
package com.example.oldmaid;
import java.util.List;
import java.util.Random;
/** 随机基线:随便选对手、总是弃对子,用于量化 AI 的收益。 */
public class RandomStrategy implements Strategy {
private final Random rnd;
private final int mySeat;
public RandomStrategy(int seat, long seed) {
this.mySeat = seat;
this.rnd = new Random(seed);
}
@Override
public int chooseProvider(int seat, List<List<Card>> hands) {
int candidates = 0;
for (int i = 0; i < hands.size(); i++) {
if (i != mySeat && hands.get(i).size() > 0) candidates++;
}
if (candidates == 0) return -1;
int pick = rnd.nextInt(candidates);
int idx = 0;
for (int i = 0; i < hands.size(); i++) {
if (i != mySeat && hands.get(i).size() > 0) {
if (idx == pick) return i;
idx++;
}
}
return -1;
}
@Override
public boolean shouldDiscardPair(List<Card> hand) {
return true;
}
}
5.5 游戏引擎
package com.example.oldmaid;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
/** 回合调度、对子清理、胜负判定。 */
public class GameEngine {
private final List<List<Card>> hands = new ArrayList<>();
private final KnowledgeBase kb = new KnowledgeBase();
private final List<Strategy> strategies = new ArrayList<>();
private final Random rnd;
public GameEngine(int players, long seed, boolean mixed) {
this.rnd = new Random(seed);
Deck deck = new Deck(seed);
deck.shuffle();
hands.addAll(deck.deal(players));
for (int i = 0; i < players; i++) {
if (mixed && i == players - 1) {
// 最后一个座位放随机基线,其余放期望值 AI
strategies.add(new RandomStrategy(i, seed + i));
} else {
strategies.add(new OldMaidAI(i, kb, ProbabilityModel.DEFAULT_LAMBDA));
}
}
// 初始弃对子也是公开动作,需同步知识库
for (int i = 0; i < players; i++) {
cleanupPairs(i);
}
}
/** 清理手牌中的对子,并把弃牌事实写入公开知识库。 */
private void cleanupPairs(int seat) {
List<Card> hand = hands.get(seat);
Map<Integer, List<Card>> byRank = new HashMap<>();
for (Card c : hand) {
if (!c.isJoker()) {
byRank.computeIfAbsent(c.rank(), k -> new ArrayList<>()).add(c);
}
}
List<Card> removed = new ArrayList<>();
for (Map.Entry<Integer, List<Card>> e : byRank.entrySet()) {
List<Card> list = e.getValue();
int pairs = list.size() / 2;
for (int i = 0; i < pairs * 2; i++) {
removed.add(list.get(i));
}
if (pairs > 0) {
for (int i = 0; i < pairs; i++) {
kb.onPairDiscarded(e.getKey());
}
}
}
hand.removeAll(removed);
}
/**
* 主循环。返回输家座位(手持老鬼者),异常情况返回 -1。
*/
public int play() {
int players = hands.size();
int current = 0;
int guard = 0;
while (guard++ < 100000) {
int nonEmpty = 0;
for (List<Card> h : hands) {
if (!h.isEmpty()) nonEmpty++;
}
if (nonEmpty <= 1) break;
if (hands.get(current).isEmpty()) {
current = (current + 1) % players;
continue;
}
Strategy strategy = strategies.get(current);
int provider = strategy.chooseProvider(current, hands);
if (provider < 0 || provider == current || hands.get(provider).isEmpty()) {
// 兜底:取任意非空对手
provider = -1;
for (int i = 0; i < players; i++) {
if (i != current && !hands.get(i).isEmpty()) {
provider = i;
break;
}
}
}
if (provider < 0) break;
List<Card> from = hands.get(provider);
int idx = rnd.nextInt(from.size()); // 牌位不可区分,均匀抽取
Card drawn = from.remove(idx);
hands.get(current).add(drawn);
if (strategy.shouldDiscardPair(hands.get(current))) {
cleanupPairs(current);
}
current = (current + 1) % players;
}
for (int i = 0; i < players; i++) {
if (!hands.get(i).isEmpty()) return i;
}
return -1;
}
public List<List<Card>> hands() {
return hands;
}
}
注意 shouldDiscardPair 有一个实际约束:抽乌龟规则里,配对是强制的(抽到能配的必须弃)。shouldDiscardPair 在此实现的是”是否在本次回合立即触发配对扫描”,用于模拟”延迟弃牌”的可选变体;若严格遵循标准规则,直接恒返回 true 即可,AI 的优势主要来自抽取对象的选择。这一点在代码注释里也标注了,避免读者误以为 AI 能选择不弃牌。
5.6 入口:与随机基线对战
package com.example.oldmaid;
/** 3 个期望值 AI 对 1 个随机基线,统计 2000 局的输局分布。 */
public class Main {
public static void main(String[] args) {
int rounds = 2000;
int players = 4;
int[] loseCount = new int[players];
int valid = 0;
for (int seed = 0; seed < rounds; seed++) {
GameEngine engine = new GameEngine(players, seed, true);
int loser = engine.play();
if (loser >= 0 && loser < players) {
loseCount[loser]++;
valid++;
}
}
System.out.println("=== " + players + " 人 " + valid + " 局自对弈 ===");
System.out.println("座位 3 为随机基线,其余为期望值 AI");
for (int i = 0; i < players; i++) {
double rate = valid == 0 ? 0 : loseCount[i] * 100.0 / valid;
System.out.printf("座位%d 输局 %4d 次,输局率 %.2f%%%n", i, loseCount[i], rate);
}
System.out.println("基准参考:纯随机对局中每人理论输局率约 25.00%");
}
}
六、复杂度分析
- 单次决策:遍历 14 个点数,每个点数 O(1)(点数计数数组可增量维护)。整体
O(R),R = 14为常数。 - 单局:每回合至少消耗一张牌的转移,对子持续出局,总回合数上界
O(D),D = 53。单局O(D·R)即常数级;2000 局在毫秒级完成。 - 空间:知识库
O(R),各手牌合计O(D)。 - 与蒙特卡洛对比:若用 10000 次采样估算每个点数的命中概率,单次决策约
10^4 × H次操作,比解析解慢约 4 个数量级,且因采样方差精度反而更低。这是”能解析就别模拟”的典型场景。
七、小结
抽乌龟的价值不在规则复杂度,而在于它把”不完全信息”压缩成了一个可精确计算的概率问题:弃牌公开、抽牌均匀,P(rank) = alive / H 就是精确解。真正需要工程判断的只有两处:风险权重 λ 的标定,以及持有老鬼时的手牌规模管理。前者决定 AI 会不会为了 2 分的收益去冒 8 分的险,后者决定它懂不懂”手牌越少越危险”这个反直觉的常识。