简介:本资源是一套面向Java游戏开发与AI算法学习者的麻将核心算法实现合集,聚焦胡牌判定、AI决策、查胡校验、手牌评估及智能出牌五大关键模块,适用于希望深入理解棋牌类游戏逻辑、提升算法设计与工程落地能力的中高级开发者。压缩包共52个文件,含15个核心Java源码(覆盖算法主干与策略类)、19张PNG流程图与界面示意图(如ting.png、hu.png、aiX.png等直观呈现算法逻辑)、9个规则配置文本(如majiang_ai_feng.txt等支持多变体规则扩展),以及README.md、pom.xml、LICENSE等工程支撑文件,整体36.41MB,结构清晰、开箱即用。已有2713人学习下载,读者可直接复用算法模块、对照图解理解状态转移与评估逻辑、基于DB与YML配置快速适配不同麻将规则,并通过bat脚本与完整目录组织掌握工业级棋牌AI的分层设计思路。
1. 为什么写“Java麻将算法汇总”?——不是教你怎么打麻将,而是帮你把胡牌逻辑从黑匣子变成可调试、可压测、可嵌入业务的确定性模块
你有没有遇到过这样的场景:在开发一款棋牌类教学系统时,学员提交一手牌,后端要秒级返回“是否可胡”“能胡几种”“哪些是有效听口”;或者在做AI陪练模块时,模型每轮出牌前得在毫秒内完成“手牌评估→听牌分析→风险权衡→最优出牌”四步闭环;又或者,测试同学甩来一个“七对子+十三幺混合型”的边界用例,结果服务直接抛StackOverflowError?这些都不是前端交互或UI动效的问题,而是底层麻将规则引擎的鲁棒性没经住真实牌局的锤炼。本篇讲的,就是如何用 Java 实现一套生产可用的麻将算法集合:它不追求炫技式 AI 决策树,而聚焦于胡牌判定(含国标/广东/日本立直等主流变体)、查胡遍历、手牌评估量化、AI 出牌策略建模这五个高频刚需模块。所有代码基于纯 JDK 8+,无第三方规则引擎依赖,支持单机高并发校验,也适配微服务中轻量嵌入。如果你正在做教育类 App、棋牌陪练工具、AI 对战平台,或只是想搞懂“为什么程序比人算得快还不出错”,这篇就是为你写的血泪复盘。
2. 胡牌算法:从“递归爆栈”到“状态压缩+缓存命中”的落地演进
麻将胡牌判定看似简单,实则是典型的组合爆炸问题。国标麻将要求“四组顺/刻 + 一对将”,但实际需覆盖七对、十三幺、清一色、混一色、七对子、连七对、碰碰胡、全带幺、一色三节、一色四步高……共 81 种番种,而每种番种对牌型结构有隐含约束。早期我们用纯递归回溯(DFS)实现,代码简洁但致命缺陷明显:面对“123456789万+中中中发发发白白白”这种 13 张复杂手牌,递归深度超 20 层,JVM 栈空间吃紧,线上偶发StackOverflowError;更糟的是,同一手牌反复请求时无法复用中间结果,QPS 上不去。
2.1 为什么选“向量+位运算”而非字符串匹配?
常见误区是把牌面转成"1m2m3m..."字符串再正则匹配。但字符串操作在高频校验中开销巨大,且无法利用 CPU 的位并行能力。我们改用136 位整数向量表示手牌状态:
- 万子:bit 0~35(每种万 1~9 各占 4 bit,因最多 4 张)
- 条子:bit 36~71
- 筒子:bit 72~107
- 字牌:bit 108~135(中发白东南西北各占 4 bit)
这样,一张“3万”对应 bit 8~11 置 1,三张“3万”即该字段值为 3。判断“能否组成顺子 123万”,只需检查(vec >> 0) & 0xF,(vec >> 4) & 0xF,(vec >> 8) & 0xF是否均 ≥1。位运算比字符串切片快 17 倍(JMH 测得),且天然支持缓存键生成。
2.2 核心胡牌判定函数:带剪枝的 DFS + LRU 缓存
// MajiangValidator.java private static final int MAX_CACHE_SIZE = 10000; private static final LoadingCache<Long, Boolean> HU_CACHE = Caffeine.newBuilder() .maximumSize(MAX_CACHE_SIZE) .expireAfterWrite(10, TimeUnit.MINUTES) .build(key -> checkHuByDfs(key)); // key 是 handVec 的 long 值 public boolean isHu(int[] hand) { long handVec = encodeHandToVector(hand); // 将 int[34] 转为 136-bit long return HU_CACHE.get(handVec); } private static boolean checkHuByDfs(long vec) { // Step 1: 快速预检 —— 总张数非14张直接false if (popcount(vec) != 14) return false; // Step 2: 枚举将牌(eye),对每种可能尝试拆解剩余12张为4组 for (int i = 0; i < 34; i++) { int count = getCardCount(vec, i); if (count < 2) continue; // 至少2张才能当将 long newVec = removeCards(vec, i, 2); // 移除2张将牌 if (canFormFourGroups(newVec)) { return true; } } return false; }提示:
popcount()是 JDK 18+ 的Long.bitCount(),若用旧版 JDK 可替换为Integer.bitCount((int)vec) + Integer.bitCount((int)(vec>>>32))。getCardCount()通过位移+掩码提取某牌数量,removeCards()用位减法安全扣减,避免负数溢出。
2.3canFormFourGroups()的关键剪枝逻辑
private static boolean canFormFourGroups(long vec) { if (vec == 0L) return true; // 全拆完 // 剪枝1:剩余张数非3的倍数,不可能拆成顺/刻 if (popcount(vec) % 3 != 0) return false; // 剪枝2:找最小非零牌(贪心起点),避免重复搜索 int first = findFirstNonZeroCard(vec); int count = getCardCount(vec, first); // 情况A:用 first 当刻子(需≥3张) if (count >= 3) { long nextVec = removeCards(vec, first, 3); if (canFormFourGroups(nextVec)) return true; } // 情况B:用 first 当顺子起点(需 first+1 和 first+2 存在,且同花色) if (isSameSuit(first, first+1) && isSameSuit(first, first+2)) { int c1 = getCardCount(vec, first+1); int c2 = getCardCount(vec, first+2); if (c1 >= 1 && c2 >= 1) { long nextVec = removeCards(vec, first, 1); nextVec = removeCards(nextVec, first+1, 1); nextVec = removeCards(nextVec, first+2, 1); if (canFormFourGroups(nextVec)) return true; } } return false; }参数说明:
findFirstNonZeroCard()返回最低位非零牌索引(0~33),确保每次从最左开始拆,消除顺序冗余;isSameSuit(i,j)判断两张牌是否同花色(如 0~8 是万,9~17 是条),避免跨花色顺子误判;- 所有
removeCards()调用前都校验count >= n,防止位减为负导致逻辑错乱。
3. 查胡算法:穷举所有听牌组合,但绝不暴力遍历全部 34 张
“查胡”指给定 13 张手牌,求出所有能使其胡牌的待摸牌(即“听口”)。暴力做法是循环 34 种牌,每种补一张后调用isHu(),共 34 次判定。但实际中,大量补牌会导致手牌总数超 14 张(如补已有的第 5 张),或根本无法构成合法牌型(如补字牌却无将牌),白白浪费计算。我们采用逆向推导+约束传播策略,将平均耗时从 34×单次判定 → 5~8 次核心判定。
3.1 听口分类与前置过滤:先筛掉 92% 的无效候选
听口本质是“补哪张牌能让手牌满足胡牌结构”。我们按胡牌结构反推约束:
| 听口类型 | 结构要求 | 过滤条件(伪代码) |
|---|---|---|
| 将牌听 | 需补一张形成将牌 | hand[i] == 1→ 可能听 i(但需后续验证能否拆剩4组) |
| 顺子听 | 补中间/边张构成顺子 | hand[i]==0 && hand[i-1]>=1 && hand[i+1]>=1(i 为中间)hand[i]==0 && hand[i+1]>=1 && hand[i+2]>=1(i 为左) |
| 刻子听 | 补一张凑成刻子 | hand[i] == 2→ 可能听 i |
| 十三幺听 | 特殊结构,仅限13种幺九牌 | hand中只含 1/9 万条筒 + 7 字牌,且每种 ≤1 张 |
public List<Integer> findListeningCards(int[] hand) { List<Integer> result = new ArrayList<>(); // Step 1: 快速收集所有可能听口索引(0~33) Set<Integer> candidates = new HashSet<>(); // 将牌听:已有1张的牌 for (int i = 0; i < 34; i++) { if (hand[i] == 1) candidates.add(i); } // 刻子听:已有2张的牌 for (int i = 0; i < 34; i++) { if (hand[i] == 2) candidates.add(i); } // 顺子听:检查所有可能的顺子缺口 for (int i = 0; i < 34; i++) { if (hand[i] == 0) { // 检查是否能作为顺子左/中/右 if (canBeShunziLeft(i, hand) || canBeShunziMiddle(i, hand) || canBeShunziRight(i, hand)) { candidates.add(i); } } } // Step 2: 对每个候选,补牌后验证是否真能胡 for (int cand : candidates) { int[] testHand = hand.clone(); testHand[cand]++; if (isHu(testHand)) { result.add(cand); } } return result; }注意:
canBeShunziMiddle(i, hand)要求i-1和i+1在同一花色且hand[i-1]>=1 && hand[i+1]>=1;canBeShunziLeft(i, hand)要求i+1>=1 && i+2>=1且三者同花色。跨花色顺子(如1万2条3筒)被严格禁止。
3.2 十三幺专项加速:用位掩码一次到位
十三幺要求手牌为 {1,9}万、{1,9}条、{1,9}筒、东、南、西、北、中、发、白 共 13 种牌各 1 张,再加其中任意 1 张作将。我们预计算一个THIRTEEN_YAO_MASK(long 类型),其第 i 位为 1 当且仅当 i 属于十三幺牌型。查胡时只需:
long handVec = encodeHandToVector(hand); long yaoMask = THIRTEEN_YAO_MASK; if ((handVec | yaoMask) == yaoMask && popcount(handVec) == 13) { // 确认是13张幺九,枚举13种补牌 for (int i = 0; i < 34; i++) { if ((yaoMask >> i) & 1L) { // i 是幺九牌 int[] test = hand.clone(); test[i]++; if (isThirteenYao(test)) result.add(i); } } }isThirteenYao()仅检查是否含14张且全为幺九、且有且仅有一个将(某牌计数为2),比通用isHu()快 40 倍。
4. AI 出牌算法:不是“随机扔”,而是基于风险-收益建模的多目标决策
AI 出牌常被误解为“扔掉不能用的牌”。但真实牌局中,一张牌的“无用性”是动态的:当前弃 5 万可能是安全的,但若下家刚打出 4 万,再打 5 万就成点炮高危牌;又如手握 1234567 万,扔 4 万看似破顺,实则保留两头听(1/7 万)和中间听(4 万)双重机会。我们的 AI 出牌模块采用三层评估体系:基础牌效分 → 安全度惩罚 → 听口增益加权。
4.1 牌效分(Base Score):量化每张牌的“结构价值”
对每张牌i,计算其参与顺子/刻子的可能性:
- 若
i是顺子中间(如 5 万),且i-1和i+1均存在 → +3 分 - 若
i是顺子边张(如 1 万),且i+1和i+2均存在 → +2 分 - 若
i数量 ≥2 → 可作将或刻子,+2 分 - 若
i是幺九牌(1/9 万条筒 或 字牌),且同花色其他幺九存在 → +1 分(利于十三幺)
private int calculateBaseScore(int[] hand, int cardIndex) { int score = 0; int count = hand[cardIndex]; // 刻子潜力 if (count >= 2) score += 2; // 顺子潜力:检查左、中、右三种位置 if (isInSuit(cardIndex, 0)) { // 万子区 0~8 if (cardIndex >= 1 && cardIndex <= 7) { // 可作中间:2~8万 if (hand[cardIndex-1] >= 1 && hand[cardIndex+1] >= 1) score += 3; } if (cardIndex <= 6) { // 可作左:1~7万 if (hand[cardIndex+1] >= 1 && hand[cardIndex+2] >= 1) score += 2; } if (cardIndex >= 2) { // 可作右:3~9万 if (hand[cardIndex-1] >= 1 && hand[cardIndex-2] >= 1) score += 2; } } // ... 条、筒、字牌类似处理 return score; }4.2 安全度惩罚(Safety Penalty):用“危险牌表”动态扣分
我们维护一张DANGER_TABLE[34][34],DANGER_TABLE[i][j]表示:若你打出牌i,而玩家 j 的舍牌历史中包含i-1和i+1(顺子听),或i出现频次高(刻子听),则j点炮概率提升。该表由离线统计 10 万局人类对战日志生成,线上只读。出牌前,对每个候选牌i,计算:
double dangerPenalty = 0.0; for (int player = 0; player < 3; player++) { dangerPenalty += DANGER_TABLE[i][player] * opponentDiscardWeight[player]; } // opponentDiscardWeight[player] 是该玩家最近3轮弃牌的权重衰减因子最终得分 =baseScore - dangerPenalty * 5.0(惩罚系数可调)
4.3 听口增益(Listening Gain):模拟补牌后的听口变化
对每张候选牌i,模拟弃掉它后,计算新听口数newListeningCount,并与原听口数oldListeningCount比较:
- 若
newListeningCount > oldListeningCount,奖励+ (new - old) * 10 - 若
newListeningCount == oldListeningCount,奖励0 - 若
newListeningCount < oldListeningCount,惩罚- (old - new) * 8
此步骤需调用findListeningCards(),但因只对当前手牌执行一次(非每张候选都全量查),整体耗时可控。
5. 避坑:那些让算法上线后半夜报警的 4 个真实翻车点
现象:线上服务在高并发时isHu()耗时突增至 200ms,CPU 持续 95%。
原因:HU_CACHE使用了Caffeine的expireAfterWrite,但未设refreshAfterWrite,导致热点手牌(如清一色 13 张)缓存过期瞬间,大量请求穿透到checkHuByDfs(),引发 GC 飙升。
解决:启用refreshAfterWrite(1, TimeUnit.MINUTES),让缓存后台异步刷新,前台始终命中。
现象:某用户提交手牌[1,1,1,2,2,2,3,3,3,4,4,4,5](13 张),findListeningCards()返回空列表,但实际应听 5 万(碰碰胡)。
原因:canBeShunzi*()过滤逻辑错误地将5判为“不可作顺子边张”,因5+1=6和5+2=7虽存在,但代码中isInSuit(5,0)误判为条子区(索引偏移计算错)。
解决:重写isInSuit(),用cardIndex / 9确定花色(0=万,1=条,2=筒,3+=字),而非硬编码区间。
现象:AI 在听 1 万和 9 万时,连续 5 轮打出 5 万,被用户投诉“AI 故意放水”。
原因:安全度惩罚中,opponentDiscardWeight未随轮次重置,导致上一轮对手弃 4/6 万的记录持续影响本轮决策,过度压制中间牌。
解决:将opponentDiscardWeight改为每轮初始化为[1.0, 1.0, 1.0],仅对当轮对手弃牌实时衰减。
现象:十三幺查胡在hand = [1,0,0,0,0,0,0,0,1, 1,0,...](13 张幺九)时返回false,但应胡。
原因:isThirteenYao()中检查“有且仅有一个将”时,用count == 2判定,但未排除count == 4(四张相同幺九)的情况,后者也符合十三幺(四张作将)。
解决:将判定改为count >= 2 && count <= 4,并确保其余 12 种牌count == 1。
6. 评估算法:用“听口熵”和“番种覆盖率”量化手牌质量,告别玄学打分
很多团队用“当前听口数”作为手牌评分唯一指标,但这极易误导:听 3 张(1/4/7 万)看似不错,但若 1 万已被下家打出两次,实际有效听口只剩 2 个。我们提出双维度评估法,输出一个[0.0, 1.0]区间的综合质量分,已在某高校棋牌教学系统中稳定运行 18 个月。
6.1 听口熵(Listening Entropy):衡量听口的“抗干扰能力”
定义听口集合L = {l1, l2, ..., lk},对每个li,计算其“存活概率”p(li):
- 若
li是字牌,且对手未打出过该字 →p = 0.95 - 若
li是数牌,且对手最近 2 轮打出li-1或li+1→p = 0.3(顺子听被破) - 若
li是数牌,且对手打出过li本身 ≥2 次 →p = 0.1(刻子听被破) - 其他情况 →
p = 0.7
则听口熵H = -Σ p(li) * log2(p(li))。熵值越高,说明听口分布越均匀、抗干扰越强。例如:
- 听
{1万, 9万}且p=0.95, 0.95→H ≈ 0.28(低熵,风险集中) - 听
{1万, 4万, 7万, 9万}且p=0.7,0.7,0.7,0.7→H ≈ 2.0(高熵,容错好)
6.2 番种覆盖率(Fan Coverage):预测胡牌后的理论番数上限
不是直接算当前番数(因未胡),而是评估手牌向高番结构演化的潜力。我们预定义 12 个高价值番种及其触发条件(如“清一色”需同花色 ≥10 张,“七对”需 7 对),对每个番种f,计算其“达成度”d(f):
- 清一色:
d = min(1.0, sameSuitCount / 14.0) - 七对:
d = pairsCount / 7.0(pairsCount 是手牌中对子数量) - 混一色:
d = (sameSuitCount + honorCount) / 14.0 - ... 其他类似
则番种覆盖率C = Σ w(f) * d(f),w(f)是该番种在国标中的权重(如清一色 w=24,七对 w=24,混一色 w=6)。C越高,说明手牌越接近高番结构。
6.3 综合质量分:加权融合与业务适配
最终质量分Q = α * H + β * C,其中α + β = 1。我们根据业务场景动态调整:
- 教学模式(强调稳健):
α=0.7, β=0.3→ 优先保听口多样性 - 竞技模式(强调爆发):
α=0.3, β=0.7→ 优先冲高番
public double evaluateHandQuality(int[] hand, GameMode mode) { double entropy = calculateListeningEntropy(hand); double coverage = calculateFanCoverage(hand); double alpha = (mode == GameMode.LEARNING) ? 0.7 : 0.3; double beta = 1.0 - alpha; return alpha * entropy + beta * coverage; }我的血泪经验:别迷信“AI 必须打最高分牌”。在真实对局中,我曾把
Q=0.82的 5 万(高熵但低番)换成Q=0.79的 1 万(低熵但清一色潜力+20%),结果下一轮自摸清一色。后来才明白:评估算法不是替你做决定,而是给你一张“决策地图”——它标出哪里平坦(安全)、哪里陡峭(高番)、哪里有暗礁(点炮风险)。真正的高手,是看着地图,结合当下牌河、对手风格、剩余牌山,自己踩出那一步。希望帮到你。
本文还有配套的精品资源,点击获取