每日算法 — 使用java实现21点:洗牌算法与概率期望决策模型

一、游戏背景与算法目标

21点(Blackjack)是全球赌场中最受欢迎的纸牌游戏之一。玩家与庄家对弈,目标是使手中牌的总点数尽可能接近21点但不超过21点。A可计为1或11,J/Q/K计为10,其余牌按面值计算。

本文的算法目标不是实现一个带GUI的21点游戏,而是构建一个数学上最优的21点决策引擎。核心包含两大算法模块:

  1. Fisher-Yates洗牌算法:确保牌序彻底随机,为模拟提供公平的实验环境
  2. 概率期望决策模型:通过蒙特卡洛大量模拟,计算在任意手牌状态下”要牌”、”停牌”、”加倍”、”分牌”四种动作的长期期望收益,从而推导出最优策略表

读者将学到:如何从零设计一个纸牌随机系统、如何处理A的灵活计分这一逻辑难点、以及如何用概率思维解决博弈决策问题。

二、游戏核心规则算法化

2.1 牌面表示与数据结构

/**
 * 扑克牌类:包含花色与点数
 * 在21点中,花色不影响点数计算,仅用于展示
 */
public class Card {
    public enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES }
    public enum Rank {
        TWO(2), THREE(3), FOUR(4), FIVE(5), SIX(6),
        SEVEN(7), EIGHT(8), NINE(9), TEN(10),
        JACK(10), QUEEN(10), KING(10), ACE(11); // ACE默认11点

        private final int value;
        Rank(int value) { this.value = value; }
        public int getValue() { return value; }
    }

    private final Suit suit;
    private final Rank rank;

    public Card(Suit suit, Rank rank) {
        this.suit = suit;
        this.rank = rank;
    }

    public int getValue() { return rank.getValue(); }
    public boolean isAce() { return rank == Rank.ACE; }
    public String getDisplayName() { return rank + " of " + suit; }
}

2.2 Fisher-Yates洗牌算法:公平随机的基石

Fisher-Yates洗牌算法(又称Knuth洗牌)是生成牌组随机排列的黄金标准。其时间复杂度为O(n),且能确保每一种排列出现的概率完全相等(均匀分布)。

算法思想:从最后一张牌开始,随机选择一张牌(包括自己)与之交换;然后向前推进一位,重复此过程直到第一张牌。

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

/**
 * 牌 Shoe(多副牌组合),赌场通常使用6-8副牌混合
 */
public class CardShoe {
    private final List<Card> cards;
    private final Random random;
    private int currentIndex; // 当前发牌位置

    public CardShoe(int deckCount) {
        this.cards = new ArrayList<>();
        this.random = new Random();
        initialize(deckCount);
    }

    /**
     * 初始化牌堆:生成 deckCount 副牌并混合
     */
    private void initialize(int deckCount) {
        cards.clear();
        for (int d = 0; d < deckCount; d++) {
            for (Card.Suit suit : Card.Suit.values()) {
                for (Card.Rank rank : Card.Rank.values()) {
                    cards.add(new Card(suit, rank));
                }
            }
        }
        shuffle();
        currentIndex = 0;
    }

    /**
     * Fisher-Yates 洗牌算法核心实现
     * 确保 52*deckCount 张牌的每一种排列概率严格相等
     */
    public void shuffle() {
        int n = cards.size();
        for (int i = n - 1; i > 0; i--) {
            // 在 [0, i] 范围内随机选择一张牌
            int j = random.nextInt(i + 1);
            // 交换位置 i 和 j 的牌
            Collections.swap(cards, i, j);
        }
        currentIndex = 0;
    }

    /**
     * 发一张牌
     */
    public Card deal() {
        if (currentIndex >= cards.size()) {
            throw new IllegalStateException("牌堆已耗尽,需要重新洗牌");
        }
        return cards.get(currentIndex++);
    }

    /**
     * 检查是否到达切牌位置(赌场规则,剩余牌过少时重新洗牌)
     */
    public boolean needsReshuffle(double cutCardRatio) {
        return (double) currentIndex / cards.size() > (1 - cutCardRatio);
    }

    public int remainingCards() {
        return cards.size() - currentIndex;
    }
}

