
简介这是一份面向Java游戏开发者的麻将算法源码合集集合了胡牌判断、AI出牌决策、查胡校验、牌面价值评估与出牌策略选择等核心算法模块适合需要自行构建或优化麻将AI的中高级开发者作为参考。压缩包共52个文件以Java源码与配套文档为主包含15个Java源文件、19张原理图示、9个牌谱文本、3个说明文档及少量配置与数据库文件压缩后整体大小约36.41MB。目前已有2705人学习下载是同类资源中关注度较高的一份。资源不仅提供了可直接运行的示例代码还配有图片和文本牌谱便于对照理解胡牌组合、AI策略及评估算法的实现细节。通过分析这些源码开发者可以快速搭建自己的麻将AI原型针对不同地区玩法调整规则逻辑从而节省大量从零开发的时间。此外目录中包含的图示与说明文档可帮助梳理算法流程对希望深入游戏AI领域的开发者而言具备较强的实战参考价值。1. Java 麻将算法最常出错的点不是不会胡牌而是不会计算向听数一个 34 张牌的 int 数组能把很多自称熟悉 Java 并发和 JVM 调优的人难住。麻将算法汇总这类项目表面上是胡牌算法、AI 算法、查胡算法、评估算法和出牌算法的集合真正落地时考验的是你在递归回溯里恢复状态、在向听数和进张数之间取舍、在牌河数据上估算安全度的能力。下面以 Java 为背景把这五个算法串成一条可复现的链路先用位图和 int[34] 编码手牌再用递归拆面子判断胡牌然后用查胡算法生成所有有效张最后给 AI 出牌算法接上评分函数和剪枝参数。适合正在做棋牌后端、或者准备在 Java 面试八股文之外讲点真实搜索场景的人。2. Java 麻将算法的基础牌型编码、向听数模型和查胡算法差异2.1 用 int[34] 和位图表达手牌常见做法是定义int[] hand new int[34]下标 0-8 表示万子 1-99-17 表示筒子 1-918-26 表示条子 1-927-33 表示东南西北白发中。为什么不用ListInteger或者String表示因为胡牌算法会高频调用递归切牌每次递归都要复制一组状态。如果是数组直接System.arraycopy只需要几十纳秒如果是集合对象会产生大量临时节点触发 GC在高并发棋牌服务端上很难接受。代码里我一般会同时维护两个视图一个int[34]用来计数一个long的低 34 位用来快速判断“这张牌是否存在”。计数用于拆牌位图用于剪枝和缓存。下面的类把这两个视图合在一起public class MahjongHand { private final int[] count new int[34]; private long bitMask; public void addTile(int tile) { count[tile]; bitMask | 1L tile; } public void removeTile(int tile) { count[tile]--; if (count[tile] 0) { bitMask ~(1L tile); } } public long getBitMask() { return bitMask; } }这个类的关键点是bitMask只表示一张牌有没有不表示数量。做记忆化缓存时可以把count数组转成一个String或long数组作为 key但不要直接用bitMask做 key因为bitMask丢了数量信息。实际工程里如果单局牌型数量不大可以直接用Arrays.hashCode(count)配合一个HashMapInteger, Boolean做胡牌结果缓存。参数说明tile的范围是 0-331L tile必须用 long如果写成int移位tile 超过 31 时会回绕字牌区就全错了。这是新手上路最常见的 bug 之一。2.2 向听数模型AI 出牌和查胡算法的共同基准向听数表示“距离胡牌还差几次有效摸牌”。标准胡牌是 3n2所以向听数为 0 就是胡牌1 是听牌2 是一向听依此类推。计算向听数的意义在于出牌算法需要知道打掉哪张牌能最小化向听数评估算法则需要把向听数作为一个大权重项。常见做法不是枚举所有摸牌路径而是递归统计“面子数搭子数”面子meld已经成型的顺子或刻子。搭子taatsu两张相邻或间隔一张的牌比如 45 万、46 筒以及两张相同的对子。向听数s 8 - 2*m - tm是面子数t是搭子数。这里有个容易踩的坑这个公式只适用于普通胡牌型。七对子的向听数要单独用6 - 对子数计算十三幺的向听数要按幺九牌的种数和对子数计算。如果混在一起AI 会做出非常奇怪的出牌决策比如无脑放弃七对子。列一张快速判断表牌型状态面子数 m搭子数 t向听数示例胡牌410111 234 567 789 11听牌321111 234 567 78 单张一向听312111 234 567 78 单张完全混乱115很少见这张表可以帮你快速验证递归写的向听数计算器对不对。手工推一个手牌把计算结果填到表里如果和公式不一致说明搭子统计里有重复计数。2.3 胡牌、查胡和 AI 三者的接口设计在 Java 工程里我会把三个算法拆成三个类WinChecker只负责胡牌判定TingCalculator负责查胡找出所有能胡的张AIPlayer负责出牌。三者依赖关系是TingCalculator调用WinCheckerAIPlayer同时调用向听数计算器和TingCalculator。查胡算法和胡牌算法最大的区别是一次算 34 种牌把每种牌依次加入手牌再调用胡牌判定。听牌张就是能让胡牌判定为 true 的那几张。这里性能瓶颈不在单次胡牌判定而在 34 次调用的重复扫描。所以查胡算法通常会先做一次牌型编码缓存或者把“是否缺一种牌就能凑成 3n2”作为预筛条件。后面的章节会专门讲这个优化。3. Java 胡牌算法实现递归回溯、记忆化搜索与剪枝参数3.1 标准胡牌 3n2 的最小递归实现直接上能跑的最小实现不依赖任何框架public class WinChecker { private final int[] tiles new int[34]; public boolean isWin(int[] hand) { System.arraycopy(hand, 0, tiles, 0, 34); int total 0; for (int c : tiles) total c; if (total % 3 ! 2) return false; for (int i 0; i 34; i) { if (tiles[i] 2) { tiles[i] - 2; if (splitSets(0)) { tiles[i] 2; return true; } tiles[i] 2; } } return false; } private boolean splitSets(int start) { while (start 34 tiles[start] 0) start; if (start 34) return true; if (tiles[start] 3) { tiles[start] - 3; if (splitSets(start)) return true; tiles[start] 3; } if (start 27 start % 9 6 tiles[start 1] 0 tiles[start 2] 0) { tiles[start]--; tiles[start 1]--; tiles[start 2]--; if (splitSets(start)) return true; tiles[start]; tiles[start 1]; tiles[start 2]; } return false; } }逻辑说明isWin先对总张数取模排除无效牌型然后枚举雀头把雀头从手牌里去掉递归拆剩余牌。splitSets从start开始找第一个非空位置优先拆刻子。拆刻子成功就进入下一层递归失败再尝试拆顺子。字牌区start 27不会进入顺子分支。每次递归前修改的计数递归后必须加回来否则上层分支会拿到脏数据。参数说明start是当前未处理牌的最小下标。用它的好处是递归不需要每次从 0 扫只往后找能减少约 80% 的无效遍历。代价是失去一些乱序组合的可能但麻将拆牌本来就按花色连续处理所以结果是正确的。3.2 剪枝顺序先刻子后顺子的两个原因为什么先刻子第一个原因是刻子分支只影响tiles[start]一个位置回退动作干净顺子分支会影响start、start1、start2三个位置回退时需要三次恢复错误概率更高。第二个原因是刻子不存在跨牌依赖剪枝后剩余状态更少。按经验先刻子后顺子能让大多数普通牌型的判定时间缩短 30%-40%。这里给出一个剪枝参数表方便你直接抄到项目注释里剪枝参数推荐值说明总张数取模total % 3 2不满足直接返回 false雀头枚举只枚举tiles[i] 2避免无效递归顺子边界start % 9 6防止 789 后越界字牌处理start 27只查刻子字牌没有顺子缓存 keyArrays.hashCode(tiles)避免重复计算同一手牌3.3 七对子和十三幺分支标准胡牌算法只能处理 3n2 的普通牌型。日麻和国标麻将有七对子国标和很多地方规则还有十三幺。如果游戏规则里允许这两种役需要在isWin一开始就检查public boolean isWin(int[] hand) { int total 0; for (int c : hand) total c; if (total % 3 ! 2) return false; System.arraycopy(hand, 0, tiles, 0, 34); if (isSevenPairs(tiles)) return true; if (isThirteenOrphans(tiles)) return true; return canWinBySplit(); }七对子的判定很简单遍历 34 张牌统计对子数要求每个计数都必须为偶数或是 0并且对子数为 7。十三幺的判定需要先定义一个ORPHANS掩码包含一万、九万、一筒、九筒、一条、九条、东南西北白发中。检查手牌里除了这些牌之外没有别的牌然后统计总对子数至少一个对子且种数为 13 或 14 张都算。注意点这里有一个常见约定七对子不允许四张相同的牌拆成两个对子。如果你做的游戏允许龙七对需要单独加一个分支把四张相同的牌也当成两对处理。如果不能确定先按不允许处理后续产品要求再改。3.4 查胡算法的听牌张搜索优化查胡算法也叫听牌判定要从 34 种牌里找出能让当前手牌胡牌的那几张。最简单的实现是public ListInteger getTingTiles(int[] hand) { ListInteger result new ArrayList(); int total Arrays.stream(hand).sum(); int[] test Arrays.copyOf(hand, 34); for (int tile 0; tile 34; tile) { if (test[tile] 4) continue; // 桌上已经用完4张 test[tile]; if (isWin(test)) result.add(tile); test[tile]--; } return result; }这段代码在本地测试没问题但放到服务端循环调用时会发现每次都要做 34 次胡牌判定。优化思路有两个。第一个是缓存把Arrays.hashCode(tiles)作为 keyBoolean作为 value缓存最近 10 万次胡牌结果。原因是同一局里很多手牌会被反复评估缓存命中率可以到 40% 以上。第二个是预筛在isWin里已经对总张数做了% 3 ! 2的判断所以查胡算法里加入一张牌后总张数一定满足(total1) % 3 2。真正耗时的分支集中在枚举雀头。可以先快速检查是否存在至少一个对子不存在就直接跳过这张牌。这里有个工程经验不要把isWin写成静态方法并在内部 new 数组因为服务端是并发环境静态数组会被多线程写坏。要么每个请求 new 一个WinChecker要么用ThreadLocalWinChecker做线程隔离。我在项目里用后一种因为查胡算法一次调用要跑 34 次isWin每次 new 对象的成本虽然不高但累积起来还是会影响 GC。4. Java AI 出牌算法与评估算法评分函数、切牌模拟与安全度4.1 评估算法把“手牌价值”拆成可加权的分评估算法是 AI 出牌算法的前置模块。它的任务不是判断某张牌好不好而是给一种手牌状态打一个分数让出牌算法能比较“打 A 比打 B 好多少”。最简单的评分函数是线性的public class HandEvaluator { private final double syantenWeight; private final double advanceWeight; private final double safetyWeight; private final double doraWeight; public double evaluateDiscard(int[] handAfterDiscard, int discardTile, TileContext context) { int syanten SyantenCalculator.calc(handAfterDiscard); int advance AdvanceCalculator.count(handAfterDiscard); double safety context.safetyOf(discardTile); double dora context.doraCount(handAfterDiscard); double score -syantenWeight * syanten advanceWeight * advance safetyWeight * safety doraWeight * dora; return score; } }逻辑说明syanten前面加负号是因为向听数越小牌型越好。advance是进张数也就是摸到哪些牌能让向听数下降。这个值可以通过遍历 34 种牌模拟“摸进后计算向听数与原向听数的差”得到。safety是切牌候选的安全度取值范围 0 到 1作用是在多个候选牌向听数相同时优先打出最安全的那张。参数说明syantenWeight通常设为 10advanceWeight设 0.8。这样一手牌如果靠进张数优势涨 10 分仍然比不过向听数降低 1 的收益。这样的权重关系是为了避免 AI 为了追求“更多有效牌”而乱打危险牌。4.2 出牌算法切牌模拟和候选牌排序出牌算法的标准流程是枚举手牌中所有可以切出的牌。对每一张复制手牌并移除它。计算移除后的向听数和进张数。如果移除后的向听数好于当前向听数这张就是强候选。在所有不坏的候选里按评估算法打分排序。代码骨架public int selectDiscard(int[] hand, TileContext ctx) { int currentSyanten SyantenCalculator.calc(hand); int best -1; double bestScore Double.NEGATIVE_INFINITY; for (int tile 0; tile 34; tile) { if (hand[tile] 0) continue; hand[tile]--; int newSyanten SyantenCalculator.calc(hand); if (newSyanten currentSyanten - 1) { hand[tile]; continue; } double score evaluator.evaluateDiscard(hand, tile, ctx); hand[tile]; if (score bestScore) { bestScore score; best tile; } } return best; }逻辑说明newSyanten currentSyanten - 1这个条件是用来过滤掉“因为计算误差导致的虚假进步”。正常情况下切一张牌最多让向听数不变或变差不可能变好但代码里经常因为七对子、十三幺分支没有完整实现导致出现负向听数。加这个保护之后异常候选会被直接丢弃。参数说明TileContext里包含牌河、宝牌、剩余牌堆等信息。evaluateDiscard里面读到的safety依赖于牌河所以同一个hand在不同牌河环境下会打出不同的牌这是期望行为。若想测试 AI 只靠手牌决策可以把safetyWeight设为 0。4.3 牌河与安全度用筋牌和熟张控制点炮率AI 只追求进张会不停打出生张点炮率很高。工程里的常见做法是用“筋牌”来估计安全度。所谓筋就是 147、258、369 这三组里相隔 3 的牌。比如牌河里有 5 万那么 2 万和 8 万是筋牌因为别人如果手里有 34 万或 67 万5 万就组成顺子反过来2 万能组成的搭子是 13、23、24和 5 万的关系较弱。可以维护一个safetyTable[34]每次有人切牌就更新public void updateSafety(int tile) { int suit tile / 9; int num tile % 9; if (suit 3) { for (int delta 0; delta 3; delta) { int neighbor suit * 9 num delta * 3; if (neighbor suit * 9 neighbor suit * 9 9) { safetyTable[neighbor] Math.max(safetyTable[neighbor], 0.7); } } } }参数说明delta遍历 0、3、6把 147、258、369 关系标记到邻接牌上。更新时用Math.max是因为一张牌可能被多张牌河里的牌打上安全标记取最高安全度即可。默认安全度为 0.5生张降为 0.2出现过一张的熟张升为 0.8出现两到三张的升为 1.0。4.4 AI 参数表让评估算法可调不同平台对 AI 的要求不一样。欢乐向的棋牌游戏希望 AI 打得快、偶尔点炮竞技向的则希望 AI 稳健。所有参数集中在配置表里是一个好习惯参数名默认值调低效果调高效果syantenWeight10.0更激进拆搭子更保守养向听advanceWeight0.8不太看重进张更看重牌效率safetyWeight1.2容易点炮防守优先doraWeight1.5忽略宝牌愿意为宝牌冒险honorsWeight0.6乱打字牌偏好字牌手役调参时要盯两个指标平均听牌巡数和点炮率。如果 AI 总是尽听或者点炮率超过 25%先调高 safetyWeight如果听牌速度太慢先调高 advanceWeight。压测时用随机种子的自对弈不要用手动打牌因为自对弈结果才能反映参数间的交互。5. 用自对弈验证 AI 的 3 个指标和一个 Java 日志调试技巧5.1 自对弈模拟的搭建把胡牌算法、查胡算法、出牌算法串起来的验证方法不是单张测试而是让 AI 自己打 1000 局。搭建逻辑不复杂一个Game类维护牌山、牌河和四个手牌数组每个回合按顺序调用selectDiscard摸牌后如果getTingTiles不为空且isWin为 true就结束牌局。注意点对局模拟要固定随机种子。否则每次跑结果波动很大没法判断是参数问题还是运气问题。可以在配置文件里让seed从外部注入比如new Random(seed)。5.2 三个观察指标第一个是平均向听数下降曲线。向听数应该随巡数增加而单调不增如果出现回升说明出牌算法把已经形成的面子拆了。第二个是平均听牌巡数。一般控制在 9 到 12 巡比较合理如果超过 14说明 AI 太保守总在打安全牌。第三个是点炮率。点炮率 点炮局数 / 总局数在四家 AI 对战中超过 30% 就需要检查安全度权重。用表格记录不同参数组合的结果safetyWeight平均听牌巡数点炮率平均胡牌番数0.510.231%3.11.211.522%2.82.013.815%2.4从表里可以看到安全度权重上涨会明显降低点炮率但听牌变慢、胡牌番数下降。实际调参时我会优先把点炮率压到 25% 以下再通过调整 advanceWeight 找回听牌速度。5.3 Java 日志调试技巧调试出牌决策时最有效的方式是把每次出牌候选的分数和向听数变化打出来。这里有个技巧不要在日志里打印整个int[34]数组而是只打印被决策的牌型缩写。比如0-8打印成1万-9万27打印成东。这个转换函数很简短却能省下大量读日志的时间。定位到一次异常出牌后再把当时的牌河序列和手牌编码一起输出复现概率会高很多。用ThreadLocalWinChecker加缓存时注意缓存要在每局开始后清空一次。否则上一局的手牌编码和新一局的编码可能冲突导致胡牌判定结果错乱。这里给出的所有算法代码都能在一个 Java 项目里直接跑。实践时先把isWin测好再写getTingTiles最后接 AI 出牌这样每一步都有明确的验证点不会出现“胡牌不对但出牌看起来正常”的假象。本文还有配套的精品资源点击获取