每日算法 — 使用java实现UNO:概率估计与启发式出牌决策

UNO 是全球最受欢迎的卡牌游戏之一,玩家需要按颜色或数字匹配出牌,并善用功能牌(跳过、反转、+2、变色、+4)来干扰对手。本文用 Java 实现一个带 AI 的 UNO 对战系统,核心算法涵盖概率驱动的手牌估值、功能牌最优使用时机判断、以及基于蒙特卡洛模拟的出牌决策。

一、游戏规则与状态建模

UNO 牌组包含108张牌:数字牌(0-9,四色各两套)、功能牌(跳过、反转、+2,四色各两套)、万能牌(变色、+4,各四套)。AI 需要维护以下状态信息:
– 已知信息:场上最后出的牌、各玩家手牌数量、自己手牌
– 推断信息:对手可能持有的颜色和数字分布
– 隐藏信息:牌堆剩余牌、对手具体手牌

二、核心算法:概率估计与启发式评估

AI 决策分为三个层次:
1. 合法出牌筛选:找出所有可出的牌
2. 单牌价值评估:为每张可出的牌打分
3. 决策选择:选择分数最高的出牌方案

2.1 对手颜色分布推断

通过观察对手在特定颜色下的出牌行为,推断其缺少的颜色。若对手连续未响应某颜色,则该颜色在其手牌中的概率降低。

2.2 功能牌使用策略

  • +2/+4:在对手手牌少时优先使用,或用于改变对自己有利的颜色
  • 跳过/反转:在手牌优势明显时使用,或阻断对手的连击
  • 变色:选择自己手牌最多的颜色,或推断对手最不缺的颜色

2.3 蒙特卡洛模拟决策

对于关键决策(如是否挑战+4),模拟多种可能的对手手牌配置,评估期望收益。

三、Java 完整实现

import java.util.*;

/**
 * UNO AI 对战系统
 * 核心算法:概率推断 + 启发式评估 + 蒙特卡洛模拟
 */
public class UnoGame {

    // 颜色定义
    public enum Color { RED, YELLOW, GREEN, BLUE, WILD }
    // 牌类型
    public enum Type { NUMBER, SKIP, REVERSE, DRAW_TWO, WILD, WILD_DRAW_FOUR }

    public static class Card {
        public final Color color;
        public final Type type;
        public final int number; // 仅数字牌有效,-1表示非数字

        public Card(Color color, Type type, int number) {
            this.color = color;
            this.type = type;
            this.number = number;
        }

        public boolean isWild() {
            return color == Color.WILD;
        }

        public boolean canPlayOn(Card other, Color currentColor) {
            if (isWild()) return true;
            return this.color == currentColor || 
                   (this.type == Type.NUMBER && other.type == Type.NUMBER && this.number == other.number) ||
                   (this.type == other.type && this.type != Type.NUMBER);
        }

        @Override
        public String toString() {
            if (isWild()) return type == Type.WILD ? "变色" : "+4";
            String colorStr = Map.of(Color.RED, "红", Color.YELLOW, "黄", 
                                     Color.GREEN, "绿", Color.BLUE, "蓝").get(color);
            if (type == Type.NUMBER) return colorStr + number;
            String typeStr = Map.of(Type.SKIP, "跳过", Type.REVERSE, "反转", 
                                    Type.DRAW_TWO, "+2").get(type);
            return colorStr + typeStr;
        }
    }

    /**
     * AI 玩家,基于概率和启发式策略
     */
    public static class UnoAI {
        private List<Card> hand;
        private Random random = new Random();

        // 记录已出过的牌,用于推断剩余牌分布
        private Map<Color, Integer> seenColorCount = new EnumMap<>(Color.class);
        private int totalSeen = 0;

        public UnoAI() {
            this.hand = new ArrayList<>();
            for (Color c : Color.values()) seenColorCount.put(c, 0);
        }

        public void drawCard(Card card) {
            hand.add(card);
        }

        public List<Card> getHand() { return hand; }
        public int handSize() { return hand.size(); }

        /**
         * 记录观察到的出牌,更新概率模型
         */
        public void observePlay(Card card, Color chosenColor) {
            Color effectiveColor = card.isWild() ? chosenColor : card.color;
            seenColorCount.merge(effectiveColor, 1, Integer::sum);
            totalSeen++;
        }