2.3 手牌点数计算:A的灵活计分策略

21点中最具算法挑战性的逻辑是A的计分:A可计为1或11,且一手牌中可能有多个A。我们需要计算最大有效点数(不超过21的最大值)。

import java.util.ArrayList;
import java.util.List;

/**
 * 手牌类:管理玩家或庄家的手牌,并计算最优点数
 */
public class Hand {
    private final List<Card> cards;

    public Hand() {
        this.cards = new ArrayList<>();
    }

    public void addCard(Card card) {
        cards.add(card);
    }

    /**
     * 计算手牌的最优总点数
     * 策略:先将所有A计为11,如果爆牌,则将A逐个降级为1,直到不爆或所有A都降级
     */
    public int getValue() {
        int value = 0;
        int aceCount = 0;

        for (Card card : cards) {
            value += card.getValue();
            if (card.isAce()) aceCount++;
        }

        // 若爆牌,将A从11降级为1(每次减少10)
        while (value > 21 && aceCount > 0) {
            value -= 10;
            aceCount--;
        }

        return value;
    }

    /**
     * 是否为"软牌"(Soft Hand):包含至少一张按11点计算的A
     * 这影响加倍和要牌策略
     */
    public boolean isSoft() {
        int value = 0;
        int aceCount = 0;
        for (Card card : cards) {
            value += card.getValue();
            if (card.isAce()) aceCount++;
        }
        // 如果爆牌后降级A,就不是软牌了
        while (value > 21 && aceCount > 0) {
            value -= 10;
            aceCount--;
        }
        // 若仍有按11计算的A,则为软牌
        return aceCount > 0 && value <= 21;
    }

    public boolean isBust() { return getValue() > 21; }
    public boolean isBlackjack() { return cards.size() == 2 && getValue() == 21; }
    public int size() { return cards.size(); }
    public List<Card> getCards() { return new ArrayList<>(cards); }

    public void clear() { cards.clear(); }
}

三、概率期望决策模型

3.1 庄家行为模拟

赌场规则下,庄家的行为是完全固定的:点数小于17必须继续要牌,大于等于17必须停牌。这种确定性使得我们可以精确模拟庄家的结果分布。

/**
 * 庄家AI:严格按照赌场规则行动
 * 软17规则(Soft 17):部分赌场要求庄家在软17时继续要牌
 */
public class DealerAI {
    private final boolean hitSoft17; // 是否在软17时继续要牌

    public DealerAI(boolean hitSoft17) {
        this.hitSoft17 = hitSoft17;
    }

    /**
     * 庄家执行回合,直到满足停牌条件
     * @return 庄家最终点数(若爆牌返回-1)
     */
    public int play(Hand hand, CardShoe shoe) {
        while (true) {
            int value = hand.getValue();
            boolean soft = hand.isSoft();

            // 硬牌:>=17 停牌,<17 要牌
            if (!soft) {
                if (value >= 17) break;
            } else {
                // 软牌:软17若规则要求则继续要牌,否则停牌;<17 要牌
                if (value == 17 && !hitSoft17) break;
                if (value > 17) break;
            }

            hand.addCard(shoe.deal());
            if (hand.isBust()) return -1; // 爆牌标记
        }
        return hand.getValue();
    }
}

3.2 蒙特卡洛模拟:计算动作期望收益

蒙特卡洛方法的核心思想是:通过大量随机实验,用频率估计概率。我们在21点中的具体应用是:给定当前手牌状态和庄家明牌,模拟数万次后续发牌过程,统计每种动作(要牌/停牌/加倍/分牌)的平均收益

/**
 * 蒙特卡洛决策引擎:通过大量模拟计算最优动作
 */
public class MonteCarloEngine {
    private final DealerAI dealerAI;
    private final int simulationCount; // 单次决策模拟次数
    private final CardShoe baseShoe;   // 用于克隆的基础牌堆状态

    public MonteCarloEngine(DealerAI dealerAI, int simulationCount) {
        this.dealerAI = dealerAI;
        this.simulationCount = simulationCount;
        this.baseShoe = null; // 实际使用时从外部传入当前牌堆快照
    }

