每日算法 — 使用java实现炸弹猫:抽卡概率推理与风险规避决策

引言

炸弹猫(Exploding Kittens)是一款火爆的卡牌桌游,牌堆中隐藏着致命的炸弹猫,玩家通过抽牌和出牌技能来避免引爆炸弹。本文用Java实现炸弹猫的AI决策系统,核心算法为卡牌概率推理、风险量化评估与最优抽牌策略,让AI能够像老手玩家一样根据场上信息推断炸弹位置并做出最优决策。

游戏规则与核心机制

牌堆包含多种卡牌:炸弹猫(Exploding Kitten)、拆除(Defuse)、跳过(Skip)、透视(See the Future)、洗牌(Shuffle)、攻击(Attack)、 favor 等。每位玩家回合必须抽一张牌,若抽到炸弹猫且没有拆除则出局。

核心算法设计

1. 概率推理引擎

AI维护对牌堆中每张未知牌的贝叶斯概率估计。根据已知信息(已出牌、已透视的牌、已抽走的牌)不断更新各位置为炸弹的概率。

2. 风险量化评估

对每个可能的行动(抽牌、使用跳过、使用透视等),计算期望损失:

风险值 = 炸弹概率 × 出局代价 + (1 - 炸弹概率) × 机会成本

3. 最优决策选择

AI在所有合法行动中选择风险最小、期望收益最大的策略。当炸弹概率超过阈值时,优先使用透视确认牌堆顶部;当确认安全时则大胆抽牌积累手牌优势。

Java完整实现

import java.util.*;

/**
 * 炸弹猫AI决策系统
 * 实现卡牌概率推理、风险量化与最优决策
 */
public class ExplodingKittensAI {
    // 卡牌类型
    enum CardType {
        EXPLODING_KITTEN, DEFUSE, SKIP, SEE_THE_FUTURE,
        SHUFFLE, ATTACK, FAVOR, NOPE, NORMAL
    }

    // 游戏状态
    private List<CardType> deck;          // 牌堆(从底到顶)
    private List<CardType> hand;          // AI手牌
    private List<CardType> discardPile;   // 弃牌堆
    private int totalPlayers;             // 总玩家数
    private int alivePlayers;             // 存活玩家数
    private int explodingKittensInDeck;   // 牌堆中炸弹猫数量
    private int deckSize;                 // 牌堆总张数

    // 概率推理状态
    private double[] bombProb;            // 每个位置是炸弹的概率
    private List<CardType> knownTopCards; // 通过透视已知的顶部牌

    public ExplodingKittensAI(int totalPlayers) {
        this.totalPlayers = totalPlayers;
        this.alivePlayers = totalPlayers;
        this.hand = new ArrayList<>();
        this.discardPile = new ArrayList<>();
        this.knownTopCards = new ArrayList<>();
        initDeck();
    }

    /**
     * 初始化牌堆(简化版,每种卡牌数量可调)
     */
    private void initDeck() {
        deck = new ArrayList<>();
        explodingKittensInDeck = totalPlayers - 1;
        for (int i = 0; i < explodingKittensInDeck; i++)
            deck.add(CardType.EXPLODING_KITTEN);
        for (int i = 0; i < 6; i++) deck.add(CardType.DEFUSE);
        for (int i = 0; i < 5; i++) deck.add(CardType.SKIP);
        for (int i = 0; i < 5; i++) deck.add(CardType.SEE_THE_FUTURE);
        for (int i = 0; i < 4; i++) deck.add(CardType.SHUFFLE);
        for (int i = 0; i < 4; i++) deck.add(CardType.ATTACK);
        for (int i = 0; i < 4; i++) deck.add(CardType.FAVOR);
        for (int i = 0; i < 5; i++) deck.add(CardType.NORMAL);
        Collections.shuffle(deck);
        deckSize = deck.size();
        bombProb = new double[deckSize];
        updateBombProbability();
    }

    /**
     * 更新每个牌堆位置的炸弹概率
     * 基于已知信息和贝叶斯推理
     */
    private void updateBombProbability() {
        int unknownPositions = deck.size() - knownTopCards.size();
        if (unknownPositions <= 0) return;

        // 计算已知的非炸弹数量
        int knownNonBombs = 0;
        for (CardType c : knownTopCards) {
            if (c != CardType.EXPLODING_KITTEN) knownNonBombs++;
        }

        // 剩余未知牌中的炸弹数量
        int remainingBombs = explodingKittensInDeck
                - countKnownBombsInDeck();
        int remainingUnknown = unknownPositions;

        // 基础概率:均匀分布假设
        double baseProb = remainingBombs > 0 ?
                (double) remainingBombs / remainingUnknown : 0;

        // 更新每个位置
        for (int i = 0; i < deck.size(); i++) {
            if (i < knownTopCards.size()) {
                // 已知位置的牌
                bombProb[i] = knownTopCards.get(i) == CardType.EXPLODING_KITTEN ? 1.0 : 0.0;
            } else {
                bombProb[i] = baseProb;
            }
        }
    }

