每日算法 — 使用java实现二十一点:Hi-Lo卡牌计数与期望值决策

游戏简介与算法核心

二十一点(Blackjack)是赌场中最受欢迎的纸牌游戏之一。玩家的目标是使手牌点数尽可能接近21点,但不能超过。本文将用Java实现一个带Hi-Lo卡牌计数系统的二十一点AI,讲解如何通过统计已出牌来估算剩余牌堆的组成,从而做出最优下注和决策。

Hi-Lo卡牌计数原理

Hi-Lo系统是最经典的卡牌计数方法,将每张牌赋予一个计数值:

  • +1:2、3、4、5、6(小牌多出对庄家有利)
  • 0:7、8、9(中性牌)
  • -1:10、J、Q、K、A(大牌多出对玩家有利)

随着游戏进行,我们维护一个 running count (流水计数)。当 running count 为正时,说明剩余牌堆中大牌比例较高,玩家优势增加;为负时则庄家优势增加。为了消除牌堆数量的影响,需要计算 true count = running count / 剩余牌堆数。

基础牌类与牌堆实现

import java.util.*;

/**
 * 扑克牌类,包含点数和花色
 */
class Card {
    enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES }

    private final Suit suit;
    private final String rank; // "2"~"10", "J", "Q", "K", "A"
    private final int value;   // 2~10, 10, 10, 10, 11

    public Card(Suit suit, String rank) {
        this.suit = suit;
        this.rank = rank;
        this.value = calculateValue(rank);
    }

    private int calculateValue(String rank) {
        switch (rank) {
            case "A": return 11;
            case "K": case "Q": case "J": return 10;
            default: return Integer.parseInt(rank);
        }
    }

    // Hi-Lo计数值
    public int getHiLoValue() {
        int v = getHardValue();
        if (v >= 2 && v <= 6) return 1;
        if (v >= 10 || rank.equals("A")) return -1;
        return 0;
    }

    public int getHardValue() { return value; }
    public String getRank() { return rank; }
    public Suit getSuit() { return suit; }

    @Override
    public String toString() {
        return rank + suit.name().charAt(0);
    }
}

/**
 * 牌堆类,支持多副牌、洗牌和发牌
 */
class Deck {
    private static final String[] RANKS = 
        {"2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"};
    private final List<Card> cards = new ArrayList<>();
    private final int numDecks;
    private int currentIndex = 0;

    public Deck(int numDecks) {
        this.numDecks = numDecks;
        reset();
    }

    public void reset() {
        cards.clear();
        for (int d = 0; d < numDecks; d++) {
            for (Card.Suit suit : Card.Suit.values()) {
                for (String rank : RANKS) {
                    cards.add(new Card(suit, rank));
                }
            }
        }
        shuffle();
    }

    public void shuffle() {
        Collections.shuffle(cards, new Random(System.nanoTime()));
        currentIndex = 0;
    }

    public Card deal() {
        if (currentIndex >= cards.size()) {
            throw new IllegalStateException("牌堆已空");
        }
        return cards.get(currentIndex++);
    }

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

    public double remainingDecks() {
        return remainingCards() / 52.0;
    }

    public boolean needsShuffle(double penetration) {
        // 当已发牌比例超过 penetration 时重新洗牌
        return (double) currentIndex / cards.size() > penetration;
    }
}

Hi-Lo计数器与基本策略表

/**
 * Hi-Lo卡牌计数器,追踪流水计数和真实计数
 */
class HiLoCounter {
    private int runningCount = 0;
    private int cardsSeen = 0;
    private final int totalDecks;

    public HiLoCounter(int totalDecks) {
        this.totalDecks = totalDecks;
    }

    public void countCard(Card card) {
        runningCount += card.getHiLoValue();
        cardsSeen++;
    }

    public void reset() {
        runningCount = 0;
        cardsSeen = 0;
    }

    public int getRunningCount() {
        return runningCount;
    }

    /**
     * 计算真实计数 = 流水计数 / 剩余牌堆数
     * 这是决定下注大小的关键指标
     */
    public double getTrueCount(double remainingDecks) {
        if (remainingDecks <= 0) return 0;
        return runningCount / remainingDecks;
    }

    public int getCardsSeen() {
        return cardsSeen;
    }
}

/**
 * 基本策略表:根据玩家手牌和庄家明牌决定最优动作
 * H=Hit(要牌), S=Stand(停牌), D=Double(加倍), Sp=Split(分牌)
 */
