一、游戏背景与算法目标
21点(Blackjack)是全球赌场中最受欢迎的纸牌游戏之一。玩家与庄家对弈,目标是使手中牌的总点数尽可能接近21点但不超过21点。A可计为1或11,J/Q/K计为10,其余牌按面值计算。
本文的算法目标不是实现一个带GUI的21点游戏,而是构建一个数学上最优的21点决策引擎。核心包含两大算法模块:
- Fisher-Yates洗牌算法:确保牌序彻底随机,为模拟提供公平的实验环境
- 概率期望决策模型:通过蒙特卡洛大量模拟,计算在任意手牌状态下”要牌”、”停牌”、”加倍”、”分牌”四种动作的长期期望收益,从而推导出最优策略表
读者将学到:如何从零设计一个纸牌随机系统、如何处理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 关键洞察
-
A的计分不是动态规划问题,而是贪心降级:先按最大值计算,爆牌后逐步降级A。因为A只能降不能升,所以贪心策略等价于最优。
-
Fisher-Yates优于随机插入:Collections.shuffle()在Java底层正是使用Fisher-Yates,这是因为它避免了早期算法的O(n²)时间和不均匀分布问题。
-
21点是”接近可解”的博弈:通过完整的基本策略表,玩家可以将赌场优势压缩到约0.5%。若进一步结合算牌(Hi-Lo系统),甚至能获得微弱正期望收益——这也是电影《决胜21点》的数学基础。
六、扩展方向
- 算牌系统:实现Hi-Lo计数法,根据剩余大牌/小牌比例动态调整下注额
- 分牌处理:完整支持一对牌拆分为两手独立对战
- 保险决策:当庄家明牌为A时,计算暗牌为10点牌的概率
- 多玩家模拟:扩展为多人对战模式,分析座位位置对胜率的影响
七、总结
本文以21点为场景,实现了从洗牌随机化到最优决策的完整算法链路。核心收获包括:Fisher-Yates洗牌的O(n)均匀随机原理、A牌灵活计分的贪心降级策略、以及蒙特卡洛模拟在博弈决策中的工程应用。通过对比实验可见,使用最优策略的玩家在10万局模拟中的胜率显著优于随机决策,验证了概率模型的实战价值。