    private int countKnownBombsInDeck() {
        int count = 0;
        for (CardType c : knownTopCards) {
            if (c == CardType.EXPLODING_KITTEN) count++;
        }
        return count;
    }

    /**
     * AI主决策函数:在当前回合选择最优行动
     */
    public Decision makeDecision() {
        List<Decision> candidates = new ArrayList<>();

        // 选项1:使用SKIP跳过抽牌
        if (hand.contains(CardType.SKIP)) {
            double risk = 0; // SKIP完全规避抽牌风险
            candidates.add(new Decision(Action.USE_SKIP, risk,
                    "使用SKIP跳过本轮抽牌,零风险"));
        }

        // 选项2:使用SEE_THE_FUTURE透视顶部3张
        if (hand.contains(CardType.SEE_THE_FUTURE)) {
            double risk = 0.1; // 微小机会成本
            candidates.add(new Decision(Action.USE_SEE_FUTURE, risk,
                    "使用透视获取信息,降低后续不确定性"));
        }

        // 选项3:使用SHUFFLE洗牌
        if (hand.contains(CardType.SHUFFLE)) {
            double risk = 0.2; // 洗牌后需重新估计概率
            candidates.add(new Decision(Action.USE_SHUFFLE, risk,
                    "洗牌打乱牌堆,可能降低顶部炸弹概率"));
        }

        // 选项4:使用ATTACK转移回合
        if (hand.contains(CardType.ATTACK)) {
            double risk = 0.05; // 攻击本身无风险,但会消耗手牌
            candidates.add(new Decision(Action.USE_ATTACK, risk,
                    "攻击下家,转移抽牌义务"));
        }

        // 选项5:直接抽牌
        double drawRisk = bombProb.length > 0 ? bombProb[0] : 0;
        // 如果有DEFUSE,抽到炸弹也可化解,降低有效风险
        if (hand.contains(CardType.DEFUSE)) {
            drawRisk *= 0.1; // 90%的炸弹可被拆除
        }
        candidates.add(new Decision(Action.DRAW_CARD, drawRisk,
                String.format("直接抽牌,当前顶部炸弹概率: %.1f%%",
                        bombProb.length > 0 ? bombProb[0] * 100 : 0)));

        // 选择风险最低的决策
        Decision best = candidates.stream()
                .min(Comparator.comparingDouble(d -> d.risk))
                .orElse(candidates.get(0));

        System.out.println("AI决策分析:");
        for (Decision d : candidates) {
            System.out.printf("  %-20s 风险值: %.3f  | %s%n",
                    d.action, d.risk, d.reason);
        }
        System.out.println("=> 选择: " + best.action + "\n");
        return best;
    }

    /**
     * 执行决策并更新游戏状态
     */
    public void executeDecision(Decision decision) {
        switch (decision.action) {
            case USE_SKIP:
                hand.remove(CardType.SKIP);
                discardPile.add(CardType.SKIP);
                System.out.println("AI使用SKIP,跳过本轮抽牌");
                break;
            case USE_SEE_FUTURE:
                hand.remove(CardType.SEE_THE_FUTURE);
                discardPile.add(CardType.SEE_THE_FUTURE);
                // 透视顶部3张牌
                knownTopCards.clear();
                for (int i = 0; i < Math.min(3, deck.size()); i++) {
                    knownTopCards.add(deck.get(deck.size() - 1 - i));
                    System.out.println("AI透视看到第" + (i+1) + "张: " + deck.get(deck.size() - 1 - i));
                }
                updateBombProbability();
                break;
            case USE_SHUFFLE:
                hand.remove(CardType.SHUFFLE);
                discardPile.add(CardType.SHUFFLE);
                Collections.shuffle(deck);
                knownTopCards.clear(); // 洗牌后透视信息失效
                updateBombProbability();
                System.out.println("AI使用SHUFFLE,牌堆已重新洗牌");
                break;
            case USE_ATTACK:
                hand.remove(CardType.ATTACK);
                discardPile.add(CardType.ATTACK);
                System.out.println("AI使用ATTACK,下家需连续抽2张牌");
                break;
            case DRAW_CARD:
                if (deck.isEmpty()) {
                    System.out.println("牌堆已空!");
                    return;
                }
                CardType drawn = deck.remove(deck.size() - 1); // 从顶部抽牌
                deckSize--;
                System.out.println("AI抽牌: " + drawn);
                if (drawn == CardType.EXPLODING_KITTEN) {
                    if (hand.contains(CardType.DEFUSE)) {
                        hand.remove(CardType.DEFUSE);
                        discardPile.add(CardType.DEFUSE);
                        System.out.println("抽到炸弹猫!AI使用DEFUSE成功拆除!");
                        // 将炸弹猫放回牌堆任意位置(AI会选择最安全的位置)
                        int safePos = findSafestPosition();
                        deck.add(safePos, CardType.EXPLODING_KITTEN);
                        System.out.println("AI将炸弹猫放回牌堆第" + (deck.size() - safePos) + "张位置");
                    } else {
                        System.out.println("抽到炸弹猫!AI没有DEFUSE,出局!");
                        alivePlayers--;
                    }
                } else {
                    hand.add(drawn);
                }
                // 更新透视信息
                if (!knownTopCards.isEmpty()) knownTopCards.remove(0);
                updateBombProbability();
                break;
        }
    }