class BasicStrategy {
    // 硬牌策略表 [玩家点数-5][庄家明牌值-2]
    private static final String[][] HARD_STRATEGY = {
        // 2  3  4  5  6  7  8  9  10 A  (庄家)
        {"H","H","H","H","H","H","H","H","H","H"}, // 5
        {"H","H","H","H","H","H","H","H","H","H"}, // 6
        {"H","H","H","H","H","H","H","H","H","H"}, // 7
        {"H","H","H","H","H","H","H","H","H","H"}, // 8
        {"H","D","D","D","D","H","H","H","H","H"}, // 9
        {"D","D","D","D","D","D","D","D","H","H"}, // 10
        {"D","D","D","D","D","D","D","D","D","H"}, // 11
        {"H","H","S","S","S","H","H","H","H","H"}, // 12
        {"S","S","S","S","S","H","H","H","H","H"}, // 13
        {"S","S","S","S","S","H","H","H","H","H"}, // 14
        {"S","S","S","S","S","H","H","H","H","H"}, // 15
        {"S","S","S","S","S","H","H","H","H","H"}, // 16
        {"S","S","S","S","S","S","S","S","S","S"}, // 17
        {"S","S","S","S","S","S","S","S","S","S"}, // 18
        {"S","S","S","S","S","S","S","S","S","S"}, // 19
        {"S","S","S","S","S","S","S","S","S","S"}, // 20
        {"S","S","S","S","S","S","S","S","S","S"}, // 21
    };

    // 软牌策略表 (含A) [软点数-13][庄家明牌值-2]
    private static final String[][] SOFT_STRATEGY = {
        {"H","H","H","D","D","H","H","H","H","H"}, // A2 (13)
        {"H","H","H","D","D","H","H","H","H","H"}, // A3 (14)
        {"H","H","D","D","D","H","H","H","H","H"}, // A4 (15)
        {"H","H","D","D","D","H","H","H","H","H"}, // A5 (16)
        {"H","D","D","D","D","H","H","H","H","H"}, // A6 (17)
        {"D","D","D","D","D","S","S","H","H","H"}, // A7 (18)
        {"S","S","S","S","D","S","S","S","S","S"}, // A8 (19)
        {"S","S","S","S","S","S","S","S","S","S"}, // A9 (20)
    };

    /**
     * 获取最优动作
     * @param playerValue 玩家手牌点数
     * @param isSoft 是否软牌(含A且A计为11)
     * @param dealerUpCard 庄家明牌
     * @return "H", "S", "D", "Sp"
     */
    public String getAction(int playerValue, boolean isSoft, Card dealerUpCard) {
        int dealerIdx = getDealerIndex(dealerUpCard);

        if (isSoft && playerValue >= 13 && playerValue <= 20) {
            return SOFT_STRATEGY[playerValue - 13][dealerIdx];
        }

        if (playerValue >= 5 && playerValue <= 21) {
            return HARD_STRATEGY[playerValue - 5][dealerIdx];
        }

        return "S"; // 默认停牌
    }

    private int getDealerIndex(Card card) {
        int v = card.getHardValue();
        if (v >= 10) return 8; // 10
        if (v == 11) return 9; // A
        return v - 2;
    }
}

手牌管理与游戏逻辑

/**
 * 手牌类,管理玩家或庄家的牌
 */
class Hand {
    private final List<Card> cards = new ArrayList<>();

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

    public int getValue() {
        int value = 0;
        int aces = 0;
        for (Card c : cards) {
            value += c.getHardValue();
            if (c.getRank().equals("A")) aces++;
        }
        // A从11降为1,避免爆牌
        while (value > 21 && aces > 0) {
            value -= 10;
            aces--;
        }
        return value;
    }

    public boolean isSoft() {
        int value = 0;
        int aces = 0;
        for (Card c : cards) {
            value += c.getHardValue();
            if (c.getRank().equals("A")) aces++;
        }
        // 检查是否存在A可以计为11而不爆牌
        while (value > 21 && aces > 0) {
            value -= 10;
            aces--;
        }
        return aces > 0; // 仍有A计为11
    }

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

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

    public List<Card> getCards() {
        return new ArrayList<>(cards);
    }

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

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

/**
 * 二十一点游戏引擎,包含完整的游戏流程和AI决策
 */
class BlackjackEngine {
    private final Deck deck;
    private final HiLoCounter counter;
    private final BasicStrategy strategy;
    private final Hand playerHand = new Hand();
    private final Hand dealerHand = new Hand();
    private double penetration = 0.75; // 洗牌穿透率

    public BlackjackEngine(int numDecks) {
        this.deck = new Deck(numDecks);
        this.counter = new HiLoCounter(numDecks);
        this.strategy = new BasicStrategy();
    }

    /**
     * 根据真实计数计算最优下注额
     * 真实计数越高,玩家优势越大,下注越大
     */
    public double calculateBet(double baseBet, double maxBet) {
        double trueCount = counter.getTrueCount(deck.remainingDecks());

        // Kelly准则简化版:bet = base * max(0, trueCount - 1)
        double multiplier = Math.max(0, trueCount - 1);
        double bet = baseBet * (1 + multiplier);
        return Math.min(bet, maxBet);
    }

