每日算法 — 使用java实现21点:基本策略表与Hi-Lo卡牌计数

21点(Blackjack)是赌场中最经典的纸牌游戏之一。玩家在不超过21点的前提下,尽量使手牌点数接近21点并超过庄家。看似简单的规则背后,隐藏着丰富的概率决策空间——从”基本策略表”到”卡牌计数系统”,数学家们早已证明了通过算法可以在这个游戏中获得统计优势。

本文将用Java实现一个完整的21点模拟器,核心聚焦两大算法:基本策略表(Basic Strategy)Hi-Lo卡牌计数系统。前者通过预计算的概率矩阵给出每一局的最优决策,后者则通过动态跟踪牌堆中高牌与低牌的比例,在牌局有利时下大注、不利时下小注。

游戏规则与点数计算

21点使用1-8副标准扑克牌(每副52张)。数字牌按面值计点,J/Q/K计10点,A可计1点或11点(取对玩家更有利的值)。初始每人发两张牌,玩家可选择”要牌(Hit)”、”停牌(Stand)”、”加倍(Double)”或”分牌(Split)”。庄家在玩家完成后按固定规则(软17点或硬17点以上停牌,否则要牌)操作。

核心算法一:基本策略表

基本策略表是21点的”最优解字典”。它基于玩家当前手牌(硬点数/软点数/对子)和庄家明牌,通过穷举所有可能的牌序组合并计算期望值,得出每个状态下的最优动作。以下是一个简化的硬手策略表示例:

玩家点数 庄家2 庄家3 庄家4 庄家5 庄家6 庄家7 庄家8 庄家9 庄家10 庄家A
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-21 S S S S S S S S S S

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

核心算法二:Hi-Lo卡牌计数

Hi-Lo系统由Harvey Dubner于1963年提出,是最广泛使用的卡牌计数方法:

  • 2, 3, 4, 5, 6:计 +1(小牌流出,剩余大牌比例上升,对玩家有利)
  • 7, 8, 9:计 0(中性牌,不影响局势)
  • 10, J, Q, K, A:计 -1(大牌流出,剩余小牌比例上升,对庄家有利)

真实计数(True Count)= 累计计数(Running Count)/ 剩余牌堆数

当真实计数 ≥ +2 时,玩家应增大下注;≤ -1 时则应减小下注或观望。

完整Java实现

import java.util.*;

/**
 * 21点(Blackjack)模拟器 - 基本策略表 + Hi-Lo卡牌计数
 * 核心算法:
 * 1. BasicStrategy: 基于概率预计算的最优决策表
 * 2. HiLoCounter: 动态跟踪牌堆高牌/低牌比例的计数系统
 */
public class BlackjackSimulator {