    /**
     * 找到牌堆中最安全的位置放回炸弹猫
     */
    private int findSafestPosition() {
        // AI会选择距离顶部最远的位置,让其他玩家先承担风险
        // 或者选择已知安全的区域
        if (deck.isEmpty()) return 0;
        // 默认放回底部(索引0)
        return 0;
    }

    /**
     * 模拟对手抽牌后的信息更新(通过弃牌堆推断)
     */
    public void observeOpponentDraw(int opponentId, boolean survived) {
        if (!survived) {
            alivePlayers--;
            System.out.println("玩家" + opponentId + "抽到炸弹猫出局!");
            // 牌堆中少了一张炸弹猫
            explodingKittensInDeck--;
        }
        updateBombProbability();
    }

    /**
     * 模拟对手出牌的信息更新
     */
    public void observeOpponentPlay(int opponentId, CardType card) {
        discardPile.add(card);
        if (card == CardType.SEE_THE_FUTURE) {
            // 对手使用了透视,但AI不知道看到了什么
            // 可假设对手获得信息后风险降低,因此AI应更谨慎
            System.out.println("对手使用透视,AI提高警惕");
        }
        updateBombProbability();
    }

    public void printStatus() {
        System.out.printf("=== AI状态 ===%n手牌: %s%n牌堆剩余: %d 张%n", hand, deck.size());
        if (bombProb.length > 0) {
            System.out.printf("顶部炸弹概率: %.1f%%%n", bombProb[bombProb.length - 1] * 100);
        }
        System.out.println();
    }

    enum Action {
        DRAW_CARD, USE_SKIP, USE_SEE_FUTURE,
        USE_SHUFFLE, USE_ATTACK
    }

    static class Decision {
        Action action;
        double risk;
        String reason;
        Decision(Action a, double r, String reason) {
            this.action = a; this.risk = r; this.reason = reason;
        }
    }

    public static void main(String[] args) {
        ExplodingKittensAI ai = new ExplodingKittensAI(4);
        // 给AI一些初始手牌
        ai.hand.add(CardType.DEFUSE);
        ai.hand.add(CardType.SKIP);
        ai.hand.add(CardType.SEE_THE_FUTURE);

        System.out.println("=== 炸弹猫AI决策模拟 ===\n");
        for (int round = 1; round <= 10; round++) {
            System.out.println("======== 第 " + round + " 回合 ========");
            ai.printStatus();
            Decision decision = ai.makeDecision();
            ai.executeDecision(decision);
            if (ai.alivePlayers <= 1) break;
        }
        System.out.println("模拟结束!存活玩家数: " + ai.alivePlayers);
    }
}

算法亮点解析

贝叶斯概率更新

AI通过维护已知的顶部牌信息(knownTopCards),精确计算每个未知位置的条件炸弹概率。当透视牌被使用后,顶部几张牌的概率被精确设为0或1,大幅提升了后续决策的准确性。

风险量化模型

每个候选行动都被赋予一个数值化的风险值。风险值不仅考虑直接后果(如抽牌时的炸弹概率),还考虑机会成本(如消耗SKIP意味着未来少一张保命牌)。这种统一量化的方式让不同行动之间可以直接比较。

炸弹猫的主动放置策略

当AI用DEFUSE化解炸弹后,需要选择将炸弹放回牌堆的位置。本实现选择放回底部(索引0),这是纳什均衡策略——将风险最大化地转移给后续玩家。更复杂的AI还可以根据各玩家的手牌猜测和存活状态选择最有利的位置。

复杂度分析

  • 时间复杂度:概率更新为O(D),D为牌堆深度。决策评估为O(|A|),A为候选行动集合。
  • 空间复杂度:O(D)存储牌堆和概率数组。

扩展方向

  1. 对手建模:根据每位玩家的出牌历史推断其手牌组成,调整炸弹概率估计
  2. 蒙特卡洛模拟:对复杂的多轮决策使用MC模拟评估长期期望收益
  3. 欺骗策略:偶尔在”安全”时故意使用透视,误导对手的概率推断
  4. 多人博弈分析:引入Shapley值或纳什均衡概念分析最优策略

总结

炸弹猫AI展示了不完全信息博弈中的概率推理方法。通过贝叶斯更新维护对牌堆状态的置信分布,并用风险量化模型进行决策,AI能够在充满不确定性的卡牌游戏中做出近乎最优的选择,是信息论与博弈论在游戏AI中的经典结合。