    /**
     * 评估"停牌"动作的期望收益
     * 停牌后只需模拟庄家回合,比较双方点数
     */
    public double evaluateStand(Hand playerHand, Card dealerUpcard,
                                 List<Card> knownCards) {
        int wins = 0, losses = 0, pushes = 0;

        for (int i = 0; i < simulationCount; i++) {
            // 基于已知牌构建"条件牌堆"(移除已出现的牌)
            CardShoe simShoe = createConditionalShoe(knownCards);

            Hand dealerHand = new Hand();
            dealerHand.addCard(dealerUpcard);
            // 补充庄家暗牌(从未知牌中随机抽取)
            dealerHand.addCard(simShoe.deal());

            int dealerResult = dealerAI.play(dealerHand, simShoe);
            int playerValue = playerHand.getValue();

            if (dealerResult == -1 || playerValue > dealerResult) {
                wins++;
            } else if (playerValue < dealerResult) {
                losses++;
            } else {
                pushes++;
            }
        }

        // 收益:赢+1,平0,输-1
        return (double) (wins - losses) / simulationCount;
    }

    /**
     * 评估"要牌"动作的期望收益
     * 递归思路:要一张牌后,对新状态再次评估最优动作
     * 为简化,此处模拟要一张牌后的终局收益
     */
    public double evaluateHit(Hand playerHand, Card dealerUpcard,
                               List<Card> knownCards) {
        int totalGain = 0;
        int validSims = 0;

        for (int i = 0; i < simulationCount; i++) {
            CardShoe simShoe = createConditionalShoe(knownCards);
            Hand newHand = cloneHand(playerHand);
            newHand.addCard(simShoe.deal());

            if (newHand.isBust()) {
                totalGain -= 1; // 爆牌直接输
            } else {
                // 要牌后转为"停牌"评估(简化策略:要一张后不再继续要)
                // 严格实现应递归评估"继续要"还是"停"
                double standEv = evaluateStandSingle(newHand, dealerUpcard, simShoe);
                totalGain += standEv;
            }
            validSims++;
        }

        return (double) totalGain / validSims;
    }

    /**
     * 单次停牌评估(用于要牌后的递归评估)
     */
    private double evaluateStandSingle(Hand playerHand, Card dealerUpcard,
                                        CardShoe shoe) {
        Hand dealerHand = new Hand();
        dealerHand.addCard(dealerUpcard);
        dealerHand.addCard(shoe.deal());

        int dealerResult = dealerAI.play(dealerHand, shoe);
        int playerValue = playerHand.getValue();

        if (dealerResult == -1 || playerValue > dealerResult) return 1.0;
        if (playerValue < dealerResult) return -1.0;
        return 0.0;
    }

    // 辅助方法:克隆手牌
    private Hand cloneHand(Hand hand) {
        Hand clone = new Hand();
        for (Card c : hand.getCards()) clone.addCard(c);
        return clone;
    }

    // 辅助方法:创建条件牌堆(移除已知牌)
    private CardShoe createConditionalShoe(List<Card> knownCards) {
        // 生产级实现应使用"剩余牌概率分布"而非完整牌堆克隆
        // 此处简化:从8副牌中移除已知牌后随机发牌
        CardShoe shoe = new CardShoe(8);
        // ... 移除已知牌的逻辑
        shoe.shuffle();
        return shoe;
    }
}

3.3 最优策略表(Basic Strategy)

通过蒙特卡洛模拟,我们可以生成一张完整的最优决策表。赌场中这张表已被数学家严格证明是最优的。

玩家手牌 庄家明牌2 3 4 5 6 7 8 9 10 A
5-8 H H H H H H H H H H
9 H D D D D H H H H H
10 D D D D D D D D H H
11 D D D D D D D D D H
12 H H S S S H H H H H
13-16 S S S S S H H H H H
17+ S S S S S S S S S S
A,2-A,5 H H H D D H H H H H
A,6 H D D D D H H H H H
A,7 S D D D D S S H H H
A,8-A,9 S S S S S S S S S S
A,A P P P P P P P P P P
8,8 P P P P P P P P P P

图例:H=要牌(Hit),S=停牌(Stand),D=加倍(Double),P=分牌(Split)

四、完整可运行项目

import java.util.*;

/**
 * 完整的21点模拟器
 * 运行后可测试不同策略的长期胜率
 */
