引言
四川麻将是中国最受欢迎的麻将变体之一,以其独特的”缺一门””血战到底”等规则著称。与普通麻将不同,四川麻将要求玩家在打缺一门(筒、条、万中必须弃掉一门)后才能胡牌,且一家胡牌后游戏不结束,剩余玩家继续血战,极大增加了策略深度。
本文将用Java实现四川麻将的AI助手核心,重点讲解牌型组合数学评估、听牌概率计算以及贪心最优弃牌策略。
游戏规则与建模
牌型结构
四川麻将使用136张牌(无花牌):
– 筒子(1~9筒)、条子(1~9条)、万子(1~9万):各4张
– 风牌(东南西北):各4张
– 箭牌(中发白):各4张
核心规则
- 缺一门:手牌中只能保留两门花色,必须打缺一门才能胡牌
- 血战到底:一家胡牌后,其他未胡玩家继续游戏
- 胡牌条件:4组面子(顺子或刻子)+ 1对将牌
- 特殊牌型:七对、十三幺、龙七对等特殊胡牌方式
手牌表示
/**
* 麻将牌枚举
*/
enum Tile {
// 筒子 (0~8)
T1, T2, T3, T4, T5, T6, T7, T8, T9,
// 条子 (9~17)
S1, S2, S3, S4, S5, S6, S7, S8, S9,
// 万子 (18~26)
W1, W2, W3, W4, W5, W6, W7, W8, W9,
// 风牌 (27~30)
EAST, SOUTH, WEST, NORTH,
// 箭牌 (31~33)
RED, GREEN, WHITE;
static final int COUNT = 34;
boolean isTong() { return ordinal() <= 8; }
boolean isTiao() { return ordinal() >= 9 && ordinal() <= 17; }
boolean isWan() { return ordinal() >= 18 && ordinal() <= 26; }
int suit() {
if (isTong()) return 0;
if (isTiao()) return 1;
if (isWan()) return 2;
return 3; // 字牌
}
}
/**
* 手牌状态
*/
class Hand {
int[] tiles = new int[Tile.COUNT]; // 每种牌的数量(0~4)
int total = 0; // 手牌总数
void add(Tile t) { tiles[t.ordinal()]++; total++; }
void remove(Tile t) { tiles[t.ordinal()]--; total--; }
}
核心算法设计
1. 牌型结构分解(递归搜索)
判断一手牌是否可胡,需将其分解为4组面子 + 1对将牌。采用递归搜索:
- 枚举所有可能的将牌(对子)
- 对剩余牌递归寻找顺子/刻子
- 若成功分解则判定为可胡
f(hand):
for each tile t where count[t] >= 2:
hand.removePair(t)
if canFormFourGroups(hand):
return true
hand.addPair(t)
return false
canFormFourGroups(hand):
if hand.isEmpty(): return true
for each tile t where count[t] > 0:
// 尝试刻子
if count[t] >= 3:
hand.removeTriplet(t)
if canFormFourGroups(hand): return true
hand.addTriplet(t)
// 尝试顺子(仅限序数牌)
if t.isSequence() and count[t+1] > 0 and count[t+2] > 0:
hand.removeSequence(t)
if canFormFourGroups(hand): return true
hand.addSequence(t)
return false
2. 向听数(Shanten Number)计算
向听数表示手牌距离胡牌还差多少步(至少需要替换多少张牌)。计算方法:
$$Shanten = 8 – 2 \times Groups – Pairs – CompleteSequences$$
其中:
– $Groups$:已完成的刻子/顺子数
– $Pairs$:对子数(最多计1个)
– $CompleteSequences$:搭子数(两面搭、边搭、嵌搭)
向听数越小,手牌越接近胡牌。AI优先将手牌优化到向听数最小。
3. 牌效评估与最优弃牌
每张牌的价值取决于:
– 保留价值:该牌能组成的面子/搭子/对子数量
– 改良价值:摸到附近牌后手牌的提升潜力
– 危险价值:打出后点炮的风险(基于对手舍牌推断)
贪心策略:弃掉牌效最低且危险度最低的牌。
4. 听牌概率计算
给定手牌和剩余牌墙信息,计算听到某张牌的概率:
$$P(听t) = \frac{Remain(t)}{WallSize}$$
综合所有听牌的期望收益,选择收益最大化的打法。
Java实现
import java.util.*;
/**
* 四川麻将AI核心
* 牌型组合评估 + 向听数计算 + 概率弃牌策略
*/
public class SichuanMahjongAI {
private static final int TILE_COUNT = 34;
private static final int[] SUIT_START = {0, 9, 18, 27}; // 筒、条、万、字牌起始索引
/**
* 计算向听数:距离胡牌还差多少张牌
*/
public int shanten(int[] hand) {
int minShanten = 8; // 最大向听数
// 标准形(4面子1将)
for (int pair = 0; pair < TILE_COUNT; pair++) {
if (hand[pair] < 2) continue;
int[] temp = hand.clone();
temp[pair] -= 2;
int groups = maxGroups(temp);
int pairs = 1;
int shan = 8 - 2 * groups - pairs;
if (shan < minShanten) minShanten = shan;
}
// 七对形
int pairCount = 0, singleCount = 0;
for (int i = 0; i < TILE_COUNT; i++) {
if (hand[i] >= 2) pairCount++;
if (hand[i] == 1) singleCount++;
}
int sevenPairsShanten = 6 - pairCount;
if (sevenPairsShanten < minShanten) minShanten = sevenPairsShanten;
// 十三幺
int thirteenOrphansShanten = calcThirteenOrphansShanten(hand);
if (thirteenOrphansShanten < minShanten) minShanten = thirteenOrphansShanten;
return Math.max(0, minShanten);
}
/**
* 计算手牌能构成的最大面子数(用于标准形向听数)
*/
private int maxGroups(int[] hand) {
return maxGroupsHelper(hand, 0);
}
private int maxGroupsHelper(int[] hand, int start) {
// 找到第一个非零牌
int first = start;
while (first < TILE_COUNT && hand[first] == 0) first++;
if (first >= TILE_COUNT) return 0;
int max = 0;
// 尝试刻子
if (hand[first] >= 3) {
hand[first] -= 3;
int g = 1 + maxGroupsHelper(hand, first);
if (g > max) max = g;
hand[first] += 3;
}
// 尝试顺子(仅限序数牌)
if (first < 27 && first % 9 <= 6 && hand[first + 1] > 0 && hand[first + 2] > 0) {
hand[first]--; hand[first + 1]--; hand[first + 2]--;
int g = 1 + maxGroupsHelper(hand, first);
if (g > max) max = g;
hand[first]++; hand[first + 1]++; hand[first + 2]++;
}
// 跳过这张牌
int g = maxGroupsHelper(hand, first + 1);
if (g > max) max = g;
return max;
}
/**
* 十三幺向听数
*/
private int calcThirteenOrphansShanten(int[] hand) {
int[] terminals = {0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33};
int have = 0, havePair = 0;
for (int t : terminals) {
if (hand[t] > 0) have++;
if (hand[t] >= 2) havePair = 1;
}
return 13 - have - havePair;
}
/**
* 计算每张牌的保留价值(牌效)
* 返回每张牌的效用分数,分数越低越应该弃掉
*/
public double[] tileEfficiency(int[] hand, int[] wall) {
double[] efficiency = new double[TILE_COUNT];
for (int i = 0; i < TILE_COUNT; i++) {
if (hand[i] == 0) {
efficiency[i] = Double.NEGATIVE_INFINITY; // 手牌中没有,不考虑
continue;
}
// 模拟弃掉这张牌后的向听数
int[] temp = hand.clone();
temp[i]--;
int shanAfter = shanten(temp);
// 基础向听数惩罚
efficiency[i] = -shanAfter * 10;
// 额外评估该牌的面子潜力
efficiency[i] += calcTilePotential(i, hand, wall);
// 孤张惩罚
if (isIsolated(i, hand)) {
efficiency[i] -= 5;
}
// 字牌单张惩罚(字牌难以组成顺子)
if (i >= 27 && hand[i] == 1) {
efficiency[i] -= 3;
}
}
return efficiency;
}
/**
* 计算单张牌的搭子潜力
*/
private double calcTilePotential(int tile, int[] hand, int[] wall) {
double potential = 0;
// 刻子潜力
if (hand[tile] >= 2) {
potential += 5; // 已有对子,价值高
if (wall[tile] > 0) potential += wall[tile]; // 剩余牌多,碰牌机会大
}
// 顺子潜力(仅限序数牌)
if (tile < 27) {
int suitStart = tile / 9 * 9;
int rank = tile % 9;
// 两面搭潜力(如3-4等5,或等2)
if (rank >= 1 && hand[tile - 1] > 0) {
int needed = tile + 1;
if (needed < suitStart + 9) potential += wall[needed];
}
if (rank <= 7 && hand[tile + 1] > 0) {
int needed = tile - 1;
if (needed >= suitStart) potential += wall[needed];
}
// 嵌搭潜力(如3-5等4)
if (rank >= 2 && hand[tile - 2] > 0) {
potential += wall[tile - 1] * 0.5;
}
if (rank <= 6 && hand[tile + 2] > 0) {
potential += wall[tile + 1] * 0.5;
}
}
return potential;
}
/**
* 判断是否为孤张(无相邻牌)
*/
private boolean isIsolated(int tile, int[] hand) {
if (hand[tile] > 1) return false; // 多张不算孤张
if (tile >= 27) return true; // 字牌单张视为孤张
int suitStart = tile / 9 * 9;
int rank = tile % 9;
boolean hasNeighbor = false;
if (rank > 0 && hand[tile - 1] > 0) hasNeighbor = true;
if (rank < 8 && hand[tile + 1] > 0) hasNeighbor = true;
if (rank > 1 && hand[tile - 2] > 0) hasNeighbor = true;
if (rank < 7 && hand[tile + 2] > 0) hasNeighbor = true;
return !hasNeighbor;
}
/**
* 选择最优弃牌
*/
public int chooseDiscard(int[] hand, int[] wall, int[] discards) {
double[] eff = tileEfficiency(hand, wall);
// 加上危险度惩罚(基于对手舍牌推断)
double[] danger = estimateDanger(hand, wall, discards);
int bestTile = -1;
double bestScore = Double.NEGATIVE_INFINITY;
for (int i = 0; i < TILE_COUNT; i++) {
if (hand[i] == 0) continue;
double score = eff[i] - danger[i] * 2; // 平衡牌效与安全性
if (score > bestScore) {
bestScore = score;
bestTile = i;
}
}
return bestTile;
}
/**
* 估计每张牌的危险度(被对手需要的概率)
*/
private double[] estimateDanger(int[] hand, int[] wall, int[] discards) {
double[] danger = new double[TILE_COUNT];
// 基于已出牌统计:某家已打出某花色很多,可能在做另外两门
int[] suitDiscards = new int[4];
for (int i = 0; i < TILE_COUNT; i++) {
suitDiscards[i / 9] += discards[i];
}
for (int i = 0; i < TILE_COUNT; i++) {
int suit = i / 9;
// 如果某家已打缺某门,则另外两门的牌更危险
if (suit < 3) {
// 简单启发:对手可能在做筒条万中的两门
danger[i] = discards[i] * 0.5; // 已被打出越多,相对越安全
}
// 生张(未见过的牌)更危险
if (discards[i] == 0 && wall[i] < 4) {
danger[i] += 2;
}
}
return danger;
}
/**
* 计算听牌列表及期望收益
*/
public List<int[]> calcTenpai(int[] hand, int[] wall) {
List<int[]> tenpai = new ArrayList<>();
for (int i = 0; i < TILE_COUNT; i++) {
if (wall[i] == 0) continue;
int[] temp = hand.clone();
temp[i]++;
if (shanten(temp) == -1) { // 注意:实际应判断是否为胡牌
// 简化:这里用向听数<=0表示可胡
tenpai.add(new int[]{i, wall[i]});
}
}
return tenpai;
}
/**
* 判断是否需要打缺一门
*/
public int chooseSuitToDiscard(int[] hand) {
int[] suitCount = new int[3];
for (int i = 0; i < 27; i++) {
suitCount[i / 9] += hand[i];
}
// 选择数量最少的一门打缺
int minSuit = 0;
for (int s = 1; s < 3; s++) {
if (suitCount[s] < suitCount[minSuit]) minSuit = s;
}
return minSuit;
}
public static void main(String[] args) {
SichuanMahjongAI ai = new SichuanMahjongAI();
// 构造测试手牌:3筒3筒, 4筒, 5筒, 6条, 7条, 8条, 1万, 1万, 3万, 3万, 3万, 东, 东
int[] hand = new int[TILE_COUNT];
hand[2] = 2; // 3筒
hand[3] = 1; // 4筒
hand[4] = 1; // 5筒
hand[14] = 1; // 6条
hand[15] = 1; // 7条
hand[16] = 1; // 8条
hand[18] = 2; // 1万
hand[20] = 3; // 3万
hand[27] = 2; // 东
int[] wall = new int[TILE_COUNT];
Arrays.fill(wall, 4);
for (int i = 0; i < TILE_COUNT; i++) wall[i] -= hand[i];
int[] discards = new int[TILE_COUNT];
System.out.println("向听数: " + ai.shanten(hand));
System.out.println("建议打缺: " + (ai.chooseSuitToDiscard(hand) == 0 ? "筒" : ai.chooseSuitToDiscard(hand) == 1 ? "条" : "万"));
int discard = ai.chooseDiscard(hand, wall, discards);
System.out.println("建议弃牌: " + tileToString(discard));
}
private static String tileToString(int tile) {
String[] names = {
"1筒","2筒","3筒","4筒","5筒","6筒","7筒","8筒","9筒",
"1条","2条","3条","4条","5条","6条","7条","8条","9条",
"1万","2万","3万","4万","5万","6万","7万","8万","9万",
"东","南","西","北","中","发","白"
};
return names[tile];
}
}
算法复杂度分析
时间复杂度
- 向听数计算:递归搜索,最坏 $O(TILE_COUNT^k)$,其中 $k$ 为面子数。实际中通过剪枝和记忆化,单次计算在毫秒级
- 牌效评估:对每张牌模拟弃牌并计算向听数,$O(TILE_COUNT \cdot ShantenTime)$
- 最优弃牌:综合牌效与危险度,$O(TILE_COUNT)$
空间复杂度
- 手牌表示:$O(TILE_COUNT)$
- 递归栈:$O(k)$,$k$ 为面子数(最大4)
扩展与改进
1. 牌河分析
通过分析对手的舍牌顺序和花色分布,使用贝叶斯推断估计对手的手牌结构,从而更精确地计算危险度。
2. 番数计算
四川麻将存在多种番数规则(如清一色、碰碰胡、杠上花等)。将番数期望纳入评估函数,在追求速度的同时最大化收益。
3. 深度强化学习
使用Self-Play DQN或PPO训练端到端的麻将AI,如Suphx等系统,可达到人类高手水平。
总结
四川麻将AI的核心在于组合数学与概率推理的融合。通过向听数计算量化手牌价值,通过牌效评估指导最优弃牌,结合危险度估计平衡进攻与防守。这套框架不仅适用于四川麻将,其牌型分解、搭子评估等方法也可推广至所有麻将变体及类似的组合牌类游戏。