麻将是中国乃至东亚地区广受欢迎的博弈游戏,其复杂的牌型组合与策略深度使其成为算法研究的绝佳对象。本文用Java实现麻将的核心算法,重点讲解手牌向听数(Shanten数)的高效计算、牌型评估函数设计以及基于贪心策略的最优打牌决策。
一、核心算法模块
麻将AI可拆解为以下算法问题:
- 向听数计算:当前手牌距离听牌(再差一张即可和牌)还需多少张牌
- 牌型评估:评估手牌的价值与完成各种牌型的概率
- 打牌决策:从14张手牌中选择一张打出,使向听数最小化
- 和牌判定:判断给定手牌是否满足和牌条件
二、麻将牌表示与基础数据结构
标准麻将共34种牌(万子1-9、筒子1-9、条子1-9、东南西北、中发白各1种),每种4张。用整数 $0 \sim 33$ 编码:
- 万子:0-8(一万到九万)
- 筒子:9-17(一筒到九筒)
- 条子:18-26(一条到九条)
- 字牌:27-33(东南西北中发白)
三、Java完整实现
import java.util.*;
/**
* 麻将核心算法实现
* 包含向听数计算、牌型评估、贪心打牌策略与和牌判定
*/
public class MahjongAI {
// 牌型常量
private static final int NUM_TILE_TYPES = 34; // 34种牌
private static final int NUM_SUIT_TILES = 9; // 每种花色9张
private static final int MAN = 0; // 万子起始
private static final int PIN = 9; // 筒子起始
private static final int SOU = 18; // 条子起始
private static final int HONOR = 27; // 字牌起始
// 牌名称(用于输出)
private static final String[] TILE_NAMES = {
"1m","2m","3m","4m","5m","6m","7m","8m","9m",
"1p","2p","3p","4p","5p","6p","7p","8p","9p",
"1s","2s","3s","4s","5s","6s","7s","8s","9s",
"东","南","西","北","中","发","白"
};
private Random random = new Random();
/**
* 手牌类
*/
static class Hand {
int[] count = new int[NUM_TILE_TYPES]; // 每种牌的数量
int total; // 总牌数
Hand() { total = 0; }
void add(int tile) {
count[tile]++;
total++;
}
void remove(int tile) {
if (count[tile] > 0) {
count[tile]--;
total--;
}
}
Hand copy() {
Hand h = new Hand();
System.arraycopy(count, 0, h.count, 0, NUM_TILE_TYPES);
h.total = total;
return h;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < NUM_TILE_TYPES; i++) {
for (int j = 0; j < count[i]; j++) {
sb.append(TILE_NAMES[i]).append(" ");
}
}
return sb.toString().trim();
}
}
/**
* 生成随机手牌(13张 + 1张 = 14张,未打牌状态)
*/
Hand generateRandomHand() {
Hand hand = new Hand();
List<Integer> deck = new ArrayList<>();
for (int i = 0; i < NUM_TILE_TYPES; i++) {
for (int j = 0; j < 4; j++) deck.add(i);
}
Collections.shuffle(deck, random);
for (int i = 0; i < 14; i++) {
hand.add(deck.get(i));
}
return hand;
}
/**
* 计算向听数(Shanten数)
* 向听数 = 当前手牌距离听牌最少还差几张有用的牌
* 0表示已经听牌,-1表示已经和牌
*/
int calculateShanten(Hand hand) {
// 标准麻将向听数:考虑面子(3张一组的顺子或刻子)与雀头(对子)
// 简化版:假设手牌是13张(未摸牌)或14张(已摸牌待打)
int minShanten = 8; // 最大向听数为8(13张完全离散)
// 尝试每种牌作为雀头(对子),计算剩余牌组成面子的能力
for (int head = 0; head < NUM_TILE_TYPES; head++) {
if (hand.count[head] < 2) continue;
Hand temp = hand.copy();
temp.remove(head);
temp.remove(head);
int groups = countMaxGroups(temp);
// 13张牌 = 1雀头 + 4面子 = 和牌
// 向听数 = (4 - groups) * 2 + 雀头缺失惩罚
int shanten = (4 - groups) * 2 - 1;
minShanten = Math.min(minShanten, shanten);
}
// 没有雀头的情况
int groupsNoHead = countMaxGroups(hand);
int shantenNoHead = (4 - groupsNoHead) * 2;
minShanten = Math.min(minShanten, shantenNoHead);
return Math.max(0, minShanten);
}
/**
* 计算手牌中最多能组成多少个面子(顺子或刻子)
* 使用贪心策略优先提取刻子,再提取顺子
*/
int countMaxGroups(Hand hand) {
int groups = 0;
Hand temp = hand.copy();
// 1. 优先提取刻子(三张相同)
for (int i = 0; i < NUM_TILE_TYPES; i++) {
while (temp.count[i] >= 3) {
temp.remove(i);
temp.remove(i);
temp.remove(i);
groups++;
}
}
// 2. 再提取顺子(同花色连续三张)
for (int suit = 0; suit < 3; suit++) { // 万、筒、条
int start = suit * 9;
// 从小的开始贪心
for (int i = start; i <= start + 6; i++) {
while (temp.count[i] > 0 && temp.count[i+1] > 0 && temp.count[i+2] > 0) {
temp.remove(i);
temp.remove(i+1);
temp.remove(i+2);
groups++;
}
}
}
return groups;
}
/**
* 更精确的向听数计算:考虑搭子(差一张成面子的两张牌)
*/
int calculateShantenAdvanced(Hand hand) {
// 基础向听数
int base = calculateShanten(hand);
// 计算搭子数(两张牌差一张成面子)
int pairs = 0; // 对子数
int protoruns = 0; // 搭子数(如12、13、23等)
for (int i = 0; i < NUM_TILE_TYPES; i++) {
if (hand.count[i] >= 2) pairs++;
}
// 顺子搭子:12, 13, 23, ..., 78, 79, 89
for (int suit = 0; suit < 3; suit++) {
int start = suit * 9;
for (int i = start; i <= start + 7; i++) {
// 12型搭子
if (hand.count[i] > 0 && hand.count[i+1] > 0) protoruns++;
// 13型搭子
if (i <= start + 6 && hand.count[i] > 0 && hand.count[i+2] > 0) protoruns++;
}
}
// 向听数修正:搭子越多,实际向听数可能越小
// 简单修正:每有一个有效搭子,向听数减0.5(取整)
int ta ziBonus = Math.min(pairs + protoruns, 4);
return Math.max(0, base - ta ziBonus / 2);
}
/**
* 评估打出每张牌后的向听数,选择最优打牌
*/
int findBestDiscard(Hand hand) {
int bestTile = -1;
int minShanten = Integer.MAX_VALUE;
double bestValue = Double.NEGATIVE_INFINITY;
for (int tile = 0; tile < NUM_TILE_TYPES; tile++) {
if (hand.count[tile] == 0) continue;
Hand temp = hand.copy();
temp.remove(tile);
int shanten = calculateShantenAdvanced(temp);
// 综合评估:向听数 + 牌的价值(字牌/孤牌优先打)
double value = -shanten * 100 + evaluateTileValue(tile, temp);
if (value > bestValue) {
bestValue = value;
bestTile = tile;
minShanten = shanten;
}
}
return bestTile;
}
/**
* 评估单张牌的价值
* 孤牌、字牌、边张(1,9)价值较低,优先打出
*/
double evaluateTileValue(int tile, Hand hand) {
double value = 0;
// 字牌价值低(难以组成顺子)
if (tile >= HONOR) {
value -= 30;
}
// 边张(1,9)价值较低
int inSuit = tile % 9;
if (inSuit == 0 || inSuit == 8) {
value -= 15;
}
// 若这张牌能与其他牌组成搭子,价值提高
int suit = tile / 9;
if (suit < 3) {
int start = suit * 9;
// 检查邻近牌
for (int d = -2; d <= 2; d++) {
int neighbor = tile + d;
if (neighbor >= start && neighbor < start + 9 && neighbor != tile) {
if (hand.count[neighbor] > 0) {
value += 20;
}
}
}
}
// 保留对子(有对子优先不打)
if (hand.count[tile] >= 1) {
value += 10;
}
return value;
}
/**
* 判断手牌是否和牌
* 标准规则:14张牌 = 4面子 + 1雀头
*/
boolean isWinning(Hand hand) {
if (hand.total != 14 && hand.total != 13) return false;
// 13张为自摸前,需检查是否差一张和牌(即向听数=0)
if (hand.total == 13) {
return calculateShantenAdvanced(hand) == 0;
}
// 14张需有1个雀头 + 4个面子
for (int head = 0; head < NUM_TILE_TYPES; head++) {
if (hand.count[head] < 2) continue;
Hand temp = hand.copy();
temp.remove(head);
temp.remove(head);
if (countMaxGroups(temp) == 4) {
return true;
}
}
return false;
}
/**
* 模拟一局游戏:摸牌、打牌循环
*/
void simulateGame() {
Hand hand = generateRandomHand();
System.out.println("初始手牌:" + hand);
System.out.println("初始向听数:" + calculateShantenAdvanced(hand));
// 模拟摸打10轮
for (int round = 0; round < 10; round++) {
// 模拟摸一张牌
int draw = random.nextInt(NUM_TILE_TYPES);
hand.add(draw);
System.out.printf("\n第%d轮 摸到:%s%n", round + 1, TILE_NAMES[draw]);
// 检查和牌
if (isWinning(hand)) {
System.out.println("🎉 和牌!" + hand);
return;
}
// AI决策:打出最优牌
int discard = findBestDiscard(hand);
hand.remove(discard);
System.out.printf("打出:%s | 向听数:%d%n",
TILE_NAMES[discard], calculateShantenAdvanced(hand));
}
System.out.println("\n10轮未和牌,最终手牌:" + hand);
System.out.println("最终向听数:" + calculateShantenAdvanced(hand));
}
/**
* 分析向听数的理论分布
*/
void analyzeShantenDistribution(int samples) {
int[] distribution = new int[9];
for (int i = 0; i < samples; i++) {
Hand hand = generateRandomHand();
int shanten = calculateShantenAdvanced(hand);
distribution[shanten]++;
}
System.out.println("\n=== 向听数分布(" + samples + "组随机手牌)===");
for (int i = 0; i < 9; i++) {
double pct = distribution[i] * 100.0 / samples;
System.out.printf("向听数%d: %.1f%%%n", i, pct);
}
}
public static void main(String[] args) {
MahjongAI ai = new MahjongAI();
ai.simulateGame();
ai.analyzeShantenDistribution(1000);
}
}
四、向听数计算的理论基础
向听数(Shanten数)是日本麻将中的核心概念,精确计算公式为:
$$\text{Shanten} = 8 – 2 \times \text{groups} – \text{ta zi} – \text{pair}$$
其中:
– groups:已完成的顺子或刻子数
– ta zi:搭子数(两张可发展为面子的牌)
– pair:是否有雀头(对子)
对于标准13张手牌,向听数范围是 $0 \sim 8$:
– 0:听牌状态(再摸一张特定牌即可和牌)
– -1:和牌状态(仅14张手牌时出现)
五、牌效与贪心策略的局限
贪心策略每步选择使向听数最小化的打法,属于短视最优。实际麻将中存在以下复杂因素:
- 防守需求:需根据对手舍牌推测其听牌状态,避免点炮
- 役种追求:不同牌型(清一色、七对子等)有不同的最优路径
- 宝牌与里宝牌:部分牌因宝牌加成价值突增
进阶策略可引入蒙特卡洛模拟:模拟大量随机后续发展,选择使和牌概率最大化的打法。
六、总结
本文实现了麻将AI的核心算法框架。从向听数计算到打牌决策,展示了组合优化在博弈游戏中的应用。核心收获包括:
- 34种麻将牌的整数编码与高效表示
- 贪心提取刻子与顺子的向听数近似算法
- 综合向听数与牌价值的贪心决策函数
- 和牌判定的雀头遍历 + 面子匹配验证