首页 / 资讯中心 / 文章详情

麻将胡牌算法与癞子判定:C++递归回溯、剪枝缓存及避坑实录

麻将胡牌算法与癞子判定:C++递归回溯、剪枝缓存及避坑实录 ★ FEATURED ARTICLE
简介一套基于C实现的麻将胡牌算法源码包面向游戏开发学习者和棋牌算法爱好者解决普通胡牌与癞子万能牌胡牌两大核心规则的编程实现。资源共4个文件均为cpp与h源码文件压缩后仅23KB结构紧凑便于阅读与移植。普通胡牌部分采用回溯法遍历手牌所有组合依次验证对子、顺子、刻子、杠等条件并借助动态规划与剪枝降低无效搜索癞子牌处理则分析其在不同牌型下的最佳替代对象同时计算额外番数覆盖牌型表示、组合枚举、合法性判定等完整流程。项目代码模块划分清晰可直接作为自研麻将游戏的核心规则引擎也可视为C递归与搜索优化的一次典型实战。目前已有1365人在CSDN浏览学习适合需要掌握复杂牌型组合与算法剪枝技巧的开发者深入研读。1. 麻将胡牌算法为什么值得自己写从判定性能到癞子复杂度很多做棋牌服务的同学第一版胡牌判断都是从“14 张牌递归拆刻子和顺子”起步的。等真把癞子规则加进去你会发现事情变了一张癞子能顶任何一张牌枚举分支瞬间变成指数级原来 0.1 秒能跑完的判定能慢到让你怀疑是不是死循环。这篇文章不扯玄学直接讲我在 C 里落地麻将胡牌算法和癞子胡牌算法的完整方案——从手牌编码、递归回溯到剪枝和缓存再到最容易“假胡”的 5 个坑。整个实现只用标准 C11不依赖第三方库服务端判牌或者客户端辅助提示都能复用。2. 无癞子胡牌判定从对子试拆到递归回溯2.1 手牌数据模型34 长度计数数组和花色边界麻将牌按牌型一共 34 种万0-8、筒9-17、条18-26、风27-30、箭31-33。手牌在算法里最适合用一个std::vectorint cnt(34)来存数量而不是把每张牌存成一个数组元素。原因有两条。第一递归回溯只关心“每种牌有多少张”不关心“牌在手里的第几个位置”计数数组天然去掉了顺序干扰。第二拆刻子和顺子本质上是消费数量对cnt[i]做减 3、减 1 的操作比从列表里删除元素简单得多也不会因为索引错位导致莫名其妙越界。写顺子分支时必须记住顺子只能在同一种花色内组成万 8 后面不会是筒 0。判断能不能组顺子要同时满足两个条件牌型属于万筒条即i / 9 3作为顺子的第一张i 必须满足i % 9 6确保后面两张还在同一花色花色起始下标结束下标可作顺子起点的 i % 9万080..6筒9170..6条18260..6风/箭2733不可组顺子很多新手在这里把边界写成i % 9 8结果 8、9 万被当成顺子牌型判定直接崩。后面避坑章节会再提一次因为这是反馈频率最高的问题。2.2 胡牌核心判定先选将牌再拆面子标准胡牌的牌型是 4 组面子刻子或顺子加 1 个雀头对子总数是 3n 2。算法统一拆成两步先枚举哪种牌能当雀头取走两张再把余下的牌递归拆成面子。#include vector using namespace std; const int PAI_TYPES 34; // 判断剩余牌能否全部拆成刻子或顺子 bool canSplit(vectorint cnt) { // 定位第一张非零牌 int i 0; while (i PAI_TYPES cnt[i] 0) i; if (i PAI_TYPES) return true; // 无牌可拆成功 // 分支一刻子 if (cnt[i] 3) { cnt[i] - 3; bool ok canSplit(cnt); cnt[i] 3; if (ok) return true; } // 分支二顺子需保证 i 属于万筒条且不跨边界 if (i / 9 3 i % 9 6) { if (cnt[i 1] 0 cnt[i 2] 0) { cnt[i]--; cnt[i 1]--; cnt[i 2]--; bool ok canSplit(cnt); cnt[i]; cnt[i 1]; cnt[i 2]; if (ok) return true; } } return false; } // 无癞子标准胡牌判定 bool isHu(const vectorint cnt) { int total 0; for (int x : cnt) total x; if (total % 3 ! 2) return false; // 总数不对直接排除 for (int i 0; i PAI_TYPES; i) { if (cnt[i] 2) { cnt[i] - 2; // 取雀头 if (canSplit(cnt)) { cnt[i] 2; // 恢复现场 return true; } cnt[i] 2; } } return false; }这段代码有两个关键设计值得说清楚。先说canSplit里“定位第一张非零牌”的贪心逻辑从左往右找到的第一个牌型如果它不能作为刻子或顺子的成员被消掉那它就永远消不掉可以直接返回 false如果能就递归继续。这个贪心配合回溯就是完备的因为每种牌型都会在某个组合里被消费组合的成员顺序不影响结果。再说“先刻子还是先顺子”。代码里刻子在前顺子在后对普通胡牌没有本质区别因为两种分支最终都会尝试到但如果你做的玩法里碰、杠之后牌型比较集中刻子优先通常能更快命中解递归深度也更浅。两块参数如果想调就调这两个分支的顺序和i % 9的边界表。2.3 特判牌型七对与十三幺为什么不能走普通拆分标准胡之外很多麻将规则还带七对、十三幺。这两个牌型不能走canSplit因为七对的结构是一对一对根本不出现三个一组的面子十三幺则固定是 13 种幺九牌加一张重牌普通拆分算法会对大量“顺子根本不成立”的花色反复回溯性能也差。// 无癞子七对14 张恰好 7 个对子 bool isSevenPairs(const vectorint cnt) { int pairs 0; for (int i 0; i PAI_TYPES; i) { if (cnt[i] % 2 ! 0) return false; // 有落单的牌直接否决 pairs cnt[i] / 2; } return pairs 7; }十三幺的判定则是一张成员表查一圈必须是 1 万、9 万、1 筒、9 筒、1 条、9 条、东南西北中发白这 13 种每种至少 1 张且总共有 14 张。两种特判必须在进入主流程前完成否则七对会被普通拆分算法误判成不胡或者漏判。3. 癞子胡牌算法把通配符拆进每一个分支3.1 癞子数据模型独立计数还是并入牌型癞子也叫赖子、烂子、万能牌在规则里虽然是实体牌但在算法模型里我会建议单独用一个int wild计数不放进取样数组cnt[34]。原因很直接癞子的作用不是“它本身是什么牌”而是“它此刻能补什么牌”单独计数方便在递归里按需消耗不会污染普通牌的牌型分布。建模方式优点缺点并入 cnt 数组牌型分布统一不用管癞子位置递归时需要反复把癞子替换成任意目标状态爆炸独立 wild 计数消费直观剪枝容易需要额外处理“癞子当雀头”等特殊分支我经手的项目里绝大多数固定癞子玩法都是用独立计数只有一个特殊玩法“癞子可被杠后重新翻”需要在外部逻辑做转换但内部判定模型不变。这里有一个重要规则一个面子最多只能由 2 张癞子参与。3 张癞子组成刻子虽然合法但不属于“普通牌 癞子补位”的模型要在递归末尾单独处理——直接看wild能否被 3 整除。这个约束如果不加递归会在“癞子补充刻子”分支里做出不存在的牌型。待补充的细节见第 5 章3.2 带癞子的面子拆分补刻子与补顺子的 6 个分支带癞子的递归拆分在canSplit基础上多了补位逻辑刻子可能是 2 张真牌加 1 张癞子也可能是 1 张真牌加 2 张癞子顺子则看 i1 和 i2 位置上缺几张缺了就补几张癞子。// 带癞子的拆分cnt 只含普通牌wild 是癞子张数 bool canSplitWild(vectorint cnt, int wild) { // 定位第一张非零牌 int i 0; while (i PAI_TYPES cnt[i] 0) i; if (i PAI_TYPES) { return wild % 3 0; // 剩下的癞子只能组成刻子 } // 刻子真牌 3 张 if (cnt[i] 3) { cnt[i] - 3; bool ok canSplitWild(cnt, wild); cnt[i] 3; if (ok) return true; } // 刻子真牌 2 张 癞子 1 张 if (cnt[i] 2 wild 1) { cnt[i] 0; bool ok canSplitWild(cnt, wild - 1); cnt[i] 2; if (ok) return true; } // 刻子真牌 1 张 癞子 2 张 if (cnt[i] 1 wild 2) { cnt[i] 0; bool ok canSplitWild(cnt, wild - 2); cnt[i] 1; if (ok) return true; } // 顺子i, i1, i2缺的位置用癞子补 if (i / 9 3 i % 9 6) { bool has1 cnt[i 1] 0; bool has2 cnt[i 2] 0; int needWild (has1 ? 0 : 1) (has2 ? 0 : 1); if (needWild wild) { cnt[i]--; if (has1) cnt[i 1]--; else wild--; if (has2) cnt[i 2]--; else wild--; bool ok canSplitWild(cnt, wild); cnt[i]; if (has1) cnt[i 1]; else wild; if (has2) cnt[i 2]; else wild; if (ok) return true; } } return false; }这里最容易看花眼的是顺子分支里的恢复逻辑。has1 和 has2 在修改前就记好了恢复战场时参考这两个布尔值就能准确还原“哪些牌被抽走了、哪些癞子被花掉了”。如果你直接写if (cnt[i1] 0) cnt[i1]; else wild;会因为递归内部已经改变过cnt[i1]而恢复错位最终出现“癞子越花越多”的诡异现象——后面第 5 章会专门说这个。3.3 癞子胡牌完整入口将牌三种来源与特判整合有了canSplitWild带癞子的胡牌入口就清晰了枚举雀头时可能是普通对子、1 张真牌加 1 张癞子、2 张癞子三种情况都要展开。// 带癞子的标准胡牌判定 bool isHuWild(vectorint cnt, int wild) { int total 0; for (int x : cnt) total x; total wild; if (total % 3 ! 2) return false; // 将牌来自普通对子 for (int i 0; i PAI_TYPES; i) { if (cnt[i] 2) { cnt[i] - 2; if (canSplitWild(cnt, wild)) { cnt[i] 2; return true; } cnt[i] 2; } } // 将牌 1 张真牌 1 张癞子 for (int i 0; i PAI_TYPES; i) { if (cnt[i] 1 wild 1) { cnt[i] - 1; if (canSplitWild(cnt, wild - 1)) { cnt[i] 1; return true; } cnt[i] 1; } } // 将牌 2 张癞子 if (wild 2 canSplitWild(cnt, wild - 2)) { return true; } return false; }三种雀头来源的顺序是人为主观的从真实牌对子开始再考虑癞子参与最后才允许癞子当一对。这样排序是因为普通对子不消耗癞子把癞子资源尽量留个面子拆分命中率最高。如果你的玩法里癞子不能当雀头直接把后两个分支注释掉即可。七对和十三幺的癞子版也要在这层入口之前判断。七对带癞子的核心是数出普通牌里有几对、几张落单落单的每张必然要配 1 张癞子剩下癞子必须是偶数才能凑对。// 带癞子七对 bool isSevenPairsWild(const vectorint cnt, int wild) { int total 0; for (int x : cnt) total x; total wild; if (total ! 14) return false; int pairs 0; // 已成对的数量 int singles 0; // 落单的牌数 for (int i 0; i PAI_TYPES; i) { pairs cnt[i] / 2; singles cnt[i] % 2; } if (wild singles) return false; // 癞子不够补落单 int remainWild wild - singles; if (remainWild % 2 ! 0) return false; // 剩余癞子要两两凑对 pairs singles remainWild / 2; return pairs 7; }这段逻辑的关键在于“落单必须先补”因为七对要求 14 张全部两两成对任何一张落单牌都必须和癞子配对否则无论普通对子再多也会缺一对。补完落单后剩余的癞子还在两两组成新对子最后总对数是 7 就赢。4. 剪枝与缓存让癞子判定跑到微秒级4.1 总张数过滤与花色分桶带癞子递归最怕的是展开 34 种牌 × 多张癞子组合那会导致一次判定几十万次递归。所以递归进来之前就要做两道快筛。第一道是最基础的总张数过滤sum(cnt) wild必须是3n 2。这在胡牌判定中能滤掉一大半非法输入尤其适合那些“差一张就听牌”的手牌状态。第二道是按花色分桶预检万、筒、条、字牌各自单独检查是否有足够结构字牌只能组成刻子或对子如果某一桶里牌数太少或组成结构根本不可能直接返回 false。这个预检可以在递归外用一个 O(34) 的循环完成收益却非常明显。还可以在入口处做“最左边癞子优先”的排序优化递归定位第一张非零牌时如果某个位置的数量只有 1 张优先让它先尝试刻子或顺子的组合路径避免癞子过早被消耗在低价值的边角牌上。4.2 使用 STL 哈希表做中间状态缓存癞子递归真正的痛点在于同一副手牌的不同分支会反复到达相同的“剩余牌癞子数”状态。比如先拆了一组 123 万再拆一组 456 万和先拆 456 万再拆 123 万最后到达的状态完全一样。如果没有缓存这两种同等状态会被重复展开。我通常用std::unordered_map把递归状态缓存起来。状态键要能唯一表达“cnt 数组 wild 数量”一个简单可靠的做法是把 34 个计数压缩成一个 64 位整数编码再把 wild 追加进去。#include unordered_map struct StateKey { unsigned long long cntCode; int wild; bool operator(const StateKey o) const { return cntCode o.cntCode wild o.wild; } }; struct StateHash { size_t operator()(const StateKey s) const { // 把 34 个计数折叠到一个哈希值 size_t h s.wild; for (int v : /* 编码数组 */) h h * 131 v; return h; } };实际项目里我一般直接用std::unordered_mapStateKey, bool, StateHash做 memo每一轮isHuWild调用开始时清空一次缓存。这样缓存生命周期只覆盖单次胡牌判定不会跨局污染数据。需要提醒的是不要把缓存做成全局静态否则癞子数量不同的手牌之间会互相命中错键反而返回错误结果。4.3 参数调优递归深度与癞子上限癞子张数的合理上限是 2 到 4 张。超过 4 张递归分支会迅速膨胀面子里补位和顺子补位的组合数呈现指数增长。我在服务端通常会做一个阈值癞子数大于 4 时改用“癞子全刻子”的快速通道直接判定因为 5 张癞子几乎总能构成胡牌再细算只是浪费时间。递归深度其实不用担心14 张牌最多拆 4 层面子深度是常数级真正会拖慢的是分支数量。开启记忆化之后同样的状态不会重复计算实测单次判定耗时从毫秒级降到几十微秒这个优化在房卡玩法的并发结算场景里非常重要。5. 常见问题与避坑五个让程序假胡的现场还原5.1 递归状态没恢复同一手牌时而胡时而不胡现象同样的手牌单测里跑是对的压测几万次后偶尔判定结果翻转或者递归里出现牌数凭空多出几张。原因递归分支里修改了cnt但某个提前返回的路径没有恢复现场。比如顺子分支已经减了cnt[i]判断不可能胡就直接return false忘了把减掉的牌加回去上层递归拿着被破坏的牌型继续算结果自然错乱。解决严格遵循“任何修改必须在分支结束前恢复”的习惯。C 里可以用 RAII 包装一个局部恢复器离开作用域自动还原也可以在每层递归入口拷贝一份cnt牺牲一点性能换正确性。我自己的经验是代码里先写恢复逻辑再写提前返回能避免九成以上的类似 bug。5.2 癞子被两次消费万能牌变成永动机现象手上明明只有 1 张癞子结果一个顺子补位用了它后面刻子缺 1 又用了它最后算法判定胡牌成功。原因编写递归时癞子的扣减没有通过参数向外层传递。常见的翻车写法是直接用全局变量wild第一次补位全局减一递归返回后没有恢复句柄下个分支再读全局又变成“癞子好像还在”。解决把 wild 当作普通参数按值传递每次补位的消耗体现在下一次递归参数的wild - 1或wild - 2。上文canSplitWild里的实现正是这样。还有一个细节在顺子分支中同时缺失两个位置时needWild等于 2但必须让两个位置的补位都真实扣减癞子而不是只判断wild 2就完事。5.3 顺子边界条件写错八九万也赶去组顺子现象手牌有 8 万、9 万没有 7 万算法却把这当成顺子处理或者到了筒子边界把筒 9 和条 1 当成连续牌。原因把顺子条件里的i % 9 6写成了 7或 7或者完全忘了检查花色分界。解决我把花色边界检查固定为两个条件同时成立i / 9 3和i % 9 6。建议在工程里抽一个函数canStartSequence(int idx)里面只做这两个判断所有递归分支都调用它。视线里只维护一处边界就永远不会出现“改了 A 处忘了 B 处”的尴尬。5.4 七对手牌被普通胡判定误伤现象一副带癞子的七对牌型isSevenPairsWild判过胡但isHuWild的普通拆分走了癞子补面子路线最后返回 false前端提示“尚未胡牌”玩家直接炸。原因两种判定是“或”的关系不是“与”。有些玩法里七对和普通胡不能并存但必须分别调用特判和主判定任中一个成立就算胡。解决把胡牌入口统一包成一个函数先判十三幺、七对再判普通胡。如果规则明确“七对不算胡”则该特判返回 false整体再走普通胡。不要在isHuWild内部隐式假设某种牌型接口语义越简单越不容易错。5.5 性能爆炸没有剪枝也没有缓存现象一副癞子多、普通牌分散的手牌在压测里单次判定耗时超过 10 毫秒服务端一轮水位结算要处理几千手牌直接卡出超时。原因癞子补位分支全部展开同一状态反复递归没有任何中间态缓存。当癞子张数大于 2 时组合数量可能到达数万常识里的“麻将胡牌很快”在这种实现下并不成立。解决按照第 4 章的做法加总张数过滤、花色分桶预检再开启状态缓存。另外可以限制癞子补位的深度一个面子最多补 2 张癞子这是规则层面的硬约束直接写进分支判断能砍掉大量无意义递归。6. 上线前的验证清单与进阶方向胡牌判定是服务端结算的高频核心上线前我建议先过一遍测试矩阵普通 4 组面子加一对的胡牌、七对、十三幺、纯癞子刻子、癞子补顺子、癞子补刻子、癞子当雀头以及“差一张就听牌但实际不胡”的边界样本。不要只测正例上一章那几个坑往往就藏在负例里。每副手牌要有明确期望值回归测试放到 CI 里后续改算法时才有后悔药可吃。验证性能时我会用一整副牌的全体摸牌序列做基准把服务器日志里带癞子的手牌样本攒成一堆逐一跑判定观察平均耗时和 P99。开了记忆化之后P99 一般能稳定在几百微秒以内这个量级才够支撑实时流水的并发结算。最后说两个可行的进阶方向。一个是“听牌求解”也就是打出一张后遍历剩余牌张看哪些能胡切记这个遍历会放大癞子分支必须在主判定里直接复用canSplitWild并且不能清空状态缓存否则重复计算会让性能回到地狱。另一个是“最优胡法提示”客户端给玩家提示时要用胡牌判定结果做反向倒推找出消耗癞子最少的拆牌路径这需要在一开始递归时把“用了多少癞子”作为偏差值记录下来作为后续路径排序的依据。我自己的习惯是在canSplitWild里加一份日志开关调试时打印每个分支的牌型消耗和癞子余额。癞子递归的坑十次有九次是日志上看不出“坏在哪个分支”但只要打出每一步的 wild 变化问题基本一眼定位。这个习惯帮我省了很多排查时间希望也能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站