C++实现带癞子的麻将胡牌判断算法详解

📅 发布时间:2026/9/8 1:39:38
C++实现带癞子的麻将胡牌判断算法详解
简介C麻将胡牌算法实现包面向游戏开发爱好者与算法学习者完整演示普通胡牌与癞子胡牌两种规则的核心编码思路。项目以回溯法遍历顺子、刻子、对子等基础牌型组合逐一验证胡牌条件并通过剪枝减少无效搜索癞子部分则根据当前手牌与潜在胡牌组合动态判断最优替代对象同时兼顾额外番数计算清晰展示万能牌在麻将判定中的处理技巧。资源中的cpp文件负责牌型判断与主流程控制h文件负责相关类与函数声明代码量不多但层次分明便于对照理解牌组表示、递归返回与边界判定。压缩包体积仅23KB共4个文件包含2个cpp与2个h结构紧凑可直接阅读调试。目前已有1361人学习下载适合希望快速掌握麻将胡牌流程、提升C算法实现能力的开发者参考。 做棋牌游戏后台的同学十有八九都会在胡牌判断上栽过跟头。尤其癞子玩法一开手里那张万能牌能当万、能当条、能当筒甚至两张癞子还能凑一对将牌改来改去总有漏判。前阵子帮一个麻将项目重构算法模块把带癞子的胡牌判断完整写了一遍今天把思路、C代码实现和调试过程中踩过的坑一起整理出来。这篇适合两类人看一是做棋牌游戏服务端的开发者二是准备面试时被问到麻将胡牌怎么判断的C岗位候选人。看完你至少能直接抄一份能跑的代码再遇到两张癞子能不能胡七对带癞子怎么判这类问题也不会慌。1. 先想清楚胡牌的本质到底是什么1.1 牌型编码与状态表示麻将牌不管什么地区玩法核心结构就两类数字牌和字牌。数字牌分为万、条、筒三门每门从1到9各四张字牌包括东南西北中发白一共七种。编码时最常用的做法是给34种牌各分配一个下标0到8代表万子9到17代表条子18到26代表筒子27到33代表字牌。这个编码方案最大的好处是判断顺子时可以直接用下标连续性。比如下标3、4、5对应的就是4万、5万、6万只要这三个位置都有牌就能组成一组顺子。字牌因为不参与顺子单独放到最后一段判断时只需要处理刻子逻辑上天然隔离。手牌的存储用int count[34]数组即可count[i]表示第 i 种牌的数量。比如手里有两张红中下标32对应的值就是2。相比用vector存储每一张牌数组计数的方式在递归回溯时更方便减法加法都直接作用于下标不需要频繁查找和删除元素。这一步是整个算法的地基选对了后面少踩很多坑。1.2 为什么回溯法是最合适的方案麻将胡牌判定标准的定义是手牌能够拆成一副将牌两张相同以及若干组顺子或刻子。以14张手牌为例就是1副将牌加4组面子如果是7对子玩法则另算。顺着这个定义往后推最直观的思路是枚举所有拆分方式逐一验证但手牌组合数量非常大直接枚举不现实。回溯法是这类问题最常用的解法。它的核心逻辑是每次从手牌里取出一组面子顺子或刻子递归处理剩余牌直到所有牌都被拆完就返回成功任何分支走不通就回溯换一种拆法。因为每一层递归都明确地消耗掉三张牌递归深度最大也只有4层到5层搜索空间非常小实际运行几乎瞬间完成。相比动态规划或者查表法回溯法还有一个优点扩展癞子规则时非常自然。癞子本质上就是这张牌缺什么就能补什么在递归过程中只需要额外维护一个癞子数量在需要凑顺子、刻子、将牌时优先消耗癞子。这个思路后面会展开讲先扎实把不带癞子的常规判断写对。2. 不带癞子的基础胡牌判断2.1 将牌必须单独拎出来处理普通胡牌的判断逻辑可以拆成两步第一步选定将牌第二步判断剩余牌能不能全部拆成顺子或刻子。为什么一定要先把将牌拎出来因为一副胡牌里只有一对将牌它的位置是唯一的如果不单独处理递归拆面子时很容易把两张相同的牌分别拆进两个不同的组最后整个拆分结果变得混乱。将牌的选取只需要遍历计数数组找到任何count[i] 2的位置先减去2张再对剩余牌做面子拆分判断。如果剩余牌能全部拆完说明这副牌能胡如果不能就把减掉的2张加回去继续尝试下一种将牌。这一步要注意如果某一种牌正好有2张它有可能是将牌也有可能分别被用进两个不同的顺子或刻子里所以必须让回溯搜索覆盖到所有可能性不能看到2张就默认是将牌。2.2 递归拆面的核心函数面子拆分的核心函数只有一个找到第一个非零计数的牌然后尝试把它拆成刻子或顺子。这里有个细节为什么只处理第一张非零牌因为不管最终怎么拆这张牌必须属于某个面子而且它是最左边的牌意味着它不可能作为顺子里的第二张或第三张去依赖更小的牌只能作为刻子的三张之一或者顺子的第一张。这大大减少了分支数量。拆刻子的情况比较简单条件是count[i] 3直接减掉3张递归判断剩余牌。拆顺子的情况就要检查下标是否落在数字牌范围内且不是该门的最后两档然后看count[i1]和count[i2]是否都大于0如果满足则各减1张继续递归。两个分支只要有一个能走通就返回成功都走不通就回溯恢复原状。基础版C代码如下先跑通这个再上癞子bool canSplit(int* cnt) { int i 0; while (i 34 cnt[i] 0) i; if (i 34) return true; // 尝试拆刻子 if (cnt[i] 3) { cnt[i] - 3; if (canSplit(cnt)) { cnt[i] 3; return true; } cnt[i] 3; } // 尝试拆顺子只针对数字牌且下标不能是本门第7、8、9张 if (i 27 i % 9 6 cnt[i 1] 0 cnt[i 2] 0) { cnt[i]--; cnt[i 1]--; cnt[i 2]--; if (canSplit(cnt)) { cnt[i]; cnt[i 1]; cnt[i 2]; return true; } cnt[i]; cnt[i 1]; cnt[i 2]; } return false; } bool isHuBasic(int* cnt) { for (int i 0; i 34; i) { if (cnt[i] 2) { cnt[i] - 2; if (canSplit(cnt)) { cnt[i] 2; return true; } cnt[i] 2; } } return false; }这里有个容易忽略的地方canSplit里的 while 循环每次都要从头扫描数组听着效率不高但实际牌型只有34种递归层数很浅一次完整的胡牌判断大概也就几百次循环耗时在微秒级别。真正上线跑服务端也完全扛得住不需要过度优化。3. 癞子加入后如何处理3.1 处理癞子的三种思路对比加入癞子后最容易想到的方案是把癞子牌的所有可能性枚举一遍。比如有两张癞子就把每一张依次当成34种牌去尝试组合数最高会膨胀到34^kk是癞子数量第一次跑就把我吓到了三层循环下去直接超时。第二种思路是预先打表把34种牌的所有胡牌组合预生成到一个哈希表里查询时直接看手牌是否匹配。这个方案在癞子数量固定、牌型范围小的场景下可行但工作量大而且遇到多种地方规则修改比如七对、十三幺时又要重新生成维护成本太高。真正可行的是第三种思路在递归过程中动态消耗癞子。癞子不是某一张具体的牌而是一种抽象的补齐能力。当递归发现手牌缺一张牌才能组成面子时直接从癞子池里扣掉一张当癞子数量不够补这个分支就走不通。这个思路在搜索过程中自动覆盖了癞子变成任意牌的所有可能不需要显式枚举复杂度只跟癞子数量和递归深度有关效率高得多代码也简洁。3.2 递归中消耗癞子的三条规则理解动态消耗癞子核心就三条规则。第一条组成刻子时如果某种牌只有1张或2张可以用癞子补足剩余数量比如1张真牌加2张癞子就凑一个刻子如果已经有3张及以上就正常拆刻子。第二条组成顺子时如果相邻位置上缺牌可以用癞子代替。例如手里有5万和7万缺6万递归处理到5万作为顺子起点时发现6万位置为空就直接消耗1张癞子补上。如果癞子池里不够补则放弃顺子分支。第三条将牌也可以由癞子参与。一种情况是一张真牌加一张癞子组成将牌另一种是两张癞子直接当一对将牌。这两个分支要在选将的枚举里单独加进去否则手里只剩两张癞子时就会误判为不能胡。还有第四条隐藏规则所有手牌都拆完后如果癞子还有剩余剩余数量必须是3的倍数。因为剩下的癞子每3张可以组成一副刻子如果只剩1张或2张说明这副牌多出来了没法成组的牌不能判胡。这个边界条件特别容易被忽略我第一次写漏了导致手里多一张癞子也误报胡牌。3.3 完整C代码带癞子的胡牌判断把上面几条规则落到代码里canSplitWithLaizi作为核心递归函数先处理第一张非零牌再看刻子和顺子的分支。刻子分支注意要区分cnt[i] 3直接拆和cnt[i] 3用癞子补两种情况。顺子分支也是类似分别统计i1和i2位置缺几张癞子缺了就从癞子池里扣。bool canSplitWithLaizi(int* cnt, int laizi) { int i 0; while (i 34 cnt[i] 0) i; // 所有真牌都用完了只剩癞子 if (i 34) { return laizi % 3 0; } // 分支1拆刻子 if (cnt[i] 3) { cnt[i] - 3; if (canSplitWithLaizi(cnt, laizi)) { cnt[i] 3; return true; } cnt[i] 3; } // 分支2用癞子补齐刻子适用于 cnt[i] 1 或 2 if (cnt[i] 3 laizi 3 - cnt[i]) { int need 3 - cnt[i]; int save cnt[i]; cnt[i] 0; if (canSplitWithLaizi(cnt, laizi - need)) { cnt[i] save; return true; } cnt[i] save; } // 分支3拆顺子 if (i 27 i % 9 6) { // 统计顺子后两张各缺几张癞子 int need1 (cnt[i 1] 0) ? 0 : 1; int need2 (cnt[i 2] 0) ? 0 : 1; if (laizi need1 need2) { int temp1 cnt[i 1]; int temp2 cnt[i 2]; cnt[i]--; if (cnt[i 1] 0) cnt[i 1]--; else laizi--; if (cnt[i 2] 0) cnt[i 2]--; else laizi--; if (canSplitWithLaizi(cnt, laizi)) { cnt[i]; cnt[i 1] temp1; cnt[i 2] temp2; return true; } cnt[i]; cnt[i 1] temp1; cnt[i 2] temp2; } } return false; }主入口isHu在选将时扩展癞子的能力。原来的遍历真牌选将保留再额外加两种分支真牌加癞子做将以及双癞子做将。这里注意双癞子做将要在最后尝试因为如果真牌本身已经能当将尽量优先用真牌避免浪费癞子导致后续面子拆不开。不过对于最终正确性来说顺序不影响结果因为每个分支只要能走通最终都会返回true。bool isHuWithLaizi(int* cnt, int laizi) { // 分支1普通真牌做将 for (int i 0; i 34; i) { if (cnt[i] 2) { cnt[i] - 2; if (canSplitWithLaizi(cnt, laizi)) { cnt[i] 2; return true; } cnt[i] 2; } } // 分支2一张真牌 一张癞子做将 if (laizi 1) { for (int i 0; i 34; i) { if (cnt[i] 1) { cnt[i]--; if (canSplitWithLaizi(cnt, laizi - 1)) { cnt[i]; return true; } cnt[i]; } } } // 分支3两张癞子自己做将 if (laizi 2) { if (canSplitWithLaizi(cnt, laizi - 2)) return true; } return false; }调用入口需要先把癞子牌从计数数组里拆出来。比如癞子固定为红中那就是int laizi cnt[32]; cnt[32] 0;然后把普通牌数组和癞子数量一起传进去。这里有个容易犯的错如果把癞子牌本身留在数组里又同时传入癞子数量递归时会把它既当作普通牌又当作万能牌数量就重复计算了。4. 实战中的坑与性能建议4.1 数组拷贝与恢复的坑写这个算法时有一个非常隐蔽的坑在canSplitWithLaizi里操作顺子时我一开始图省事没有保存cnt[i1]和cnt[i2]的原始值而是走完分支后手工加回来。表面看没问题但一旦某个分支里递归函数提前返回true后面的代码就不执行了状态恢复被跳过。特别是递归返回true时我们根本不需要恢复现场因为整个函数要结束了但如果后续还要尝试其他分支就必须确保现场已经完全恢复。我的做法是每个分支在递归调用前保存涉及的所有修改点的原值递归返回后立即恢复如果递归返回true直接return不需要再恢复。代码里的temp1、temp2就是干这个的。另外整个判断过程中cnt数组是会被反复修改的所以调用isHuWithLaizi之前一定要传一份数组副本进去避免外层函数的手牌被破坏。4.2 处理特殊牌型七对与十三幺上面的算法只能判断平胡牌型也就是常规的将牌加面子结构。但很多麻将规则里有七对、豪华七对甚至十三幺。七对的判断其实非常简单14张牌每一种牌的张数必须都是偶数1对、2对或3对再加癞子补对子。用癞子时更加宽松因为癞子可以补任意对子。一个常见需求是七对带癞子判断方式可以先统计真牌中的对子数量再算需要多少个癞子去补足7对。如果真牌里奇数张的存在数量不超过癞子数量再把多余癞子成对处理整体满足7对即可。这个逻辑和平胡判断完全独立通常放在isHuWithLaizi之前单独分支判断哪个规则返回true就算胡。十三幺是比较特殊的地域玩法一手牌全是幺九和字牌再加任意一个对子。判断时枚举幺九字牌的种类是否齐全缺几个用癞子补最后看有没有对子或癞子补对子。这类牌型频率低对性能影响不大但千万别漏掉否则玩家摸到十三幺报不了胡投诉电话很快就会打过来。4.3 性能实测与优化建议这套算法在递归深度上非常克制正常情况下处理14张牌的判断耗时不到1微秒单机每秒能跑上百万次。但如果癞子数量有4张甚至更多递归分支会变多最坏情况耗时可能到几十微秒。对于服务端来说仍然可以接受但如果某个房间同时有大量玩家频繁操作还是值得做一层缓存。我的优化经验有两条。第一在递归函数最前面加一个快速剪枝统计所有剩余真牌数量加上癞子数量如果不是3的倍数直接返回false。这个剪枝看似简单实际能省掉大量无效递归分支。第二利用牌总数较少的特性把所有非法分支概率最高的牌先处理优先处理字牌和数量大于等于3的牌因为字牌不能组顺子能拆就拆拆不了就尽早返回。另外服务端多线程跑房间时每个房间可以独立使用一份计数数组避免线程间共享状态。递归函数本身是无状态的只要入口保证传入的是副本并发安全就没有问题。5. 常见问题速查表问题原因解决方案手里剩两张癞子却判胡不了双癞子做将的枚举分支没加在选将阶段增加 laizi 2 时 canSplit(cnt, laizi - 2) 的判断癞子数量被重复计算癞子牌同时留在 count 数组里又传入 laizi 参数入口处先把癞子牌从数组中清零再传参递归返回后死循环或结果错乱回溯时没有恢复修改过的数组元素每个分支进入前保存原值return 前恢复现场剩余癞子不是3的倍数也判胡结束条件只判断了真牌用完真牌用完时加判断laizi % 3 0七对带癞子场景漏判主流程只走了平胡分支单独写七对判断函数在平胡判断之前或之后并行走字牌被当成顺子拆分字牌范围 27 到 33下标连续导致误判顺子分支加i 27且i % 9 6的条件再补充一个调试技巧测试胡牌算法时不要只看几个正常case要把缺一张癞子补顺子、两张癞子补刻子、一张真牌一张癞子做将、双癞子做将这四种情况各写进单元测试里。我当初整理了一个用例文件包含二十多组手牌数据每次改动算法后跑一遍基本能拦住99%的回归问题。6. 写在最后的工程建议癞子胡牌算法写完只是第一步真正考验人的是它和整体业务代码怎么整合。我习惯把胡牌判断封装成一个纯函数模块输入是手牌数组和癞子数量输出只有 true 或 false不依赖任何全局状态。这样不管是做三人麻将、四人麻将还是血流成河只要把癞子定义和特殊牌型开关作为配置传进来同一个函数都能复用。实际项目里还有一个细节每次玩家摸牌、出牌、碰杠后都要调用一次胡牌判断所以在接入消息循环时一定要控制调用频率。我见过有项目直接在每帧全量判断房间内所有玩家的手牌结果造成明显卡顿。正确的做法是只在有胡需求的时候判断自己摸牌后、别人出牌后并且每次都基于当前玩家的手牌独立判断不缓存旧结果。这样既不会有性能问题代码逻辑也清晰。最后再说一句个人心得这个算法写一次不难写对是真的考细节。递归回溯的核心代码只有几十行但每一步都得想清楚癞子从哪里来状态什么时候恢复边界条件是什么。把上面几个坑都踩一遍再回头看你会发现麻将胡牌判断也不过如此。本文还有配套的精品资源点击获取