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)和频繁洗牌所削弱,但其背后”通过有限信息推断系统状态并动态调整策略”的思想,在量化交易、资源调度和博弈论中有着广泛的迁移应用。
扩展方向
- Omega II计数系统:在Hi-Lo基础上细化牌值分级(2=+1, 3=+1, 4=+2, 5=+2, 6=+2, 7=+1, 8=0, 9=-1, 10=-2, A=0),灵敏度更高但心智负担更重。
- Kelly Criterion下注公式:将真实计数映射为最优下注比例(f = 优势 / 赔率),实现资金曲线的指数增长与风险控制。
- AI强化学习版本:用Q-Learning或策略梯度训练神经网络代理,让AI从零开始自我对弈学习21点最优策略。