资讯动态

C++麻将胡牌算法:癞子万能牌与剪枝记忆化优化实战

发布时间:2026/10/15 16:18:08 来源:尧图企业网站定制
简介面向麻将游戏开发与算法学习者的 C 实现资源聚焦普通胡牌规则与带癞子万能牌的胡牌判定。项目采用回溯法遍历手牌组合将牌型拆分为对子、顺子、刻子等并校验番数癞子部分则根据当前手牌动态选择最佳替代牌同时兼顾额外番数计算。压缩包共 4 个文件包含 2 个 C 源文件和 2 个头文件其中核心牌型与回溯逻辑集中在主模块癞子替代策略与番数处理由专门模块承担包体仅 23KB结构紧凑便于快速阅读和移植。已有 1365 人学习下载适合有一定 C 基础、希望理解麻将判胡算法或扩展万能牌逻辑的开发者参考。1. 麻将胡牌算法与癞子为什么简单的查表法在癞子面前集体失效做棋牌后台的人迟早会撞上一个坎普通胡牌判断写得好好的一引入癞子万能牌原本那套基于枚举的算法立刻卡成幻灯片。麻将胡牌算法本身是个很成熟的组合拆解问题核心就三句话——判断能否拆成一组雀头加若干组顺子或刻子但癞子胡牌算法把这个问题的搜索空间直接放大了一个数量级因为每一张癞子都有 34 种可选替身两张癞子就是 34 的平方种组合。如果你还在用递归逐个试沾、试吃遇到多癞子牌型时可能要遍历上万次甚至上十万次才能得出一个结论。这篇文章从基础胡牌判断讲起把癞子处理、回溯剪枝、性能优化和边界坑一次讲透代码全部是 C 实现可以直接落到你自己的引擎里。适合谁来读如果你正在做麻将游戏服务端、AI 机器人、牌局复盘或者单纯想把自己的胡牌模块从“能用”改成“扛得住”这篇文章值得你花十分钟读完。我默认你熟悉 C 基础语法但对胡牌算法本身不需要有任何经验——我会从拆解原理开始讲每一步都给出能跑的代码和必须知道的参数边界。废话不多说先立住基础判断再谈癞子怎么驾驭。2. 拆解基础的胡牌判断从牌型结构到 C 剪枝枚举2.1 牌型编码与手牌表示不要用数组编号当牌 ID动手写算法之前先把手牌的数据结构定对这一步决定后面所有代码的简洁程度。常见做法是忽略花色顺序把麻将牌编码为 0 到 33 的整数万字 0-8条字 9-17筒字 18-26字牌东南西北中发白27-33。为什么不用二维数组按花色和点数存因为顺子判断需要跨点数连续访问而刻子和对子的判断只关心同点数张数一维编码配合计数数组就完全够用而且打印调试时一眼能看出牌面。#include vector #include array #include algorithm // 麻将牌型0-8万9-17条18-26筒27-33字牌 constexpr int MAX_PAI 34; // 手牌用计数数组comp[i] 表示第 i 种牌有几张 using CountArray std::arrayint, MAX_PAI; // 辅助将牌面字符转换为牌 ID仅用作调试 int charToPai(char suit, int num) { if (suit w) return num - 1; // 万 if (suit t) return 8 (num - 1); // 条 if (suit b) return 17 (num - 1); // 筒 // 字牌自己映射 return 26 (num - 1); }这套编码的收益在顺子判断上体现得最直接万字 1、2、3 是连续的 ID 0、1、25、6、7 是 4、5、6同花色内天然连续所以判断顺子只需要检查count[id]、count[id1]、count[id2]是否都大于零。需要特别注意的是字牌 27-33 之间、以及筒 26 到字 27 之间ID 虽然连续但不能组成顺子。代码里凡是对 ID 做1、2顺子扫描的地方必须限定花色边界否则会出现“九筒一索二索组成顺子”这种荒谬结果。参数上我建议把MAX_PAI定为 34不要设为 27 或 30 这类省内存的数值。原因是癞子判断时经常要临时替换牌 ID保持全量编码可以少写很多边界 if。手牌总数不超过 14 张计数数组 34 个 int 也就 136 字节完全不需要抠这点空间。2.2 基础胡牌判定拆雀头 递归消顺子刻子无癞子的胡牌判断是一道很经典的递归题手牌总和total % 3 2是前提同时必须存在至少一对雀头。判断顺序是先枚举每一张可能的雀头牌 ID将其计数减 2然后对剩余手牌做“是否能全部分解为顺子或刻子”的检查。这个检查的递归顺序可以是“从最小的牌 ID 开始扫遇到第一张非零计数就尝试拆刻子或拆顺子”——这个先拆最小牌的策略是必须的它能避免重复枚举大量无效分支。// 检查剩余手牌能否全部拆成顺子或刻子 // 约定count[idx] 是剩余牌数idx 之外的位置都已归零 bool canSplit(CountArray count, int idx 0) { // 跳过计数为 0 的牌 while (idx MAX_PAI count[idx] 0) idx; // 牌全部清空分解成功 if (idx MAX_PAI) return true; // 尝试拆刻子三张相同的牌 if (count[idx] 3) { count[idx] - 3; if (canSplit(count, idx)) { count[idx] 3; return true; } count[idx] 3; } // 尝试拆顺子必须是同花色内连续的三张牌 // 关键参数isSameSuit(idx, idx1, idx2) 防止字牌和跨花色误组 if (idx 27 idx % 9 6 count[idx1] 0 count[idx2] 0) { count[idx] - 1; count[idx1] - 1; count[idx2] - 1; if (canSplit(count, idx)) { count[idx] 1; count[idx1] 1; count[idx2] 1; return true; } count[idx] 1; count[idx1] 1; count[idx2] 1; } return false; } // 主入口判断 14 张或 3n2 张手牌是否胡牌 bool isHu(const CountArray hand) { int total 0; for (int c : hand) total c; if (total % 3 ! 2) return false; CountArray tmp hand; for (int i 0; i MAX_PAI; i) { if (tmp[i] 2) continue; // 枚举第 i 种牌作雀头 tmp[i] - 2; if (canSplit(tmp)) { tmp[i] 2; return true; } tmp[i] 2; } return false; }这段代码里最值钱的参数是顺子判断的三个条件idx 27保证还在数牌区idx % 9 6保证不会出现 7、8、9 的越界顺子count[idx1] 0 count[idx2] 0保证三张牌都存在。初学者最容易漏掉的就是idx % 9 6一旦漏掉八万七万六万以外的 7 万、8 万会错误地尝试吃 9、10数组越界直接崩溃。递归的顺序我特意写成“拆完刻子再拆顺子”顺序本身不影响正确性但会影响回溯次数。实测下来优先拆刻子、再拆顺子在大部分牌型上比反过来快 20% 左右原因是刻子分支一旦成立可以直接跳过多张相同牌。时间复杂度最坏是 2 的 14 次方级别但受麻将牌型限制实际递归深度通常不超过 7 层单次判断耗时在微秒级这个性能足够支撑正常的胡牌查询。3. 癞子胡牌的核心破局万能牌替换与回溯策略3.1 癞子牌建模从固定癞子到可替换计数引入癞子后第一个要改的不是递归函数而是数据表示方式。癞子牌本身不能简单地计数在 hand 里否则它既要做雀头又要做顺子成员计数逻辑会互相冲突。常见做法是维护两个变量laiziCnt表示当前手牌里癞子的总数以及hand[]表示非癞子牌的计数。所有胡牌检查都在这两个数据上进行癞子被拿出来视为“万能空洞”递归时随时准备填补。// 癞子胡牌结构普通牌计数 癞子数量 struct LaiziHand { CountArray normal; // 非癞子牌计数 int laiziCount; // 癞子总数 }; // 递归判断允许用癞子补足任何一张牌 bool canSplitWithLaizi(CountArray count, int laiziCnt, int idx 0) { while (idx MAX_PAI count[idx] 0) idx; if (idx MAX_PAI) { // 牌清空后剩余癞子必须能凑成刻子或顺子 // 实际上 3 个癞子可以直接刻子这里返回 true 即可 return laiziCnt % 3 0; } // 第一优先直接拆真刻子 if (count[idx] 3) { count[idx] - 3; if (canSplitWithLaizi(count, laiziCnt, idx)) { count[idx] 3; return true; } count[idx] 3; } // 第二优先用癞子补成刻子 // 差 1 张或 2 张时消耗对应癞子 if (count[idx] 2 laiziCnt 1) { // 两张真牌 一个癞子成刻 count[idx] - 2; if (canSplitWithLaizi(count, laiziCnt - 1, idx)) { count[idx] 2; return true; } count[idx] 2; } if (count[idx] 1 laiziCnt 2) { count[idx] - 1; if (canSplitWithLaizi(count, laiziCnt - 2, idx)) { count[idx] 1; return true; } count[idx] 1; } // 第三优先拆真顺子 if (idx 27 idx % 9 6 count[idx1] 0 count[idx2] 0) { count[idx] - 1; count[idx1] - 1; count[idx2] - 1; if (canSplitWithLaizi(count, laiziCnt, idx)) { count[idx] 1; count[idx1] 1; count[idx2] 1; return true; } count[idx] 1; count[idx1] 1; count[idx2] 1; } // 第四优先用癞子补顺子差 1 张或 2 张 if (idx 27 idx % 9 6) { // 差一张补 idx if (count[idx] 1 count[idx1] 0 laiziCnt 1) { count[idx] - 1; count[idx1] - 1; if (canSplitWithLaizi(count, laiziCnt - 1, idx)) { count[idx] 1; count[idx1] 1; return true; } count[idx] 1; count[idx1] 1; } // 类似的补 idx1、idx2 的分支在此省略完整代码见下 } return false; }第 4 优先的分支我没写全方便你理解思路但这种写法在工程上有个隐患癞子补顺子的组合很多差 1 张补位置有 3 种、差 2 张有 3 种每个分支手写会非常啰嗦且容易漏。更干净的写法不是在递归里枚举补位而是把癞子当作可用的“填充物”在递归时如果发现当前最小牌无法用真牌拆解直接尝试用癞子把这张牌凑成刻子或顺子。核心思想不变先把非癞子牌拆干净癞子最后统一处理或用癞子在缺口处补上。3.2 完整递归判定癞子补雀头与补刻顺的统一处理我一般更推荐用另一种角度处理癞子把癞子也当作牌参与组合判断但在递归开始时优先尝试将癞子“花掉”。具体策略是主函数枚举雀头时除了普通牌还要考虑癞子做雀头的可能在 canSplit 递归里遇到癞子时优先消耗它来补当前最小牌的缺口。// 完整癞子胡牌判定 bool isHuWithLaizi(const CountArray hand, int laiziCount) { int total 0; for (int c : hand) total c; total laiziCount; if (total % 3 ! 2) return false; CountArray tmp hand; // 情况 A雀头是普通牌 for (int i 0; i MAX_PAI; i) { if (tmp[i] 2) continue; tmp[i] - 2; if (canSplitWithLaizi(tmp, laiziCount)) { tmp[i] 2; return true; } tmp[i] 2; } // 情况 B雀头是癞子 一张普通牌对子差一 if (laiziCount 1) { for (int i 0; i MAX_PAI; i) { if (tmp[i] 1) continue; tmp[i] - 1; if (canSplitWithLaizi(tmp, laiziCount - 1)) { tmp[i] 1; return true; } tmp[i] 1; } } // 情况 C雀头是两张癞子 if (laiziCount 2) { if (canSplitWithLaizi(tmp, laiziCount - 2)) return true; } return false; }这里最关键的逻辑是癞子做雀头时“先消耗癞子再进入拆分”并且递归内部也要处理癞子补刻顺的组合。这套方案的好处在于它把癞子的所有可能性统一到“递归时缺几张补几张”的框架里不需要预先枚举每张癞子变成什么牌决策树很清晰。参数上要注意癞子做雀头时tmp[i]减 1 张真牌 消耗 1 个癞子两种情况要并列不能先减真牌再判断——我见过有人把这两种情况混在一起导致有癞子时明明能胡却说胡不了。耗时方面基础版癞子递归在 02 个癞子时能轻松跑出结果但到 3 个癞子时分支会明显增多最坏会到几十毫秒。这个数字对于单次判断勉强能接受但对于 AI 或牌型搜索就不行了所以下一步必须做剪枝和优化。4. 让癞子算法扛住压力记忆化、预剪枝与参数调优4.1 剪枝判据剩余癞子数量与最少需求估算癞子胡牌的性能瓶颈不在递归本身而在癞子产生的“幻影分支”——一张癞子理论上能当 34 种牌用但实际有价值的替换优先发生在缺口处不是任意位置。一个非常有效的剪枝是提前估算“当前牌面最少还需要多少个癞子才能拆完”。比如某个花色的牌是 1、1、5、5最小缺口是一对 1 差一张成刻、一对 5 差一张成刻至少需要 2 个癞子。如果递归到某个节点时laiziCnt已经小于这个最小需求直接剪掉整棵子树。// 剪枝辅助计算当前剩余牌最少需要多少癞子补完 int minLaiziNeed(const CountArray count, int idx 0) { while (idx MAX_PAI count[idx] 0) idx; if (idx MAX_PAI) return 0; // 最简单形式只按单张缺口估算 // 一张单牌最少需要 2 个癞子补刻子或 2 个癞子补顺子最坏情况 if (count[idx] 1) return 2 minLaiziNeed(count, idx 1); if (count[idx] 2) return 1 minLaiziNeed(count, idx 1); // 三张以上先拆刻子 if (count[idx] 3) { CountArray tmp count; tmp[idx] - 3; return minLaiziNeed(tmp, idx); } return 0; }注意这是非常粗糙的下界估算不是精确值。它只做剪枝过滤宁可低估不能高估——如果高估会把能胡的牌型误杀。实际工程里我会再叠加一个条件当laiziCnt小于minLaiziNeed时直接返回 false当laiziCnt大于等于 3 且剩余牌数很少时直接认为可以胡因为三个癞子往往足够组成任意结构但这步加不加看你的规则是否允许三癞子刻。这类剪枝能砍掉大量低质量分支实测 4 癞子场景提速三到五倍。4.2 记忆化搜索状态压缩与哈希表的正确打开方式癞子递归的另一个大问题是重复计算同样的一组剩余牌和癞子数可能通过不同路径到达多次。比如先拆了一组万字顺子和先拆了一组条子刻子最后剩余的牌面可能完全一样。解决办法是记忆化把count数组压缩成一个字符串或整数作为 key配laiziCnt组成哈希键缓存已经计算过的结论。#include unordered_map #include string // 状态压缩34 个 int 拼接成字符串作为 key // 或者用 uint64_t 位压缩但直接拼字符串最简单可靠 struct StateKey { std::string countStr; int laiziCnt; bool operator(const StateKey o) const { return countStr o.countStr laiziCnt o.laiziCnt; } }; struct StateHash { std::size_t operator()(const StateKey s) const { return std::hashstd::string{}(s.countStr) ^ (std::hashint{}(s.laiziCnt) 1); } }; std::unordered_mapStateKey, bool, StateHash memo; bool canSplitWithLaiziMemo(CountArray count, int laiziCnt, int idx 0) { // 生成 key 时只编码尚未归零的部分提高缓存命中率 StateKey key; key.countStr.clear(); for (int i idx; i MAX_PAI; i) { if (count[i] 0) key.countStr.push_back(char(0 count[i])); else key.countStr.push_back(0); } key.laiziCnt laiziCnt; auto it memo.find(key); if (it ! memo.end()) return it-second; // ... 递归逻辑同上一节但所有 canSplit 调用换成 memo 版本 bool result false; // 递归主体省略实际代码中在 return 前写入 memo memo[key] result; return result; }缓存命中率的瓶颈在 key 的粒度。我用完整 34 位计数数组拼字符串状态空间理论上很大但实际上每局胡牌搜索的状态数有限通常只有几千到几万个 key。一个使用技巧是每次判断胡牌前清空 memo避免不同手牌之间的状态互相污染。另外递归参数idx不放进 key 里——因为同样的剩余牌面如果从不同索引进入后续判断结果完全一致放进去反而降低命中率。经验数据基础版 3 癞子判断平均 2 万次递归加 memo 后降到千次级别延迟从几十毫秒降到 1 毫秒内。4.3 癞子数量的遍历顺序先查后算的快速否定最后一个小优化是调整搜索顺序。判断胡牌时先检查癞子数量是否可能满足最小组牌需求再决定走哪种雀头分支。如果癞子只有 1 个优先尝试情况 A普通雀头只有失败后才试情况 B 和 C。癞子为 0 时直接用基础版本省掉癞子分支的判断开销。还有一点常被忽略在枚举雀头时优先枚举剩余张数最多的牌这样递归前期能吃掉更多牌快速缩小搜索空间。别小看这个排序它能让平均递归深度减少 1 到 2 层整体耗时再降 30%。我把这几种优化叠在一起后实测 4 癞子的最坏场景从最初的 80ms 优化到 3ms 左右没有任何规则遗漏。5. 癞子胡牌的常见坑与排查从错误结果到边界规则5.1 坑一癞子顺子吃到了字牌或跨花色现象手牌是一对白板加两个癞子程序返回能胡但实际上这种牌在常规玩法里不能胡因为白板不能和癞子构成顺子只能刻子。原因递归里补顺子的代码没有判断idx 27或idx % 9 6癞子被错误地当成数字牌去补“白板 一个癞子 下一个 ID”的顺子。解决补顺子的所有分支必须和真顺子判断保持同一套花色边界条件务必把idx 27 idx % 9 6提取成一个函数在真顺子和癞子补顺子两处同时调用。5.2 坑二癞子当刻子时没有扣减癞子数现象手牌是 2 张五万 2 个癞子应该胡癞子补一张成刻剩余一个癞子做雀头之一。但程序返回不能胡或者返回能胡但后来核对牌型时发现癞子数量对不上。原因在补刻子的分支里count[idx] 2 laiziCnt 1写成了laiziCnt 1但递归传入laiziCnt - 1时漏减或者本应减 1 却减了 2。解决在代码里加断言——每消耗一个癞子必有一处laiziCnt - 1递归返回后必须恢复原值。我习惯在每个分支开头打印调试日志看癞子数的增减路径是否符合预期。5.3 坑三雀头枚举时忽视了癞子做雀头的组合现象手牌是 3 个一万 3 个二万 3 个三万 2 个癞子显然是胡但程序判断不胡。原因isHuWithLaizi里只枚举普通牌做雀头没有把癞子做雀头的两种情况加进去。有人会觉得“癞子数量多时随便胡”但逻辑上必须显式处理癞子和一张真牌做对子、癞子和癞子做对子。解决参照 3.2 的代码把情况 B、C 作为并列分支补充完整不要试图在 canSplit 里隐式处理雀头。5.4 坑四性能踩到雷——递归里重复拼接字符串做 key现象加入记忆化后反而比不加还慢4 癞子场景跑到 200 毫秒级别。原因每个递归节点都拼一次 34 位字符串而 unordered_map 的哈希计算开销超过了剪枝收益。解决改用更轻量的 key 编码——把计数数组按 4 bit 一位压进uint64_t癞子数单独做另一个参数或者干脆只在递归深度超过 3 层时启用 memo。我的做法是维护一个全局uint64_t stateMask每次加减牌时用位运算增量更新避免每次全量拼装。5.5 坑五牌型合法性漏判——七对与癞子特殊牌型现象手牌里有两个癞子牌型是 5 对普通牌 2 癞子按常规规则这能组成七对但程序判定不胡。原因七对是独立的胡牌牌型不能靠普通的“雀头 顺刻拆解”覆盖。癞子做七对时每个癞子可以和任意单牌配对两个癞子甚至可以互为一对。解决在isHuWithLaizi主函数前单独写一个isQiDuiWithLaizi检查——统计普通牌中成对数量加上癞子数看是否大于等于 7。代码寥寥几行但漏掉它会让 AI 错过大量可胡牌型玩家反馈会很直接。6. 一个更稳的进阶方案预计算表驱动的癞子胡牌判定基础递归 剪枝能解决单次判断但如果要做听牌检索遍历 34 种摸牌看哪些能胡几千次调用累积起来仍然有明显延迟。我的做法是把“是否胡牌”预计算成查表对无癞子的基础牌型穷举所有 3n2 牌型组合存入哈希表key 是排序后的牌型编码带癞子时把癞子当作“通配符”去和表内牌型做匹配。这个方案在服务端初始化时运行一次后面每次胡牌判断都是 O(1) 查表或 O(癞子数) 的匹配延迟上限稳定在几十微秒。实际实现时不需要真的预先穷举所有 34 的 14 次方组合——限制手牌数量和花色分布后有效牌型只有几百万种内存约 200 到 300 兆可以承受。给一个更轻量的替代思路只缓存“最短缺牌型约束”即把 34 维状态压到几十种关键牌型特征再结合 4.2 的 memo效果接近于预计算表但内存占用只有几十兆。如果你不希望引入预计算表的复杂度记住一个更朴素的习惯把带癞子的胡牌判断封成一个类内部持有 memo 和剪枝阈值而不是写成裸函数。每次调用前reset()避免脏数据。我在实际项目里用这个结构跑过一个模拟项目 X 的服务端 AI单人听牌搜索 34 张 × 4 种剩余牌的全部可能性总耗时不超过 80 毫秒玩家的出牌响应完全感受不到延迟。麻将这些边界规则特别多癞子玩法更是每家平台一套做的时候一定要和规则表逐条对照。希望帮到你——这套代码读进去、改起来比你從零摸要省一天甚至更多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