2026/10/30 11:00:09

C++麻将胡牌算法:递归拆解、记忆化优化与避坑指南

C++麻将胡牌算法:递归拆解、记忆化优化与避坑指南 简介面向C开发者与游戏算法学习者的麻将胡牌判断实现重点演示如何把对子、顺子、刻子等组合规则转化为可在线性时间复杂度内完成的牌型检测逻辑。压缩包为zip格式共2个文件一个cpp源程序提供可直接使用的判断代码一份pdf围绕算法设计思路展开说明包体仅132KB结构精简。已有919人学习浏览适合想用数据结构与动态规划策略处理组合判断问题的读者。源程序借助数组与位操作构建牌面实现完整的胡牌检测并包含错误处理逻辑设计文档从麻将规则建模、胡牌条件检查到番数计算与无额外空间的原地优化过程均进行了系统梳理能够帮助读者掌握从规则理解到代码落地的全链路方法也为后续扩展其他牌型算法留下清晰思路。1. C麻将胡牌算法从3n2到递归拆解先想明白再动手算法岗面试里常考的C麻将胡牌算法本质上只回答一个问题手里14张牌能不能拆成“4个面子加1个将牌”。新手通常先写暴力枚举把所有拆法试一遍能跑通简单用例就觉得完事了结果在122334这种复杂牌型上翻车老手则知道真正的难点不在单次判断而在听牌轮询的计算量、特殊牌型的判定入口、以及不同地区规则之间的参数化。这篇文章按我实际拆过的一套胡牌引擎来写从牌的编码讲到记忆化优化最后落到避坑和牌局接入适合正在写棋牌服务端的C开发也适合拿这道题准备算法岗面试的从业者。2. 牌型编码与胡牌判定counts数组 回溯递归的完整实现2.1 把麻将牌映射成数字0-33编码方案和字牌边界麻将有34种牌我常用的编码方案是“数牌在前、字牌在后”0-8代表1万到9万9-17代表1条到9条18-26代表1筒到9筒27-33代表东南西北中发白。这个顺序不是随手定的万条筒连续排在[0,26]字牌单独留在[27,33]之后判断顺子时只要检查 idx 27就能天然避开“字牌不能组成顺子”的规则。牌类型编码范围数量说明万0-89种数牌区可组成顺子条9-179种数牌区可组成顺子筒18-269种数牌区可组成顺子风牌箭牌27-337种字牌区只能组刻子或将牌判定胡牌时用计数数组而不是真实牌序列const int PAI_TYPE_COUNT 34; // counts[i] 表示 i 号牌有几张正常范围 0-4 std::vectorint counts(PAI_TYPE_COUNT, 0);为什么选计数数组因为胡牌判定的核心操作是“拿掉一张牌”“去掉一个面子”计数数组可以O(1)修改和恢复如果用真实牌序列每次拆面子都要做一次查找和移除回溯恢复还要记录原位置代码复杂度直接翻倍。后续所有递归拆解都围绕这个数组展开。2.2 基础胡牌判定先枚将牌再递归拆面子标准胡牌结构是3n2牌数14张时就是4个面子加1对将牌。将牌必须是两张相同的牌不能是两张不同牌组成的对子。判定主干分两步先选一种牌拿两张当将牌再把剩余的12张牌递归拆成4个面子。面子只有两种形式刻子三张相同牌和顺子三张连续数牌。bool splitMelds(std::vectorint cnt, int idx) { while (idx PAI_TYPE_COUNT cnt[idx] 0) { idx; // 跳过没有牌的位置 } if (idx PAI_TYPE_COUNT) { return true; // 所有牌都拆完了 } // 尝试去掉一个刻子 if (cnt[idx] 3) { cnt[idx] - 3; if (splitMelds(cnt, idx)) { cnt[idx] 3; return true; } cnt[idx] 3; // 回溯恢复 } // 尝试去掉一个顺子只有数牌区能组顺子 if (idx 27 idx % 9 6 cnt[idx 1] 0 cnt[idx 2] 0) { cnt[idx]--, cnt[idx 1]--, cnt[idx 2]--; if (splitMelds(cnt, idx)) { cnt[idx], cnt[idx 1], cnt[idx 2]; return true; } cnt[idx], cnt[idx 1], cnt[idx 2]; // 回溯 } return false; }这个递归固定找“当前最左边的第一张牌”作为拆解基准。这样做的好处是idx只向后移动搜索空间有序不会漏组合如果从任意位置拆后续的跳过逻辑会乱掉。刻子分支放在顺子前面在标准规则下两者都会回溯正确性没有差别。bool isBasicHu(std::vectorint cnt) { for (int i 0; i PAI_TYPE_COUNT; i) { if (cnt[i] 2) { // 先取两张做将牌 cnt[i] - 2; bool ok splitMelds(cnt, 0); cnt[i] 2; if (ok) return true; } } return false; }将牌选择最多循环34次每次进入splitMelds递归。很多新手在这里漏掉“递归返回true后也要恢复cnt”——如果不恢复下一次尝试将牌时手牌已经被改过了会产生假胡或漏胡。这段代码的递归深度最多等于需要拆分的面子数也就是5层左右单次判定很快真正的问题在第4章的听牌轮询中放大。2.3 顺子检查的两个边界细节顺子分支里的 idx % 9 6 很多人会漏。如果idx是7万idx6idx2就是9万合法如果idx是8万idx7idx2就跨到1条区域属于不同花色必须用余数排除。idx 27 则是把字牌挡在顺子之外避免“中发白”被当成顺子通过。这两个条件少任何一个都会在某些特殊手牌上产生诡异结果单测样本少的时候完全发现不了海量真牌局一跑就冒出来。关于刻子和顺子的拆分顺序网上争论不少。我实测在标准规则下先刻子后顺子和先顺子后刻子差别不大因为回溯保证正确性如果你要追求性能可以在刻子分支前置一个“若当前牌只能组顺子则优先顺子”的启发式但那是微优化先把正确版本写对更重要。3. 特殊牌型与听牌轮询七对的四张陷阱与34次补牌检测3.1 七对判定四张相同牌算两对还是非法七对要求手牌能分成7个对子。最容易踩坑的点是“四张相同牌”的处理不少地区规则允许四张牌算两对例如四张2万等同于两对2万。如果只用 cnt[i] 2 来判断四张牌会被直接跳过导致能胡的牌型判不胡。bool isQiDui(const std::vectorint cnt, bool allowFourAsTwoPairs) { int pairCount 0; for (int i 0; i PAI_TYPE_COUNT; i) { if (!allowFourAsTwoPairs cnt[i] 4) { return false; // 部分地区不允许四张当两对 } if (cnt[i] % 2 ! 0) { return false; // 奇数张牌不可能组成对子 } pairCount cnt[i] / 2; } return pairCount 7; }参数 allowFourAsTwoPairs 控制四张牌规则这样可以适应不同地区。国标麻将对四张是否算两对存在争议广东麻将有些玩法也不认所以我建议把这条参数放进规则配置里而不是写死在函数里。十三幺的判定比七对更直接只需检查一万九万、一条九条、一筒九筒、东南西北中发白这13个关键位置是否都有牌且总张数等于14。bool isShiSanYao(const std::vectorint cnt) { int keyPos[] {0,8,9,17,18,26,27,28,29,30,31,32,33}; int total 0; bool hasExtra false; for (int i 0; i PAI_TYPE_COUNT; i) { if (cnt[i] 0) continue; bool isKey false; for (int pos : keyPos) { if (i pos) { isKey true; break; } } if (!isKey) return false; // 出现非一九牌/字牌不是十三幺 total cnt[i]; } return total 14; }注意total的统计方式每张牌都计入如果某个关键位置有两张total会超过13最后等于14时正好构成“13张幺九牌加任意一张做将牌”。非关键位置有牌直接返回false这一步能防止把普通平胡误判成十三幺。3.2 听牌轮询把胡牌判定重复跑34次“听牌”在算法上等价于现有手牌基础上补进某一张牌后能胡。最简单可靠的做法就是遍历34种牌每种补进去试一次。std::vectorint getTing(const std::vectorint hand, int rules) { std::vectorint tingList; for (int i 0; i PAI_TYPE_COUNT; i) { if (hand[i] 4) continue; // 手里已有4张摸不到第5张 std::vectorint trial hand; trial[i]; if (isHu(trial, rules)) { tingList.push_back(i); } } return tingList; }这里的isHu是统一入口内部先走七对、十三幺再走基础3n2判定。第2章的判定逻辑到这里直接复用不用改。效率方面每次循环都复制一份完整计数数组copy成本是可接受的但如果做高频牌局模拟我一般会在循环外准备一个int copy[34]每次用memcpy覆盖比vector拷贝快一个量级。3.3 规则参数化不同地区玩法如何配置规则掩码含义典型玩法RULE_QI_DUI七对算胡国标、四川RULE_SHI_SAN_YAO十三幺算胡国标、广东部分RULE_NO_WORD去掉字牌四川麻将RULE_FOUR_AS_TWO_PAIRS四张当两对四川血战统一入口isHu根据规则掩码做分支把地区差异挡在递归之外bool isHu(const std::vectorint cnt, int rules) { if ((rules RULE_NO_WORD) ! 0) { for (int i 27; i 34; i) { if (cnt[i] ! 0) return false; } } if ((rules RULE_QI_DUI) ! 0 isQiDui(cnt, (rules RULE_FOUR_AS_TWO_PAIRS) ! 0)) { return true; } if ((rules RULE_SHI_SAN_YAO) ! 0 isShiSanYao(cnt)) { return true; } return isBasicHu(cnt); }规则位掩码的好处是不同房间只需要传入不同整数判定引擎内部完全不用感知业务。真正生产环境还有“缺一门”“只能碰不能吃”等约束那些都是胡牌之前的合法性检查不要把地区规则卷进拆面子递归里否则会把核心逻辑拖成一团乱麻。4. 性能优化暴力枚举的瓶颈、剪枝与记忆化缓存4.1 暴力枚举为什么在听牌场景不够用把第2章的判定直接塞进听牌轮询每个玩家每轮都要对34种候选牌各跑一次isHu。isHu内部最坏情况出现在“全是连续数牌”的牌型每层顺子分支都可能成功回溯会让递归树扩张得很厉害。再加上七对、十三幺的判定单次听牌轮询的递归调用量往往在百万级别。在房间类游戏服务端同时在线几百桌时这种计算量会明显抬升CPU占用所以你迟早要面对优化问题。4.2 剪枝把明显不胡的牌先拦下来最安全的剪枝是判断单张孤牌一张牌既凑不成刻子又无法嵌入任何顺子那这副牌必然不胡。bool quickPrune(const std::vectorint cnt) { int total 0; for (int i 0; i PAI_TYPE_COUNT; i) { total cnt[i]; if (cnt[i] 1) { if (i 27) return false; // 字牌单张没法组顺子直接不胡 bool inSeq false; int m i % 9; // 三种顺子位置i在中间/ i在开头 / i在结尾 inSeq (m 2 cnt[i - 1] 0 cnt[i - 2] 0) || (m 1 m 7 cnt[i - 1] 0 cnt[i 1] 0) || (m 6 cnt[i 1] 0 cnt[i 2] 0); if (!inSeq) return false; } } return total 14; }这里的剪枝条件是充分必要条件单张牌不能做刻子也不能做将牌必须存在于某个顺子中。如果左右邻牌不存在这张牌永远无法被消费后面不用算了。注意顺子三种位置都要覆盖只检查一侧邻牌会有漏网。4.3 记忆化把拆分状态压成整数做缓存同一个剩余牌型在递归中可能被反复计算。比如先拆2万刻子后再拆3万刻子和先拆3万刻子后再拆2万刻子最终会进入同一个状态。解决办法是把cnt编码成整数作为key缓存splitMelds的结果。uint64_t encodeState(const std::vectorint cnt) { uint64_t key 0; for (int i 0; i PAI_TYPE_COUNT; i) { key key * 5 cnt[i]; // 每位0-4五进制互不干扰 } return key; }五进制的含义是每位可以存0到4正好对应牌张数不会产生进位冲突。用四进制会存不下4张的情况用十进制又可能让不同状态撞到同一个key。class SplitCacher { public: bool dfs(std::vectorint cnt, int idx) { uint64_t key encodeState(cnt); auto it memo_.find(key); if (it ! memo_.end()) return it-second; bool result splitMelds(cnt, idx); memo_[key] result; return result; } private: std::unordered_mapuint64_t, bool memo_; };缓存的前提是splitMelds是纯函数——它对同一个cnt状态必然返回同一个结果不依赖外部变量。这里有个关键细节isBasicHu里“将牌选在哪”不能混进缓存key否则会把已取将牌和未取将牌的两个状态混为一谈。规则变化时缓存要清空比如切房从国标切到四川旧的缓存结果不再适用。4.4 查表法的边界全量预计算没那么美好真正极致性能的做法是离线预计算所有胡牌状态运行期直接查表。但面对的是“34种牌、每种0-4张、总和14”的巨大组合空间全量生成一次的成本并不低而且规则一换就要重新生成。我一般只在固定规则、固定牌数、判定频率极高的场景下才上这套方案比如跑一亿次模拟做天胡概率检测普通牌桌服务端用记忆化已经足够没必要为额外复杂度买单。5. 避坑指南麻将胡牌算法里最容易翻车的五个细节5.1 先刻子贪心导致漏判以及递归恢复现场现象手牌122334被判不胡人工一看明明能拆成123、123、4的刻子加将。原因贪心拆牌。有些版本在拆面子前先找“能凑刻子就凑刻子”比如122334先把2万刻子拆掉剩下的1万3万4万组不出顺子于是返回false。核心问题是贪心不回溯它接受了局部最优却堵死了另一条拆法。解决拆面子必须回溯逐分支尝试并在失败时恢复现场。恢复现场的关键点是“递归返回true时同样要恢复”——表面上函数提前退出了但外层循环还要用原始计数做下一次尝试。每次改完cnt我建议在出口处统一reset而不是散落在各个分支里手写恢复。5.2 记忆化缓存命中后结果不稳定现象同一个牌型加了缓存后有时胡有时不胡换成release版现象消失。原因最常见的两种情况一是递归里改完cnt后没有及时恢复后继分支读到的是污染后的状态二是把“已选将牌”和“未选将牌”的cnt共用同一个缓存key导致错误命中。解决记忆化必须是纯粹基于状态值的缓存所有修改操作在函数返回前恢复原状将牌选择前后的cnt状态不同要么分开缓存要么只缓存splitMelds而不缓存isBasicHu。排查时把encodeState打日志和手工推演的状态对比很快能定位。5.3 字牌被组进顺子中发白当顺子通过现象手牌包含中发白各一张程序认为它是顺子整副牌胡了。原因把字牌编码在27-33之后顺子分支却只检查了idx % 9 6漏掉idx 27的约束。任意两张数牌加一张字牌或者字牌之间都可能被错误组成顺子。解决顺子分支入口加idx 27判断。更稳妥的做法是在拆牌逻辑里加debug断言确保进入顺子分支的牌一定是数牌区我现在的习惯是开局跑一遍全牌型样例任何一个字牌顺子都会被样例打出来。5.4 七对漏判四张牌现象手里有四个2万、一对3万、一对5万、一对6万、一对7万、一对8万七对判不胡。原因判定条件写成了“如果cnt[i]2就算一对”四张相同牌被跳过。这个问题戴得很深因为普通手牌样本里四张同牌出现频率不高但一旦出现就是必漏。解决pairCount cnt[i] / 2并在循环开头用cnt[i] % 2 ! 0做退出。是否允许四张当两对是规则配置问题不要用写死的分支。5.5 递归层数不多却爆栈或性能崩塌现象debug版跑得极慢甚至崩release版正常或者同样代码在别人机器上没问题。原因vector当参数传值复制导致每层递归都拷贝整个34位数组有些写法还在递归里new vector内存分配次数爆炸。调试版异常慢通常也是STL容器调试检查叠加复制开销导致的。解决递归函数用std::vector 传引用所有改动都在回溯处恢复需要临时副本时用栈上的int copy[34]替代vector构造。遇到这类问题用vscode配好断点直接观察调用栈和cnt内容比打印日志定位快得多。6. 进阶把胡牌判定封装成HuEngine再接入牌局6.1 用一个类隔离规则和缓存单函数版本验证思路可以落地到牌局还得封装。我会把规则配置、记忆化缓存、判定入口收进同一个类class HuEngine { public: explicit HuEngine(int rules) : rules_(rules) {} bool check(const std::vectorint hand) { memo_.clear(); // 规则固定的话也可以只清一次 return isHuWithCache(hand, rules_); } private: int rules_; std::unordered_mapuint64_t, bool memo_; };这样做的价值有两个一是规则切换时缓存能随实例重建不影响其他对局二是听牌轮询、自摸检测、点炮检测都复用同一个check入口。如果你做的是麻将Demo不要全局变量满天飞把状态装进类里后面扩展AI打牌逻辑会轻松很多。6.2 一组黄金样例每次改动都要跑静态检查永远不如回归测试。我把所有规则分支的边界用例放在一个测试文件里每次改完胡牌逻辑先跑一遍用例类型手牌示例期望结果普通平胡123万 456万 789条 123筒 55万胡复杂顺子交叉112233万 456条 456筒 99筒胡字牌顺子误判东南西北中发白 123万 456万 77筒不胡七对带四张1111万 22条 33条 44筒 55筒 66筒按规则开关十三幺一九万 九条 一九筒 东南西北中发白 加一张胡非胡牌型123万 456万 789条 123筒 99条不胡我整理的完整工程里就包含这些测试样例和一个可编译的C版本源码下载后建议先运行test_cases.cpp把预期结果全跑通再去看听牌轮询和规则开关部分比自己从零串起来要省事得多。前两年做四川麻将房间时我用贪心拆面子的版本通过了七成测试用例上线后玩家在112233这种牌型上连续反馈“能胡却不胡”最后才定位到是拆牌顺序把状态锁死了。从那以后我每次改胡牌逻辑都会强制跑一遍上面的样例表再合入虽然每次多花五分钟但省掉的线上事故远远不止五分钟。希望帮到你。本文还有配套的精品资源点击获取