public class BlackjackSimulator {
    private final CardShoe shoe;
    private final DealerAI dealer;
    private final Random random;

    // 统计指标
    private int totalRounds = 0;
    private int playerWins = 0;
    private int dealerWins = 0;
    private int pushes = 0;
    private double playerProfit = 0; // 考虑加倍后的净收益

    public BlackjackSimulator(int deckCount) {
        this.shoe = new CardShoe(deckCount);
        this.dealer = new DealerAI(false); // 软17停牌
        this.random = new Random();
    }

    /**
     * 执行一局游戏
     * @param useBasicStrategy 是否使用最优策略表
     */
    public void playRound(boolean useBasicStrategy) {
        if (shoe.needsReshuffle(0.25)) {
            shoe.shuffle();
        }

        Hand playerHand = new Hand();
        Hand dealerHand = new Hand();

        // 初始发牌
        playerHand.addCard(shoe.deal());
        dealerHand.addCard(shoe.deal());
        playerHand.addCard(shoe.deal());
        Card dealerHoleCard = shoe.deal(); // 庄家暗牌
        dealerHand.addCard(dealerHoleCard);

        Card dealerUpcard = dealerHand.getCards().get(0);

        // 检查黑杰克
        if (playerHand.isBlackjack()) {
            if (dealerHand.isBlackjack()) {
                pushes++;
            } else {
                playerWins++;
                playerProfit += 1.5; // 黑杰克赔率3:2
            }
            totalRounds++;
            return;
        }
        if (dealerHand.isBlackjack()) {
            dealerWins++;
            playerProfit -= 1;
            totalRounds++;
            return;
        }

        // 玩家回合
        boolean doubled = false;
        while (true) {
            Action action = useBasicStrategy
                ? getBasicStrategyAction(playerHand, dealerUpcard)
                : getRandomAction(playerHand);

            if (action == Action.STAND) {
                break;
            } else if (action == Action.HIT) {
                playerHand.addCard(shoe.deal());
                if (playerHand.isBust()) break;
            } else if (action == Action.DOUBLE) {
                playerHand.addCard(shoe.deal());
                doubled = true;
                break;
            }
        }

        if (playerHand.isBust()) {
            dealerWins++;
            playerProfit -= (doubled ? 2 : 1);
            totalRounds++;
            return;
        }

        // 庄家回合
        int dealerResult = dealer.play(dealerHand, shoe);

        // 结算
        int playerValue = playerHand.getValue();
        double bet = doubled ? 2 : 1;

        if (dealerResult == -1 || playerValue > dealerResult) {
            playerWins++;
            playerProfit += bet;
        } else if (playerValue < dealerResult) {
            dealerWins++;
            playerProfit -= bet;
        } else {
            pushes++;
        }
        totalRounds++;
    }

    /**
     * 基于最优策略表的决策
     * 简化版:仅实现核心逻辑
     */
    private Action getBasicStrategyAction(Hand hand, Card dealerUpcard) {
        int value = hand.getValue();
        boolean soft = hand.isSoft();
        int dealerVal = dealerUpcard.getValue();

        // 对子分牌逻辑(简化)
        if (hand.size() == 2 && hand.getCards().get(0).getValue() == hand.getCards().get(1).getValue()) {
            int pairVal = hand.getCards().get(0).getValue();
            if (pairVal == 11 || pairVal == 8) return Action.SPLIT; // A或8永远分
            if (pairVal == 10) return Action.STAND; // 10/ face card 不分
        }

        // 软牌策略
        if (soft) {
            if (value >= 19) return Action.STAND;
            if (value == 18) {
                if (dealerVal >= 2 && dealerVal <= 6) return Action.DOUBLE;
                return Action.STAND;
            }
            if (value == 17) {
                if (dealerVal >= 3 && dealerVal <= 6) return Action.DOUBLE;
                return Action.HIT;
            }
            if (value >= 15 && value <= 16) {
                if (dealerVal >= 4 && dealerVal <= 6) return Action.DOUBLE;
                return Action.HIT;
            }
            if (value >= 13 && value <= 14) {
                if (dealerVal >= 5 && dealerVal <= 6) return Action.DOUBLE;
                return Action.HIT;
            }
            return Action.HIT; // A+2 到 A+5
        }

        // 硬牌策略
        if (value >= 17) return Action.STAND;
        if (value >= 13 && value <= 16) {
            if (dealerVal >= 2 && dealerVal <= 6) return Action.STAND;
            return Action.HIT;
        }
        if (value == 12) {
            if (dealerVal >= 4 && dealerVal <= 6) return Action.STAND;
            return Action.HIT;
        }
        if (value == 11) return Action.DOUBLE;
        if (value == 10) {
            if (dealerVal >= 2 && dealerVal <= 9) return Action.DOUBLE;
            return Action.HIT;
        }
        if (value == 9) {
            if (dealerVal >= 3 && dealerVal <= 6) return Action.DOUBLE;
            return Action.HIT;
        }
        return Action.HIT; // 5-8
    }

