游戏简介与算法核心
二十一点(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) |
总结
本文实现了一个完整的二十一点卡牌计数系统,核心算法包括:
- Hi-Lo计数:通过给不同牌赋分,追踪剩余牌堆的组成偏向
- 真实计数:消除牌堆数量影响,得到标准化优势指标
- 基本策略:基于数学期望的最优决策表,使玩家损失最小化
- Kelly下注:根据优势大小动态调整下注额
卡牌计数不改变每一手牌的胜率,但通过在高优势时下大注、低优势时下小注,长期能够获得正期望值。这也是赌场禁止使用卡牌计数的原因。