每日算法 — 使用java实现炸弹猫:概率抽牌分析与期望值最优决策

引言

炸弹猫(Exploding Kittens)是一款风靡全球的卡牌桌游,玩家轮流抽牌,抽到”炸弹猫”即出局,除非手中有”拆除”卡。本文用Java实现炸弹猫游戏的核心AI决策逻辑,重点讲解条件概率计算如何实时评估抽牌风险,以及期望值最大化如何帮助AI做出最优出牌与抽牌决策。

问题建模

将游戏简化为以下核心元素:
– 牌堆:剩余 N 张未知卡牌,其中包含 B 张炸弹猫
– 手牌:玩家可见的私人手牌(拆除卡、窥视卡、洗牌卡等)
– 弃牌堆:已知的公开信息,可推算剩余牌堆组成
– 特殊卡牌效果:
– 拆除卡:抵消炸弹猫,并将炸弹猫秘密放回牌堆任意位置
– 窥视卡:查看牌堆顶部3张牌
– 洗牌卡:打乱牌堆顺序
– skip卡:跳过抽牌阶段

AI 的核心决策问题是:在当前已知信息下,是否抽牌?如果抽牌,风险有多大?

核心算法一:贝叶斯概率与牌堆推断

基于弃牌堆和已使用的卡牌,AI 需要持续更新对牌堆组成的概率估计。

import java.util.*;

public class ExplodingKittensAI {
    // 卡牌类型
    public enum CardType {
        EXPLODING_KITTEN, DEFUSE, PEEK, SHUFFLE, SKIP, ATTACK, SEE_FUTURE, FAVOR
    }

    // 每种卡牌的总数量(标准游戏规则)
    private static final Map<CardType, Integer> TOTAL_COUNTS = Map.of(
        CardType.EXPLODING_KITTEN, 4,
        CardType.DEFUSE, 6,
        CardType.PEEK, 5,
        CardType.SHUFFLE, 4,
        CardType.SKIP, 4,
        CardType.ATTACK, 4,
        CardType.SEE_FUTURE, 5,
        CardType.FAVOR, 4
    );

    private List<CardType> deck; // 牌堆(从底到顶)
    private List<CardType> discardPile; // 弃牌堆
    private List<CardType> hand; // AI手牌
    private int totalPlayers; // 玩家总数
    private int knownDefusesUsed; // 已知已使用的拆除卡数量

    public ExplodingKittensAI(int totalPlayers) {
        this.totalPlayers = totalPlayers;
        this.deck = new ArrayList<>();
        this.discardPile = new ArrayList<>();
        this.hand = new ArrayList<>();
        this.knownDefusesUsed = 0;
    }

    /**
     * 基于贝叶斯推断,计算牌堆中炸弹猫的期望数量
     * 考虑因素:弃牌堆信息、手牌信息、已知的拆除卡使用
     */
    public double estimateExplodingKittensInDeck() {
        int totalKittens = TOTAL_COUNTS.get(CardType.EXPLODING_KITTEN);
        int totalDefuses = TOTAL_COUNTS.get(CardType.DEFUSE);

        // 从弃牌堆中统计已出现的炸弹猫和拆除卡
        int kittensInDiscard = countInDiscard(CardType.EXPLODING_KITTEN);
        int defusesInDiscard = countInDiscard(CardType.DEFUSE);
        int defusesInHand = countInHand(CardType.DEFUSE);

        // 已知已被拆除卡抵消的炸弹猫(通过弃牌堆中的拆除卡推断)
        int defusedKittens = Math.min(defusesInDiscard, totalKittens);

        // 剩余炸弹猫的期望数量
        int remainingKittens = totalKittens - kittensInDiscard - defusedKittens;

        // 考虑牌堆总张数,做比例修正
        int knownCards = deck.size() + discardPile.size() + hand.size();
        int totalCards = TOTAL_COUNTS.values().stream().mapToInt(Integer::intValue).sum();

        // 如果牌堆大小已知,直接按比例估算
        if (!deck.isEmpty()) {
            double ratio = (double) deck.size() / (totalCards - discardPile.size() - hand.size());
            return Math.max(0, remainingKittens * ratio);
        }
        return Math.max(0, remainingKittens);
    }

    private int countInDiscard(CardType type) {
        return (int) discardPile.stream().filter(c -> c == type).count();
    }

    private int countInHand(CardType type) {
        return (int) hand.stream().filter(c -> c == type).count();
    }

    /**
     * 计算从牌堆顶部抽一张牌时,抽到炸弹猫的概率
     * 使用超几何分布思想:剩余炸弹猫 / 剩余未知牌数
     */
    public double drawExplosionProbability() {
        if (deck.isEmpty()) return 0.0;
        double estimatedKittens = estimateExplodingKittensInDeck();
        return estimatedKittens / deck.size();
    }

核心算法二:期望值决策模型

AI 需要在每一轮评估”抽牌”vs”使用skip/其他卡”的期望收益。