    // ==================== 枚举定义 ====================
    enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES }
    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);

        final int value;
        Rank(int value) { this.value = value; }
    }

    enum Action { HIT, STAND, DOUBLE, SPLIT }

    // ==================== 纸牌类 ====================
    static class Card {
        final Suit suit;
        final Rank rank;

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

        int getValue() { return rank.value; }

        boolean isAce() { return rank == Rank.ACE; }

        /**
         * Hi-Lo计数系统的点值分配
         * 小牌(2-6)计+1,中牌(7-9)计0,大牌(10-A)计-1
         */
        int getHiLoValue() {
            return switch (rank) {
                case TWO, THREE, FOUR, FIVE, SIX -> 1;
                case SEVEN, EIGHT, NINE -> 0;
                case TEN, JACK, QUEEN, KING, ACE -> -1;
            };
        }

        @Override
        public String toString() {
            return rank.name().charAt(0) + rank.name().substring(1).toLowerCase()
                   + " of " + suit.name().charAt(0) + suit.name().substring(1).toLowerCase();
        }
    }

    // ==================== 手牌类 ====================
    static class Hand {
        private final List<Card> cards = new ArrayList<>();

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

        /**
         * 计算手牌点数,A自动按1或11计算取最优值
         * 动态调整A的值以避免爆牌
         */
        int getValue() {
            int sum = 0, aces = 0;
            for (Card c : cards) {
                sum += c.getValue();
                if (c.isAce()) aces++;
            }
            // A从11降为1,每次减10,直到不爆牌或没有A为止
            while (sum > 21 && aces > 0) {
                sum -= 10;
                aces--;
            }
            return sum;
        }

        boolean isBust() { return getValue() > 21; }

        boolean isBlackjack() { return cards.size() == 2 && getValue() == 21; }

        boolean isSoft() {
            // 软手:包含A且A按11计算时不爆牌
            int sum = 0, aces = 0;
            for (Card c : cards) {
                sum += c.getValue();
                if (c.isAce()) aces++;
            }
            return aces > 0 && sum <= 21;
        }

        boolean isPair() {
            return cards.size() == 2 && cards.get(0).rank == cards.get(1).rank;
        }

        Card getFirst() { return cards.get(0); }

        List<Card> getCards() { return Collections.unmodifiableList(cards); }

        @Override
        public String toString() {
            return cards + " = " + getValue();
        }
    }

    // ==================== 牌堆类(支持多副牌) ====================
    static class Shoe {
        private final List<Card> cards = new ArrayList<>();
        private final int decksCount;
        private final Random random = new Random();

        Shoe(int decksCount) {
            this.decksCount = decksCount;
            reset();
        }

        void reset() {
            cards.clear();
            // 初始化多副牌
            for (int d = 0; d < decksCount; d++) {
                for (Suit suit : Suit.values()) {
                    for (Rank rank : Rank.values()) {
                        cards.add(new Card(suit, rank));
                    }
                }
            }
            Collections.shuffle(cards, random);
        }

        Card deal() {
            if (cards.isEmpty()) throw new IllegalStateException("牌堆已空");
            return cards.remove(cards.size() - 1);
        }

        int remainingDecks() {
            // 每副52张,向上取整估算剩余牌堆数(最少为1防止除零)
            return Math.max(1, (int) Math.ceil(cards.size() / 52.0));
        }

        int size() { return cards.size(); }
    }

    // ==================== Hi-Lo卡牌计数器 ====================
    static class HiLoCounter {
        private int runningCount = 0;      // 累计计数
        private int totalCardsSeen = 0;    // 已见牌数(用于估算剩余牌堆)
        private final int decksInShoe;     // 初始牌副数

        HiLoCounter(int decksInShoe) {
            this.decksInShoe = decksInShoe;
        }

        void reset() {
            runningCount = 0;
            totalCardsSeen = 0;
        }

        /**
         * 记录一张已发出的牌,更新累计计数
         */
        void count(Card card) {
            runningCount += card.getHiLoValue();
            totalCardsSeen++;
        }

        /**
         * 真实计数 = 累计计数 / 剩余牌堆数
         * 关键指标:真实计数 > 0 表示牌堆中高牌偏多(对玩家有利)
         */
        double getTrueCount() {
            double remainingDecks = decksInShoe - (totalCardsSeen / 52.0);
            remainingDecks = Math.max(0.5, remainingDecks); // 防止除零
            return runningCount / remainingDecks;
        }

        int getRunningCount() { return runningCount; }

        /**
         * 根据真实计数推荐下注单位数
         * 计数越高,优势越大,下注越大
         */
        int getBetUnits() {
            double tc = getTrueCount();
            if (tc <= -1) return 1;      // 劣势,最小注
            if (tc < 1) return 1;        // 均势,基准注
            if (tc < 2) return 2;        // 微优势
            if (tc < 3) return 4;        // 中等优势
            if (tc < 5) return 8;        // 较大优势
            return 12;                   // 极大优势
        }

        @Override
        public String toString() {
            return String.format("RC=%d, TC=%.2f, Bet=%d units",
                runningCount, getTrueCount(), getBetUnits());
        }
    }

    // ==================== 基本策略表 ====================
    static class BasicStrategy {

        /**
         * 决策主入口:根据玩家手牌和庄家明牌返回最优动作
         * 决策优先级:对子分牌 > 软手策略 > 硬手策略
         */
        Action decide(Hand playerHand, Card dealerUpCard) {
            int dealerValue = dealerUpCard.getValue();

            // 1. 对子优先判断是否分牌
            if (playerHand.isPair()) {
                Action splitAction = getPairSplitAction(playerHand.getFirst().rank, dealerValue);
                if (splitAction != null) return splitAction;
            }

            // 2. 软手(含A按11计算)策略
            if (playerHand.isSoft()) {
                return getSoftHandAction(playerHand.getValue(), dealerValue);
            }

            // 3. 硬手策略
            return getHardHandAction(playerHand.getValue(), dealerValue);
        }

        /**
         * 硬手策略表(无A或A只能按1计算)
         * 硬17以上必须停牌,硬11以下通常要牌,中间区间视庄家牌而定
         */
        private Action getHardHandAction(int playerValue, int dealerValue) {
            if (playerValue >= 17) return Action.STAND;
            if (playerValue <= 8) return Action.HIT;
            if (playerValue == 9) {
                return (dealerValue >= 3 && dealerValue <= 6) ? Action.DOUBLE : Action.HIT;
            }
            if (playerValue == 10) {
                return (dealerValue >= 2 && dealerValue <= 9) ? Action.DOUBLE : Action.HIT;
            }
            if (playerValue == 11) {
                return (dealerValue >= 2 && dealerValue <= 10) ? Action.DOUBLE : Action.HIT;
            }
            if (playerValue == 12) {
                return (dealerValue >= 4 && dealerValue <= 6) ? Action.STAND : Action.HIT;
            }
            // 13-16
            return (dealerValue >= 2 && dealerValue <= 6) ? Action.STAND : Action.HIT;
        }

        /**
         * 软手策略表(A按11计算)
         * 软手可以安全要牌(A可降为1),因此更激进
         */
        private Action getSoftHandAction(int playerValue, int dealerValue) {
            if (playerValue >= 19) return Action.STAND;
            if (playerValue == 18) {
                if (dealerValue >= 2 && dealerValue <= 6) return Action.DOUBLE;
                if (dealerValue >= 9) return Action.HIT;
                return Action.STAND;
            }
            if (playerValue == 17) {
                return (dealerValue >= 3 && dealerValue <= 6) ? Action.DOUBLE : Action.HIT;
            }
            if (playerValue == 16 || playerValue == 15) {
                return (dealerValue >= 4 && dealerValue <= 6) ? Action.DOUBLE : Action.HIT;
            }
            // 软13-14
            return (dealerValue >= 5 && dealerValue <= 6) ? Action.DOUBLE : Action.HIT;
        }

        /**
         * 对子分牌策略表
         * A和8永远分牌,10和5永不分牌,其余视庄家牌而定
         */
        private Action getPairSplitAction(Rank rank, int dealerValue) {
            return switch (rank) {
                case ACE, EIGHT -> Action.SPLIT;
                case TEN, JACK, QUEEN, KING, FIVE -> null; // 不分牌,返回null走硬手/软手逻辑
                case TWO, THREE, SEVEN ->
                    (dealerValue >= 2 && dealerValue <= 7) ? Action.SPLIT : null;
                case FOUR ->
                    (dealerValue >= 5 && dealerValue <= 6) ? Action.SPLIT : null;
                case SIX ->
                    (dealerValue >= 2 && dealerValue <= 6) ? Action.SPLIT : null;
                case NINE ->
                    (dealerValue >= 2 && dealerValue <= 9 && dealerValue != 7) ? Action.SPLIT : null;
            };
        }
    }

    // ==================== 游戏引擎 ====================
    static class GameEngine {
        private final Shoe shoe;
        private final BasicStrategy strategy;
        private final HiLoCounter counter;
        private final int baseBet; // 基准下注额

        GameEngine(int decks, int baseBet) {
            this.shoe = new Shoe(decks);
            this.strategy = new BasicStrategy();
            this.counter = new HiLoCounter(decks);
            this.baseBet = baseBet;
        }

        /**
         * 执行一局完整的21点游戏
         * 返回净盈亏(正数赢,负数输)
         */
        int playRound() {
            // 检查是否需要洗牌(剩余少于1/4副牌时重洗)
            if (shoe.size() < 13) {
                shoe.reset();
                counter.reset();
            }

            // 根据真实计数确定下注额
            int bet = baseBet * counter.getBetUnits();

            // 发初始四张牌
            Hand playerHand = new Hand();
            Hand dealerHand = new Hand();

            Card p1 = shoe.deal(), d1 = shoe.deal();
            Card p2 = shoe.deal(), d2 = shoe.deal();

            playerHand.add(p1); playerHand.add(p2);
            dealerHand.add(d1); dealerHand.add(d2);

            // 更新计数器(玩家可见的牌:自己和庄家明牌)
            counter.count(p1); counter.count(p2); counter.count(d1);
            // 暗牌d2暂不计数(模拟真实游戏:玩家看不到庄家暗牌)

            // 检查黑杰克
            if (playerHand.isBlackjack()) {
                counter.count(d2); // 结算后暗牌可见
                if (dealerHand.isBlackjack()) return 0; // 平局
                return (int) (bet * 1.5); // 黑杰克赔付1.5倍
            }
            if (dealerHand.isBlackjack()) {
                counter.count(d2);
                return -bet;
            }

            // 玩家决策阶段
            boolean playerBust = !playPlayerTurn(playerHand, d1);

            // 结算后暗牌可见,更新计数
            counter.count(d2);

            if (playerBust) return -bet;

            // 庄家决策阶段(固定规则:软17点以上停牌)
            playDealerTurn(dealerHand);

            // 结算
            int playerVal = playerHand.getValue();
            int dealerVal = dealerHand.getValue();

            if (dealerHand.isBust() || playerVal > dealerVal) return bet;
            if (playerVal < dealerVal) return -bet;
            return 0; // 平局
        }

        /**
         * 玩家回合:根据基本策略表循环决策
         * 加倍后只能再发一张牌
         */
        private boolean playPlayerTurn(Hand hand, Card dealerUpCard) {
            while (true) {
                Action action = strategy.decide(hand, dealerUpCard);

                switch (action) {
                    case STAND -> {
                        return true;
                    }
                    case HIT -> {
                        Card c = shoe.deal();
                        hand.add(c);
                        counter.count(c);
                        if (hand.isBust()) return false;
                    }
                    case DOUBLE -> {
                        // 加倍:赌注翻倍,只发一张牌
                        Card c = shoe.deal();
                        hand.add(c);
                        counter.count(c);
                        return !hand.isBust();
                    }
                    case SPLIT -> {
                        // 简化处理:分牌后每手独立按基本策略操作
                        // 实际分牌需要更复杂的逻辑,此处模拟为第一手继续要牌
                        Card c = shoe.deal();
                        hand.add(c);
                        counter.count(c);
                        if (hand.isBust()) return false;
                    }
                }
            }
        }

        /**
         * 庄家回合:固定规则,软17点以上停牌,否则要牌
         */
        private void playDealerTurn(Hand hand) {
            while (hand.getValue() < 17) {
                Card c = shoe.deal();
                hand.add(c);
                counter.count(c);
            }
        }

        HiLoCounter getCounter() { return counter; }
    }

    // ==================== 主程序:蒙特卡洛模拟 ====================
    public static void main(String[] args) {
        final int DECKS = 6;           // 6副牌(标准赌场配置)
        final int BASE_BET = 10;       // 基准下注额
        final int ROUNDS = 100000;     // 模拟局数

        GameEngine engine = new GameEngine(DECKS, BASE_BET);
        int totalProfit = 0;
        int maxDrawdown = 0;
        int peak = 0;

        System.out.println("=== 21点(Blackjack) 蒙特卡洛模拟 ===");
        System.out.printf("配置: %d副牌, 基准注%d, 模拟%d局\n\n", DECKS, BASE_BET, ROUNDS);

        // 分段统计
        int[] segmentProfit = new int[10];
        int segmentSize = ROUNDS / 10;

        for (int i = 1; i <= ROUNDS; i++) {
            int result = engine.playRound();
            totalProfit += result;

            // 计算最大回撤
            if (totalProfit > peak) peak = totalProfit;
            int drawdown = peak - totalProfit;
            if (drawdown > maxDrawdown) maxDrawdown = drawdown;

            int segIdx = (i - 1) / segmentSize;
            if (segIdx < 10) segmentProfit[segIdx] += result;

            // 每1万局输出一次计数器状态
            if (i % 10000 == 0) {
                System.out.printf("局数: %d | 累计盈亏: %d | %s\n",
                    i, totalProfit, engine.getCounter());
            }
        }

        // 输出最终统计
        System.out.println("\n=== 模拟结果统计 ===");
        System.out.printf("总盈亏: %d (%.3f%% 回报率)\n",
            totalProfit, (totalProfit * 100.0) / (BASE_BET * ROUNDS));
        System.out.printf("最大回撤: %d\n", maxDrawdown);
        System.out.printf("最终计数器: %s\n", engine.getCounter());

        System.out.println("\n=== 分段盈亏 ===");
        for (int i = 0; i < 10; i++) {
            System.out.printf("第%02d万局: %s%d\n",
                i + 1, segmentProfit[i] >= 0 ? "+" : "", segmentProfit[i]);
        }

        System.out.println("\n=== 算法说明 ===");
        System.out.println("基本策略表通过预计算的概率期望,确保每张牌都按最优方式处理。");
        System.out.println("Hi-Lo计数系统跟踪牌堆组成,在剩余高牌(10/J/Q/K/A)比例高时下大注,");
        System.out.println("因为高牌比例高时玩家更容易拿到 blackjack(1.5倍赔付),");
        System.out.println("且庄家更容易因硬手爆牌(必须抽到17以上才停)。");
    }
}

