简介这是一份面向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 为什么用计数数组而不是牌对象列表很多人第一反应是把手牌存成vectorCard每张牌一个对象带花色和点数。这个做法在业务层没问题但到了胡牌判定这种高频调用的核心逻辑里对象列表的遍历和删除成本太高。常见做法是用一个长度为 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 个 bit34 种牌需要 102 个 bit超过 64 位。常见做法是用两个 64 位整数或者直接用字符串做 key。#include unordered_map #include string std::unordered_mapstd::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 的倍数加 214 张、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_mapstd::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 万下标 8first 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 个 bit34 种牌需要 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% 的翻车场景比随机测试更容易发现问题。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?