简介:这是一份面向C++游戏开发者的麻将胡牌算法实现代码,聚焦普通胡牌与癞子(万能牌)胡牌两类核心规则,适合有一定C++基础、想深入理解回溯搜索与牌型判定的开发者参考。压缩包共4个文件,包含2个cpp源文件与2个h头文件,整体约23KB,其中头文件负责牌型与规则的数据结构声明,源文件则承载胡牌判定与癞子替代策略的具体逻辑。算法以回溯法遍历手牌组合,逐一验证对子、顺子、刻子、杠等牌型,并借助剪枝与动态规划思路降低无效计算;癞子部分则需分析万能牌的最佳替代对象及额外番数。目前已有1365人学习下载,读者可从中获得完整的胡牌判定框架、癞子处理思路与可复用的代码结构,便于移植到实际麻将游戏项目中。
1. 从一副乱牌到胡牌判定:癞子麻将算法到底难在哪
手上摸到第 14 张牌,牌面是「1万 2万 3万 5万 5万 6万 7万 8万 9万 东 东 东 白」,白板是癞子。你盯着这手牌想确认能不能胡,脑子里飞快地拆:123 万顺子、567 万顺子、789 万顺子、东东东刻子,剩下 5 万和白板——白板当 5 万,胡了。这个判断你用了三秒,但要让 C++ 程序在毫秒级、在成千上万次搜索里稳定给出同样的答案,事情就没那么直观了。麻将胡牌算法要解决的是「给定 14 张牌,判断是否存在一种拆分方式,使其构成 4 组面子加 1 对将牌」;而癞子胡牌算法在这个基础上多了一层——万能牌可以变成任意一张牌,搜索空间瞬间膨胀。这篇文章面向正在写麻将服务端、做棋牌 AI 或者准备面试棋牌岗位的 C++ 开发者,把从普通胡牌到癞子胡牌的完整落地路径拆开讲清楚,包括数据结构怎么选、递归怎么剪枝、癞子怎么处理、性能怎么压到可接受范围。如果你只写过冒泡排序和链表,这篇会让你看到算法在真实业务里的样子;如果你已经写过普通胡牌,癞子部分和性能优化部分值得细看。
2. 普通胡牌算法的底层模型:从牌型编码到递归拆解
2.1 为什么用计数数组而不是牌对象列表
很多人第一反应是把手牌存成vector<Card>,每张牌一个对象,带花色和点数。这个做法在业务层没问题,但到了胡牌判定这种高频调用的核心逻辑里,对象列表的遍历和删除成本太高。常见做法是用一个长度为 34 的整型数组int cnt[34],把万、条、筒各 9 张映射到 0-26,风牌箭牌映射到 27-33。数组下标就是牌的唯一标识,值就是这张牌有几张。
// 34 位计数数组:0-8 万(1-9),9-17 条(1-9),18-26 筒(1-9),27-33 字牌 // 字牌顺序:东南西北中发白 int cnt[34]; // 从牌面编码转计数数组,tile 取值 0-33 void addTile(int tile) { cnt[tile]++; } // 判断当前计数是否全部归零,用于递归终止 bool isEmpty() { for (int i = 0; i < 34; i++) { if (cnt[i] != 0) return false; } return true; }这个结构的好处是:判断「某张牌还剩几张」是 O(1),修改状态是 O(1),回溯时恢复现场也是 O(1)。递归拆牌时,我们只需要在数组上做加减,不需要任何内存分配。参数上唯一要注意的是编码顺序必须全局统一,客户端传来的牌面、服务端存储的手牌、算法内部的计数数组,三者映射规则要写在一处,避免出现「万子从 1 开始还是从 0 开始」这种低级但致命的错位。
2.2 递归拆牌的核心逻辑与剪枝
胡牌判定的本质是:从 34 种牌里,每次选一种还有剩余的牌,尝试把它作为顺子的起点、刻子的组成或者将牌,然后递归判断剩下的牌能否继续拆完。朴素的递归会重复搜索大量相同状态,所以必须剪枝。
// 判断剩余牌能否拆成若干面子(不含将牌) bool canFormMelds() { // 找到第一张还有剩余的牌 int first = -1; for (int i = 0; i < 34; i++) { if (cnt[i] > 0) { first = i; break; } } if (first == -1) return true; // 全部拆完 // 尝试一:作为刻子 if (cnt[first] >= 3) { cnt[first] -= 3; if (canFormMelds()) { cnt[first] += 3; return true; } cnt[first] += 3; } // 尝试二:作为顺子起点(字牌不能组顺子) if (first < 27 && first % 9 <= 6 && cnt[first + 1] > 0 && cnt[first + 2] > 0) { cnt[first]--; cnt[first + 1]--; cnt[first + 2]--; if (canFormMelds()) { cnt[first]++; cnt[first + 1]++; cnt[first + 2]++; return true; } cnt[first]++; cnt[first + 1]++; cnt[first + 2]++; } return false; } // 完整胡牌判定:枚举将牌,剩余部分判断能否拆成面子 bool canHu() { for (int i = 0; i < 34; i++) { if (cnt[i] >= 2) { cnt[i] -= 2; if (canFormMelds()) { cnt[i] += 2; return true; } cnt[i] += 2; } } return false; }这段代码的关键在于「总是从最小的还有剩余的牌开始拆」。因为顺子必须连续,刻子只看自身,从最小牌开始可以保证不会漏掉任何一种拆法。first % 9 <= 6这个条件确保顺子不会跨花色——比如 8 万和 1 条在数组里相邻,但实际不能组成顺子。参数上,first < 27排除了字牌参与顺子的可能,这是规则层面的硬约束,写错会导致字牌被错误地当成顺子起点。
2.3 七对与特殊牌型的处理
标准胡牌之外,七对是另一条独立分支。判断七对只需要统计有多少种牌的数量是 2 的倍数,且总数为 14。但要注意,七对不允许有四张相同牌算两对的情况(不同规则有差异,常见做法是四张算两对但只能出现一次)。实现上单独写一个函数,在canHu入口处先判断七对,再走面子拆解逻辑。
bool canHuSevenPairs() { int pairCount = 0; for (int i = 0; i < 34; i++) { if (cnt[i] % 2 != 0) return false; pairCount += cnt[i] / 2; } return pairCount == 7; }这里有个容易翻车的点:如果手牌里有四张相同的牌,cnt[i] / 2会得到 2,相当于两组对子。有些规则不允许这种「龙七对」重复计数,需要额外判断cnt[i] == 4时是否合法。我一般会把规则差异做成配置项,而不是硬编码在算法里,因为不同地区的麻将规则对七对的认定差别很大。
3. 癞子胡牌算法:万能牌带来的搜索空间爆炸与收敛
3.1 癞子的两种建模思路对比
癞子(万能牌)的处理有两种主流思路。第一种是「替换法」:枚举癞子变成哪张具体的牌,然后调用普通胡牌算法。第二种是「吸收法」:在递归拆牌过程中,当发现某张牌不够组成面子时,用癞子补上。两种思路各有适用场景。
| 思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 替换法 | 逻辑简单,复用普通胡牌代码 | 癞子数量多时枚举爆炸 | 癞子 ≤ 2 张 |
| 吸收法 | 不需要枚举,递归中直接消耗癞子 | 递归分支判断复杂,容易漏解 | 癞子数量任意 |
我一般会选吸收法,因为实战中癞子数量可能到 4 张甚至更多,替换法的枚举量是 34 的癞子数次方,完全不可接受。吸收法的核心思想是:在递归拆牌时,如果当前最小的牌无法单独组成刻子或顺子,就尝试用癞子去补足缺少的部分。
3.2 吸收法递归实现与癞子计数
int laiziCnt; // 癞子数量,全局或传参 // 判断剩余牌能否拆成面子,癞子可以补任意牌 bool canFormMeldsWithLaizi() { int first = -1; for (int i = 0; i < 34; i++) { if (cnt[i] > 0) { first = i; break; } } if (first == -1) return true; // 牌全部拆完,癞子不用也合法 // 尝试一:刻子,缺的用癞子补 int need = 3 - cnt[first]; if (need <= laiziCnt) { int used = min(cnt[first], 3); cnt[first] -= used; laiziCnt -= need; if (canFormMeldsWithLaizi()) { laiziCnt += need; cnt[first] += used; return true; } laiziCnt += need; cnt[first] += used; } // 尝试二:顺子,从 first 开始连续三张,缺的用癞子补 if (first < 27 && first % 9 <= 6) { int needLaizi = 0; int usedCnt[3] = {0, 0, 0}; for (int k = 0; k < 3; k++) { int t = first + k; if (cnt[t] > 0) { cnt[t]--; usedCnt[k] = 1; } else { needLaizi++; } } if (needLaizi <= laiziCnt) { laiziCnt -= needLaizi; if (canFormMeldsWithLaizi()) { laiziCnt += needLaizi; for (int k = 0; k < 3; k++) { if (usedCnt[k]) cnt[first + k]++; } return true; } laiziCnt += needLaizi; } // 恢复现场 for (int k = 0; k < 3; k++) { if (usedCnt[k]) cnt[first + k]++; } } return false; }这段代码比普通版本多了两个关键操作:计算need(当前牌型缺几张)和消耗/恢复laiziCnt。参数上,laiziCnt必须是可回溯的,每次递归尝试后要精确恢复到尝试前的值,否则会出现「癞子越用越多」的玄学 bug。另外,first % 9 <= 6这个条件在癞子场景下依然要保留,因为癞子不能改变牌的花色归属,8 万加癞子不能变成 9 万加 1 条这种跨花色的顺子。
3.3 将牌枚举与癞子作为将牌的处理
完整胡牌判定还需要枚举将牌。在癞子场景下,将牌可能是两张真实牌,也可能是一张真实牌加一张癞子,甚至两张癞子。枚举逻辑要覆盖这三种情况。
bool canHuWithLaizi() { // 情况一:两张真实牌作将 for (int i = 0; i < 34; i++) { if (cnt[i] >= 2) { cnt[i] -= 2; if (canFormMeldsWithLaizi()) { cnt[i] += 2; return true; } cnt[i] += 2; } } // 情况二:一张真实牌 + 一张癞子作将 if (laiziCnt >= 1) { for (int i = 0; i < 34; i++) { if (cnt[i] >= 1) { cnt[i]--; laiziCnt--; if (canFormMeldsWithLaizi()) { laiziCnt++; cnt[i]++; return true; } laiziCnt++; cnt[i]++; } } } // 情况三:两张癞子作将 if (laiziCnt >= 2) { laiziCnt -= 2; if (canFormMeldsWithLaizi()) { laiziCnt += 2; return true; } laiziCnt += 2; } return false; }这里有个血泪经验:情况二的枚举顺序会影响性能。如果先枚举真实牌再消耗癞子,当手牌种类很多时,循环次数会很大。我一般会先判断laiziCnt是否足够,再决定是否进入这个分支,避免无意义的遍历。另外,七对在癞子场景下也要重新定义——癞子可以补成任意对子,所以判断条件变成「真实牌中奇数数量的种类数 ≤ 癞子数,且总牌数 + 癞子数 = 14」。
4. 性能优化与工程落地:从能跑到跑得快
4.1 记忆化与状态压缩
递归拆牌的最大问题是重复状态。同一手牌,不同的拆解顺序会到达相同的剩余牌型。用记忆化把已经判断过的状态存下来,可以大幅减少递归次数。状态可以用 34 位计数数组的哈希值表示,但更高效的做法是用一个 64 位整数做位压缩——每种牌的数量最多 4 张,需要 3 个 bit,34 种牌需要 102 个 bit,超过 64 位。常见做法是用两个 64 位整数或者直接用字符串做 key。
#include <unordered_map> #include <string> std::unordered_map<std::string, bool> memo; std::string encodeState() { std::string key; key.reserve(34); for (int i = 0; i < 34; i++) { key.push_back('0' + cnt[i]); } key.push_back('0' + laiziCnt); return key; } bool canFormMeldsWithMemo() { std::string key = encodeState(); auto it = memo.find(key); if (it != memo.end()) return it->second; bool result = canFormMeldsWithLaizi(); // 实际递归逻辑 memo[key] = result; return result; }参数上要注意:memo的生命周期应该和一次胡牌判定绑定,每次新的手牌进来要清空,否则会读到上一局的缓存。如果服务端 QPS 很高,可以考虑用固定大小的环形缓冲区或者带淘汰的 LRU,避免内存无限增长。
4.2 剪枝策略:剩余牌数校验与癞子上限
在进入递归之前,先做一轮快速校验可以过滤掉大量不可能胡的牌型。最基本的校验是:剩余牌数必须是 3 的倍数加 2(14 张、11 张、8 张等)。对于癞子场景,还要校验「真实牌中无法组成面子的牌数」是否超过癞子能弥补的上限。
// 快速预判:如果某种牌的数量超过 4 张,直接不合法 bool quickCheck() { int total = 0; for (int i = 0; i < 34; i++) { if (cnt[i] > 4) return false; total += cnt[i]; } total += laiziCnt; if (total % 3 != 2) return false; // 癞子最多 4 张,超过则规则本身有问题 if (laiziCnt > 4) return false; return true; }这个预判能在递归之前砍掉大部分非法输入,尤其是客户端传参错误或者网络包解析异常导致的脏数据。我一般会把quickCheck放在canHu的第一行,不通过直接返回 false,不进入任何递归。
4.3 多线程与批量判定场景
服务端经常需要批量判断多手牌,比如 AI 搜索时对每个可能的出牌结果做胡牌预判。这时候单次判定的耗时不关键,关键是吞吐量。可以把memo做成线程局部的,每个线程独立缓存,避免锁竞争。如果手牌数量不大,也可以考虑用 SIMD 指令并行处理多个计数数组的比较,但这属于进阶优化,普通业务场景用记忆化加剪枝已经足够。
// 线程局部缓存示例 thread_local std::unordered_map<std::string, bool> tlsMemo; bool canHuThreadSafe() { tlsMemo.clear(); // ... 调用递归逻辑,内部使用 tlsMemo }参数上,thread_local的初始化开销在第一次调用时产生,之后复用。如果线程池长期存活,这个开销可以忽略。要注意的是,tlsMemo不会自动释放,线程结束时才销毁,所以不要在里面存太大的对象。
5. 避坑与排查:癞子胡牌算法里那些让人翻车的细节
5.1 癞子回溯不干净导致「越用越多」
现象:同一手牌,第一次调用返回 true,第二次调用返回 false,或者结果不稳定。原因:递归过程中修改了laiziCnt但没有在所有分支正确恢复。尤其是提前 return 的分支,容易漏掉恢复操作。解决:把所有对cnt和laiziCnt的修改写成「修改-递归-恢复」的三段式,恢复代码放在递归调用之后、return 之前。可以用 RAII 封装一个计数器守卫,析构时自动恢复。
5.2 顺子跨花色判断遗漏
现象:手牌「8万 9万 1条」加一个癞子,程序判定可以胡,但实际不能组成顺子。原因:first % 9 <= 6这个条件只判断了起点,没有判断first + 1和first + 2是否在同一花色内。当first是 8 万(下标 7)时,first + 1是 9 万(下标 8),first + 2是 1 条(下标 9),跨了花色。解决:在顺子判断里加上(first + 2) % 9 <= 8且first / 9 == (first + 2) / 9,确保三张牌在同一花色段内。
5.3 七对与面子拆解的优先级错误
现象:手牌既能拆成七对又能拆成面子,程序只返回了其中一种结果,导致某些规则下漏判。原因:canHu入口先判断七对,如果七对不成立才走面子拆解。但有些牌型七对不成立、面子成立,有些反过来,逻辑上没问题。真正的问题是七对的判定条件写错,比如把「四张相同牌算两对」和「四张相同牌算一对加一个刻子」混淆。解决:把七对判定独立成函数,规则差异做成配置项。在canHu里先调七对,再调面子,两者是「或」的关系,不要互相干扰。
5.4 记忆化缓存未清空导致脏读
现象:连续判断多手牌,后面的手牌结果受前面影响。原因:memo是全局的,没有在每次新牌局开始时清空。解决:把memo的生命周期绑定到单次canHu调用,或者用带作用域的局部变量。如果性能要求高需要跨调用复用,必须在 key 里加入手牌的唯一标识(比如牌局 ID),避免不同手牌命中同一缓存。
5.5 癞子数量超过 4 张时的边界处理
现象:某些娱乐玩法允许 4 张以上癞子,程序递归深度爆炸或者结果错误。原因:laiziCnt没有上限校验,递归时癞子可以无限补牌,导致搜索空间失控。解决:在quickCheck里加上laiziCnt <= 4的硬限制,超过直接返回 false。如果规则确实允许更多癞子,需要重新设计算法,比如用动态规划代替递归,或者对癞子数量做分段处理。
6. 进阶技巧:用位运算加速状态编码与批量判定
前面用的字符串编码虽然直观,但每次递归都要构造字符串、计算哈希,开销不小。如果对性能有极致要求,可以把 34 位计数数组压缩成两个 64 位整数。每种牌的数量是 0-4,需要 3 个 bit,34 种牌需要 102 个 bit,刚好可以用两个uint64_t表示。
// 位压缩:低 51 位存前 17 种牌,高 51 位存后 17 种牌 struct PackedState { uint64_t lo; // 前 17 种牌,每种 3 bit uint64_t hi; // 后 17 种牌,每种 3 bit uint8_t laizi; // 癞子数量 bool operator==(const PackedState& o) const { return lo == o.lo && hi == o.hi && laizi == o.laizi; } }; PackedState packState(int cnt[34], int laiziCnt) { PackedState s{0, 0, (uint8_t)laiziCnt}; for (int i = 0; i < 17; i++) { s.lo |= ((uint64_t)cnt[i]) << (i * 3); } for (int i = 17; i < 34; i++) { s.hi |= ((uint64_t)cnt[i]) << ((i - 17) * 3); } return s; }配合自定义哈希函数,可以把PackedState直接用作unordered_map的 key,省去字符串构造和比较的开销。实测在批量判定场景下,位压缩版本比字符串版本快 3 到 5 倍。参数上要注意i * 3的最大值是 16 * 3 = 48,加上 3 个 bit 不超过 51 位,不会溢出uint64_t。
另一个实用技巧是「预计算所有可能的胡牌牌型」。如果牌的种类固定(比如只有万条筒加字牌),可以离线生成所有胡牌组合的位掩码,运行时直接做位与运算判断。这个做法适合规则固定的服务端,不适合规则频繁变更的场景。
验证算法正确性的方法也很直接:写一个暴力枚举版本,用随机手牌跑十万次,对比两个版本的结果是否一致。暴力版本不需要任何优化,就是枚举所有可能的癞子替换和所有拆牌顺序,虽然慢但绝对正确。我一般会在单元测试里保留这个暴力版本,每次改算法都跑一遍对拍,这是最可靠的后悔药。
最后说个习惯:每次写完胡牌算法,我都会手动构造几组边界牌型——全是癞子、癞子加单张、四张相同牌、跨花色顺子——单独跑一遍。这些 case 覆盖了 90% 的翻车场景,比随机测试更容易发现问题。希望帮到你。
本文还有配套的精品资源,点击获取