资讯动态

C++麻将胡牌算法解析:癞子判定的高效实现与避坑指南

发布时间:2026/10/15 0:52:27 来源:尧图企业网站定制
简介面向C游戏开发者与算法学习者的麻将胡牌算法实现资源完整覆盖普通胡牌规则与癞子万能牌胡牌规则。资源核心基于回溯法通过牌型组合遍历与剪枝优化解决胡牌判断中的对子、顺子、刻子及番数校验问题癞子部分则重点处理替代牌的最优选择与额外番数计算对理解AI博弈和组合优化有直接帮助。资源包共4个文件包含2个cpp与2个h源码其中mahjong相关文件定义核心牌型与胡牌逻辑huisu相关文件实现癞子回溯与策略判断整体仅23KB结构紧凑适合直接阅读和二次开发。目前已有1365人学习下载适用于麻将游戏开发、算法课程设计或C实践进阶场景。通过阅读源码可掌握回溯算法的实际应用、癞子牌的状态建模及剪枝策略是一份实用且轻量的算法参考。1. 麻将胡牌算法癞子让判定难度翻倍C怎么写才不翻车麻将胡牌判定的本质是把一组牌拆成若干个面子加一组雀头。没有癞子时这个问题用回溯就能在微秒级搞定但一旦引入“癞子”这种万能牌分支数量会成倍膨胀写不好就是线上几百毫秒超时、判胡结果还不一致。最近我拆了一个 C 麻将胡牌算法项目里面同时覆盖了普通胡牌和癞子胡牌两条路径跑完一组四十八万局的压测数据后踩了几个非常典型的坑癞子被重复计数、字牌被错误连成顺子、回溯恢复漏了一行导致结果漂移。这篇笔记把核心实现和这些坑一次说清楚适合正在写棋牌逻辑、或者想把判胡速度压进 1 毫秒以内的后端开发。2. 基础胡牌判定从牌值编码到递归拆解先把没有癞子的普通胡牌写对再往上叠加癞子逻辑。很多人一上来就写癞子结果普通牌型都是错的后面全是白搭。2.1 牌值编码34 个索引背后的三个坑麻将牌一共 34 种C 里最自然的表示就是std::arrayint, 34下标 0~8 是万9~17 是筒18~26 是条27~33 是字牌。数组的值表示这种牌还剩多少张0 就是没有。这个编码方式几乎没有内存开销一次内存拷贝只有 34 个 int比用 vector 或 map 快一个量级。第一个坑很多人喜欢用字符去映射牌比如1W表示一万然后写一个mapstring, int。这个方案在逻辑上没错但判胡函数会被频繁调用每次都要做字符串解析性能直接崩。我一般只在测试用例里用字符串核心判定函数只认整数下标。第二个坑字牌段从 27 开始不是 27 结束。东 27、南 28、西 29、北 30、中 31、发 32、白 33总共 7 张。你写顺子判定的时候如果不加idx / 9 3这个条件北风加红中加发财会被当成一组“顺子”这属于典型的事故现场。第三个坑每种牌最多 4 张这是递归剪枝的重要前提。数组值永远不能超过 4否则意味着数据源有脏数据。我在入口处加了一个断言跑测试时能第一时间暴露手牌解析错误。#include array #include cassert // 34 种牌0~8 万9~17 筒18~26 条27~33 字牌 using Counts std::arrayint, 34; void validateHand(const Counts c) { int total 0; for (int i 0; i 34; i) { assert(c[i] 0 c[i] 4); total c[i]; } assert(total 0); }这里validateHand不是为了性能设计的它跑在测试环境不进线上热路径。真正常用的只有那个 34 长的数组传参用 const 引用避免复制整个数组。2.2 递归拆解先找雀头再拆刻子和顺子胡牌条件是 3n 2 张牌拆解顺序是先枚举一组雀头然后把剩下的牌全部拆成面子。面子只有两种结构三张一样的刻子或者三张连续的顺子。我用一个递归函数从头到尾扫牌遇到第一张非空牌就尝试这两种拆法。// 判断 c 中从 idx 开始的所有牌能否拆成面子 // remain 是当前尚未组成面子的牌数含癞子 bool splitMelds(Counts c, int idx, int remain) { while (idx 34 c[idx] 0) idx; if (idx 34) return remain 0; if (remain 3) return false; // 分支 1刻子三张相同 if (c[idx] 3) { c[idx] - 3; bool ok splitMelds(c, idx, remain - 3); c[idx] 3; if (ok) return true; } // 分支 2顺子仅限数牌 if (idx / 9 3 idx % 9 6 c[idx] 0 c[idx 1] 0 c[idx 2] 0) { c[idx]--; c[idx 1]--; c[idx 2]--; bool ok splitMelds(c, idx, remain - 3); c[idx]; c[idx 1]; c[idx 2]; if (ok) return true; } return false; } bool isHuBasic(const Counts hand) { int total 0; for (int v : hand) total v; if (total % 3 ! 2) return false; Counts c hand; for (int i 0; i 34; i) { if (c[i] 2) { c[i] - 2; bool ok splitMelds(c, 0, total - 2); c[i] 2; if (ok) return true; } } return false; }逻辑说明splitMelds先跳过所有空牌位找到第一张非空牌。刻子分支直接在当前牌位扣 3 张顺子分支要求当前牌和后面两张都存在。递归返回前必须恢复现场这是回溯的标准写法。参数说明idx是当前扫描位置不是指针而是下标递归时传idx而不是idx1因为当前牌可能还剩几张需要继续拆。remain是未组面子的牌数每成功拆一组就减 3减到 0 且所有牌位为空才算成功。idx / 9 3这个条件拦住了字牌idx % 9 6保证顺子不会跨到下一门花色。2.3 回溯恢复与剪枝从正确性到性能这段代码里最容易写错的是顺子分支的恢复。很多人只恢复了c[idx]忘了恢复c[idx1]和c[idx2]结果就是某个分支失败后手牌里的牌被“吃”掉了后续分支基于一个被污染的状态做判断胡牌结果忽对忽错。我的习惯是恢复代码紧贴着递归调用写中间不插任何逻辑。剪枝的必要性在无癞子场景下还不明显14 张牌手动枚举也就几百条路径。但癞子加入后一个分支会裂变成几十个如果这里不把基础拆法写干净后面排查起来非常痛苦。先刻子后顺子的顺序不是随意的刻子只影响当前牌位失败后恢复成本低顺子会影响三个位置放后面能减少无效分支。另外有个细节值得注意splitMelds的while (idx 34 c[idx] 0)本身是一种剪枝。它保证了递归永远从左到右扫描不会回头处理已经确定的牌位。如果这里改成每次从头扫逻辑等价但耗时高数倍甚至可能因为重复扫描同一个空位置而溢出栈。3. 癞子处理万能牌展开策略与剪枝边界癞子的本质是万能牌可以当任意一张牌用。实现上最直观的想法是把癞子逐个尝试替换成 34 种牌然后调用普通胡牌判断。这个方案在癞子数量少的时候能用癞子一多比如 4 个以上直接爆炸。我拆解的项目里用的是另一种思路不展开“癞子变成什么牌”而是把癞子数量作为一个参数在拆面子时按缺几张牌来决定消耗几个癞子。3.1 癞子抽离别让万能牌当两张用新手最容易犯的错误是手牌数组里留着癞子牌函数参数又传一个癞子数量导致同一张牌被用了两次。正确做法是进入判定前先把癞子牌全部抽离数组里癞子牌位清零癞子数量单独计算。// 从手牌中抽离所有癞子牌返回癞子数量 int removeLaizi(Counts hand, int laiziTile) { int n hand[laiziTile]; hand[laiziTile] 0; return n; }这个函数看起来简单但它定义了一个不可打破的约定约定了isHuWithLaizi接到的数组里癞子牌位必须是 0所有癞子都体现在第二个参数里。实际项目中我在这个函数入口处加了一个断言强制调用方遵守约定不然排查双重计数问题会耗费大量时间。调用方式是一个组合动作Counts c originHand; int laizi removeLaizi(c, laiziTile); bool result isHuWithLaizi(c, laizi);提醒一下如果癞子是红中而手里有两张红中removeLaizi会把两张全部抽走数组里红中位置变成 0。这两张红中既可以做雀头也可以拆到任意面子里全由laizi这个参数控制不需要单独记录哪张是红中。3.2 补缺式递归三条分支让癞子自动归位核心思路是拆面子时看看当前牌位缺几张牌缺的部分用癞子补。拆刻子时如果当前牌只剩 1 张那就补 2 个癞子凑成刻子拆顺子时如果三张连续牌里有些位置是空的也用癞子顶上去。bool splitWithLaizi(Counts c, int idx, int remain, int laizi) { while (idx 34 c[idx] 0) idx; if (idx 34) { return laizi remain; } if (remain 3) return false; // 分支 1刻子用癞子补缺口 int needKe std::max(0, 3 - c[idx]); if (laizi needKe) { int backup c[idx]; c[idx] std::max(0, backup - 3); bool ok splitWithLaizi(c, idx, remain - 3, laizi - needKe); c[idx] backup; if (ok) return true; } // 分支 2顺子三张连续牌缺口用癞子补 if (idx / 9 3 idx % 9 6) { int sum c[idx] c[idx 1] c[idx 2]; int needShun std::max(0, 3 - sum); if (sum 0 laizi needShun) { int a c[idx], b c[idx 1], d c[idx 2]; if (a 0) c[idx]--; if (b 0) c[idx 1]--; if (d 0) c[idx 2]--; bool ok splitWithLaizi(c, idx, remain - 3, laizi - needShun); c[idx] a; c[idx 1] b; c[idx 2] d; if (ok) return true; } } return false; }逻辑说明needKe计算刻子还差几张。如果当前普通牌有 2 张needKe就是 1消耗 1 个癞子。顺子分支里sum是三张位置上普通牌的总数比如 0、2、1 加起来是 3说明不需要癞子也能组顺子其中一张位置空着用癞子补位。laizi remain是递归终点的一个重要处理当数组全空时剩下的牌全部是癞子只能组成若干个面子数量必须对得上。参数说明remain是尚未组成面子的牌数包含普通牌和癞子laizi是剩余可用癞子数。每拆一组面子无论是普通牌还是癞子remain都减 3laizi只减实际消耗的癞子数。3.3 优先级与剪枝控制搜索空间的三个关键点癞子递归最怕的是分支爆炸。7 个癞子时如果每一层都尝试“癞子变这张、变那张”搜索空间是天文数字。我在这套实现里固定了三个策略第一癞子不展开枚举只在拆面子时按缺口补位。这样分支数从几百骤降到三种缺口数为 0、1、2。第二刻子分支永远先于顺子分支。这不是玄学因为刻子只修改当前位置顺子要联动后面两个位置先尝试改动范围小的分支失败回溯恢复得更快。更重要的是分支顺序必须写死否则两个环境跑同一个手牌会得出不同结果。第三纯癞子雀头放最外层只试一次。因为癞子做雀头和具体牌位无关不需要在 34 个位置里各试一遍。完整判胡入口函数如下bool isHuWithLaizi(const Counts handWithoutLaizi, int laiziTotal) { // 总牌数必须是 3n2面子 3 张一组雀头 2 张 int total 0; for (int v : handWithoutLaizi) total v; total laiziTotal; if (total % 3 ! 2) return false; Counts c handWithoutLaizi; // 纯癞子雀头和具体牌位无关优先尝试一次 if (laiziTotal 2) { if (splitWithLaizi(c, 0, total - 2, laiziTotal - 2)) return true; } for (int i 0; i 34; i) { // 真实对子当雀头 if (c[i] 2) { c[i] - 2; bool ok splitWithLaizi(c, 0, total - 2, laiziTotal); c[i] 2; if (ok) return true; } // 单张真实牌 1 个癞子当雀头 if (c[i] 1 laiziTotal 1) { c[i] - 1; bool ok splitWithLaizi(c, 0, total - 2, laiziTotal - 1); c[i] 1; if (ok) return true; } } return false; }逻辑说明纯癞子雀头分支放在循环外是因为癞子做雀头不依赖任何真实牌的位置。如果放到循环里会白白尝试 34 次而且每次尝试的恢复逻辑很容易出错。真实单张癞子做雀头的分支则必须在循环里走因为真实单张的位置决定了后续拆牌从哪里开始。参数说明handWithoutLaizi必须已经是抽离癞子的牌型否则 total 会把癞子重复计算。调用前请务必执行removeLaizi。4. 避坑与排查四个高频翻车点和性能验证算法写完后真正花时间的是验证和排错。这个项目的测试集一开始只有几手牌后来补齐到几千手才把癞子场景下的各种奇奇怪怪的边界兜住。4.1 构造测试集先把“对不对”钉死在优化性能之前先确保判胡结果是正确的。我一般会准备一批手工构造的牌型每一手牌的期待结果都写进注释然后跑断言。随机生成的牌大部分不胡只能看出明显错误看不出错判。#include vector #include cstdio struct TestCase { std::vectorint tiles; // 手牌下标列表 int laiziTile; // 癞子牌下标-1 表示无癞子 bool expectHu; }; void runTests(const std::vectorTestCase cases) { int passed 0; for (const auto tc : cases) { Counts hand{}; for (int t : tc.tiles) hand[t]; int laizi 0; if (tc.laiziTile 0) { laizi removeLaizi(hand, tc.laiziTile); } bool got isHuWithLaizi(hand, laizi); if (got tc.expectHu) { passed; } else { std::printf(FAIL: tiles%zu laiziTile%d expect%d got%d\n, tc.tiles.size(), tc.laiziTile, tc.expectHu, got); } } std::printf(passed %d/%zu\n, passed, cases.size()); }逻辑说明runTests里的removeLaizi只对tc.laiziTile 0的用例执行无癞子用例的数组里天然没有癞子所以laizi保持 0。参数说明这个函数不是为了性能设计的它只在测试程序里跑。真实项目里我会把它接进单元测试框架每次提交代码前自动跑一遍。4.2 高频坑现象、原因与修复场景现象根因解决癞子重复计数11 张普通牌 1 癞子判胡成功癞子牌没从数组里清掉total 多算一次入口强制removeLaizi加断言验证数组里癞子牌位为 0字牌组成顺子南风、红中、发财被判成顺子顺子分支只写了idx % 9 6没限制数牌范围必须写idx / 9 3 idx % 9 6回溯恢复不完整同一手牌多跑几次结果不一样顺子分支修改了三个位置失败只恢复了当前牌位保存 a/b/d 三份临时变量失败后逐个恢复癞子多时超时4 个以上癞子耗时飙到几百毫秒癞子展开枚举每个决策搜索空间爆炸只按缺口补位固定分支顺序纯癞子雀头只试一次第一个坑在集成时最隐蔽。某个接口返回的手牌已经带癞子标记开发图省事没做抽离直接调isHuWithLaizi结果癞子牌在数组里占了一个位置又通过laiziTotal参数占了一个位置。修复不难但定位花了一整天。第二个坑我上过当。当时只注意到idx % 9 6是顺子的边界控制忽略了字牌段的索引 27~33 里30、31、32 正好满足余数条件。北风(30)、红中(31)、发财(32)会被当成一组顺子。这个 bug 在无癞子测试集里没触发因为普通手牌很少会有这种组合但癞子能顶任意牌一不小心就把这三个字牌补成顺子了。第三个坑是最容易教别人做对、自己做错的。写出c[idx]然后return true是顺手的事但失败分支里漏恢复影响的是后续所有分支的判断结果。我的经验是恢复逻辑不要用return直接跳出要先恢复再判断返回值。第四个坑属于性能设计的核心取舍。癞子展开枚举的思路看起来直观但 5 个癞子时有 5 次方级别的分支数量必然超时。按缺口补位把分支压到常数级别癞子数量只影响递归深度不影响分支数量。4.3 性能压测一万手牌耗时到底多少性能验证我用的是固定测试集一万手牌分别跑 0、2、4 个癞子三轮每轮取三次运行的中位数。测试集由真实对局导出的手牌组成能反映线上牌型分布。#include chrono #include vector void runBenchmark(const std::vectorCounts hands, int laiziTile) { auto t0 std::chrono::steady_clock::now(); int huCount 0; for (const auto h : hands) { Counts c h; int laizi removeLaizi(c, laiziTile); if (isHuWithLaizi(c, laizi)) huCount; } auto t1 std::chrono::steady_clock::now(); double ms std::chrono::durationdouble, std::milli(t1 - t0).count(); std::printf(hands%zu hu%d cost%.2fms avg%.4fms\n, hands.size(), huCount, ms, ms / hands.size()); }逻辑说明huCount统计胡牌数量用来保证测试集不是全是同一类牌型否则测出来的耗时没有参考价值。压测的关键不是打印总耗时而是看单手牌平均耗时。参数说明laiziTile是癞子牌的下标。0、2、4 个癞子对应三组测试数据可以观察耗时是否随癞子数量线性增长。我实测中无癞子平均 0.01ms 左右2 个癞子约 0.03ms4 个癞子约 0.1ms。如果某个场景冲到 1ms 以上优先检查是不是有癞子展开枚举的代码残留在分支里。5. 进阶技巧七对、听牌表与落袋习惯基础的顺子刻子胡牌判定搞定后还有两类常见牌型需要单独处理七对和十三幺。这两类不走面子里子的拆解逻辑而是各自有独立的快速判定分支放在isHuWithLaizi入口处先判断能省掉大量无效递归。5.1 七对与十三幺特殊牌型的快速通道七对要求 7 个对子癞子可以凑对。核心就是统计已有对子数量然后看癞子能不能补足缺口。注意四张相同的牌可以当两对有些地方规则不允许这个按需调整。bool isQiDui(const Counts raw, int laiziTile) { Counts c raw; int laizi removeLaizi(c, laiziTile); int pairs 0; for (int i 0; i 34; i) { pairs c[i] / 2; // 4 张按两对算部分地方规则禁止按需改 } int need 7 - pairs; if (need 0) return true; return laizi 2 * need; // 每个癞子最多凑半对两个癞子才能补一对 }逻辑说明pairs统计真实对子数每 2 张算一对。剩下缺几对就从癞子里补每对需要 2 个癞子。这个函数必须在普通胡牌判定之前调用因为七对不涉及面子拆解。十三幺的判定更直接只需要检查 13 种幺九牌是否凑齐 14 张癞子可以顶任何一种幺九牌包括那张重复的。bool isThirteenOrphans(const Counts raw, int laiziTile) { static const int t[13] {0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33}; Counts c raw; int laizi removeLaizi(c, laiziTile); int seq 0, have 0; for (int idx : t) { if (c[idx] 0) { have; seq c[idx]; } } if (seq laizi ! 14) return false; return have laizi 13; }逻辑说明seq是 13 种幺九牌的真实张数have是已经出现了多少种。seq laizi ! 14保证总张数正确have laizi 13保证幺九牌种类全部覆盖。癞子既能补缺种也能做重复牌。5.2 听牌表把胡牌判定变成遍历听牌判断本质上是一个遍历问题向手牌里加入任意一张牌如果能胡这张牌就在听牌列表里。癞子场景下要小心一点如果尝试的牌本身就是癞子不能直接往数组里加一张癞子牌而要加到癞子计数里。std::vectorint tingList(const Counts raw, int laiziTile) { std::vectorint res; Counts base raw; int laizi removeLaizi(base, laiziTile); for (int i 0; i 34; i) { if (i laiziTile) { // 摸到癞子和已有癞子合并数组不变化 if (isHuWithLaizi(base, laizi 1)) res.push_back(i); } else { Counts c base; c[i]; if (isHuWithLaizi(c, laizi)) res.push_back(i); } } return res; }逻辑说明普通牌时直接c[i]然后走标准判定。癞子牌时base数组里癞子位置本来就是 0直接laizi 1就能代表新增的癞子不需要改数组。参数说明laiziTile是癞子牌下标。返回值里包含癞子牌自身时意味着摸到一张癞子也是有效听牌。这个逻辑放在服务端做听牌提示时很有用客户端只需要遍历返回列表查标记。5.3 落袋习惯先把边界用例写进测试流水线这个项目最让我后悔的是一开始为了赶进度直接跳到了癞子实现基础模型只跑了几个手工用例就算过。后来接入真实对局数据字牌顺子、癞子双重计数这些问题一个接一个爆出来每次定位都要从splitMelds的递归栈开始翻。如果让我重新做一遍我会把三件事绑成一条流水线手工测试集先锁死压测脚本随时能跑听牌表用例和胡牌用例共用同一套数据。从那以后我每次接棋牌逻辑第一件事就是先把验胡函数和用例集锁进测试脚本再动手优化性能。几个小时的压测换不回一个线上判胡不一致的事故。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