
简介这套C源码以麻将胡牌判定为核心覆盖普通胡牌规则与癞子万能牌胡牌规则适合棋牌游戏开发者和对回溯算法感兴趣的读者研读。代码围绕牌型检测与回溯搜索展开基础胡牌逻辑与癞子牌的最佳替代策略、额外番数判定被拆分为两个模块结构分明便于逐块剖析。资源共4个文件含2个C源文件和2个头文件可直接查看算法实现压缩包仅23KB轻量易读。目前已有1365人学习下载。通过代码可以掌握回溯法在胡牌判定中的落地方式包括手牌的数据结构组织、组合穷举与剪枝优化以及引入癞子牌后的匹配复杂度处理思路对课程设计、麻将类游戏开发或算法进阶均有直接的参考价值。1. 癞子一多胡牌判定就从查表题变成了搜索题做 C 麻将胡牌算法最容易被低估的就是「癞子」。不带癞子的胡牌判定本质是个查表加简单递归的问题一旦引入癞子一张牌可以顶替任意牌面判断逻辑立刻从「验证型」变成「组合搜索型」。很多从业者第一版算法在无癞子时跑得飞快把癞子一加上去就开始翻车要么漏判、要么误判、要么癞子数量稍多直接指数爆炸。我见过有人在某个模拟项目X里用四张癞子的牌型做听牌检测单次耗时从微秒级跳到几十毫秒最后只能靠砍癞子数量收场。这篇文章围绕癞子胡牌算法讲清楚一件事怎么在 C 里用带剪枝的深度优先搜索处理癞子既能判准又能扛住听牌检测这类高频调用。2. 先立住牌型模型counts[34] 与无癞子胡牌判定2.1 牌型编码万字、筒子、条子、风牌与箭牌麻将牌型标准编码是 0 到 33 共 34 个索引这是胡牌算法的基础约定。万字 0~8筒子 9~17条子 18~26风牌 27~30箭牌 31~33。癞子本身也是一张具体牌比如常见的「癞子为红中」那么癞子牌型索引就是 31 或 32取决于你按「中发白」还是「白板」排。编码时把所有牌按计数存进std::arrayint, 34数组下标就是牌型值是手里该牌型的张数。0 1 2 3 4 5 6 7 8 万一万一万到九万 9 10 11 12 13 14 15 16 17 筒一筒到九筒 18 19 20 21 22 23 24 25 26 条一条到九条 27 28 29 30 风东南西北 31 32 33 箭中发白牌型表的意义在于让顺子判断有明确的边界同一花色只有连续 9 个索引才可能组成顺子风牌和箭牌不参与顺子。这个边界在癞子算法里尤其重要因为癞子可能被用来补任意位置一旦忽略花色段边界就会出现「9 万、1 筒、癞子」被错误拼成顺子的情况。计数数组还有一个额外好处手牌的数量和总张数可以瞬间求和摸牌、打牌、吃碰杠的状态变化都只影响少数几个下标。后续递归搜索里无论怎么尝试拆牌都是在数组拷贝或回溯的基础上操作不会引入额外的花色判断逻辑。2.2 无癞子胡牌判断拆将 递归拆面子不带癞子时胡牌的必要条件是手牌总数模 3 余 2因为一副胡牌等于 4 个面子加 1 对将。判定流程分两层外层枚举哪一对牌做将内层递归判断剩余牌能否全部拆成顺子或刻子。递归的关键是每次找第一个非零牌作为锚点锚点牌如果不能和相邻牌组成面子直接返回失败。static bool canFormMelds(std::arrayint, 34 cnt) { int p -1; for (int i 0; i 34; i) { if (cnt[i] 0) { p i; break; } } if (p -1) return true; if (cnt[p] 3) { // 刻子 cnt[p] - 3; bool ok canFormMelds(cnt); cnt[p] 3; if (ok) return true; } int a p, b p 1, c p 2; // 顺子 if (c 34 a / 9 c / 9 cnt[b] 0 cnt[c] 0) { cnt[a] - 1; cnt[b] - 1; cnt[c] - 1; bool ok canFormMelds(cnt); cnt[a] 1; cnt[b] 1; cnt[c] 1; if (ok) return true; } return false; }外层的拆将逻辑很简单从 0 到 33 枚举将牌只要cnt[i] 2就扣掉两张再跑canFormMelds。这个递归有一个隐蔽的优点锚点永远取最小牌所以顺子方向被唯一锁定。比如同样一组 3、4、5 万从 3 万起步只尝试 3、4、5 这一种方向不会出现把同一组牌反复拆出不同顺序的重复搜索。这个「最小锚点」思路也是后续癞子算法的基石。区别在于无癞子时锚点牌只能向前组顺子因为最小的牌不可能再往左借牌有癞子后这个假设被打破所以癞子版本必须重新设计分支方向。2.3 为什么这个骨架不能直接塞癞子最常见的错误做法是先把癞子当成某种具体牌塞进数组然后调用无癞子的胡牌判断。癞子数量少时还能凑合癞子到 3 张以上就出问题。原因有两点一是枚举癞子替代哪张牌会引入组合爆炸4 张癞子光是分配方式就有几十万种二是癞子替代牌的位置会影响顺子方向同样的手牌换个癞子位置可能就是胡与不胡的区别。我一般把癞子从计数数组里单独抽出来不参与cnt的维护cnt只存真实非癞子牌。这样递归时可以把癞子当成一种「补位资源」每到需要凑面子时计算当前缺几张牌再从癞子池里扣。这个思路避免了枚举癞子变成什么牌而是让癞子在被需要的地方自动现身。3. 带癞子的核心 DFS真牌锚点 癞子补缺3.1 选型不枚举癞子而是让癞子被缺位召唤先算一笔账如果直接枚举「每张癞子当什么牌」4 张癞子每张有 34 种选择约 133 万种组合每种组合还要跑一次完整胡牌判断听牌检测再乘 34 种补牌单次听牌就是千万级操作。即便在 C 里这也很难接受更别说还要同时支持多套规则。换成「补缺」思路后癞子不再是主动选择身份的牌而是被动补齐缺口。递归处理每一张真实牌时顺子固定需要三张具体牌、刻子需要三张同牌真实牌不够的部分才消耗癞子。这样癞子的开销只发生在真正需要补位的地方剪枝后节点数通常比枚举法少两到三个数量级这也是工程里能用纯 DFS 扛住听牌检测的原因。搜索时保留一个原则以最小真实牌作为锚点枚举这个锚点牌参与刻子、顺子的所有可能位置。癞子可以出现在锚点牌的左边所以锚点牌在顺子里的位置要尝试头、中、尾三种这和无癞子时只向前组顺子的做法有本质差异。3.2 C 递归实现canFormMelds 与癞子消耗把癞子抽离后递归函数维护两个核心状态cnt数组表示剩余真实牌laizi表示剩余癞子数。每次找到一个最小真实牌尝试用刻子或顺子把它消化掉。顺子分支里锚点牌可能是顺子的头、中间牌或尾三种情况都要走每种情况计算三个牌位的真实缺口缺口数就是本次要消耗的癞子数。static bool dfs(std::arrayint, 34 cnt, int laizi, int laiziType) { int p -1; for (int i 0; i 34; i) { if (i ! laiziType cnt[i] 0) { p i; break; } } if (p -1) { return laizi % 3 0; // 真实牌已清空剩余癞子必须能自组面子 } // 分支 A刻子 if (cnt[p] 3) { cnt[p] - 3; bool ok dfs(cnt, laizi, laiziType); cnt[p] 3; if (ok) return true; } else { int need 3 - cnt[p]; if (need laizi) { cnt[p] 0; bool ok dfs(cnt, laizi - need, laiziType); cnt[p] 3 - need; if (ok) return true; } } // 分支 B顺子p 可以是头、中、尾 for (int head p - 2; head p; head) { int a head, b head 1, c head 2; if (a 0 || c 33) continue; if (a / 9 ! c / 9 || a / 9 3) continue; // 同花色且非字牌 int need 0; for (int x : {a, b, c}) { if (cnt[x] 0) need; } if (need laizi) continue; int backup[3] {cnt[a], cnt[b], cnt[c]}; if (cnt[a] 0) --cnt[a]; if (cnt[b] 0) --cnt[b]; if (cnt[c] 0) --cnt[c]; bool ok dfs(cnt, laizi - need, laiziType); cnt[a] backup[0]; cnt[b] backup[1]; cnt[c] backup[2]; if (ok) return true; } return false; }逻辑上要盯住三个关键点。第一锚点牌p必须是真实牌所以取最小非零牌时要把laiziType跳过。第二顺子分支里head从p - 2枚举到p覆盖锚点牌位于顺子尾部、中间、头部三种情况这是癞子算法不漏判的根本保证。第三每个分支无论成不成立都要恢复现场cnt和laizi保持一致因为递归返回后还要继续尝试别的分支。参数说明laiziType是癞子牌型索引调用前要把cnt[laiziType]的值抽出来放到laizi变量里然后把cnt[laiziType]清零。need表示当前面子还差几张真实牌比如顺子缺两张、刻子缺一张这个值必须小于等于剩余癞子数否则直接剪枝。3.3 将牌分支真牌将与癞子将胡牌必须有一对将癞子是否能当将要由规则决定不能写死。我习惯给huWithLaizi加一个allowLaiziPair开关有的麻将规则允许两张癞子直接做将有的要求将必须是真实牌。拆将之前还要做一次快速取模判断手牌总张数真实牌加癞子必须模 3 余 2否则不用进递归。bool huWithLaizi(std::arrayint, 34 cnt, int laiziType, bool allowLaiziPair) { int laizi cnt[laiziType]; cnt[laiziType] 0; int total 0; for (int x : cnt) total x; total laizi; if (total % 3 ! 2) return false; for (int i 0; i 34; i) { if (i laiziType) { if (allowLaiziPair laizi 2) { laizi - 2; bool ok dfs(cnt, laizi, laiziType); laizi 2; if (ok) return true; } } else if (cnt[i] 2) { cnt[i] - 2; bool ok dfs(cnt, laizi, laiziType); cnt[i] 2; if (ok) return true; } } return false; }注意一个容易踩的坑laizi是用引用还是局部变量。上面代码里外层循环通过laizi - 2再恢复dfs内部也是传值语义不会把改动带出来。如果让dfs直接修改外层的laizi回滚顺序稍微一乱整个判断就废了。3.4 剪枝顺序先真牌后癞子控制搜索规模递归搜索不加剪枝癞子一多照样卡。常用的剪枝策略是按「先便宜后贵」的顺序走分支优先尝试完全不消耗癞子的刻子和顺子因为这些分支最接近正常胡牌结构命中率高消耗癞子的分支放在后面。这样做的副作用是有可能找到第一个解就直接返回而第一个解往往就是真实胡牌方案搜索深度天然压到最低。另一个值得做的剪枝是在进入递归前快速算一笔账剩余真实牌加上剩余癞子数的总和是否还够凑出当前需要完成的面子数。比如手里已经有一对将剩下 5 张牌还要凑两个面子而真实牌加癞子总数只有 4那这个分支连递归都不必进。这类检查放在dfs入口处能把无效分支提前砍掉大半。4. 癞子算法的 5 个高频翻车现场与排查4.1 癞子被重复消耗一个癞子同时补两个缺口现象某几手牌明明只有两张癞子程序却判成胡牌。肉眼检查手牌发现癞子要同时补刻子和顺子才成立也就是一个癞子被用了两次。原因在递归实现里把癞子当成「全局万能牌」每个面子分支都单独计算need但没有把laizi从父状态带下去或者统计缺口时没扣掉已经被癞子补上的牌位导致同一张癞子在两个面子分支里被各算一次。解决把last作为递归参数严格传递每次消耗都写成laizi - need回来后立刻恢复。可以在dfs入口打印一行业志当前need、剩余laizi、锚点牌跑几组用例就能看出哪一层多扣少扣。4.2 癞子做将规则开关没留换规则就翻车现象同一份手牌在一套规则里判胡换一套麻将规则判不胡或者反过来。问题集中在两张癞子能否作为将牌。原因不同地方规则对癞子的限制不一样。有的玩法允许癞子完全替代任何牌包括做成将有的玩法要求胡牌时必须至少保留一组真实面对子或真实将牌这套规则下癞子做将会被禁止。解决拆将之前先读一个配置项。allowLaiziPair、allowLaiziMeld这类开关建议一开始就设计成参数不要写死在函数里。后续接不同产品需求时只需要在配置层调布尔值算法层不动。4.3 9 万加 1 筒被凑成顺子跨花色越界现象手里有 9 万和 1 筒再加一个癞子程序判定这三张可以组成顺子。但麻将里 9 万和 1 筒不属于同一花色永远不可能组成顺子。原因顺子判断只做了0 head head 2 34这种全数组范围检查没有按花色段切断。9 万对应索引 81 筒对应索引 9索引连续就被误当成了同一花色。解决顺子分支必须同时检查head / 9 (head 2) / 9。顺带说一句字牌索引 27 到 33 虽然也在 34 个索引范围内但同样不能被组进顺子判断花色段时要把head / 9 3的情况直接排除。4.4 真实牌清空就返回 true忘了检查剩余癞子现象一个只有真实牌的面子组合已经拆完但还剩下三张癞子程序依然返回胡牌。排查后发现递归在cnt全零时直接return true没处理癞子。原因把癞子抽离后真实牌清空不等于牌局结束。剩余癞子必须还能自己凑成面子也就是剩余癞子数必须是 3 的倍数如果还剩两张这两张癞子在递归内部无法组成任何面子整个牌型就不成立。解决dfs入口处遇到p -1时返回laizi % 3 0。这个检查是和拆将配合使用的如果两张癞子做了将外层已经扣掉递归里剩余癞子会是 0 或 3 的倍数如果癞子不做将剩余两张就判负。4.5 听牌检测状态污染加牌后没恢复 counts现象听牌检测时从 0 到 33 遍历补一张牌第一圈还能判断正确第二圈开始结果越来越乱甚至同一手牌不同遍历顺序结果不同。原因检测代码在循环里修改了cnt判断完后没有恢复下一轮补牌检测拿到的是已经被污染的牌型。这类问题在裸数组实现里特别容易犯因为数组不是局部对象改动会跨调用存留。解决给每个补牌试算用局部副本或者用一个 RAII 风格的恢复对象离开作用域自动还原。我常用前者简单直接std::arrayint, 34 trial cnt;改trial不碰原始数组。恢复现场要像释放内存一样养成习惯。5. 从判胡到听牌检测癞子场景下的扩展5.1 听牌检测对 34 种补牌各做一次胡牌判定听牌检测是胡牌算法最直接的落地场景逻辑很简单遍历所有可能的补牌牌型每补一张就调用一次huWithLaizi。如果补进去能胡说明当前手牌听这张牌。注意补牌有两种情况补普通牌直接改cnt补癞子牌型则相当于增加一张癞子。std::vectorint tingTiles(std::arrayint, 34 cnt, int laiziType, bool allowLaiziPair) { std::vectorint result; for (int add 0; add 34; add) { std::arrayint, 34 trial cnt; int extraLaizi 0; if (add laiziType) { extraLaizi 1; } else { trial[add]; } if (huWithLaizi(trial, laiziType, allowLaiziPair) extraLaizi 0) { result.push_back(add); } } return result; }这里有个需要注意的参数语义huWithLaizi内部会把cnt[laiziType]抽出来当癞子池所以补癞子牌型时不用修改cnt直接临时加一个extraLaizi即可。更省事的做法是把听牌检测放在业务层由业务层决定补牌是否还有剩余张数算法层只负责纯牌型判断。算一笔性能账一次听牌检测最多 34 次 H 胡牌判断而每次胡牌判断的递归节点控制在几百到几千整体耗时就只有几十微秒。这也是这个算法结构适合放进实时游戏逻辑里的原因。5.2 区分癞子胡与硬胡透传 usedLaizi有些玩法在算番时需要区分「硬胡」和「癞子胡」完全没用癞子的算硬胡用了癞子的只算普通胡。实现上不用额外写一套逻辑只要在dfs里加一个引用参数usedLaizi任何位置消耗癞子时置为 true。具体做法是给dfs增加一个bool usedLaizi参数在刻子补位、顺子补位、癞子做将三个位置统一赋值。递归返回后如果usedLaizi为 false说明这手牌完全没有依赖癞子是标准硬胡否则就是癞子胡。这个标记不会影响搜索过程只影响最后的算番结论对性能几乎没有额外开销。5.3 七对判定的癞子补齐逻辑癞子同样参与七对判定逻辑和普通胡不同。七对要求 7 组对子癞子可以补充任意缺失的对子甚至是两两成对。判定的核心是数两件事非癞子牌里有多少张单牌剩余癞子数能不能把这些单牌补齐并且补齐后的剩余癞子还能两两成对。bool huWithLaiziSevenPairs(const std::arrayint, 34 cnt, int laizi, int laiziType) { int odd 0, realCount 0; for (int i 0; i 34; i) { if (i ! laiziType) { odd cnt[i] 1; realCount cnt[i]; } } return laizi odd (laizi - odd) % 2 0 realCount laizi 14; }这个判断成立的前提是总牌数为 14 张也就是一个完整的七对手牌。逻辑上每个odd代表一个需要癞子补成对子的单张补完后剩余癞子必须成双出现因为两张癞子可以自己组成一对。注意七对和普通胡是两种独立牌型有了这个函数主逻辑里只需在胡牌判断入口按规则分流。6. 验证与性能边界自动化回归兜底6.1 回归用例表把规则边界固化下来癞子算法最容易出现「改一个 bug 引入另一个 bug」的情况所以我会在项目里维护一张回归用例表把规则边界全部固化成单元测试。以下是一个最小用例集覆盖真实的普通胡、癞子补顺、癞子做将、跨花色非法、非胡牌等典型场景。用例手牌数字为索引癞子类型期望结果无癞子平胡0,1,2,3,4,5,6,6,7,8,9,10,11,11无true癞子补顺子1,2,4,5,7,8,10,11,13,14,16,17,18,3131true两张癞子做将0,1,2,3,4,5,6,7,8,31,31,31,31,3131按规则跨花色顺子8,9,31,2,3,4,5,6,7,10,11,12,13,1431false不能胡的牌型0,0,1,2,3,4,5,6,7,8,9,10,11,1231false用例表的价值在于每次改动递归、剪枝或参数开关后先跑一遍全表能拦截大部分回归问题。真实项目里这套用例可以直接塞进工程化的测试框架不需要额外开发测试工具。6.2 随机压力测试用暴力枚举验证 DFS 结果当你有了一套能跑的 DFS 实现我建议再写一个暴力验证器专门交叉检验正确性。暴力器思路简单把每张癞子逐一枚举成 0 到 33 的任意牌型再调用无癞子的胡牌判定癞子数量少时这种枚举量完全可以接受。然后用随机生成的手牌同时喂给主算法和暴力器对比结果是否一致。bool bruteForceHu(std::arrayint, 34 cnt, int laiziType) { int laizi cnt[laiziType]; cnt[laiziType] 0; return bruteForceImpl(cnt, laizi); } bool bruteForceImpl(std::arrayint, 34 cnt, int laizi) { if (laizi 0) return huWithoutLaizi(cnt); for (int i 0; i 34; i) { cnt[i]; if (bruteForceImpl(cnt, laizi - 1)) { cnt[i]--; return true; } cnt[i]--; } return false; }这个验证器只建议离线跑不建议上线。因为癞子一旦到 4 张枚举量会放大到百万级跑完一轮随机测试都要很久。我一般做法是癞子数限制在 1 到 3 张随机生成 1000 手牌主算法和暴力器一个不能差全部通过后再放开癞子到 4 张做性能压测。6.3 工程参数递归节点数阈值与日志搜索类算法上线前要留一个「后门」参数单次胡牌判断的递归节点数上限。我在dfs里加一个thread_local int nodeCount每次进入递归自增超过设定值比如 50000 就提前终止并记录日志。为什么有这个必要因为某些极端牌型可能让分支数量激增节点数一旦失控线上就会出现偶发卡顿。有了阈值至少能立刻发现是哪手牌触发了异常。日志建议记录三样东西当前锚点牌、剩余癞子数、节点数。复盘异常牌型时这三样足够定位是分支枚举不全、剪枝失效还是癞子消耗逻辑错误。我自己就在某个图像处理 Demo 改麻将模块时栽过一回癞子做将的恢复逻辑漏了一行回归用例全绿压测跑到第 800 手才崩。后来把节点数和癞子消耗路径打进日志才看清恢复现场的顺序问题。这个习惯我一直留到现在希望帮到你。本文还有配套的精品资源点击获取