算法复杂度分析

模块 时间复杂度 空间复杂度 说明
基本策略查表 O(1) O(1) 硬编码决策矩阵,常数时间查询
Hi-Lo计数更新 O(1) O(1) 每张牌发出时仅需一次加减运算
真实计数计算 O(1) O(1) 除法运算,基于剩余牌堆估算
单局游戏模拟 O(k) O(1) k为单局发出的牌数,通常k≤20
N局蒙特卡洛 O(N) O(1) 线性遍历,除计数器外无额外状态

策略效果与期望值

在标准6副牌、庄家软17停牌规则下:

  • 纯随机策略:玩家期望回报率约为 -5%至-8%(大劣势)
  • 基本策略:期望回报率提升至约 -0.5%(接近均势,赌场优势极小)
  • 基本策略 + Hi-Lo计数:在真实计数有效跟踪下,期望回报率可转为 +0.5%至+1.5%(玩家获得微弱优势)

虽然Hi-Lo计数在实际赌场中已被多重洗牌机(CSM)和频繁洗牌所削弱,但其背后”通过有限信息推断系统状态并动态调整策略”的思想,在量化交易、资源调度和博弈论中有着广泛的迁移应用。

扩展方向

  1. Omega II计数系统:在Hi-Lo基础上细化牌值分级(2=+1, 3=+1, 4=+2, 5=+2, 6=+2, 7=+1, 8=0, 9=-1, 10=-2, A=0),灵敏度更高但心智负担更重。
  2. Kelly Criterion下注公式:将真实计数映射为最优下注比例(f = 优势 / 赔率),实现资金曲线的指数增长与风险控制。
  3. AI强化学习版本:用Q-Learning或策略梯度训练神经网络代理,让AI从零开始自我对弈学习21点最优策略。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注