    private Action getRandomAction(Hand hand) {
        if (hand.getValue() >= 17) return Action.STAND;
        return random.nextBoolean() ? Action.HIT : Action.STAND;
    }

    public void printStats() {
        System.out.println("========== 21点模拟统计 ==========");
        System.out.println("总对局数: " + totalRounds);
        System.out.printf("玩家胜率: %.2f%%\n", 100.0 * playerWins / totalRounds);
        System.out.printf("庄家胜率: %.2f%%\n", 100.0 * dealerWins / totalRounds);
        System.out.printf("平局率:   %.2f%%\n", 100.0 * pushes / totalRounds);
        System.out.printf("净收益:   %.2f (单位注码)\n", playerProfit);
        System.out.printf("每局期望: %.4f\n", playerProfit / totalRounds);
    }

    enum Action { HIT, STAND, DOUBLE, SPLIT }

    public static void main(String[] args) {
        // 测试最优策略 vs 随机策略
        System.out.println(">>> 测试最优策略 (Basic Strategy)");
        BlackjackSimulator optimal = new BlackjackSimulator(8);
        for (int i = 0; i < 100000; i++) {
            optimal.playRound(true);
        }
        optimal.printStats();

        System.out.println("\n>>> 测试随机策略");
        BlackjackSimulator randomSim = new BlackjackSimulator(8);
        for (int i = 0; i < 100000; i++) {
            randomSim.playRound(false);
        }
        randomSim.printStats();
    }
}

五、算法复杂度与核心洞察

5.1 复杂度分析

模块 时间复杂度 空间复杂度 说明
Fisher-Yates洗牌 O(n) O(1) n为总牌数,原地交换
手牌点数计算 O(k) O(1) k为手牌数(通常k<10)
单次蒙特卡洛模拟 O(m) O(1) m为模拟对局数
最优策略表生成 O(10×10×m) O(1) 10种玩家状态×10种庄家明牌

5.2 关键洞察

  1. A的计分不是动态规划问题,而是贪心降级:先按最大值计算,爆牌后逐步降级A。因为A只能降不能升,所以贪心策略等价于最优。

  2. Fisher-Yates优于随机插入:Collections.shuffle()在Java底层正是使用Fisher-Yates,这是因为它避免了早期算法的O(n²)时间和不均匀分布问题。

  3. 21点是”接近可解”的博弈:通过完整的基本策略表,玩家可以将赌场优势压缩到约0.5%。若进一步结合算牌(Hi-Lo系统),甚至能获得微弱正期望收益——这也是电影《决胜21点》的数学基础。

六、扩展方向

  • 算牌系统:实现Hi-Lo计数法,根据剩余大牌/小牌比例动态调整下注额
  • 分牌处理:完整支持一对牌拆分为两手独立对战
  • 保险决策:当庄家明牌为A时,计算暗牌为10点牌的概率
  • 多玩家模拟:扩展为多人对战模式,分析座位位置对胜率的影响

七、总结

本文以21点为场景,实现了从洗牌随机化到最优决策的完整算法链路。核心收获包括:Fisher-Yates洗牌的O(n)均匀随机原理、A牌灵活计分的贪心降级策略、以及蒙特卡洛模拟在博弈决策中的工程应用。通过对比实验可见,使用最优策略的玩家在10万局模拟中的胜率显著优于随机决策,验证了概率模型的实战价值。