简介:一套基于C++实现的麻将胡牌算法源码包,面向游戏开发学习者和棋牌算法爱好者,解决普通胡牌与癞子(万能牌)胡牌两大核心规则的编程实现。资源共4个文件,均为cpp与h源码文件,压缩后仅23KB,结构紧凑,便于阅读与移植。普通胡牌部分采用回溯法遍历手牌所有组合,依次验证对子、顺子、刻子、杠等条件,并借助动态规划与剪枝降低无效搜索;癞子牌处理则分析其在不同牌型下的最佳替代对象,同时计算额外番数,覆盖牌型表示、组合枚举、合法性判定等完整流程。项目代码模块划分清晰,可直接作为自研麻将游戏的核心规则引擎,也可视为C++递归与搜索优化的一次典型实战。目前已有1365人在CSDN浏览学习,适合需要掌握复杂牌型组合与算法剪枝技巧的开发者深入研读。
1. 麻将胡牌算法为什么值得自己写:从判定性能到癞子复杂度
很多做棋牌服务的同学,第一版胡牌判断都是从“14 张牌递归拆刻子和顺子”起步的。等真把癞子规则加进去,你会发现事情变了:一张癞子能顶任何一张牌,枚举分支瞬间变成指数级,原来 0.1 秒能跑完的判定,能慢到让你怀疑是不是死循环。这篇文章不扯玄学,直接讲我在 C++ 里落地麻将胡牌算法和癞子胡牌算法的完整方案——从手牌编码、递归回溯到剪枝和缓存,再到最容易“假胡”的 5 个坑。整个实现只用标准 C++11,不依赖第三方库,服务端判牌或者客户端辅助提示都能复用。
2. 无癞子胡牌判定:从对子试拆到递归回溯
2.1 手牌数据模型:34 长度计数数组和花色边界
麻将牌按牌型一共 34 种:万(0-8)、筒(9-17)、条(18-26)、风(27-30)、箭(31-33)。手牌在算法里最适合用一个std::vector<int> cnt(34)来存数量,而不是把每张牌存成一个数组元素。原因有两条。
第一,递归回溯只关心“每种牌有多少张”,不关心“牌在手里的第几个位置”,计数数组天然去掉了顺序干扰。第二,拆刻子和顺子本质上是消费数量,对cnt[i]做减 3、减 1 的操作,比从列表里删除元素简单得多,也不会因为索引错位导致莫名其妙越界。
写顺子分支时必须记住:顺子只能在同一种花色内组成,万 8 后面不会是筒 0。判断能不能组顺子,要同时满足两个条件:
- 牌型属于万筒条,即
i / 9 < 3 - 作为顺子的第一张,i 必须满足
i % 9 <= 6,确保后面两张还在同一花色
| 花色 | 起始下标 | 结束下标 | 可作顺子起点的 i % 9 |
|---|---|---|---|
| 万 | 0 | 8 | 0..6 |
| 筒 | 9 | 17 | 0..6 |
| 条 | 18 | 26 | 0..6 |
| 风/箭 | 27 | 33 | 不可组顺子 |
很多新手在这里把边界写成i % 9 < 8,结果 8、9 万被当成顺子,牌型判定直接崩。后面避坑章节会再提一次,因为这是反馈频率最高的问题。
2.2 胡牌核心判定:先选将牌再拆面子
标准胡牌的牌型是 4 组面子(刻子或顺子)加 1 个雀头(对子),总数是 3n + 2。算法统一拆成两步:先枚举哪种牌能当雀头,取走两张;再把余下的牌递归拆成面子。
#include <vector> using namespace std; const int PAI_TYPES = 34; // 判断剩余牌能否全部拆成刻子或顺子 bool canSplit(vector<int>& cnt) { // 定位第一张非零牌 int i = 0; while (i < PAI_TYPES && cnt[i] == 0) ++i; if (i == PAI_TYPES) return true; // 无牌可拆,成功 // 分支一:刻子 if (cnt[i] >= 3) { cnt[i] -= 3; bool ok = canSplit(cnt); cnt[i] += 3; if (ok) return true; } // 分支二:顺子,需保证 i 属于万筒条且不跨边界 if (i / 9 < 3 && i % 9 <= 6) { if (cnt[i + 1] > 0 && cnt[i + 2] > 0) { cnt[i]--; cnt[i + 1]--; cnt[i + 2]--; bool ok = canSplit(cnt); cnt[i]++; cnt[i + 1]++; cnt[i + 2]++; if (ok) return true; } } return false; } // 无癞子标准胡牌判定 bool isHu(const vector<int>& cnt) { int total = 0; for (int x : cnt) total += x; if (total % 3 != 2) return false; // 总数不对直接排除 for (int i = 0; i < PAI_TYPES; ++i) { if (cnt[i] >= 2) { cnt[i] -= 2; // 取雀头 if (canSplit(cnt)) { cnt[i] += 2; // 恢复现场 return true; } cnt[i] += 2; } } return false; }这段代码有两个关键设计值得说清楚。
先说canSplit里“定位第一张非零牌”的贪心逻辑:从左往右找到的第一个牌型,如果它不能作为刻子或顺子的成员被消掉,那它就永远消不掉,可以直接返回 false;如果能,就递归继续。这个贪心配合回溯就是完备的,因为每种牌型都会在某个组合里被消费,组合的成员顺序不影响结果。
再说“先刻子还是先顺子”。代码里刻子在前,顺子在后,对普通胡牌没有本质区别,因为两种分支最终都会尝试到;但如果你做的玩法里碰、杠之后牌型比较集中,刻子优先通常能更快命中解,递归深度也更浅。两块参数如果想调,就调这两个分支的顺序和i % 9的边界表。
2.3 特判牌型:七对与十三幺为什么不能走普通拆分
标准胡之外,很多麻将规则还带七对、十三幺。这两个牌型不能走canSplit,因为七对的结构是一对一对,根本不出现三个一组的面子;十三幺则固定是 13 种幺九牌加一张重牌,普通拆分算法会对大量“顺子根本不成立”的花色反复回溯,性能也差。
// 无癞子七对:14 张恰好 7 个对子 bool isSevenPairs(const vector<int>& cnt) { int pairs = 0; for (int i = 0; i < PAI_TYPES; ++i) { if (cnt[i] % 2 != 0) return false; // 有落单的牌,直接否决 pairs += cnt[i] / 2; } return pairs == 7; }十三幺的判定则是一张成员表查一圈:必须是 1 万、9 万、1 筒、9 筒、1 条、9 条、东南西北中发白这 13 种,每种至少 1 张,且总共有 14 张。两种特判必须在进入主流程前完成,否则七对会被普通拆分算法误判成不胡或者漏判。
3. 癞子胡牌算法:把通配符拆进每一个分支
3.1 癞子数据模型:独立计数还是并入牌型
癞子(也叫赖子、烂子、万能牌)在规则里虽然是实体牌,但在算法模型里我会建议单独用一个int wild计数,不放进取样数组cnt[34]。原因很直接:癞子的作用不是“它本身是什么牌”,而是“它此刻能补什么牌”,单独计数方便在递归里按需消耗,不会污染普通牌的牌型分布。
| 建模方式 | 优点 | 缺点 |
|---|---|---|
| 并入 cnt 数组 | 牌型分布统一,不用管癞子位置 | 递归时需要反复把癞子替换成任意目标,状态爆炸 |
| 独立 wild 计数 | 消费直观,剪枝容易 | 需要额外处理“癞子当雀头”等特殊分支 |
我经手的项目里,绝大多数固定癞子玩法都是用独立计数,只有一个特殊玩法“癞子可被杠后重新翻”需要在外部逻辑做转换,但内部判定模型不变。
这里有一个重要规则:一个面子最多只能由 2 张癞子参与。3 张癞子组成刻子虽然合法,但不属于“普通牌 + 癞子补位”的模型,要在递归末尾单独处理——直接看wild能否被 3 整除。这个约束如果不加,递归会在“癞子补充刻子”分支里做出不存在的牌型。(待补充的细节见第 5 章)
3.2 带癞子的面子拆分:补刻子与补顺子的 6 个分支
带癞子的递归拆分在canSplit基础上多了补位逻辑:刻子可能是 2 张真牌加 1 张癞子,也可能是 1 张真牌加 2 张癞子;顺子则看 i+1 和 i+2 位置上缺几张,缺了就补几张癞子。
// 带癞子的拆分:cnt 只含普通牌,wild 是癞子张数 bool canSplitWild(vector<int>& cnt, int wild) { // 定位第一张非零牌 int i = 0; while (i < PAI_TYPES && cnt[i] == 0) ++i; if (i == PAI_TYPES) { return wild % 3 == 0; // 剩下的癞子只能组成刻子 } // 刻子:真牌 3 张 if (cnt[i] >= 3) { cnt[i] -= 3; bool ok = canSplitWild(cnt, wild); cnt[i] += 3; if (ok) return true; } // 刻子:真牌 2 张 + 癞子 1 张 if (cnt[i] == 2 && wild >= 1) { cnt[i] = 0; bool ok = canSplitWild(cnt, wild - 1); cnt[i] = 2; if (ok) return true; } // 刻子:真牌 1 张 + 癞子 2 张 if (cnt[i] == 1 && wild >= 2) { cnt[i] = 0; bool ok = canSplitWild(cnt, wild - 2); cnt[i] = 1; if (ok) return true; } // 顺子:i, i+1, i+2,缺的位置用癞子补 if (i / 9 < 3 && i % 9 <= 6) { bool has1 = cnt[i + 1] > 0; bool has2 = cnt[i + 2] > 0; int needWild = (has1 ? 0 : 1) + (has2 ? 0 : 1); if (needWild <= wild) { cnt[i]--; if (has1) cnt[i + 1]--; else wild--; if (has2) cnt[i + 2]--; else wild--; bool ok = canSplitWild(cnt, wild); cnt[i]++; if (has1) cnt[i + 1]++; else wild++; if (has2) cnt[i + 2]++; else wild++; if (ok) return true; } } return false; }这里最容易看花眼的,是顺子分支里的恢复逻辑。has1 和 has2 在修改前就记好了,恢复战场时参考这两个布尔值,就能准确还原“哪些牌被抽走了、哪些癞子被花掉了”。如果你直接写if (cnt[i+1] > 0) cnt[i+1]++; else wild++;,会因为递归内部已经改变过cnt[i+1]而恢复错位,最终出现“癞子越花越多”的诡异现象——后面第 5 章会专门说这个。
3.3 癞子胡牌完整入口:将牌三种来源与特判整合
有了canSplitWild,带癞子的胡牌入口就清晰了:枚举雀头时,可能是普通对子、1 张真牌加 1 张癞子、2 张癞子,三种情况都要展开。
// 带癞子的标准胡牌判定 bool isHuWild(vector<int>& cnt, int wild) { int total = 0; for (int x : cnt) total += x; total += wild; if (total % 3 != 2) return false; // 将牌来自普通对子 for (int i = 0; i < PAI_TYPES; ++i) { if (cnt[i] >= 2) { cnt[i] -= 2; if (canSplitWild(cnt, wild)) { cnt[i] += 2; return true; } cnt[i] += 2; } } // 将牌 = 1 张真牌 + 1 张癞子 for (int i = 0; i < PAI_TYPES; ++i) { if (cnt[i] >= 1 && wild >= 1) { cnt[i] -= 1; if (canSplitWild(cnt, wild - 1)) { cnt[i] += 1; return true; } cnt[i] += 1; } } // 将牌 = 2 张癞子 if (wild >= 2 && canSplitWild(cnt, wild - 2)) { return true; } return false; }三种雀头来源的顺序是人为主观的:从真实牌对子开始,再考虑癞子参与,最后才允许癞子当一对。这样排序是因为普通对子不消耗癞子,把癞子资源尽量留个面子拆分,命中率最高。如果你的玩法里癞子不能当雀头,直接把后两个分支注释掉即可。
七对和十三幺的癞子版也要在这层入口之前判断。七对带癞子的核心是:数出普通牌里有几对、几张落单,落单的每张必然要配 1 张癞子,剩下癞子必须是偶数才能凑对。
// 带癞子七对 bool isSevenPairsWild(const vector<int>& cnt, int wild) { int total = 0; for (int x : cnt) total += x; total += wild; if (total != 14) return false; int pairs = 0; // 已成对的数量 int singles = 0; // 落单的牌数 for (int i = 0; i < PAI_TYPES; ++i) { pairs += cnt[i] / 2; singles += cnt[i] % 2; } if (wild < singles) return false; // 癞子不够补落单 int remainWild = wild - singles; if (remainWild % 2 != 0) return false; // 剩余癞子要两两凑对 pairs += singles + remainWild / 2; return pairs == 7; }这段逻辑的关键在于“落单必须先补”:因为七对要求 14 张全部两两成对,任何一张落单牌都必须和癞子配对,否则无论普通对子再多也会缺一对。补完落单后,剩余的癞子还在两两组成新对子,最后总对数是 7 就赢。
4. 剪枝与缓存:让癞子判定跑到微秒级
4.1 总张数过滤与花色分桶
带癞子递归最怕的是展开 34 种牌 × 多张癞子组合,那会导致一次判定几十万次递归。所以递归进来之前就要做两道快筛。
第一道是最基础的总张数过滤:sum(cnt) + wild必须是3n + 2。这在胡牌判定中能滤掉一大半非法输入,尤其适合那些“差一张就听牌”的手牌状态。第二道是按花色分桶预检:万、筒、条、字牌各自单独检查是否有足够结构,字牌只能组成刻子或对子;如果某一桶里牌数太少或组成结构根本不可能,直接返回 false。这个预检可以在递归外用一个 O(34) 的循环完成,收益却非常明显。
还可以在入口处做“最左边癞子优先”的排序优化:递归定位第一张非零牌时,如果某个位置的数量只有 1 张,优先让它先尝试刻子或顺子的组合路径,避免癞子过早被消耗在低价值的边角牌上。
4.2 使用 STL 哈希表做中间状态缓存
癞子递归真正的痛点在于,同一副手牌的不同分支会反复到达相同的“剩余牌+癞子数”状态。比如先拆了一组 123 万,再拆一组 456 万,和先拆 456 万再拆 123 万,最后到达的状态完全一样。如果没有缓存,这两种同等状态会被重复展开。
我通常用std::unordered_map把递归状态缓存起来。状态键要能唯一表达“cnt 数组 + wild 数量”,一个简单可靠的做法是把 34 个计数压缩成一个 64 位整数编码,再把 wild 追加进去。
#include <unordered_map> struct StateKey { unsigned long long cntCode; int wild; bool operator==(const StateKey& o) const { return cntCode == o.cntCode && wild == o.wild; } }; struct StateHash { size_t operator()(const StateKey& s) const { // 把 34 个计数折叠到一个哈希值 size_t h = s.wild; for (int v : /* 编码数组 */) h = h * 131 + v; return h; } };实际项目里,我一般直接用std::unordered_map<StateKey, bool, StateHash>做 memo,每一轮isHuWild调用开始时清空一次缓存。这样缓存生命周期只覆盖单次胡牌判定,不会跨局污染数据。需要提醒的是,不要把缓存做成全局静态,否则癞子数量不同的手牌之间会互相命中错键,反而返回错误结果。
4.3 参数调优:递归深度与癞子上限
癞子张数的合理上限是 2 到 4 张。超过 4 张,递归分支会迅速膨胀,面子里补位和顺子补位的组合数呈现指数增长。我在服务端通常会做一个阈值:癞子数大于 4 时,改用“癞子全刻子”的快速通道直接判定,因为 5 张癞子几乎总能构成胡牌,再细算只是浪费时间。
递归深度其实不用担心,14 张牌最多拆 4 层面子,深度是常数级;真正会拖慢的是分支数量。开启记忆化之后,同样的状态不会重复计算,实测单次判定耗时从毫秒级降到几十微秒,这个优化在房卡玩法的并发结算场景里非常重要。
5. 常见问题与避坑:五个让程序假胡的现场还原
5.1 递归状态没恢复:同一手牌时而胡时而不胡
现象:同样的手牌,单测里跑是对的,压测几万次后偶尔判定结果翻转,或者递归里出现牌数凭空多出几张。
原因:递归分支里修改了cnt,但某个提前返回的路径没有恢复现场。比如顺子分支已经减了cnt[i],判断不可能胡就直接return false,忘了把减掉的牌加回去,上层递归拿着被破坏的牌型继续算,结果自然错乱。
解决:严格遵循“任何修改必须在分支结束前恢复”的习惯。C++ 里可以用 RAII 包装一个局部恢复器,离开作用域自动还原;也可以在每层递归入口拷贝一份cnt,牺牲一点性能换正确性。我自己的经验是,代码里先写恢复逻辑,再写提前返回,能避免九成以上的类似 bug。
5.2 癞子被两次消费:万能牌变成永动机
现象:手上明明只有 1 张癞子,结果一个顺子补位用了它,后面刻子缺 1 又用了它,最后算法判定胡牌成功。
原因:编写递归时,癞子的扣减没有通过参数向外层传递。常见的翻车写法是直接用全局变量wild,第一次补位全局减一,递归返回后没有恢复句柄,下个分支再读全局又变成“癞子好像还在”。
解决:把 wild 当作普通参数按值传递,每次补位的消耗体现在下一次递归参数的wild - 1或wild - 2。上文canSplitWild里的实现正是这样。还有一个细节:在顺子分支中同时缺失两个位置时,needWild等于 2,但必须让两个位置的补位都真实扣减癞子,而不是只判断wild >= 2就完事。
5.3 顺子边界条件写错:八九万也赶去组顺子
现象:手牌有 8 万、9 万,没有 7 万,算法却把这当成顺子处理;或者到了筒子边界,把筒 9 和条 1 当成连续牌。
原因:把顺子条件里的i % 9 <= 6写成了< 7或<= 7,或者完全忘了检查花色分界。
解决:我把花色边界检查固定为两个条件同时成立:i / 9 < 3和i % 9 <= 6。建议在工程里抽一个函数canStartSequence(int idx),里面只做这两个判断,所有递归分支都调用它。视线里只维护一处边界,就永远不会出现“改了 A 处忘了 B 处”的尴尬。
5.4 七对手牌被普通胡判定误伤
现象:一副带癞子的七对牌型,isSevenPairsWild判过胡,但isHuWild的普通拆分走了癞子补面子路线,最后返回 false,前端提示“尚未胡牌”,玩家直接炸。
原因:两种判定是“或”的关系,不是“与”。有些玩法里七对和普通胡不能并存,但必须分别调用特判和主判定,任中一个成立就算胡。
解决:把胡牌入口统一包成一个函数,先判十三幺、七对,再判普通胡。如果规则明确“七对不算胡”,则该特判返回 false,整体再走普通胡。不要在isHuWild内部隐式假设某种牌型,接口语义越简单越不容易错。
5.5 性能爆炸:没有剪枝也没有缓存
现象:一副癞子多、普通牌分散的手牌,在压测里单次判定耗时超过 10 毫秒,服务端一轮水位结算要处理几千手牌,直接卡出超时。
原因:癞子补位分支全部展开,同一状态反复递归,没有任何中间态缓存。当癞子张数大于 2 时,组合数量可能到达数万,常识里的“麻将胡牌很快”在这种实现下并不成立。
解决:按照第 4 章的做法,加总张数过滤、花色分桶预检,再开启状态缓存。另外可以限制癞子补位的深度:一个面子最多补 2 张癞子,这是规则层面的硬约束,直接写进分支判断,能砍掉大量无意义递归。
6. 上线前的验证清单与进阶方向
胡牌判定是服务端结算的高频核心,上线前我建议先过一遍测试矩阵:普通 4 组面子加一对的胡牌、七对、十三幺、纯癞子刻子、癞子补顺子、癞子补刻子、癞子当雀头,以及“差一张就听牌但实际不胡”的边界样本。不要只测正例,上一章那几个坑往往就藏在负例里。每副手牌要有明确期望值,回归测试放到 CI 里,后续改算法时才有后悔药可吃。
验证性能时,我会用一整副牌的全体摸牌序列做基准:把服务器日志里带癞子的手牌样本攒成一堆,逐一跑判定,观察平均耗时和 P99。开了记忆化之后,P99 一般能稳定在几百微秒以内,这个量级才够支撑实时流水的并发结算。
最后说两个可行的进阶方向。一个是“听牌求解”,也就是打出一张后,遍历剩余牌张看哪些能胡;切记这个遍历会放大癞子分支,必须在主判定里直接复用canSplitWild,并且不能清空状态缓存,否则重复计算会让性能回到地狱。另一个是“最优胡法提示”,客户端给玩家提示时,要用胡牌判定结果做反向倒推,找出消耗癞子最少的拆牌路径;这需要在一开始递归时把“用了多少癞子”作为偏差值记录下来,作为后续路径排序的依据。
我自己的习惯是在canSplitWild里加一份日志开关,调试时打印每个分支的牌型消耗和癞子余额。癞子递归的坑,十次有九次是日志上看不出“坏在哪个分支”,但只要打出每一步的 wild 变化,问题基本一眼定位。这个习惯帮我省了很多排查时间,希望也能帮到你。
本文还有配套的精品资源,点击获取