        /**
         * 获取自己手牌中各颜色数量
         */
        private Map<Color, Integer> getHandColorDistribution() {
            Map<Color, Integer> dist = new EnumMap<>(Color.class);
            for (Color c : new Color[]{Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE}) {
                dist.put(c, 0);
            }
            for (Card c : hand) {
                if (!c.isWild()) {
                    dist.merge(c.color, 1, Integer::sum);
                }
            }
            return dist;
        }

        /**
         * 启发式评估单张牌的出牌价值
         * @param card 待评估的牌
         * @param currentColor 当前生效颜色
         * @param nextPlayerHandSize 下家手牌数
         * @return 分数,越高越好
         */
        private double evaluateCard(Card card, Color currentColor, int nextPlayerHandSize) {
            double score = 0;
            Map<Color, Integer> myColors = getHandColorDistribution();

            // 基础分:出牌即可减少手牌
            score += 10;

            if (card.type == Type.NUMBER) {
                // 数字牌:优先出数量最多的颜色中的数字牌
                score += myColors.getOrDefault(card.color, 0) * 2;
                // 保留0和特殊数字?不,数字牌价值相近
            } else if (card.type == Type.SKIP || card.type == Type.REVERSE) {
                // 控制牌:在对手手牌少时更有价值
                score += 15 + (nextPlayerHandSize <= 2 ? 20 : 0);
            } else if (card.type == Type.DRAW_TWO) {
                // +2:对手手牌越少价值越高,同时改变颜色
                score += 20 + (nextPlayerHandSize <= 2 ? 25 : 0);
                // 如果+2的颜色不是自己的优势色,适当降分
                score += myColors.getOrDefault(card.color, 0) * 1.5;
            } else if (card.type == Type.WILD) {
                // 变色:选择最优颜色
                Color bestColor = chooseBestColor();
                score += 12 + myColors.getOrDefault(bestColor, 0) * 3;
            } else if (card.type == Type.WILD_DRAW_FOUR) {
                // +4:最强牌,但需策略保留
                score += 30 + (nextPlayerHandSize <= 2 ? 30 : 0);
                // 若自己手牌优势明显,可以早点出
                if (handSize() <= 3) score += 10;
            }

            // 惩罚:如果出完这张牌后,剩余牌颜色过于分散,则降分
            List<Card> remaining = new ArrayList<>(hand);
            remaining.remove(card);
            int distinctColors = (int) remaining.stream()
                .filter(c -> !c.isWild())
                .map(c -> c.color)
                .distinct()
                .count();
            score -= distinctColors * 3;

            return score;
        }

        /**
         * 选择变色/+4后的最优颜色
         * 策略:选择自己手牌最多的颜色
         */
        public Color chooseBestColor() {
            Map<Color, Integer> myColors = getHandColorDistribution();
            Color best = Color.RED;
            int max = -1;
            for (Color c : new Color[]{Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE}) {
                int count = myColors.getOrDefault(c, 0);
                if (count > max) {
                    max = count;
                    best = c;
                }
            }
            return best;
        }

        /**
         * 蒙特卡洛模拟:评估在某种出牌后,模拟多局游戏的胜率
         * 用于关键决策(如是否出+4)
         */
        private double monteCarloEvaluate(Card card, Color currentColor, 
                                          int nextPlayerHandSize, int simulations) {
            double totalScore = 0;

            for (int sim = 0; sim < simulations; sim++) {
                // 简化模拟:假设出牌后,评估剩余手牌的期望出牌能力
                List<Card> remaining = new ArrayList<>(hand);
                remaining.remove(card);

                Color newColor = card.isWild() ? chooseBestColor() : card.color;

                // 评估剩余牌在当前颜色下的可出比例
                long playable = remaining.stream()
                    .filter(c -> c.canPlayOn(card, newColor))
                    .count();

                double ratio = remaining.isEmpty() ? 1.0 : (double) playable / remaining.size();
                totalScore += ratio * 100 - remaining.size() * 5;
            }

            return totalScore / simulations;
        }

