简介: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[i+1]和count[i+2]是否都大于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^k(k是癞子数量),第一次跑就把我吓到了,三层循环下去直接超时。
第二种思路是预先打表:把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用癞子补两种情况。顺子分支也是类似,分别统计i+1和i+2位置缺几张癞子,缺了就从癞子池里扣。
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[i+1]和cnt[i+2]的原始值,而是走完分支后手工加回来。表面看没问题,但一旦某个分支里递归函数提前返回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,不依赖任何全局状态。这样不管是做三人麻将、四人麻将还是血流成河,只要把癞子定义和特殊牌型开关作为配置传进来,同一个函数都能复用。
实际项目里还有一个细节:每次玩家摸牌、出牌、碰杠后都要调用一次胡牌判断,所以在接入消息循环时一定要控制调用频率。我见过有项目直接在每帧全量判断房间内所有玩家的手牌,结果造成明显卡顿。正确的做法是只在有"胡"需求的时候判断:自己摸牌后、别人出牌后,并且每次都基于当前玩家的手牌独立判断,不缓存旧结果。这样既不会有性能问题,代码逻辑也清晰。
最后再说一句个人心得:这个算法写一次不难,写对是真的考细节。递归回溯的核心代码只有几十行,但每一步都得想清楚"癞子从哪里来""状态什么时候恢复""边界条件是什么"。把上面几个坑都踩一遍再回头看,你会发现麻将胡牌判断也不过如此。
本文还有配套的精品资源,点击获取