一、心脏病游戏规则与算法挑战
心脏病(又称”Stress”或” Dutch Blitz”变体)是一款考验玩家反应速度的经典纸牌游戏。基本规则如下:
- 一副牌(不含大小王)均分给所有玩家
- 玩家轮流从自己牌堆顶部翻牌到桌面中央
- 当翻出的牌与桌面已有牌点数相同时,所有玩家要尽快拍击桌面
- 最先拍到的玩家赢得桌面上所有牌
- 牌先用完者出局,最后剩牌者获胜
本文的算法挑战在于:
– 泊松过程建模:模拟翻牌事件的随机到达
– 反应时间分布:分析人类反应时间的统计特性
– 最优拍击策略:在假阳性(拍错)与假阴性(漏拍)之间权衡
二、泊松过程与翻牌事件建模
2.1 泊松过程简介
在心脏病游戏中,翻牌事件可以建模为泊松过程——单位时间内事件发生次数服从泊松分布:
P(N(t) = k) = (λt)^k * e^(-λt) / k!
其中 λ 是单位时间内的平均翻牌次数。相邻两次翻牌的时间间隔服从指数分布:
f(t) = λ * e^(-λt)
2.2 匹配概率分析
假设桌面已有 n 张牌,新翻一张牌与已有牌点数匹配的概率为:
P(匹配) = 1 - (12/13)^n
(一副牌有13种点数,假设均匀分布)
当 n = 5 时,匹配概率约为 33.5%;当 n = 10 时,匹配概率升至 55.7%。
三、核心算法实现
3.1 卡牌与牌堆类
import java.util.*;
public class Card {
public enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES }
private final Suit suit;
private final int rank; // 1-13 (A-K)
public Card(Suit suit, int rank) {
this.suit = suit;
this.rank = rank;
}
public int getRank() { return rank; }
public Suit getSuit() { return suit; }
/**
* 判断两张牌点数是否相同
*/
public boolean matchesRank(Card other) {
return this.rank == other.rank;
}
@Override
public String toString() {
String[] ranks = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"};
return ranks[rank - 1] + suit.toString().charAt(0);
}
}
public class Deck {
private List<Card> cards;
private Random random;
public Deck() {
cards = new ArrayList<>();
random = new Random();
for (Card.Suit suit : Card.Suit.values()) {
for (int rank = 1; rank <= 13; rank++) {
cards.add(new Card(suit, rank));
}
}
}
public void shuffle() {
Collections.shuffle(cards, random);
}
public Card draw() {
return cards.isEmpty() ? null : cards.remove(cards.size() - 1);
}
public boolean isEmpty() { return cards.isEmpty(); }
public int size() { return cards.size(); }
}
3.2 泊松过程翻牌调度器
public class PoissonScheduler {
private double lambda; // 单位时间平均翻牌率(张/秒)
private Random random;
private double nextEventTime;
private double elapsedTime;
public PoissonScheduler(double lambda) {
this.lambda = lambda;
this.random = new Random();
this.elapsedTime = 0;
scheduleNext();
}
/**
* 根据指数分布生成下一次翻牌时间
* T = -ln(U) / λ, U ~ Uniform(0,1)
*/
private void scheduleNext() {
double u = random.nextDouble();
// 避免ln(0)
while (u < 1e-10) u = random.nextDouble();
double interval = -Math.log(u) / lambda;
nextEventTime = elapsedTime + interval;
}
/**
* 更新时间并检查是否该翻牌
*/
public boolean update(double dt) {
elapsedTime += dt;
if (elapsedTime >= nextEventTime) {
scheduleNext();
return true;
}
return false;
}
public double getLambda() { return lambda; }
}
3.3 反应时间模型
人类反应时间通常服从对数正态分布或伽马分布。本文使用对数正态分布建模:
public class ReactionTimeModel {
private Random random;
private double mu; // 对数均值(约 -1.0 到 -0.5,对应200-300ms)
private double sigma; // 对数标准差(约 0.2-0.4)
/**
* 默认参数:平均反应时间约250ms,标准差约50ms
*/
public ReactionTimeModel() {
this.random = new Random();
// ln(250ms) ≈ 5.52, 但这里用秒为单位
this.mu = -1.38; // ln(0.25) ≈ -1.39
this.sigma = 0.25;
}
public ReactionTimeModel(double meanMs, double stdMs) {
this.random = new Random();
double meanS = meanMs / 1000.0;
double varS = Math.pow(stdMs / 1000.0, 2);
this.sigma = Math.sqrt(Math.log(1 + varS / (meanS * meanS)));
this.mu = Math.log(meanS) - sigma * sigma / 2;
}
/**
* 生成一次反应时间(秒)
*/
public double sample() {
double z = random.nextGaussian();
return Math.exp(mu + sigma * z);
}
/**
* 计算在t秒内完成反应的概率
*/
public double cdf(double t) {
if (t <= 0) return 0;
double z = (Math.log(t) - mu) / sigma;
return 0.5 * (1 + erf(z / Math.sqrt(2)));
}
/**
* 误差函数近似(Abramowitz and Stegun)
*/
private double erf(double x) {
double a1 = 0.254829592;
double a2 = -0.284496736;
double a3 = 1.421413741;
double a4 = -1.453152027;
double a5 = 1.061405429;
double p = 0.3275911;
int sign = x < 0 ? -1 : 1;
x = Math.abs(x);
double t = 1.0 / (1.0 + p * x);
double y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return sign * y;
}
public double getMeanMs() {
return Math.exp(mu + sigma * sigma / 2) * 1000;
}
}
3.4 AI玩家:最优拍击策略
public class AIPlayer {
private String name;
private List<Card> hand; // 手牌
private List<Card> wonCards; // 赢取的牌
private ReactionTimeModel reactionModel;
private double reactionTime; // 本次反应时间
private Random random;
// 策略参数
private double falsePositiveRate; // 假阳性率(拍错概率)
private double attentiveness; // 专注度(0-1)
public AIPlayer(String name, double meanReactionMs, double falsePositiveRate) {
this.name = name;
this.hand = new ArrayList<>();
this.wonCards = new ArrayList<>();
this.reactionModel = new ReactionTimeModel(meanReactionMs, 30);
this.falsePositiveRate = falsePositiveRate;
this.attentiveness = 1.0;
this.random = new Random();
}
public String getName() { return name; }
public int getCardCount() { return hand.size(); }
public boolean isOut() { return hand.isEmpty(); }
public void receiveCards(List<Card> cards) {
hand.addAll(cards);
}
public Card playCard() {
return hand.isEmpty() ? null : hand.remove(hand.size() - 1);
}
/**
* 核心决策:是否拍击桌面
* @param isRealMatch 是否真的有匹配
* @param tableSize 桌面牌数
* @return 拍击反应时间(毫秒),如果决定不拍返回 -1
*/
public double decideToSlap(boolean isRealMatch, int tableSize) {
// 根据专注度决定是否注意到翻牌
if (random.nextDouble() > attentiveness) {
return -1; // 走神了,没看到
}
// 生成反应时间
reactionTime = reactionModel.sample() * 1000; // 转为毫秒
if (isRealMatch) {
// 真匹配:总是尝试拍击(除非反应太慢被抢)
return reactionTime;
} else {
// 假匹配:以一定概率错误拍击(假阳性)
// 假阳性率与桌面牌数正相关(牌越多越容易看花眼)
double fpProb = falsePositiveRate * (1 + tableSize * 0.05);
if (random.nextDouble() < fpProb) {
return reactionTime;
}
return -1;
}
}
public void winCards(List<Card> cards) {
wonCards.addAll(cards);
// 将赢的牌加入手牌底部
hand.addAll(0, cards);
}
public double getLastReactionTime() { return reactionTime; }
@Override
public String toString() {
return String.format("%s(%d张)", name, hand.size());
}
}
3.5 游戏主引擎
public class HeartAttackGame {
private List<AIPlayer> players;
private List<Card> table; // 桌面牌堆
private Set<Integer> tableRanks; // 桌面已有的点数(用于O(1)匹配检测)
private PoissonScheduler scheduler;
private double gameTime;
private Random random;
// 统计
private int totalFlips;
private int totalMatches;
private int totalSlaps;
private int falsePositives;
public HeartAttackGame(int numPlayers, double flipRate) {
this.players = new ArrayList<>();
this.table = new ArrayList<>();
this.tableRanks = new HashSet<>();
this.scheduler = new PoissonScheduler(flipRate);
this.gameTime = 0;
this.random = new Random();
// 创建玩家(不同反应速度)
String[] names = {"闪电", "疾风", "稳健", "迟钝"};
double[] speeds = {180, 220, 260, 320}; // 毫秒
double[] fpRates = {0.02, 0.05, 0.08, 0.12};
for (int i = 0; i < numPlayers && i < names.length; i++) {
players.add(new AIPlayer(names[i], speeds[i], fpRates[i]));
}
}
public void initialize() {
Deck deck = new Deck();
deck.shuffle();
// 均分牌给玩家
int cardsPerPlayer = deck.size() / players.size();
for (AIPlayer player : players) {
List<Card> hand = new ArrayList<>();
for (int i = 0; i < cardsPerPlayer && !deck.isEmpty(); i++) {
hand.add(deck.draw());
}
player.receiveCards(hand);
}
table.clear();
tableRanks.clear();
}
/**
* 执行一次翻牌
*/
private void flipCard() {
// 轮转到当前玩家
AIPlayer currentPlayer = players.get(totalFlips % players.size());
if (currentPlayer.isOut()) {
totalFlips++;
return;
}
Card card = currentPlayer.playCard();
if (card == null) return;
// 检查是否匹配
boolean isMatch = tableRanks.contains(card.getRank());
// 加入桌面
table.add(card);
tableRanks.add(card.getRank());
totalFlips++;
if (isMatch) {
totalMatches++;
resolveSlap(true, card);
} else {
// 检查是否有玩家假阳性拍击
// 简化处理:以概率发生
resolveSlap(false, card);
}
}
/**
* 处理拍击竞争
*/
private void resolveSlap(boolean isRealMatch, Card flippedCard) {
double bestTime = Double.MAX_VALUE;
AIPlayer winner = null;
for (AIPlayer player : players) {
if (player.isOut()) continue;
double slapTime = player.decideToSlap(isRealMatch, table.size());
if (slapTime >= 0 && slapTime < bestTime) {
bestTime = slapTime;
winner = player;
}
if (slapTime >= 0 && !isRealMatch) {
falsePositives++;
}
}
if (winner != null) {
totalSlaps++;
winner.winCards(new ArrayList<>(table));
table.clear();
tableRanks.clear();
if (!isRealMatch) {
System.out.printf("[%.2fs] %s 错误拍击!假阳性处罚\n",
gameTime, winner.getName());
} else {
System.out.printf("[%.2fs] %s 拍到匹配!反应时间%.0fms\n",
gameTime, winner.getName(), bestTime);
}
}
}
/**
* 模拟运行
*/
public void simulate(double duration) {
initialize();
System.out.println("=== 心脏病游戏开始 ===");
System.out.println("玩家: " + players);
System.out.println();
double dt = 0.01; // 10ms 步长
while (gameTime < duration && getActivePlayers() > 1) {
if (scheduler.update(dt)) {
flipCard();
}
gameTime += dt;
}
printStatistics();
}
private int getActivePlayers() {
int count = 0;
for (AIPlayer p : players) {
if (!p.isOut()) count++;
}
return count;
}
private void printStatistics() {
System.out.println("\n=== 游戏统计 ===");
System.out.printf("总时长: %.2f秒\n", gameTime);
System.out.printf("总翻牌: %d次\n", totalFlips);
System.out.printf("真实匹配: %d次 (%.1f%%)\n", totalMatches,
100.0 * totalMatches / totalFlips);
System.out.printf("总拍击: %d次\n", totalSlaps);
System.out.printf("假阳性: %d次 (%.1f%%)\n", falsePositives,
totalSlaps > 0 ? 100.0 * falsePositives / totalSlaps : 0);
System.out.println("\n玩家最终牌数:");
for (AIPlayer p : players) {
System.out.printf(" %s: %d张\n", p.getName(), p.getCardCount());
}
}
public static void main(String[] args) {
HeartAttackGame game = new HeartAttackGame(4, 2.0); // 4人,每秒2张
game.simulate(60.0); // 模拟60秒
}
}
四、反应时间统计实验
public class ReactionTimeExperiment {
public static void main(String[] args) {
ReactionTimeModel model = new ReactionTimeModel(250, 50);
int n = 10000;
double[] samples = new double[n];
double sum = 0, sumSq = 0;
for (int i = 0; i < n; i++) {
samples[i] = model.sample() * 1000; // ms
sum += samples[i];
sumSq += samples[i] * samples[i];
}
double mean = sum / n;
double variance = sumSq / n - mean * mean;
System.out.println("=== 反应时间统计实验 ===");
System.out.printf("样本数: %d\n", n);
System.out.printf("均值: %.2f ms\n", mean);
System.out.printf("标准差: %.2f ms\n", Math.sqrt(variance));
System.out.printf("200ms内完成概率: %.2f%%\n", model.cdf(0.2) * 100);
System.out.printf("300ms内完成概率: %.2f%%\n", model.cdf(0.3) * 100);
}
}
五、最优策略分析
5.1 反应速度 vs 准确率权衡
在心脏病游戏中存在经典的速度-准确率权衡(Speed-Accuracy Tradeoff):
– 快速反应:更容易抢到匹配,但假阳性率高
– 保守策略:减少假阳性,但可能错失匹配
5.2 最优拍击阈值
设真实匹配概率为 p,拍击收益为 +1(赢得牌),假阳性惩罚为 -2(给其他玩家送牌)。最优策略满足:
拍击期望收益 = p * 1 - (1-p) * 2 > 0
=> p > 2/3
即当匹配概率超过66.7%时,盲目拍击才是正期望策略。
六、复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 翻牌匹配检测 | O(1) | HashSet存储桌面点数 |
| 拍击竞争判定 | O(P) | P为玩家数 |
| 泊松事件生成 | O(1) | 指数分布采样 |
| 整局模拟 | O(T * P) | T为翻牌次数 |
七、总结
心脏病游戏看似简单,却蕴含丰富的概率论与决策论思想。通过泊松过程建模翻牌事件、对数正态分布建模反应时间、期望收益计算推导最优策略,我们不仅实现了一个有趣的模拟器,也掌握了随机过程在博弈场景中的应用方法。