
简介面向Java游戏开发与AI算法研究者的麻将算法实现合集涵盖胡牌判断、AI决策、查胡校验、牌面评估与出牌策略等核心模块适合正在开发棋牌类游戏、希望理解麻将规则逻辑或设计智能对手的开发者。压缩包共52个文件以Java源码为主辅以png示意图、txt规则说明、md文档和配置文件可结合图示与文档快速定位代码逻辑包体36.41MB结构清晰便于按模块查阅。已有2705人学习浏览是同类资源中较实用的参考。通过阅读源码可掌握顺子、刻子、杠等牌型判断方式了解基于概率或搜索的AI选牌思路并学习评估函数如何量化手牌价值同时仓库包含多种规则变体的文本约定便于对照研究地方麻将或竞技麻将的差异为后续自研或扩展麻将系统节省大量基础工作。1. 麻将算法全链路从胡牌判定到 AI 出牌的价值一次胡牌判定要不要两秒一次 AI 托管出牌要不要十秒决定的是一个麻将房间的用户体验。标题里这五个算法——胡牌、查胡、评估、AI、出牌看起来是五个独立函数实际上是一条流水线胡牌算法负责结果判定查胡算法告诉你差哪张牌能胡评估算法给手牌算分AI 算法才能决定打哪张、留哪张。要写判胡服务、AI 托管、麻将学习平台的后端或者想拿树搜索实例刷 java 面试八股文的人这套链路都值得完整过一遍。下面从牌型编码开始把每一环的参数、代码和容易踩的坑讲清楚。2. 胡牌算法牌型编码与 DFS 拆牌判定2.1 手牌怎么编码34 种牌与 int[34] 计数麻将的胡牌判定不关心花色名称只关心“牌的种类和数量”。标准做法是把所有牌映射成连续整数用计数数组保存手牌。万、筒、条各 9 种字牌 7 种合计 34 种花牌春夏秋冬梅兰竹菊不参与胡牌组成通常在进入算法前单独计数。牌型编码区间示例万0 - 80 表示一万8 表示九万筒9 - 179 表示一筒17 表示九筒条18 - 2618 表示一条26 表示九条字牌27 - 3327 东28 南29 西30 北31 中32 发33 白手牌用一个int[34]保存数组下标就是牌 id值是该牌的张数。编码的好处有两个第一回溯修改某个下标的时间复杂度是 O(1)不需要频繁创建集合对象第二判断顺子时只需要检查i1和i2两个位置是否连续。下面是编码和计数方式的示例。public int[] encodeHand(int[] tileIds) { int[] hand new int[34]; for (int id : tileIds) { hand[id]; } return hand; }这段代码把牌 id 列表转换成计数数组。tileIds是已经完成编码的牌序列比如[0, 0, 1, 9]表示一万两张、二万一张、一筒一张。hand[id]是全部逻辑后续所有算法都基于这个数组操作。2.2 胡牌判定的递归拆解去掉将牌后拆面子胡牌判定的核心思路很朴素14 张牌里先拿出一对做将牌剩下 12 张必须能被拆成 4 组“面子”。面子只有两种刻子三张相同和顺子三张连续同花色。递归函数要做的事情就是从计数数组里不断移除面子直到数组全部归零。public boolean canWin(int[] hand) { int total 0; for (int c : hand) { total c; } if (total % 3 ! 2) { return false; } for (int i 0; i 34; i) { if (hand[i] 2) { hand[i] - 2; if (isAllMelds(hand)) { hand[i] 2; return true; } hand[i] 2; } } return false; } private boolean isAllMelds(int[] hand) { int i 0; while (i 34 hand[i] 0) { i; } if (i 34) { return true; } if (hand[i] 3) { hand[i] - 3; if (isAllMelds(hand)) { hand[i] 3; return true; } hand[i] 3; } if (i 27 i % 9 6 hand[i 1] 0 hand[i 2] 0) { hand[i]--; hand[i 1]--; hand[i 2]--; if (isAllMelds(hand)) { hand[i]; hand[i 1]; hand[i 2]; return true; } hand[i]; hand[i 1]; hand[i 2]; } return false; }canWin先排除总数不满足3n2的手牌再枚举每一张牌作为将牌。isAllMelds的作用是判断剩下的牌能否全部拆成面子先定位第一个非空位置尝试拆刻子再尝试拆顺子。i % 9 6这个条件保证顺子不会跨越花色边界比如八万九万和一筒不会被误判成顺子。递归深度最多 12 层不会造成栈溢出如果对性能有更高要求可以加一个已经失败的状态缓存但常规单次判胡用不到。2.3 七对与十三幺的边界处理普通牌型走 DFS 拆面子但七对和十三幺必须单独判断否则会误判。七对的要求是 14 张牌刚好组成 7 个对子但不同平台对“四张相同的牌能不能算两对”有分歧。常见做法是提供一个开关参数默认允许 4 张拆成两对因为国标麻将里七对允许四个相同牌算两对日本麻将则不允许。public boolean isSevenPairs(int[] hand, boolean allowQuadAsTwoPairs) { int pairs 0; for (int count : hand) { if (count % 2 ! 0) { return false; } if (count 4 !allowQuadAsTwoPairs) { return false; } pairs count / 2; } return pairs 7; }理解这个开关很重要allowQuadAsTwoPairs为 true 时count 4直接按两对计入为 false 时只要出现 4 张就必须返回 false。十三幺的判断逻辑则是“19 牌和字牌各一张另外再有一张重复牌”先检查 1、9、字牌是否都有至少一张再检查总张数是否为 14。实现时不要把这些特判塞进canWin的主体而是让canWin内部依次调用三个独立的判定函数任何一个返回 true 就胡牌这样后续加新牌型比如清一色、不靠不会动到底层拆牌逻辑。3. 查胡算法遍历进张生成听牌列表3.1 枚举 34 种进张并复用胡牌判断查胡算法也叫听牌检测本质是“把每一张还没见过的牌加入手牌然后调用胡牌判断能胡就说明听这张”。实现上直接复用第 2 章的canWin枚举 0 到 33 共 34 种牌逐个尝试即可。这个玩法在 java 面试题里经常被拿来考递归和剪枝。public ListInteger findWaitingTiles(int[] hand) { ListInteger waits new ArrayList(); for (int tile 0; tile 34; tile) { if (hand[tile] 4) { continue; } hand[tile]; if (canWin(hand)) { waits.add(tile); } hand[tile]--; } return waits; }这里有两个关键细节。第一hand[tile] 4直接跳过因为一副牌里同种牌只有 4 张手牌已经有 4 张就不可能存在第五张作为进张。第二hand[tile] / wait / hand[tile]--是在原数组上做增删不需要克隆整个数组这也是用计数数组的好处。注意canWin内部会修改hand所以它必须保证在返回前把数组恢复原样前面的实现里加减操作是成对出现的正好满足这个约束。3.2 听牌类型分类与出牌后的枚举手牌查胡除了要给出“听哪些牌”实战中还会要求输出听牌类型比如两面听、边张听、单骑听。想让 AI 更聪明就不能只返回牌 id还要在枚举时记录每组拆法。常见做法是把canWin改造成返回拆牌路径的版本或者退一步既然只需要听牌类型可以在查胡时额外传入“剩余牌组成”的快照对每种摸到的牌再走一次完整拆解。一个更工程化的思路是出牌后查胡当 AI 想评估“打某张牌后听什么”时需要先把手牌中的这张牌移除再调用查胡。这个操作会频繁创建数组副本推荐用手牌对象来管理计数数组每次 discard 时返回一份新拷贝。public ListInteger findWaitsAfterDiscard(int[] hand, int discardId) { int[] next hand.clone(); next[discardId]--; return findWaitingTiles(next); }clone()在这里是必须的因为后续查胡内部的判胡过程会修改数组。如果直接传原数组手牌状态会被污染AI 决策循环里的其他候选牌就全错了。这个 bug 非常隐蔽排查的时候最有效的办法是在findWaitingTiles入口处打印Arrays.toString(hand)比较每次调用前后的状态差异。3.3 听牌表缓存与 AI 预计算查胡算法的调用频率远高于单次胡牌判定。一个 AI 出牌决策要对多张候选牌做查胡每张牌又要评估多个模拟分支累计下来可能执行几千次判胡。把每次结果都现算在 Java 里会有明显的 GC 压力。常见的优化是建立手牌到听牌列表的缓存。public class WaitCache { private final MapString, ListInteger cache new HashMap(); public ListInteger get(int[] hand) { String key Arrays.toString(hand); return cache.computeIfAbsent(key, k - findWaitingTiles(hand)); } }用数组内容的字符串形式作为 key 是写起来最快的方式缺点是字符串拼接有额外开销。如果对性能更敏感可以把 34 个byte数量的计数压缩到一个long里每个计数占用 2 bit因为单种牌最多 4 张。在 AI 决策这种高频场景下缓存命中率通常非常高因为手牌在前面几轮模拟中变化不大。实现时注意缓存容量控制当 AI 跑完整局后清空避免内存里堆满历史牌型。4. 评估算法向听数、有效进张与牌效率打分4.1 向听数的忽略牌型差异的简化模型向听数描述的是“距离胡牌还差几次有效进张”是评估一手牌价值的核心指标。精确计算向听数要枚举所有拆牌方案逻辑比胡牌判定复杂得多工程上常用一个近似公式向听数 8 - 2 × 面子数 - 搭子数 - 雀头数。这里面子是完整的刻子或顺子搭子是差一张成面子的组合雀头是能当将对的对子。public int estimateShanten(int[] hand) { int melds 0; int partial 0; boolean hasPair false; int[] counts hand.clone(); for (int i 0; i 34; i) { if (counts[i] 3) { counts[i] - 3; melds; } if (counts[i] 2 !hasPair) { counts[i] - 2; hasPair true; } if (i 27 i % 9 6 counts[i] 0 counts[i 1] 0 counts[i 2] 0) { counts[i]--; counts[i 1]--; counts[i 2]--; melds; } } for (int i 0; i 27; i) { if (i % 9 7 counts[i] 0 counts[i 1] 0) { partial; } } return 8 - 2 * melds - partial - (hasPair ? 1 : 0); }这个实现把一个完整的评估计算压缩成了单次扫描和精确 DFS 的区别在于贪心拆面子可能把顺子拆散导致结果偏大但作为 AI 排序依据已经够用。注释里应标注“估计值用于排序不用于最终胡牌判断”。如果你把这段代码放进 java 面试八股文里聊一定要说明这是近似算法精确向听数需要回溯穷举复杂度指数级。4.2 有效进张与出牌后的价值变化向听数只回答“快慢”不回答“有多少种方式变快”。有效进张的定义是当前手牌摸到某张牌后向听数下降 1。有效进张的数量是评估一张牌好不好的最重要指标。计算方式就是枚举 34 种牌分别加入手牌并计算向听数统计下降次数。public int effectiveTiles(int[] hand) { int count 0; int base estimateShanten(hand); for (int tile 0; tile 34; tile) { hand[tile]; int next estimateShanten(hand); if (next base) { count 4 - hand[tile] 1; } hand[tile]--; } return count; }注意代码里4 - hand[tile] 1是为了统计“剩余牌张数”当前手牌已经有hand[tile]张加 1 后一副牌总共 4 张所以剩余进张是4 - (hand[tile])。这行容易写错很多人直接return count导致同一种牌有多张时进张数被高估。出牌决策里候选牌的打分公式通常是打出该牌后的有效进张数减去打出前的有效进张数差值越大说明越不该打。4.3 评估结果如何服务攻守倾向评估算法不只能给手牌打分还能根据向听数划分牌局阶段。向听数为 0 表示已经听牌AI 的行为应该偏向和牌选择危险牌也要博向听数大于 3 时手牌还很散应该优先保留安全牌避免放炮。这些规则在出牌算法里用阈值表达比纯写死规则更灵活。向听数牌局阶段出牌倾向0听牌追求最大和牌概率必要时打危险牌1 - 2进攻优先保留搭子出牌以有效进张最大化为目标3 以上防守优先弃熟牌保留安全张把评估算法和出牌算法解耦之后调整 AI 风格只需要改阈值和权重不用动搜牌逻辑。这也是评估算法在这个标题里被单独列出来的原因它决定 AI 的“性格”。5. AI 算法与出牌决策蒙特卡洛模拟的落地与排错5.1 候选牌生成与模拟框架出牌决策是 AI 算法的最终出口。最简单的可落地方案是对手牌中每张候选牌做一次模拟模拟未来若干轮摸牌用评估算法计算每轮结束后的得分最后取平均值最高的候选牌打出。蒙特卡洛模拟在这里的价值是处理“摸到什么牌”的随机性。public int decideDiscard(int[] hand, int[] wall) { int[] candidates distinctTiles(hand); double bestScore Double.NEGATIVE_INFINITY; int bestDiscard candidates[0]; for (int discard : candidates) { int[] next hand.clone(); next[discard]--; double total 0; for (int sim 0; sim 100; sim) { total simulateDraw(next, wall, 6); } double avg total / 100; if (avg bestScore) { bestScore avg; bestDiscard discard; } } return bestDiscard; } private double simulateDraw(int[] hand, int[] wall, int depth) { Random random new Random(seed depth); int[] state hand.clone(); for (int i 0; i depth; i) { int draw wall[random.nextInt(wall.length)]; state[draw]; for (int t 0; t 34; t) { if (state[t] 0) { continue; } state[t]--; break; } } return effectiveTiles(state); }模拟次数和摸牌深度是两个必须调的参数。模拟次数 100 次在回合制 AI 里已经够用深度取 6 是因为再往后推的牌局变化已经很难影响当前出牌决策。break那行是简化处理实际项目里应该用评估算法选出每轮的最优弃牌否则模拟结果偏乐观。这个简化框架的好处是容易替换成更复杂的搜索策略比如给每种进张加权统计。5.2 并行模拟与 JVM 启动参数蒙特卡洛模拟天然适合并行。把 100 次模拟拆到多个线程里执行在 8 核机器上能把单次决策时间压缩到可接受范围。Java 里直接用ExecutorService管理线程池就行。mvn -q compile exec:java -Dexec.mainClasscom.example.majiang.AiBenchmark \ -Dexec.args--simulation 200 --threads 4 --wall-seed 1024这里--simulation控制总模拟次数--threads控制并行线程数--wall-seed是随机种子。种子参数最容易忽略不固定种子时每次决策结果会有细微差别导致同一手牌在不同时间跑出不同打法这在评测和回归测试里非常痛苦。固定 seed 后结果变得可复现下面讨论的缓存和日志排查才有意义。5.3 三个常见坑与验证技巧第一个坑是数组污染。模拟框架里手牌对象被反复 clone 和修改只要某个分支忘记恢复数组后面全部决策都会错。建议在测试里写一个断言决策前后手牌数组必须内容相等。int[] before hand.clone(); int discard decideDiscard(hand, wall); assert Arrays.equals(before, hand) : hand mutated;第二个坑是缓存 key 太长。用Arrays.toString做 key 在监听到高频调用时会创建大量字符串建议改用 long 压缩编码。第三个坑是模拟次数不足导致的震荡100 次模拟在边缘牌型上可能给出相反排序这时候不要盲目调大模拟次数可以先用固定 seed 跑回归集再根据偏差方向判断是模拟方差还是评估函数缺陷。验证整个算法链路最直接的方法是做自对弈把同一副手牌喂给两只 AI一只用贪心有效进张出牌一只用蒙特卡洛打 1000 局后看平均向听数收敛速度和和牌率差异。除了胡牌时间把听牌表序列化成本地文件、启动时预加载缓存也是引擎上线前值得做的工程优化。本文还有配套的精品资源点击获取