    /**
     * 计算当前状态下,执行某一动作的期望收益
     * @param action 动作类型
     * @return 期望收益值(越高越好)
     */
    public double calculateExpectedValue(Action action) {
        double survivalProb = 1.0;
        double futureValue = 0.0;

        switch (action) {
            case DRAW:
                // 直接抽牌的期望
                double expProb = drawExplosionProbability();
                int defuseCount = countInHand(CardType.DEFUSE);

                if (expProb == 0) {
                    // 安全抽牌
                    survivalProb = 1.0;
                    futureValue = 0.5; // 保留手牌的价值
                } else if (defuseCount > 0) {
                    // 有拆除卡兜底,可以冒险
                    survivalProb = 1.0;
                    // 但消耗了一张宝贵的拆除卡
                    futureValue = -2.0 * expProb;
                } else {
                    // 无拆除卡,抽到炸弹即死
                    survivalProb = 1.0 - expProb;
                    futureValue = -10.0 * expProb; // 死亡惩罚
                }
                break;

            case PLAY_SKIP:
                // 使用SKIP跳过抽牌
                survivalProb = 1.0;
                futureValue = 1.0; // 安全度过一轮
                break;

            case PLAY_PEEK:
                // 使用窥视卡查看顶部3张
                survivalProb = 1.0;
                // 信息价值:后续可以更准确地决策
                futureValue = 1.5 * Math.min(3, deck.size()) / 3.0;
                break;

            case PLAY_SHUFFLE:
                // 洗牌打乱顺序
                survivalProb = 1.0;
                // 当已知顶部有炸弹时洗牌有价值
                futureValue = hasFutureInfoAboutTop() ? 2.0 : 0.2;
                break;

            case PLAY_SEE_FUTURE:
                // 查看未来(看牌堆顶5张)
                survivalProb = 1.0;
                futureValue = 2.0 * Math.min(5, deck.size()) / 5.0;
                break;
        }

        return survivalProb * 10.0 + futureValue;
    }

    /**
     * AI主决策函数:选择期望收益最高的动作
     */
    public Action chooseBestAction() {
        Action[] candidates = Action.values();
        Action best = Action.DRAW;
        double bestValue = Double.NEGATIVE_INFINITY;

        for (Action a : candidates) {
            // 检查动作是否合法(手牌中是否有对应卡牌)
            if (!isActionValid(a)) continue;

            double ev = calculateExpectedValue(a);
            if (ev > bestValue) {
                bestValue = ev;
                best = a;
            }
        }
        return best;
    }

    private boolean isActionValid(Action action) {
        return switch (action) {
            case DRAW -> true;
            case PLAY_SKIP -> countInHand(CardType.SKIP) > 0;
            case PLAY_PEEK -> countInHand(CardType.PEEK) > 0;
            case PLAY_SHUFFLE -> countInHand(CardType.SHUFFLE) > 0;
            case PLAY_SEE_FUTURE -> countInHand(CardType.SEE_FUTURE) > 0;
        };
    }

    private boolean hasFutureInfoAboutTop() {
        // 如果之前用过SEE_FUTURE或PEEK,可能对顶部有信息
        // 简化实现:假设没有持久化记忆
        return false;
    }

    enum Action {
        DRAW, PLAY_SKIP, PLAY_PEEK, PLAY_SHUFFLE, PLAY_SEE_FUTURE
    }

蒙特卡洛模拟验证

为了验证AI决策的合理性,可以用蒙特卡洛模拟大量对局,统计不同策略的胜率。

    /**
     * 蒙特卡洛模拟:在当前状态下模拟多次抽牌结果
     * @param simulations 模拟次数
     * @return 生存概率估计
     */
    public double monteCarloSurvivalRate(int simulations) {
        int survived = 0;
        int deckSize = deck.size();
        double kittenProb = drawExplosionProbability();
        int defuseCount = countInHand(CardType.DEFUSE);

        Random rand = new Random();
        for (int i = 0; i < simulations; i++) {
            boolean exploded = rand.nextDouble() < kittenProb;
            if (!exploded) {
                survived++;
            } else if (defuseCount > 0) {
                survived++; // 用拆除卡存活
            }
            // 若爆炸且无拆除卡,则死亡
        }
        return (double) survived / simulations;
    }

复杂度分析

算法 时间复杂度 空间复杂度
牌堆概率估计 O(D) O(1)
期望收益计算 O(1) O(1)
蒙特卡洛模拟 O(S) O(1)

其中 D 为弃牌堆大小,S 为模拟次数。由于卡牌种类固定且数量不多,所有计算都在常数时间内完成,非常适合实时游戏AI。

总结

炸弹猫游戏的AI核心在于不完全信息下的概率推理。通过持续跟踪弃牌堆和手牌信息,AI 可以用贝叶斯方法更新对牌堆组成的估计;再通过期望值模型比较”冒险抽牌”与”保守出牌”的收益。当炸弹猫概率超过阈值且手中无拆除卡时,AI 会果断使用 SKIP 等防御卡牌——这种”概率驱动决策”的思路,同样适用于任何带有随机抽牌机制的策略游戏设计。