每日算法 — 使用java实现心脏病:反应时间建模与泊松过程概率博弈

一、心脏病游戏规则与算法挑战

心脏病(又称”Stress”或” Dutch Blitz”变体)是一款考验玩家反应速度的经典纸牌游戏。基本规则如下:

  1. 一副牌(不含大小王)均分给所有玩家
  2. 玩家轮流从自己牌堆顶部翻牌到桌面中央
  3. 当翻出的牌与桌面已有牌点数相同时,所有玩家要尽快拍击桌面
  4. 最先拍到的玩家赢得桌面上所有牌
  5. 牌先用完者出局,最后剩牌者获胜

本文的算法挑战在于:
– 泊松过程建模:模拟翻牌事件的随机到达
– 反应时间分布:分析人类反应时间的统计特性
– 最优拍击策略:在假阳性(拍错)与假阴性(漏拍)之间权衡

二、泊松过程与翻牌事件建模

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为翻牌次数

七、总结

心脏病游戏看似简单,却蕴含丰富的概率论与决策论思想。通过泊松过程建模翻牌事件、对数正态分布建模反应时间、期望收益计算推导最优策略,我们不仅实现了一个有趣的模拟器,也掌握了随机过程在博弈场景中的应用方法。