    /**
     * 执行一轮游戏,返回玩家净收益(正为赢,负为输)
     */
    public double playRound(double bet) {
        if (deck.needsShuffle(penetration)) {
            deck.reset();
            counter.reset();
            System.out.println("=== 重新洗牌 ===");
        }

        playerHand.clear();
        dealerHand.clear();

        // 发初始牌
        Card p1 = deck.deal(); counter.countCard(p1); playerHand.addCard(p1);
        Card d1 = deck.deal(); counter.countCard(d1); dealerHand.addCard(d1);
        Card p2 = deck.deal(); counter.countCard(p2); playerHand.addCard(p2);
        Card d2 = deck.deal(); counter.countCard(d2); dealerHand.addCard(d2);

        System.out.println("玩家: " + playerHand);
        System.out.println("庄家明牌: " + d1);

        // 检查黑杰克
        if (playerHand.isBlackjack()) {
            if (dealerHand.isBlackjack()) return 0; // 平局
            return bet * 1.5; // 黑杰克赔1.5倍
        }
        if (dealerHand.isBlackjack()) {
            return -bet;
        }

        // 玩家决策
        boolean playerBust = playerTurn();
        if (playerBust) {
            System.out.println("玩家爆牌! " + playerHand);
            return -bet;
        }

        // 庄家决策
        dealerTurn();

        // 结算
        return settleBet(bet);
    }

    private boolean playerTurn() {
        while (true) {
            int value = playerHand.getValue();
            if (value > 21) return true; // 爆牌

            String action = strategy.getAction(value, playerHand.isSoft(), 
                                               dealerHand.getCards().get(0));
            System.out.println("玩家决策: " + action + " (手牌" + value + ")");

            if (action.equals("S")) break;
            if (action.equals("H")) {
                Card c = deck.deal(); counter.countCard(c); playerHand.addCard(c);
            }
            if (action.equals("D")) {
                // 简化处理:加倍后只发一张牌
                Card c = deck.deal(); counter.countCard(c); playerHand.addCard(c);
                break;
            }
        }
        return false;
    }

    private void dealerTurn() {
        // 庄家软17点必须要牌
        while (dealerHand.getValue() < 17 || 
               (dealerHand.getValue() == 17 && dealerHand.isSoft())) {
            Card c = deck.deal(); counter.countCard(c); dealerHand.addCard(c);
        }
        System.out.println("庄家: " + dealerHand);
    }

    private double settleBet(double bet) {
        int pv = playerHand.getValue();
        int dv = dealerHand.getValue();

        if (dv > 21 || pv > dv) {
            System.out.println("玩家赢!");
            return bet;
        } else if (pv < dv) {
            System.out.println("庄家赢!");
            return -bet;
        } else {
            System.out.println("平局!");
            return 0;
        }
    }

    public double getTrueCount() {
        return counter.getTrueCount(deck.remainingDecks());
    }

    public int getRunningCount() {
        return counter.getRunningCount();
    }
}

主程序与模拟测试

/**
 * 主程序:运行二十一点模拟,统计卡牌计数的效果
 */
public class BlackjackMain {
    public static void main(String[] args) {
        int numDecks = 6;
        int numRounds = 1000;
        double baseBet = 10;
        double maxBet = 100;
        double bankroll = 1000;

        BlackjackEngine engine = new BlackjackEngine(numDecks);
        double totalProfit = 0;
        int wins = 0, losses = 0, pushes = 0;

        System.out.println("=== 二十一点 Hi-Lo 卡牌计数模拟 ===");
        System.out.println("牌堆数: " + numDecks);
        System.out.println("模拟轮数: " + numRounds);
        System.out.println();

        for (int i = 0; i < numRounds; i++) {
            double trueCount = engine.getTrueCount();
            double bet = engine.calculateBet(baseBet, maxBet);

            double result = engine.playRound(bet);
            totalProfit += result;
            bankroll += result;

            if (result > 0) wins++;
            else if (result < 0) losses++;
            else pushes++;

            if ((i + 1) % 100 == 0) {
                System.out.printf("第%d轮: 资金=%.2f, 累计收益=%.2f, 真实计数=%.2f%n",
                    i + 1, bankroll, totalProfit, trueCount);
            }
        }

        System.out.println("\n=== 模拟结果统计 ===");
        System.out.println("总收益: " + totalProfit);
        System.out.println("胜率: " + (double) wins / numRounds * 100 + "%");
        System.out.println("败率: " + (double) losses / numRounds * 100 + "%");
        System.out.println("平局率: " + (double) pushes / numRounds * 100 + "%");
        System.out.println("平均每轮收益: " + totalProfit / numRounds);
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度
发牌 O(1) O(1)
Hi-Lo计数更新 O(1) O(1)
真实计数计算 O(1) O(1)
基本策略查表 O(1) O(1)
手牌点数计算 O(k),k为手牌数 O(1)
整轮游戏 O(1) ~ O(发牌数) O(1)

总结

本文实现了一个完整的二十一点卡牌计数系统,核心算法包括:

  1. Hi-Lo计数:通过给不同牌赋分,追踪剩余牌堆的组成偏向
  2. 真实计数:消除牌堆数量影响,得到标准化优势指标
  3. 基本策略:基于数学期望的最优决策表,使玩家损失最小化
  4. Kelly下注:根据优势大小动态调整下注额

卡牌计数不改变每一手牌的胜率,但通过在高优势时下大注、低优势时下小注,长期能够获得正期望值。这也是赌场禁止使用卡牌计数的原因。