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

简介:面向C++开发者与游戏算法学习者的麻将胡牌判断实现,重点演示如何把对子、顺子、刻子等组合规则转化为可在线性时间复杂度内完成的牌型检测逻辑。压缩包为zip格式,共2个文件:一个cpp源程序提供可直接使用的判断代码,一份pdf围绕算法设计思路展开说明,包体仅132KB,结构精简。已有919人学习浏览,适合想用数据结构与动态规划策略处理组合判断问题的读者。源程序借助数组与位操作构建牌面,实现完整的胡牌检测并包含错误处理逻辑;设计文档从麻将规则建模、胡牌条件检查到番数计算与无额外空间的原地优化过程均进行了系统梳理,能够帮助读者掌握从规则理解到代码落地的全链路方法,也为后续扩展其他牌型算法留下清晰思路。

1. C++麻将胡牌算法:从3n+2到递归拆解,先想明白再动手

算法岗面试里常考的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::vector<int> counts(PAI_TYPE_COUNT, 0);

为什么选计数数组?因为胡牌判定的核心操作是“拿掉一张牌”“去掉一个面子”,计数数组可以O(1)修改和恢复;如果用真实牌序列,每次拆面子都要做一次查找和移除,回溯恢复还要记录原位置,代码复杂度直接翻倍。后续所有递归拆解都围绕这个数组展开。

2.2 基础胡牌判定:先枚将牌,再递归拆面子

标准胡牌结构是3n+2,牌数14张时就是4个面子加1对将牌。将牌必须是两张相同的牌,不能是两张不同牌组成的对子。判定主干分两步:先选一种牌拿两张当将牌,再把剩余的12张牌递归拆成4个面子。面子只有两种形式:刻子(三张相同牌)和顺子(三张连续数牌)。

bool splitMelds(std::vector<int>& 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::vector<int>& 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万(idx=6),idx+2就是9万,合法;如果idx是8万(idx=7),idx+2就跨到1条区域,属于不同花色,必须用余数排除。idx < 27 则是把字牌挡在顺子之外,避免“中发白”被当成顺子通过。这两个条件少任何一个,都会在某些特殊手牌上产生诡异结果:单测样本少的时候完全发现不了,海量真牌局一跑就冒出来。

关于刻子和顺子的拆分顺序,网上争论不少。我实测在标准规则下,先刻子后顺子和先顺子后刻子差别不大,因为回溯保证正确性;如果你要追求性能,可以在刻子分支前置一个“若当前牌只能组顺子则优先顺子”的启发式,但那是微优化,先把正确版本写对更重要。

3. 特殊牌型与听牌轮询:七对的四张陷阱与34次补牌检测

3.1 七对判定:四张相同牌算两对还是非法

七对要求手牌能分成7个对子。最容易踩坑的点是“四张相同牌”的处理:不少地区规则允许四张牌算两对,例如四张2万等同于两对2万。如果只用 cnt[i] == 2 来判断,四张牌会被直接跳过,导致能胡的牌型判不胡。

bool isQiDui(const std::vector<int>& 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::vector<int>& 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::vector<int> getTing(const std::vector<int>& hand, int rules) { std::vector<int> tingList; for (int i = 0; i < PAI_TYPE_COUNT; ++i) { if (hand[i] >= 4) continue; // 手里已有4张,摸不到第5张 std::vector<int> trial = hand; trial[i]++; if (isHu(trial, rules)) { tingList.push_back(i); } } return tingList; }

这里的isHu是统一入口,内部先走七对、十三幺,再走基础3n+2判定。第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::vector<int>& 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::vector<int>& 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::vector<int>& 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::vector<int>& 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_map<uint64_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::vector<int>& hand) { memo_.clear(); // 规则固定的话也可以只清一次 return isHuWithCache(hand, rules_); } private: int rules_; std::unordered_map<uint64_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这种牌型上连续反馈“能胡却不胡”,最后才定位到是拆牌顺序把状态锁死了。从那以后我每次改胡牌逻辑,都会强制跑一遍上面的样例表再合入,虽然每次多花五分钟,但省掉的线上事故远远不止五分钟。希望帮到你。

本文还有配套的精品资源,点击获取