简介:面向游戏开发与算法学习场景,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::array<uint8_t, 34>存储手牌,下标就是牌 ID,值是张数。这样设计有几个实际好处:
- 34 字节的栈上数组,拷贝、比较、遍历都极快,配合递归回溯很舒服。
- 判断“是否有某张牌”从 O(n) 变成 O(1)。
- 顺子的连续性判断可以靠“下标差为 1”完成,因为万、条、筒内部是连续区间。
这里的一个关键细节是:9 万(ID=8)和 1 条(ID=9)在数值上相邻。所以后面拆顺子时,必须先判断“当前牌是否处于花色段末尾”,否则就会出现 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::array<uint8_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 张,且 i+1、i+2 有牌 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是花色边界检查。以万为例,ID=6 是 7 万,7 万可以和 8 万、9 万组成顺子,所以 ID=6 允许;ID=7 是 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_list<int> ids) { Counts cnt{}; for (int id : ids) ++cnt[id]; return cnt; } int main() { // 例1:1万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::vector<int> calcTing(const Counts& hand) { std::vector<int> 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条当成顺子拆掉了,导致明明能胡的牌被判不胡。
原因:编码表里 8(9万)和 9(1条)在数值上相邻,cnt[i+1]、cnt[i+2]没有检查花色边界,程序自然认为它们是连续牌。
解决:在顺子分支加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_map<int, int>在每次递归拆牌时都要做哈希查询,34 种牌的哈希冲突和动态扩容带来的开销,比定长数组高一个数量级。
解决:换回std::array<uint8_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_set<uint64_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++ 麻将胡牌算法里的思路和代码骨架,直接抄下来改参数就能用,希望你也能少踩几个我踩过的坑。
本文还有配套的精品资源,点击获取