资讯动态

C++实现麻将胡牌算法:从编码到递归回溯与缓存优化

发布时间:2026/10/14 21:21:40 来源:尧图企业网站定制
简介麻将胡牌判断是C游戏开发与算法训练中的经典问题这套资料面向对数据结构、逻辑推理感兴趣的开发者聚焦如何用高效代码实现胡牌条件检测与番数计算。压缩包内共2个文件分别为可运行的C源程序与配套PDF设计分析文档整体仅132KB轻量便于快速查阅与调试。源程序在时间复杂度上优化至O(n)采用原地工作策略不依赖额外存储空间文档则展开讲解牌型数据结构设计、初始化、胡牌条件遍历、番数计算及容错处理等关键环节适合用来理解从规则到代码的完整转化过程。目前已有920人学习下载无论用于课程设计还是业余项目都能借此掌握一种兼顾效率与可读性的C实现思路并可直接沿用或扩展其核心框架。1. 麻将胡牌算法棋牌后端躲不开的性能底线麻将胡牌算法在棋牌项目里几乎等于判胡器的同义词任何一个带麻将玩法的房间出牌、碰杠、点炮、自摸最终都要落到同一段判断逻辑上。我在某棋牌项目组调后端房间逻辑时高峰期每秒要跑上万次判胡一次误判就会让客户端和服务端状态对不上引发连锁投诉。这个算法的任务很简单给你 13 或 14 张手牌判断它能不能拆成 4 个面子和 1 个对子或者满足七对、国士无双这类特殊牌型。用 C 写的原因也很直接判胡不是跑一次就结束AI 出牌、听牌提示、向听数计算会把它放大几十倍甚至上百倍脚本语言在这种热点路径上撑不住。这篇笔记会把牌型编码、递归判胡、听牌扩展、常见踩坑和缓存加速一次讲透适合棋牌后端开发、客户端离线诊断脚本的作者以及想把麻将 AI 先做扎实的人。2. 把一副手牌变成 C 能算的结构牌编码与手牌存储2.1 0~33 的牌编码一段数字覆盖万条筒与字牌麻将牌可以抽象成 34 种万、条、筒各 9 种风牌 4 种箭牌 3 种。最常见做法是把它们映射成 0~33 的整数数组下标直接当牌型用省去字符串比较的开销。编码规则很固定0~8 是万9~17 是条18~26 是筒27~30 是东南西北31~33 是中发白。这样一张牌的索引、它所属的花色、它在花色内的位置都能用简单的算术算出来。牌种编码区间说明万0~8一万到九万条9~17一条到九条筒18~26一筒到九筒风27~30东南西北箭31~33中发白这个编码方案带来的直接好处是顺子判断非常便宜。要检查从 i 开始的顺子只需要确认 i、i1、i2 三张都有余量并且 i 落在 0~26 范围内且 i%9≤6。字牌从 27 开始天然不会走进顺子分支。风牌和箭牌只能组成刻子或对子它们的编码区段刚好排在数牌后面让边界判断变成一次大小比较不用逐个判断花色。选择整数编码而不是字符串还有一个容易被忽略的原因递归回溯时需要在手牌上反复做加牌、减牌操作。整数下标访问是 O(1)而字符串拼接、拆分在每次递归都会产生临时对象累积起来就是肉眼可见的性能差距。用编码还有一个额外收益手牌状态可以打包成固定长度的字节序列直接用作缓存 key这一点到后面做记忆化时非常省事。2.2 手牌结构体计数数组、总数与增减接口牌型存储最省心的结构是 34 个元素的计数数组cnt[i] 表示第 i 种牌有几张。再加一个 total 记录总张数能让 13/14 张的判断少一次遍历。增减牌的操作直接对下标做自增自减成本可以忽略不计。#include array #include cstdint #include string #include cctype using TileCode std::uint16_t; struct HandTiles { std::arraystd::uint16_t, 34 cnt{}; int total 0; void add(TileCode t) { cnt[t]; total; } void remove(TileCode t) { --cnt[t]; --total; } };这里把计数数组元素类型定为std::uint16_t而不是int是因为递归回溯时要反复加减uint16_t在缓存行占用上更友好34 个元素只占 68 字节整个结构体能放进一个 cache line 附近高频调用时命中率有优势。total 字段实际上可以从数组重新求和得到但单独维护它能把isWin入口的前置判断从 O(34) 降到 O(1)代码也更直白。add和remove写成成员函数是为了把“修改计数并同步 total”这个约束收口。如果直接在外部操作 cnt 数组很容易出现张数和 total 不一致的隐性 bug比如做回溯时改了 cnt 忘了改 total后续剪枝判断全部失真。这类问题排查起来非常费劲我当时就吃过一次亏后来所有对牌型的修改都强制走这两个接口。2.3 拿到手牌先解析把 123m456p789s111z 翻译成牌型网络协议或配置里传进来的手牌通常是字符串常见格式是数字串后跟一个花色字母比如 123m456p789s111z 表示一二三万、四五六筒、七八九条、三个中。解析器要把这种字符串翻译成 HandTiles并且处理越界、重复超过四张、非法花色的情况。bool parseHand(const std::string input, HandTiles out) { HandTiles tmp; size_t i 0; while (i input.size()) { size_t j i; while (j input.size() std::isdigit(static_castunsigned char(input[j]))) { j; } if (j i || j input.size()) return false; char suit input[j]; int base 0; if (suit m) base 0; else if (suit s) base 9; else if (suit p) base 18; else if (suit z) base 27; else return false; for (size_t k i; k j; k) { char c input[k]; if (suit z (c 1 || c 7)) return false; if (suit ! z (c 1 || c 9)) return false; TileCode code static_castTileCode(base (c - 1)); if (tmp.cnt[code] 4) return false; tmp.add(code); } i j 1; } out tmp; return true; }解析器的核心是“先收数字串再碰花色字母”。每遇到一组数字就把它们全部归属到后面那个花色下这样 123m 会被拆成三张万。isdigit的判断用static_castunsigned char包裹是为了避免负值字符在部分平台上的未定义行为。字牌的区间被限制在 1~7因为牌型里没有 8 筒之类的东西。每种牌超过 4 张直接判失败这是麻将规则里“每种牌最多四张”的硬约束提前拦截能避免后续判胡时出现无法解释的状态。解析失败时函数返回 false调用方可以打出错误日志把原始字符串一并记录。我在项目里习惯把解析失败和判胡失败分开记日志否则线上看到一条“牌型不合法”根本分不清是客户端传错数据还是解析器有 bug。3. 核心判胡逻辑先过特殊牌型再递归拆面子3.1 国士无双与七对两个特殊牌型先短路普通胡牌模型是“4 个面子 1 个将牌”但七对和国士无双不遵守这个拆法。七对是 7 个对子国士无双是 13 种幺九牌各一张再加其中任意一张。这两个牌型必须在进入通用递归前做短路判断否则用普通拆面子逻辑去搜会白白浪费大量回溯时间甚至因为拆解模型不匹配而漏判。七对的判断很简单每种牌的计数必须是偶数并且对子总数等于 7。需要注意有些地方规则允许四张相同的牌拆成两个对子来算七对有些则不允许这属于规则开关而不是算法核心我一般把开关设计成参数。bool isSevenPairs(const HandTiles h, bool allowFourInPairs) { if (h.total ! 14) return false; int pairCount 0; for (int i 0; i 34; i) { if (h.cnt[i] 0) continue; if (h.cnt[i] % 2 ! 0) return false; if (!allowFourInPairs h.cnt[i] 4) return false; pairCount h.cnt[i] / 2; } return pairCount 7; }国士无双的幺九牌集合是固定的 13 种三种花色的 1 和 9加上东南西北中发白。判断逻辑是这 13 种每种至少一张并且总牌数是 14其中一种至少有两张充当重复牌。bool isThirteenOrphans(const HandTiles h) { static constexpr std::arrayint, 13 kinds { 0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33 }; if (h.total ! 14) return false; bool hasExtra false; for (int k : kinds) { if (h.cnt[k] 0) return false; if (h.cnt[k] 2) hasExtra true; } return hasExtra; }注意国士无双判断里的hasExtra是必须的。13 种幺九牌各一张只有 13 张要实现 14 张且成胡必须多出一张也落在幺九牌集合里的牌。如果写成“任意一张牌都可以”就会把“13 幺九加一张普通牌”误判成国士无双这是新手实现里很典型的错法。3.2 普通胡的递归回溯将牌、刻子、顺子在同一个搜索空间里找普通胡的判断核心是回溯搜索从手牌里挑出 1 个对子当将牌再把剩下 12 张拆成 4 个面子。面子包括刻子和顺子。最容易理解的写法是三层循环先选将牌再在剩余牌里反复尝试刻子和顺子。但三层循环嵌套会让代码很难维护而且剪枝逻辑会被循环结构搞乱。递归写法更干净。每次从最小的非零牌开始处理它一定要参与某个结构可能做刻子可能做顺子的第一张也可能做将牌。三种可能性逐一尝试尝试完就回溯恢复现场。递归参数里用need表示还缺几个面子用hasPair表示是否已经安排好将牌。bool canFormMelds(HandTiles h, int pos, int need, bool hasPair) { int rest h.total; if (rest ! need * 3 (hasPair ? 0 : 2)) return false; while (pos 34 h.cnt[pos] 0) pos; if (pos 34) return need 0 hasPair; // 尝试刻子 if (h.cnt[pos] 3) { h.cnt[pos] - 3; h.total - 3; if (canFormMelds(h, pos, need - 1, hasPair)) return true; h.cnt[pos] 3; h.total 3; } // 尝试顺子仅限数牌且不跨花色 if (pos 27 pos % 9 6) { if (h.cnt[pos 1] 0 h.cnt[pos 2] 0) { --h.cnt[pos]; --h.cnt[pos 1]; --h.cnt[pos 2]; h.total - 3; if (canFormMelds(h, pos, need - 1, hasPair)) return true; h.cnt[pos]; h.cnt[pos 1]; h.cnt[pos 2]; h.total 3; } } // 尝试将牌 if (!hasPair h.cnt[pos] 2) { h.cnt[pos] - 2; h.total - 2; if (canFormMelds(h, pos, need, true)) return true; h.cnt[pos] 2; h.total 2; } return false; } bool isWin(const HandTiles h) { if (h.total ! 14) return false; if (isSevenPairs(h, true)) return true; if (isThirteenOrphans(h)) return true; HandTiles tmp h; return canFormMelds(tmp, 0, 4, false); }这里的递归语义是“从 pos 开始用当前剩余牌凑出 need 个面子并按需补上一个将牌”。pos只往前走不走回头路因为每次取的都是当前最小的非零牌它不可能作为更大牌的“附属品”被漏掉。比如手牌里有一张 2 万一张 3 万一张 4 万最小的非零牌是 2 万先处理它时要么做刻子要么做顺子开头绝不会出现“2 万应该留在后面给别人当顺子中间张”的情况因为它是当前最小的没有更小的牌能替它开启一个新的结构。rest的剪枝条件很关键如果还缺 need 个面子且没定将那么剩余牌数必须等于 need 乘以 3 再加 2如果已经定了将就只能是 need 乘以 3。这个剪枝能在递归入口直接砍掉大量不可能的分支比等到递归深入再判断有效得多。3.3 剪枝顺序与边界为什么从当前 pos 能兜住所有拆法很多人第一次写这个递归时会怀疑“从最小的牌开始枚举”会不会漏掉某些拆解。这里的关键在于任何一组面子组合都可以按每张牌的大小排序最小的那张牌一定属于某个面子而且它在这个面子里的角色是确定的。所以从最小非零牌出发把三种可能试一遍就能覆盖所有拆法。搜索顺序也会影响速度。我习惯按“刻子、顺子、将牌”的顺序尝试刻子的检查最便宜而且能一次性消掉三张越早消掉越是能缩小问题规模。将牌放在最后是因为将牌本身不参与面子组合优先把它定下来反而会拖慢“凑面子”的进度。两个顺序在正确性上没有差别但性能差异在极端牌型上能拉开好几倍。提示canFormMelds 改的是传入参数的副本还是原对象决定了回溯要不要手动还原。上面代码用HandTiles直接改原对象所以每种尝试失败后必须把 cnt 和 total 都恢复原样少恢复一个都会让后续分支拿到污染状态。边界条件里最容易写错的是顺子的跨花色检查。pos 27 pos % 9 6保证了 pos、pos1、pos2 在同一花色内。如果不加这个条件9 条和 1 筒会被错误地当成相邻牌出现跨越花色的假顺子。这种 bug 不会让程序崩溃只会让判胡结果时对时错极难定位。4. 从判胡到听牌与向听数同一个牌型引擎的三个入口4.1 听牌判断把 34 种牌各试一遍比你想的更省事听牌判断可以复用判胡逻辑对 34 种牌逐一加入手牌如果加入后能胡这张牌就是听牌。听起来像 34 次判胡但判胡本身有剪枝优化34 次调用在 C 里耗时完全可接受不需要额外设计复杂算法。std::vectorTileCode tilesForWin(const HandTiles h) { std::vectorTileCode result; if (h.total ! 13) return result; HandTiles tmp h; for (TileCode t 0; t 34; t) { if (tmp.cnt[t] 4) continue; tmp.add(t); if (isWin(tmp)) result.push_back(t); tmp.remove(t); } return result; }这里必须先复制一份手牌再增减因为循环里要反复在不同的牌型上做 add 和 remove如果直接动原手牌一次循环之后状态就全乱了。跳过 cnt 为 4 的牌也是必要的牌型里每种牌最多 4 张不可能再摸一张进来。这个函数还有一个隐含用途判断自摸时可以直接传玩家手牌返回的列表里包含摸到的牌就说明自摸成立。听牌函数还能顺手判断“是否已胡”。当 total 是 14 时直接判胡当 total 是 13 时先算听牌这样两个场景用的是同一套底层逻辑不会出现两处实现互相打架的情况。4.2 向听数AI 拆牌与“还差几张”的计算模型向听数是比听牌更进阶的需求0 向听表示已经听牌1 向听表示还差一张有效牌就能听牌以此类推。AI 出牌时通常会计算打出哪张牌能让向听数最小所以它本质上是判胡引擎的延伸。指标输入规模计算量级典型用途判胡14 张微秒级点炮、自摸结算听牌13 张34 次判胡出牌提示、自摸检测向听数13 张指数级搜索AI 选牌、拆牌教学向听数的朴素算法是枚举所有可打出的牌递归计算下一状态的向听数最终收敛到听牌或胡牌。这个做法有几个工程问题一是搜索树非常深13 张牌逐张打出会形成海量分支二是很多中间状态重复出现必须做记忆化三是递归语义要清晰定义“打出一张之后是否还要摸一张”定义错了结果就会偏。我一般不建议自己从零写完整的向听数搜索除非项目就是奔着强 AI 去的。更稳妥的路线是先把判胡和听牌做扎实向听数先用带缓存的递归加超时保护落地性能不够再换“拆面子 算搭子”的剪枝方案。搭子包括两面、坎张、边张和对子计算时还要考虑已经形成的面子数量逻辑比判胡复杂一个量级。4.3 缓存子问题让高频重复的拆牌路径复用结果判胡递归里大量子问题会重复出现比如同样一组 6 张牌可能从不同的拆解路径走到完全相同的计数状态。如果每次都重新递归等于把同一段计算做好几遍。在递归入口做状态缓存可以把重复路径合并成一次计算。static std::unordered_mapstd::string, bool winCache; std::string tilesToKey(const HandTiles h) { std::string key; key.reserve(34); for (int i 0; i 34; i) { key.push_back(static_castchar(h.cnt[i])); } return key; } bool isWinCached(const HandTiles h) { std::string key tilesToKey(h); auto it winCache.find(key); if (it ! winCache.end()) return it-second; bool result isWin(h); winCache.emplace(std::move(key), result); return result; }缓存 key 直接用 34 个计数组成的字符串简单可靠。要注意缓存的是“完整输入状态”的结果不是递归中间被改了一半的状态。如果在回溯过程里一边修改 cnt 一边查缓存查到的可能是脏数据。正确做法是在canFormMelds的每一层递归入口额外传入一个当前状态的副本用于查表或者干脆只在顶层调用isWinCached不把缓存塞进回溯内部。订单量大的线上环境缓存表会不断增长需要加一个上限或者定期清理。我习惯用 LRU 策略或者按房间会话维度隔离缓存否则一场牌打完缓存还占着内存时间久了就成了内存泄漏的变体。5. 判胡避坑手册五种常见的翻车现场与排查方法5.1 索引错位导致顺子永远拆不出来现象手牌明明是一二三万判胡结果却返回 false而且只在包含数字较小的顺子时出错。检查日志能看到解析后的 cnt 数组里万牌的计数落在完全错误的位置上。原因解析字符串时把字符转数字的基准算错了。比如base (c - 1)写成了base (c - 0)或者花色映射顺序搞反把条和筒的区间互换。顺子判断依赖相邻索引错一位就会让 1 万和 2 万之间少一个位置顺子自然凑不起来。解决写一个单测用例把手牌字符串逐张打印成“花色点数编码”的三元组核对每张牌是否落在预期区间。索引映射是所有牌型判断的地基这个错了后面全是空中楼阁不要指望其他环节能纠错。5.2 将牌不回溯导致漏胡现象某些牌型明明可以胡判胡却返回 false。比如手牌里有两个对子都能当将程序只选了其中一个这个将拆不出面子就整体返回失败没有尝试另一个对子。原因将牌选择被实现成了循环或 if 判断外的“决定”而不是回溯分支。常见写法是先找到第一个出现次数大于等于 2 的牌当将然后递归面子递归失败就直接 return false没回到将牌选择点再试下一个对子。解决将牌选择和刻子、顺子一样必须放在回溯分支里。将牌尝试失败后要还原 cnt 和 total再继续试其他牌型。写代码时记住一个原则所有“可能成功也可能失败”的选择都要进入同一个递归搜索空间而不是在递归外层拍板。5.3 “只拆顺子不拆刻子”的隐身搜索缺陷现象同一段判胡代码在线上偶尔漏判复现时把牌型手工拆一遍发现能胡但程序就是返回 false。原因递归实现为了图快在某个分支成功后直接 return true没有尝试另一个分支。比如先尝试顺子如果顺子分支走到一半失败代码可能提前返回 false没有回头尝试刻子。这在一些“剪枝过度”的写法里很常见剪枝把正确分支也剪掉了。解决回溯函数的每一层都要保证“先尝试 A失败后回到原始状态再尝试 B”。用递归局部变量保存现场或者确认所有分支都从原始状态出发。我推荐写一个 BFS 式枚举或者把三种分支都写完再用一组手工牌型测试覆盖单刻子、单顺子、混合拆解每个都要有对应用例不要只测一条顺子路径。5.4 深拷贝与动态分配让判胡慢成黑匣子现象判胡逻辑在单机上只能跑几百次每秒并发一高 CPU 直接拉满耗时分布还很平均看不出哪个环节最慢。原因手牌结构体每次递归按值传递或者内部用了std::vector每次递归都触发堆分配。堆分配比栈操作慢一个数量级递归深度一上去分配次数就爆炸。有的人还会在canFormMelds里新建HandTiles每层都在 new性能自然惨不忍睹。解决HandTiles 用定长std::array递归传引用并在回溯中手动恢复现场。整个判胡过程不出现任何堆分配内存访问只有一段连续数组性能会好很多。如果仍然不够用性能分析工具抓热点重点看 malloc 和 memcpy 的调用频率。5.5 缓存键设计不对hash 对了但语义错了现象加了缓存之后判胡结果开始出现偶发的假阳性或假阴性而且只在特定牌型下复现。缓存命中率很高但错误率让人摸不着头脑。原因缓存 key 没有覆盖全部状态维度。比如只缓存了 cnt 数组但没有包含 total 字段而 total 是剪枝判断的输入之一或者把不同规则房间的手牌结果混用同一个缓存七对开关不一致导致同一手牌在不同规则下得到不同结果却被同一个 key 命中。解决缓存 key 必须包含所有影响结果的输入包括手牌计数、将牌规则开关、七对是否允许四归一。房间规则作为 key 的一部分混进去最简单的方式是在外层拼接房间规则 ID。我另外的习惯是给缓存留一个显眼的开关线上调规则时可以一键禁用缓存避免规则变更期间命中旧数据。6. 进阶提速把牌型状态做哈希缓存再做拆表6.1 用 Zobrist 哈希让手牌状态变成 64 位整数手牌状态做缓存时字符串 key 虽然简单但 34 个字节的拼接和哈希在超高并发下仍有开销。一个更顺手的做法是用 Zobrist 哈希给“第 t 种牌出现 1 张、2 张、3 张、4 张”分别生成随机 64 位整数整个手牌的状态就是这些随机数的异或。class ZobristHasher { public: ZobristHasher() { std::mt19937_64 rng(0x9E3779B97F4A7C15ull); for (auto v : table_) v rng(); } std::uint64_t key(const HandTiles h) const noexcept { std::uint64_t x 0; for (int t 0; t 34; t) { for (int k 1; k h.cnt[t]; k) { x ^ table_[t * 5 k]; } } return x; } private: std::arraystd::uint64_t, 34 * 5 table_{}; };这里的随机表大小是 34 乘 5留出索引 0 不用实际只用到 1 到 4。为什么是 34 乘 5 而不是 34 乘 4因为有些调试场景想给 0 张也分配一个随机值索引 0 空着可以让后续扩展更安全。哈希碰撞的概率很低但低不等于零所以缓存表里仍然要存完整手牌做二次确认不能只拿 key 当唯一信物。我在项目里的习惯是把这个哈希器变成房间逻辑的成员变量每个房间的随机表互不相同避免跨房间缓存互污染。调试时还可以把随机表打印出来复现碰撞这一点比字符串 key 难定位所以 Zobrist 方案更适合缓存层已经稳定的阶段再上。另一个实在的提速手段是按数牌拆表。数牌只有 0~26很多胡牌判断可以拆成三段独立数牌和字牌的组合提前预计算“某花色 9 张牌能否组成若干面子”的查表结果。查表不能覆盖七对和国士无双但普通胡牌型里大量是顺子和刻子组合命中率很高。遇到不命中的牌型再走完整回溯整体耗时能再降一截。每次上线新规则我都会先用老规则跑一遍全部测试用例确认拆表结果和递归结果完全一致再放量到线上。判胡这种直接决定玩家输赢的模块宁可慢一点也要求稳希望这篇能帮你少踩几个坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