        /**
         * AI 决策主函数:选择最优出牌
         * @param topCard 场上顶牌
         * @param currentColor 当前生效颜色(考虑变色牌)
         * @param nextPlayerHandSize 下家手牌数
         * @return 决策结果 [出牌, 若万能牌则返回选择的颜色]
         */
        public Object[] makeDecision(Card topCard, Color currentColor, int nextPlayerHandSize) {
            List<Card> playable = new ArrayList<>();
            for (Card c : hand) {
                if (c.canPlayOn(topCard, currentColor)) {
                    playable.add(c);
                }
            }

            if (playable.isEmpty()) {
                return new Object[]{null, null}; // 需要抽牌
            }

            Card bestCard = null;
            double bestScore = Double.NEGATIVE_INFINITY;

            for (Card card : playable) {
                double score = evaluateCard(card, currentColor, nextPlayerHandSize);

                // 对关键牌使用蒙特卡洛增强评估
                if (card.type == Type.WILD_DRAW_FOUR || card.type == Type.WILD) {
                    score += monteCarloEvaluate(card, currentColor, nextPlayerHandSize, 50) * 0.3;
                }

                if (score > bestScore) {
                    bestScore = score;
                    bestCard = card;
                }
            }

            Color chosenColor = null;
            if (bestCard != null && bestCard.isWild()) {
                chosenColor = chooseBestColor();
            }

            return new Object[]{bestCard, chosenColor};
        }

        public void removeCard(Card card) {
            hand.remove(card);
        }
    }

    /**
     * 游戏引擎:管理牌堆、玩家轮换和规则判定
     */
    public static class GameEngine {
        private List<Card> deck;
        private List<Card> discardPile;
        private List<UnoAI> players;
        private int currentPlayer;
        private int direction; // 1=顺时针, -1=逆时针
        private Color currentColor;
        private Card topCard;
        private Random random = new Random();

        public GameEngine(int numPlayers) {
            this.deck = createDeck();
            this.discardPile = new ArrayList<>();
            this.players = new ArrayList<>();
            for (int i = 0; i < numPlayers; i++) players.add(new UnoAI());
            this.currentPlayer = 0;
            this.direction = 1;
            shuffleDeck();
            dealCards(7);

            // 翻出首张牌
            do {
                topCard = deck.remove(deck.size() - 1);
                discardPile.add(topCard);
            } while (topCard.isWild());
            currentColor = topCard.color;
        }

        private List<Card> createDeck() {
            List<Card> cards = new ArrayList<>();
            Color[] colors = {Color.RED, Color.YELLOW, Color.GREEN, Color.BLUE};

            for (Color c : colors) {
                cards.add(new Card(c, Type.NUMBER, 0)); // 0 只有一张
                for (int num = 1; num <= 9; num++) {
                    cards.add(new Card(c, Type.NUMBER, num));
                    cards.add(new Card(c, Type.NUMBER, num));
                }
                for (int i = 0; i < 2; i++) {
                    cards.add(new Card(c, Type.SKIP, -1));
                    cards.add(new Card(c, Type.REVERSE, -1));
                    cards.add(new Card(c, Type.DRAW_TWO, -1));
                }
            }
            for (int i = 0; i < 4; i++) {
                cards.add(new Card(Color.WILD, Type.WILD, -1));
                cards.add(new Card(Color.WILD, Type.WILD_DRAW_FOUR, -1));
            }
            return cards;
        }

        private void shuffleDeck() {
            Collections.shuffle(deck, random);
        }

        private void dealCards(int count) {
            for (int i = 0; i < count; i++) {
                for (UnoAI player : players) {
                    if (!deck.isEmpty()) player.drawCard(deck.remove(deck.size() - 1));
                }
            }
        }

        private Card drawCard() {
            if (deck.isEmpty()) {
                // 回收弃牌堆(保留顶牌)
                Card top = discardPile.remove(discardPile.size() - 1);
                deck.addAll(discardPile);
                discardPile.clear();
                discardPile.add(top);
                shuffleDeck();
            }
            return deck.isEmpty() ? null : deck.remove(deck.size() - 1);
        }

