引言
斗地主是中国最经典的扑克牌游戏之一,三人对抗的玩法融合了运气与策略。作为AI算法研究者,我们可以将斗地主抽象为组合数学与贪心决策问题:如何枚举所有合法牌型、评估手牌价值、以及制定最优出牌策略。本文将用Java实现斗地主的核心牌型识别引擎与贪心AI决策系统。
问题建模
一副扑克54张(含大小王),三人游戏每人17张,留3张底牌。斗地主的算法核心包括:
- 牌型表示:如何高效表示手牌,支持快速的牌型检测与出牌操作
- 牌型识别:给定一组牌,判断它们构成什么牌型(单张、对子、三张、顺子、连对、飞机、炸弹等)
- 合法出牌:给定当前出牌,计算手牌中所有能压过的牌型组合
- 贪心策略:在多个可选出牌中,选择最优策略(尽快走完、保留炸弹、配合队友等)
核心算法设计
1. 手牌表示
使用计数数组表示手牌,索引0-12对应3-A-2(斗地主点数顺序),索引13为小王,14为大王。数组值表示该点数的牌数量(0-4)。
2. 牌型枚举
斗地主共有约15种牌型,可分为:
– 单牌型:单张、对子、三张、炸弹、王炸
– 带牌型:三带一、三带二、四带二
– 连续型:单顺(5+连张)、连对(3+连对)、飞机(2+连三)
对于带牌和连续型,需要组合数学枚举所有可能。
3. 贪心出牌策略
核心启发式:
– 优先出非炸弹的牌型,保留炸弹到最后
– 优先出难以组成的牌型(如长顺子),保留灵活的牌
– 牌力评估:计算每种出牌后剩余手牌的”走牌难度”
– 压牌策略:对手出牌时,能用最小代价压住则压,否则选择过
Java完整实现
import java.util.*;
/**
* 斗地主牌型识别与贪心出牌策略实现
*/
public class DoudizhuAI {
// 点数:3,4,5,6,7,8,9,10,J,Q,K,A,2,小王,大王
private static final String[] RANK_NAMES = {
"3","4","5","6","7","8","9","10","J","Q","K","A","2","小王","大王"
};
// 牌型定义
enum HandType {
INVALID, // 无效
SINGLE, // 单张
PAIR, // 对子
TRIPLE, // 三张
BOMB, // 炸弹
ROCKET, // 王炸
TRIPLE_SINGLE, // 三带一
TRIPLE_PAIR, // 三带二
STRAIGHT, // 单顺(5张及以上)
DOUBLE_STRAIGHT,// 连对(3对及以上)
PLANE, // 飞机(2个及以上连续三张)
PLANE_SINGLE, // 飞机带翅膀(单张)
PLANE_PAIR, // 飞机带翅膀(对子)
FOUR_TWO_SINGLE,// 四带二(单张)
FOUR_TWO_PAIR // 四带二(对子)
}
static class CardGroup {
HandType type;
int mainRank; // 主牌点数(如三带一的主牌是三张的点数)
int length; // 连续型牌型的长度(如顺子长度)
List<Integer> cards; // 实际使用的牌(点数索引)
CardGroup(HandType type, int mainRank, int length, List<Integer> cards) {
this.type = type;
this.mainRank = mainRank;
this.length = length;
this.cards = cards;
}
/**
* 判断当前牌型是否能压住other
*/
boolean beats(CardGroup other) {
if (other == null) return true; // 首家出牌
if (this.type == HandType.ROCKET) return true;
if (other.type == HandType.ROCKET) return false;
if (this.type == HandType.BOMB && other.type != HandType.BOMB) return true;
if (this.type != HandType.BOMB && other.type == HandType.BOMB) return false;
if (this.type != other.type) return false;
if (this.length != other.length) return false;
return this.mainRank > other.mainRank;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder(type.name() + "[");
for (int c : cards) sb.append(RANK_NAMES[c]).append(" ");
return sb.append("]").toString();
}
}
/**
* 手牌类:使用计数数组表示
*/
static class Hand {
int[] count = new int[15]; // 各点数牌的数量
int totalCards = 0;
void addCard(int rank) {
count[rank]++;
totalCards++;
}
void removeCards(List<Integer> cards) {
for (int c : cards) {
count[c]--;
totalCards--;
}
}
Hand copy() {
Hand h = new Hand();
System.arraycopy(this.count, 0, h.count, 0, 15);
h.totalCards = this.totalCards;
return h;
}
boolean isEmpty() {
return totalCards == 0;
}
}
/**
* 识别一组牌构成的牌型
*/
static CardGroup identifyHandType(int[] cardCounts) {
List<Integer> cards = new ArrayList<>();
for (int i = 0; i < 15; i++) {
for (int j = 0; j < cardCounts[i]; j++) cards.add(i);
}
int n = cards.size();
// 王炸
if (n == 2 && cardCounts[13] == 1 && cardCounts[14] == 1) {
return new CardGroup(HandType.ROCKET, 14, 1, cards);
}
// 单张
if (n == 1) {
return new CardGroup(HandType.SINGLE, cards.get(0), 1, cards);
}
// 对子
if (n == 2 && cardCounts[cards.get(0)] == 2) {
return new CardGroup(HandType.PAIR, cards.get(0), 1, cards);
}
// 三张
if (n == 3 && cardCounts[cards.get(0)] == 3) {
return new CardGroup(HandType.TRIPLE, cards.get(0), 1, cards);
}
// 炸弹
if (n == 4 && cardCounts[cards.get(0)] == 4) {
return new CardGroup(HandType.BOMB, cards.get(0), 1, cards);
}
// 三带一
if (n == 4) {
for (int i = 0; i < 15; i++) {
if (cardCounts[i] == 3) {
return new CardGroup(HandType.TRIPLE_SINGLE, i, 1, cards);
}
}
}
// 三带二
if (n == 5) {
for (int i = 0; i < 15; i++) {
if (cardCounts[i] == 3) {
return new CardGroup(HandType.TRIPLE_PAIR, i, 1, cards);
}
}
}
// 顺子(5张及以上连续单张,2和王不能参与)
if (n >= 5) {
boolean isStraight = true;
Collections.sort(cards);
for (int i = 1; i < n; i++) {
if (cards.get(i) - cards.get(i-1) != 1 || cards.get(i) >= 13) {
isStraight = false; break;
}
}
if (isStraight) {
return new CardGroup(HandType.STRAIGHT, cards.get(n-1), n, cards);
}
}
// 连对(3对及以上连续对子)
if (n >= 6 && n % 2 == 0) {
List<Integer> pairs = new ArrayList<>();
for (int i = 0; i < 13; i++) {
if (cardCounts[i] == 2) pairs.add(i);
}
if (pairs.size() == n / 2 && pairs.size() >= 3) {
boolean isDoubleStraight = true;
for (int i = 1; i < pairs.size(); i++) {
if (pairs.get(i) - pairs.get(i-1) != 1) isDoubleStraight = false;
}
if (isDoubleStraight) {
return new CardGroup(HandType.DOUBLE_STRAIGHT, pairs.get(pairs.size()-1), pairs.size(), cards);
}
}
}
// 飞机(2个及以上连续三张)
if (n >= 6 && n % 3 == 0) {
List<Integer> triples = new ArrayList<>();
for (int i = 0; i < 13; i++) {
if (cardCounts[i] == 3) triples.add(i);
}
if (triples.size() == n / 3 && triples.size() >= 2) {
boolean isPlane = true;
for (int i = 1; i < triples.size(); i++) {
if (triples.get(i) - triples.get(i-1) != 1) isPlane = false;
}
if (isPlane) {
return new CardGroup(HandType.PLANE, triples.get(triples.size()-1), triples.size(), cards);
}
}
}
return new CardGroup(HandType.INVALID, -1, 0, cards);
}
/**
* 生成手牌中所有能压过target的出牌方案
*/
static List<CardGroup> getValidPlays(Hand hand, CardGroup target) {
List<CardGroup> validPlays = new ArrayList<>();
// 如果target为null,生成所有合法牌型
if (target == null) {
generateAllHandTypes(hand, validPlays);
return validPlays;
}
// 王炸直接压
if (hand.count[13] == 1 && hand.count[14] == 1) {
List<Integer> rocket = Arrays.asList(13, 14);
validPlays.add(new CardGroup(HandType.ROCKET, 14, 1, rocket));
}
// 炸弹压非炸弹
if (target.type != HandType.ROCKET && target.type != HandType.BOMB) {
for (int i = 0; i < 13; i++) {
if (hand.count[i] == 4) {
List<Integer> bomb = new ArrayList<>();
for (int j = 0; j < 4; j++) bomb.add(i);
validPlays.add(new CardGroup(HandType.BOMB, i, 1, bomb));
}
}
}
// 同类型压制
switch (target.type) {
case SINGLE:
for (int i = target.mainRank + 1; i < 15; i++) {
if (hand.count[i] >= 1) {
validPlays.add(new CardGroup(HandType.SINGLE, i, 1, Arrays.asList(i)));
}
}
break;
case PAIR:
for (int i = target.mainRank + 1; i < 13; i++) {
if (hand.count[i] >= 2) {
validPlays.add(new CardGroup(HandType.PAIR, i, 1, Arrays.asList(i, i)));
}
}
break;
case TRIPLE:
for (int i = target.mainRank + 1; i < 13; i++) {
if (hand.count[i] >= 3) {
validPlays.add(new CardGroup(HandType.TRIPLE, i, 1, Arrays.asList(i, i, i)));
}
}
break;
case TRIPLE_SINGLE:
for (int i = target.mainRank + 1; i < 13; i++) {
if (hand.count[i] >= 3) {
for (int j = 0; j < 15; j++) {
if (j != i && hand.count[j] >= 1) {
List<Integer> cards = new ArrayList<>(Arrays.asList(i, i, i, j));
validPlays.add(new CardGroup(HandType.TRIPLE_SINGLE, i, 1, cards));
}
}
}
}
break;
case STRAIGHT:
int len = target.length;
for (int start = 0; start <= 13 - len; start++) {
if (start + len - 1 <= target.mainRank) continue;
boolean canForm = true;
for (int i = start; i < start + len; i++) {
if (hand.count[i] < 1 || i >= 13) canForm = false;
}
if (canForm) {
List<Integer> cards = new ArrayList<>();
for (int i = start; i < start + len; i++) cards.add(i);
validPlays.add(new CardGroup(HandType.STRAIGHT, start + len - 1, len, cards));
}
}
break;
case BOMB:
for (int i = target.mainRank + 1; i < 13; i++) {
if (hand.count[i] == 4) {
List<Integer> bomb = new ArrayList<>();
for (int j = 0; j < 4; j++) bomb.add(i);
validPlays.add(new CardGroup(HandType.BOMB, i, 1, bomb));
}
}
break;
// 其他类型类似处理...
}
return validPlays;
}
/**
* 生成所有可能的出牌(首家出牌时)
*/
static void generateAllHandTypes(Hand hand, List<CardGroup> result) {
// 单张
for (int i = 0; i < 15; i++) {
if (hand.count[i] >= 1) result.add(new CardGroup(HandType.SINGLE, i, 1, Arrays.asList(i)));
}
// 对子
for (int i = 0; i < 13; i++) {
if (hand.count[i] >= 2) result.add(new CardGroup(HandType.PAIR, i, 1, Arrays.asList(i, i)));
}
// 三张
for (int i = 0; i < 13; i++) {
if (hand.count[i] >= 3) result.add(new CardGroup(HandType.TRIPLE, i, 1, Arrays.asList(i, i, i)));
}
// 炸弹
for (int i = 0; i < 13; i++) {
if (hand.count[i] == 4) result.add(new CardGroup(HandType.BOMB, i, 1, Arrays.asList(i, i, i, i)));
}
// 王炸
if (hand.count[13] == 1 && hand.count[14] == 1) {
result.add(new CardGroup(HandType.ROCKET, 14, 1, Arrays.asList(13, 14)));
}
// 更多类型可继续扩展...
}
/**
* 贪心策略:评估一手牌的价值
* 值越小越好走(牌型越整齐)
*/
static int evaluateHand(Hand hand) {
int score = 0;
// 剩余牌越少越好
score += hand.totalCards * 10;
// 零散单牌扣分
for (int i = 0; i < 13; i++) {
if (hand.count[i] == 1) score += 5;
}
// 保留炸弹加分(最后出)
for (int i = 0; i < 13; i++) {
if (hand.count[i] == 4) score -= 20;
}
return score;
}
/**
* 贪心选择最优出牌
*/
static CardGroup selectBestPlay(Hand hand, List<CardGroup> options) {
if (options.isEmpty()) return null;
CardGroup best = null;
int bestScore = Integer.MAX_VALUE;
for (CardGroup play : options) {
Hand after = hand.copy();
after.removeCards(play.cards);
int score = evaluateHand(after);
// 如果出完所有牌,优先级最高
if (after.isEmpty()) score = Integer.MIN_VALUE;
if (score < bestScore) {
bestScore = score;
best = play;
}
}
return best;
}
public static void main(String[] args) {
// 构造示例手牌:3,3,3,4,5,6,7,8,9,10,J,Q,K,A,A,2,大王
Hand hand = new Hand();
int[] sample = {0,0,0, 1,2,3,4,5,6,7,8,9,10,10, 12, 14};
for (int c : sample) hand.addCard(c);
System.out.println("手牌: " + Arrays.toString(sample));
System.out.println("可出牌型数量: " + getValidPlays(hand, null).size());
// 对手出了一张5
CardGroup opponentPlay = new CardGroup(HandType.SINGLE, 2, 1, Arrays.asList(2));
System.out.println("\n对手出: " + opponentPlay);
List<CardGroup> responses = getValidPlays(hand, opponentPlay);
System.out.println("可压制方案数量: " + responses.size());
CardGroup best = selectBestPlay(hand, responses);
System.out.println("AI最优应对: " + best);
}
}
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 牌型识别 | O(1) | O(1) | 固定15种牌型判断 |
| 生成所有出牌 | O(C) | O(C) | C为合法组合数,通常<1000 |
| 贪心评估 | O(C × 15) | O(15) | 评估每种出牌后的手牌价值 |
| 牌力评估 | O(15) | O(1) | 线性扫描计数数组 |
算法优化方向
- 带牌枚举优化:三带一、飞机带翅膀需要枚举所有带牌组合,可用回溯法系统生成
- 连续型牌型识别:顺子、连对、飞机的检测可用滑动窗口优化
- 蒙特卡洛模拟:通过大量随机对局模拟评估出牌选择的胜率
- 强化学习:使用DQN训练斗地主AI,学习复杂的出牌策略
总结
斗地主AI的核心在于组合数学枚举所有合法牌型,以及贪心启发式评估手牌价值。通过计数数组表示手牌,我们可以高效地进行牌型检测和出牌模拟。虽然贪心策略在简单场景下表现良好,但面对复杂牌局时,结合蒙特卡洛模拟或强化学习才是通往顶级AI的必经之路。