C++麻将胡牌算法:递归拆牌、七对子与十三幺判定实现 简介面向游戏开发与算法学习场景C麻将胡牌算法源码及配套设计文档专用于高效判断手牌是否满足胡牌条件。压缩包内共2个文件1个源代码文件提供可运行的算法实现1个PDF文档阐述数据结构设计、O(n)时间复杂度优化及番数计算等要点整体仅132KB内容紧凑。目前已有920人浏览学习适合需要理解棋牌规则代码化、熟悉典型优化手法的读者。通过源码与文档可学习用数组和位操作表示牌面完成对子、顺子、刻子检查以及非法输入处理掌握利用贪心策略与原地计算减少额外空间开销的方法并了解从规则分析到动态规划落地的完整思路。读者可对照文档逐行理解源码掌握如何避免重复组合、处理非法输入并迁移到其他棋牌规则判断中。1. C 麻将胡牌算法为什么递归拆牌是最值得先抄的解法做过棋牌服务端或麻将 AI 的人都有体会整个对局逻辑里胡牌判定是最容易被当成“黑匣子”的一个模块。网上搜 C 麻将胡牌算法能搜到一堆零散代码但多数要么只支持一种规则要么回溯逻辑写得稀烂换个牌型就翻车。这份资源的核心是用“将牌枚举 递归拆面子”的思路把普通胡、七对子、十三幺统一在一套代码框架里数据结构简单逻辑直白适合直接抄进自己的项目里改。适合正在写麻将教室、人机对战、毕业设计或者给棋牌引擎做底层判定的人。下面按我实际拆过的思路把编码、递归、边界坑和性能调优一次讲透。2. 牌型编码与胡牌判定模型先定数据结构再写递归2.1 0-33 编码表把万条筒字牌映射到定长数组麻将手牌在代码里怎么存决定了后面所有逻辑的复杂度。我见过有人用字符串、用 vector 存牌面值、甚至用 unordered_map 统计张数这些都能跑但性能与可读性都不如一张定长计数表。这份资源采用国内棋牌项目最常见的方案0 到 33 共 34 个整数按花色段连续编码。// 万0~8 1万0, 2万1, ..., 9万8 // 条9~17 1条9, ..., 9条17 // 筒18~26 1筒18, ..., 9筒26 // 字27~33 东27, 南28, 西29, 北30, 中31, 发32, 白33用std::arrayuint8_t, 34存储手牌下标就是牌 ID值是张数。这样设计有几个实际好处34 字节的栈上数组拷贝、比较、遍历都极快配合递归回溯很舒服。判断“是否有某张牌”从 O(n) 变成 O(1)。顺子的连续性判断可以靠“下标差为 1”完成因为万、条、筒内部是连续区间。这里的一个关键细节是9 万ID8和 1 条ID9在数值上相邻。所以后面拆顺子时必须先判断“当前牌是否处于花色段末尾”否则就会出现 7万、8万、1条被误判成顺子的经典事故。这个问题很常见后面避坑章会专门讲。2.2 将牌 4 组面子的拆分模型抛开七对子、十三幺等特殊牌型标准胡牌的结构是固定的1 组将牌对子 4 组面子顺子或刻子。任何普通胡牌判定算法本质上都是在一手 14 张牌里寻找这个结构。判断顺序通常分两步枚举哪两张牌作为将牌。剩余 12 张牌尝试拆成 4 组面子每组要么是刻子3 张相同要么是顺子同花色连续 3 张。为什么先定将牌而不是先拆面子因为将牌只有 1 组枚举代价小如果先把面子拆了剩下两张不成对还要回退重来回溯层级更深。先定位将牌可以把问题稳定切割成“4 组面子”的递归搜索。这个模型有一个容易被忽略的前提手牌总数必须满足总张数 % 3 2。这不是废话很多新人直接把任何牌型往递归里丢结果什么也拆不出来。在做胡牌判定前这个最简单的过滤条件应该最先执行。2.3 递归拆面子的核心字牌不做顺子、花色段内连续递归拆面子的思路是每次找到手牌计数中“第一张还有剩余”的牌然后尝试把它处理掉。处理方式只有三种可能作为刻子的一部分一次消耗 3 张。作为顺子的最小牌消耗自己、下一位、下下位各 1 张。如果当前是字牌只能组成刻子因为字牌没有连续概念不能进顺子。为什么总是从最小牌开始因为这样做能减少分支数量。假设当前最小牌是 3 万它要么是刻子的 3 万要么是顺子 3-4-5 中的 3 万不可能是其他顺子的中间牌或结尾牌。从这个固定起点出发试错每个递归层级的分支被压缩到最多两个回溯次数可控。这里需要注意一个性能陷阱有些开源实现只尝试一种分支比如先试刻子不行就返回 false或者在顺子和刻子都可以时只选一种丢失了另一条路径导致漏判。正确的做法是两条分支都试只有两者都失败才回溯到上一层。3. 完整胡牌检测实现核心递归函数与七对子、十三幺3.1 单文件实现从 cnt 数组到 isHu 判定把上面模型落成代码只需要一个递归函数splitFaces配合一个枚举将牌的isHu。下面这份代码可以直接复制进项目里跑依赖只用到标准库。#include array #include cstdint #include vector #include iostream // 编码规则 // 万0~8 条9~17 筒18~26 字27~33 using Counts std::arrayuint8_t, 34; constexpr int NUM_PAI 34; // 递归拆面子从 cnt 中拆出 remain 组面子 bool splitFaces(Counts cnt, int remain) { if (remain 0) return true; // 面子全部拆完 // 找第一张还有剩余的牌作为本次尝试对象 int i 0; while (i NUM_PAI cnt[i] 0) i; if (i NUM_PAI) return false; // 还有面子没拆牌却没了 // 字牌段27~33只能做刻子 if (i 27) { if (cnt[i] 3) return false; // 不成刻子就无解 cnt[i] - 3; bool ok splitFaces(cnt, remain - 1); cnt[i] 3; return ok; } // 数牌先试刻子 if (cnt[i] 3) { cnt[i] - 3; bool ok splitFaces(cnt, remain - 1); cnt[i] 3; if (ok) return true; // 刻子路线成功提前返回 } // 再试顺子i 必须是花色段前 7 张且 i1、i2 有牌 if (i % 9 6 cnt[i 1] 0 cnt[i 2] 0) { --cnt[i]; --cnt[i 1]; --cnt[i 2]; bool ok splitFaces(cnt, remain - 1); cnt[i]; cnt[i 1]; cnt[i 2]; if (ok) return true; } return false; // 两种组装方式都失败 } // 普通牌型胡牌1 对将牌 4 组面子 bool isHu(const Counts cnt) { int total 0; for (uint8_t c : cnt) total c; if (total % 3 ! 2) return false; // 手牌数不满足胡牌结构 Counts tmp cnt; for (int jiang 0; jiang NUM_PAI; jiang) { if (tmp[jiang] 2) { tmp[jiang] - 2; // 试将牌 if (splitFaces(tmp, (total - 2) / 3)) { return true; } tmp[jiang] 2; // 回溯还原试下一个将牌 } } return false; }这段代码里有三个点需要说明。第一splitFaces的remain参数表示“还有几组面子要拆”递归每成功拆出一组就减一归零即成功。我没用“剩余牌数”做终止条件因为“牌数归零”和“面子拆完”不是一回事——牌数归零时可能还剩散牌没组成面子。第二isHu里用Counts tmp cnt创建一个副本是为了让每个将牌候选都从干净状态开始。如果直接用引用内部递归会把数组改乱。这个副本只有 34 字节拷贝成本可以忽略。第三顺子分支里的i % 9 6是花色边界检查。以万为例ID6 是 7 万7 万可以和 8 万、9 万组成顺子所以 ID6 允许ID7 是 8 万8 万后面只有 9 万没有 10 万所以不允许再往后组顺子。这个条件千万不要省否则跨花色顺子会让你的牌型判断彻底失控。3.2 七对子与十三幺特殊牌型的独立分支普通胡的“1 将 4 面子”模型天然不覆盖七对子和十三幺。七对子是 7 组对子没有顺子结构十三幺是 13 种幺九牌各一张再加任意一张作将。这两种牌型必须在进isHu之前单独判断。// 七对子7 组不同的对子可配置是否允许四张相同拆成两对 bool isSevenPairs(const Counts cnt) { int pairCount 0; for (uint8_t c : cnt) { if (c 2) { pairCount; // 恰好一对 } else if (c % 2 ! 0) { return false; // 奇数张不可能组成对子 } } return pairCount 7; } // 十三幺13 种幺九牌各至少 1 张总共有 14 张且其中一个作将 bool isThirteenOrphans(const Counts cnt) { static const bool isTerminal[34] { true, false, false, false, false, false, false, false, true, // 万 true, false, false, false, false, false, false, false, true, // 条 true, false, false, false, false, false, false, false, true, // 筒 true, true, true, true, true, true, true // 字牌 }; int kinds 0; bool hasPair false; for (int i 0; i 34; i) { if (cnt[i] 0) continue; if (!isTerminal[i]) return false; // 出现非幺九牌直接排除 kinds; if (cnt[i] 2) hasPair true; } return kinds 13 hasPair; }七对子的判定要注意一个规则分叉点很多地方麻将允许“四张相同”拆成两对算七对但竞技麻将规则下不允许。这份代码默认把c 2才计入一对c 4时不会计入两对因为c 4既不等于 2模 2 又是 0最终pairCount不会因此增加。如果你们的规则允许四张拆两对把分支改成c 2 || c 4然后按c / 2累加即可。十三幺的判断逻辑里isTerminal数组把万条筒的 1 和 9、以及全部字牌标记为幺九牌。注意这里要求“13 种都至少出现一次”并且至少有一种出现 2 次。为什么不是直接判断总数 14因为 13 种各一张是 13 张必须有一种重复才能凑满 14 张手牌。用hasPair而不是张数统计能避免“某一种牌出现 3 次”这种异常输入蒙混过关。3.3 参数说明与调用示例实际调用时先根据手牌 ID 列表生成Counts数组再依次做特殊牌型和普通牌型判定。// 把牌 ID 列表转成计数表 Counts makeHand(std::initializer_listint ids) { Counts cnt{}; for (int id : ids) cnt[id]; return cnt; } int main() { // 例11万x3 2万 3万 4万 5万 6万 7万 8万x2 9万x3 // 拆法111万(刻子) 234万 567万 999万 88万(将) Counts hand1 makeHand({0,0,0, 1,2,3, 4,5,6, 7,7, 8,8,8}); std::cout hand1 isHu: isHu(hand1) \n; // 例2七对子11万 22万 33条 44筒 55筒 东东 南南 Counts hand2 makeHand({0,0, 1,1, 11,11, 20,20, 21,21, 27,27, 28,28}); std::cout hand2 isSevenPairs: isSevenPairs(hand2) \n; // 例3十三幺1万9万1条9条1筒9筒 东南西北中发白 9万作将 Counts hand3 makeHand({0, 8, 9, 17, 18, 26, 27,28,29,30,31,32,33, 8}); std::cout hand3 isThirteenOrphans: isThirteenOrphans(hand3) \n; }三个测试用例分别覆盖“普通胡”“七对子”“十三幺”。把这段接进主流程时推荐判定顺序是先查特殊牌型再走isHu。因为大部分手牌是普通型把isHu放在最后作为兜底让特殊牌型优先短路逻辑更干净。4. 接进对局听牌计算、出牌校验与性能预算4.1 听牌计算34 种牌遍历 增量修改胡牌判定只是第一步真正对局里调用最频繁的是“听牌计算”——也就是遍历 34 种牌逐张尝试加入手牌看能否胡。听牌结果直接驱动 AI 决策和人机交互提示。// 返回听哪些牌能胡 std::vectorint calcTing(const Counts hand) { std::vectorint ting; Counts test hand; for (int t 0; t 34; t) { if (test[t] 4) continue; // 牌山已无剩余跳过 test[t]; if (isHu(test) || isSevenPairs(test) || isThirteenOrphans(test)) { ting.push_back(t); } --test[t]; // 必须还原否则影响下一张牌 } return ting; }这里用test作为工作副本循环内、判断、--三连。注意--不能省否则第 5 张同样的牌会被错误判定。成本方面一次calcTing最多调用 34 次判定函数实际会因test[t] 4跳过几张每次判定内部又要枚举将牌候选所以总操作量在千次级现代 CPU 上完全可以忽略。4.2 出牌校验先移除再判定服务端做“出牌合法性校验”时逻辑是反过来的玩家打出一张牌服务器要确认“打出这张牌后剩余手牌不处于可以胡的状态”。这里的判定顺序是先从手牌计数中减掉要打出的牌再调用isHu判断剩余牌型。// 判断打出牌 out 后手牌是否仍然能胡 bool canDiscard(const Counts hand, int out) { if (hand[out] 0) return false; // 手里没有这张牌非法出牌 Counts tmp hand; --tmp[out]; // 多数规则打出牌后手牌不再满足胡牌结构属于合法动作 // 如果规则允许“打出后仍听牌”这里需要结合上下文处理 return !(isHu(tmp) || isSevenPairs(tmp) || isThirteenOrphans(tmp)); }这个函数的实际作用不只是校验合法性在部分带“出牌提示”“防漏听”功能的对局里服务端还会拿它算“打出哪些牌后听的张数最多”。那就是在canDiscard里再套一层calcTing复杂度翻倍所以更推荐做法是先算当前手牌的听牌集合再反推“哪些牌打出后听牌集合更大”否则每次出牌都全量重算性能会很紧张。4.3 性能预算为什么单次判定必须压在微秒级麻将对局的判定调用频率比很多人想象的更高。单局游戏里每次摸牌、每次出牌、每次碰杠后都可能触发胡牌检查。如果是带 AI 的房卡棋牌AI 每回合要做一次完整的搜寻听牌计算一变多压力成倍上升。操作调用次数单次耗时量级isHu 单次判定1微秒级calcTing 听牌计算34 次 isHu百微秒级AI 多步搜索多次 calcTing毫秒级在我的经验里这份递归实现单次isHu最坏情况在几微秒到几十微秒之间取决于将牌候选的枚举顺序。想要更稳做一层“结果缓存”同一手牌在一回合内听牌结果不会变化摸牌或出牌后才失效。常见做法是用牌型 ID 做 key把calcTing结果缓存起来牌局事件触发时清空。// 用 64 位整数粗略压码手牌作为缓存 key uint64_t encodeHand(const Counts cnt) { uint64_t key 0; for (int i 0; i 34; i) { key key * 5 cnt[i]; // 每张牌最多 4 张用 5 进制压码 } return key; }这个 encode 函数不是必须的牌局规模小的时候直接每次重算也没什么问题。但如果你们的服务端要同时开几千桌缓存带来的收益就很明显了。5. 避坑指南胡牌算法最常见的 5 个翻车点5.1 九万和一条被拆成顺子现象手牌包含 7万、8万、1条、2条时程序把 7万、8万、1条当成顺子拆掉了导致明明能胡的牌被判不胡。原因编码表里 89万和 91条在数值上相邻cnt[i1]、cnt[i2]没有检查花色边界程序自然认为它们是连续牌。解决在顺子分支加i % 9 6条件。这个条件的意思是“当前牌必须是花色段的前 7 张后面才存在两个同花色的后继”。数字牌三个花色段都是 0-8 的结构i % 9能拿到当前牌在花色段内的索引索引小于等于 6 才允许组顺子。5.2 递归回溯没还原现场同一个将牌候选被污染现象手牌里有多个可能的将牌第一个候选失败后第二个候选判定结果异常有时会漏判。原因splitFaces里对cnt做了修改如果某个分支失败后没有把cnt恢复原样isHu外层循环枚举下一个将牌时手牌数组已经被改乱。解决代码里每个分支都有对应cnt[i] 3或cnt[i]的还原操作。写这段逻辑时我习惯把“修改 → 递归 → 还原”三行连在一起中间不插任何可能提前返回的代码。如果怕忘了可以换一种写法递归调用时传值拷贝而不是引用牺牲一点性能换绝对安全。5.3 七对子把四张相同的牌算成两对现象手牌有一组四张相同的牌按七对子判定时被判为 7 对但竞技麻将规则下这种牌型不成立。原因七对子的定义在不同地方麻将里不一样。部分规则允许四张相同牌拆成两个对子竞技麻将不允许。代码里c 2和c % 2 ! 0的判断已经把四张牌排除在“一对”之外但如果你直接抄了别处允许拆两对的实现就会踩坑。解决按你们的规则配置文件加开关。我一般会在判定函数前面加一个allowFourAsTwoPairs布尔值从规则配置里读取而不是写死在函数里。这样同一套胡牌算法可以复用于多个房型。5.4 用 unordered_map 存手牌性能直接崩现象牌型不大但判定一次要几十微秒甚至上百微秒AI 搜索时明显卡顿。原因unordered_mapint, int在每次递归拆牌时都要做哈希查询34 种牌的哈希冲突和动态扩容带来的开销比定长数组高一个数量级。解决换回std::arrayuint8_t, 34。这里没有任何技巧定长数组是胡牌判定的最优数据结构。如果非要用哈希表存手牌那也要在进入判定函数前先转换成计数数组不要在递归里反复查表。5.5 先拆刻子还是先拆顺子分支遗漏是最大玄学现象同一手牌有的人实现判胡有的人实现判不胡两边代码看起来都“没什么问题”。原因递归拆面子时同一张牌可能既满足刻子条件又满足顺子条件。比如手中有 333 万和 234 万时3 万既能在刻子里也能在顺子里。如果实现只试一条分支碰到另一条分支才能成功的牌型就会漏判。这是胡牌判定里最典型的分支遗漏坑。解决两条分支都试返回条件改成“任一分支成功即整体成功”。上面的代码里先试刻子再试顺子两个分支独立回溯。你也可以把顺序反过来对正确性没有影响但不要只留一个分支。测试时专门准备几组“双重身份”的牌型来验这个逻辑比如 111 万、123 万、123 万、123 万、11 筒这种能同时拆出多种解。6. 进阶技巧用奇偶优先和记忆化把判定再压一个量级基础实现已经能跑但要把它用到对局 AI 里还有两个值得掌握的优化点。它们不改算法主干只调整搜索策略和缓存方式。第一个优化是将牌候选的搜索顺序。将牌是一手牌里唯一出现偶数次影响结构的一组牌。数学上每个面子消耗 3 张牌不改变手牌奇偶性只有将牌消耗 2 张会改变奇偶分布。所以多数情况下出现次数为奇数的牌是更强的将牌候选。把搜索顺序改成“先奇数后偶数”能更快命中正确将牌尤其在中后期手牌牌型复杂时省掉大量无效递归。// 奇数优先的将牌枚举先试奇数次张数的牌再试偶数次 for (int pass 0; pass 2; pass) { for (int jiang 0; jiang NUM_PAI; jiang) { if (tmp[jiang] 2) continue; if ((pass 0) ! (tmp[jiang] % 2 1)) continue; tmp[jiang] - 2; if (splitFaces(tmp, (total - 2) / 3)) return true; tmp[jiang] 2; } }注意这是搜索顺序优化不是剪枝。优化后如果没搜到第二遍仍会枚举全部偶数候选所以不会漏解。实际测试里大多数能胡的牌型用奇数优先第一遍就能命中。第二个优化是记忆化。递归拆面子时同样的中间状态可能从不同路径到达。比如 123 万和 456 万组合顺序不同拆到某一步时剩余牌完全相同。记录“已失败的剩余牌状态”下次遇到直接返回 false能避免重复计算。std::unordered_setuint64_t failedState; // 记录已证明失败的牌型指纹 bool splitFacesMemo(Counts cnt, int remain) { if (remain 0) return true; uint64_t key encodeHand(cnt); if (failedState.count(key)) return false; // ... 与 splitFaces 相同的刻子/顺子分支逻辑 // 全部失败后 failedState.insert(key); return false; }这里encodeHand用 5 进制压码把 34 个计数位塞进一个 64 位整数。每次递归进入时先查缓存。注意这个记忆化函数只能缓存“失败”状态不能缓存“成功”状态因为成功就提前返回了没必要记录。而且每次新一手牌开始时要failedState.clear()不能用全局状态跨手牌。最后一个技巧是验证方法也是我每次改动算法后必做的一步把“能胡的手牌”和“不能胡的手牌”分别准备 10 组测试样例跑完判定后打印拆牌结果。为什么不是打印 bool因为 bool 只能告诉你通没通过不能告诉你拆牌路径真出了问题你还要回去打日志。直接在测试代码里输出拆出的刻子和顺子组合可以看到具体哪一步走错了。从那以后我每写一版胡牌判定都强制按这个流程走一遍先画编码表再写最小牌优先的递归接着补七对子和十三幺分支最后跑一遍带拆牌日志的测试用例。这套流程帮我挡过至少三次跨花色顺子和回溯污染的问题。这套 C 麻将胡牌算法里的思路和代码骨架直接抄下来改参数就能用希望你也能少踩几个我踩过的坑。本文还有配套的精品资源点击获取