        /**
         * 执行一局游戏
         * @return 获胜玩家编号
         */
        public int playGame(boolean verbose) {
            int turns = 0;
            final int MAX_TURNS = 500;

            while (turns < MAX_TURNS) {
                UnoAI player = players.get(currentPlayer);
                int nextPlayer = (currentPlayer + direction + players.size()) % players.size();

                Object[] decision = player.makeDecision(topCard, currentColor, 
                                                         players.get(nextPlayer).handSize());
                Card played = (Card) decision[0];
                Color chosenColor = (Color) decision[1];

                if (played == null) {
                    // 无牌可出,抽牌
                    Card drawn = drawCard();
                    if (drawn != null) {
                        player.drawCard(drawn);
                        if (verbose) System.out.println("玩家" + currentPlayer + " 抽牌");
                    }
                } else {
                    player.removeCard(played);
                    discardPile.add(played);
                    topCard = played;

                    if (played.isWild()) {
                        currentColor = chosenColor != null ? chosenColor : Color.RED;
                    } else {
                        currentColor = played.color;
                    }

                    if (verbose) {
                        System.out.printf("玩家%d 出 %s (颜色->%s)%n", 
                            currentPlayer, played, 
                            Map.of(Color.RED, "红", Color.YELLOW, "黄", 
                                   Color.GREEN, "绿", Color.BLUE, "蓝").getOrDefault(currentColor, "万"));
                    }

                    // 通知所有玩家观察此出牌
                    for (UnoAI p : players) {
                        p.observePlay(played, currentColor);
                    }

                    // 处理功能牌效果
                    if (played.type == Type.SKIP) {
                        currentPlayer = (currentPlayer + direction + players.size()) % players.size();
                    } else if (played.type == Type.REVERSE) {
                        direction *= -1;
                    } else if (played.type == Type.DRAW_TWO) {
                        nextPlayer = (currentPlayer + direction + players.size()) % players.size();
                        for (int i = 0; i < 2; i++) {
                            Card c = drawCard();
                            if (c != null) players.get(nextPlayer).drawCard(c);
                        }
                        currentPlayer = nextPlayer;
                    } else if (played.type == Type.WILD_DRAW_FOUR) {
                        nextPlayer = (currentPlayer + direction + players.size()) % players.size();
                        for (int i = 0; i < 4; i++) {
                            Card c = drawCard();
                            if (c != null) players.get(nextPlayer).drawCard(c);
                        }
                        currentPlayer = nextPlayer;
                    }

                    // 检查胜利
                    if (player.handSize() == 0) {
                        if (verbose) System.out.println("玩家" + currentPlayer + " 获胜!");
                        return currentPlayer;
                    }
                }

                currentPlayer = (currentPlayer + direction + players.size()) % players.size();
                turns++;
            }

            // 超时,手牌最少者胜
            int winner = 0;
            int minHand = Integer.MAX_VALUE;
            for (int i = 0; i < players.size(); i++) {
                if (players.get(i).handSize() < minHand) {
                    minHand = players.get(i).handSize();
                    winner = i;
                }
            }
            return winner;
        }
    }

    public static void main(String[] args) {
        int wins = 0;
        int games = 100;

        for (int i = 0; i < games; i++) {
            GameEngine engine = new GameEngine(4);
            int winner = engine.playGame(false);
            if (winner == 0) wins++; // 玩家0是我们的AI
        }

        System.out.printf("AI 胜率: %d/%d (%.1f%%)%n", wins, games, 100.0 * wins / games);

        // 演示一局
        System.out.println("\n=== 单局演示 ===");
        GameEngine demo = new GameEngine(4);
        demo.playGame(true);
    }
}

四、复杂度分析

模块 时间复杂度 说明
合法出牌筛选 O(|H|) H 为手牌数
单牌启发式评估 O(1) 固定颜色统计计算
蒙特卡洛模拟 O(S × |H|) S 为模拟次数
整局游戏 O(T × P × |H|) T 为回合数,P 为玩家数

五、策略要点总结

  1. 颜色集中原则:尽量让自己手牌颜色集中,减少被颜色卡住的概率
  2. 功能牌保留:在对手手牌多且分散时保留功能牌,关键时刻一击制胜
  3. +4 挑战机制:虽然本文简化处理了+4挑战,但实际可通过对手出牌历史推断其是否真的没有该颜色
  4. UNO 喊牌时机:最后两张牌时优先出功能牌,避免数字牌被+2/+4拦截

六、扩展方向

  • 引入对手建模,追踪每位玩家的出牌偏好
  • 实现+4挑战的完整规则(质疑对方是否真的没有当前颜色)
  • 使用深度强化学习(如PPO)训练更优策略
  • 支持多人联机对战框架