尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

麻将胡牌算法递归实现:C++/C#/Lua/JS多语言落地指南

麻将胡牌算法递归实现:C++/C#/Lua/JS多语言落地指南 简介这份压缩包汇集了C、C#、Lua、Go与JavaScript五种语言实现的麻将胡牌算法适合游戏开发人员、算法学习者和麻将游戏爱好者阅读参考。包内共334个文件核心代码分散在cpp、cs、lua、go、js等源文件中同时包含tbl查表数据、h头文件、工程配置、可执行程序及说明文档压缩后大小约18.87MB。目前已有494人浏览学习。内容围绕胡牌判定的核心逻辑展开覆盖顺子、刻子、对子等基础牌型识别番数计算、合法性检查以及基于查表法的性能优化思路。不同语言的实现各自体现了语言特性C利用STL管理牌型数据C#通过LINQ简化集合运算Lua以table表达手牌结构Go借助并发模型处理多玩家判定JavaScript则运用ES6语法让代码更简洁。通过横向对比这些实现读者既能理解麻将算法的通用流程也能体会多种编程语言在解决同一问题时的设计取舍是一份兼具学习与参考价值的实用资源。1. 麻将胡牌算法一张递归蓝图四种语言落地麻将胡牌算法在棋牌项目里出现的频率比想象中高服务端要校验玩家是否真的胡牌客户端要做听牌提示训练 AI 时还要在大量局面下快速判胡。它看起来像一道状态搜索题实际拆开就三步把牌编码成 34 维计数数组挑出一对将牌再用递归把剩余 12 张拆成刻子或顺子。C、C#、Lua、JS 的差异不在算法本身而在数组语义和传参方式C 的引用回滚、C# 的 Span、Lua 的 1-based table、JS 的纯函数习惯都会影响最终写法。下面按编码、判胡、听牌这条线把 C、C#、Lua、JS 以及 Go 的落地写法一次讲清。2. 麻将胡牌算法的核心牌型编码与面子拆解2.1 34 维牌型向量一张牌在代码里长什么样现实里的“一万”“东风”是字符串直接作为函数参数会让后续判断变成一串字符串比较。常见做法是给每种牌分配固定数字编号用一个长度 34 的数组存手牌数组下标就是牌的类型下标对应的值表示这种牌有几张。花色C / C# / JS 索引Lua 索引说明万0-81-9一万到九万条9-1710-18一条到九条筒18-2619-27一筒到九筒字27-3328-34东南西北中发白这套编码的价值在于顺子判断可以退化成“相邻三个下标都有值”。8 号是九万9 号是一条虽然数字相邻但花色不同所以顺子判断必须同时检查“不跨越花色边界”。字牌只能组成对子和刻子永远不能进顺子。实际项目中有人把数组扩大成 38 位留出第 35-37 位做花牌和百搭牌底层逻辑不变。2.2 递归拆将与面子判胡函数的最小可运行版本判胡的指导思想是枚举。先在所有牌里选一张出现次数不少于 2 的牌作为将牌把它从计数数组中减掉 2剩下的牌必须能完整拆成 4 组面子。拆面子用递归每次从最左边找到第一张非零牌它只能走两条路或者和另外两张同样的牌组成刻子或者和它右边的两张牌组成顺子一条路走不通就回滚试另一条。// 判断剩下的牌能否全部拆成面子 function formMelds(cnt, start 0) { let i start; while (i 34 cnt[i] 0) i; // 跳过空位找最左边的牌 if (i 34) return true; // 全部清空拆解成功 // 先试刻子三张一样的牌 if (cnt[i] 3) { cnt[i] - 3; if (formMelds(cnt, i)) { cnt[i] 3; return true; } cnt[i] 3; // 回滚恢复现场 } // 再试顺子i、i1、i2 三张牌 // i 小于 27 保证是数牌i % 9 7 保证 i2 不跨到下一花色 if (i 27 i % 9 7 cnt[i 1] 0 cnt[i 2] 0) { cnt[i]--; cnt[i 1]--; cnt[i 2]--; if (formMelds(cnt, i)) { cnt[i]; cnt[i 1]; cnt[i 2]; return true; } cnt[i]; cnt[i 1]; cnt[i 2]; // 回滚 } return false; }这段代码里最容易被忽略的是i % 9 7这个边界条件。以 0-based 数组为例索引 6 是七万6 % 9 66 2 8 正好是九万合法索引 7 是八万7 % 9 77 2 9 已经跨到一条非法。字牌区 27 到 33 被i 27挡住不会进入顺子分支。formMelds递归时第二个参数传i而不是i 1原因是当前位置的牌可能已经被消耗成 0下一次 while 循环会自动跳过从 i 开始扫描能少一轮从头遍历。外层isHu的枚举逻辑是遍历所有可能做将牌的牌型。找到一组能成功拆分的将牌就立刻返回 true全部试完返回 false。2.3 七对与十三幺规则开关不能写死在函数里普通胡牌判定只覆盖“将牌 四组面子”但各地规则里七对和十三幺是高频特殊牌型。七对要求 14 张牌组成 7 个对子这里有个容易踩的规则细节严格模式下一张牌出现 4 次不能算两对必须每个牌型数量恰好为 0 或 2。十三幺则需要 1、9 万1、9 条1、9 筒东南西北中发白各一张再额外多一张幺九牌作将。这两个检查可以做成布尔开关因为地区规则差异确实存在把开关留在函数外部能减少后面改需求时动核心递归的成本。3. C 与 C# 写判胡std::array、Span 和回滚现场3.1 C 实现std::array 的引用传参与原地修改C 里最常见的写法是std::arrayint, 34配合递归函数引用传参。数组只有 34 个 int但递归深度通常在 10 到 20 层之间如果每一层递归都复制整个数组操作次数会放大一个量级所以递归函数内部直接修改传入的数组回溯时再回滚。这个“修改 回滚”的口诀在 C 面试里也常被问到很多候选人能写出递归却忘了在递归失败后恢复现场导致下一次尝试基于脏数据判断。#include array using Tiles std::arrayint, 34; bool formMelds(Tiles cnt, int idx) { while (idx 34 cnt[idx] 0) idx; if (idx 34) return true; // 先试刻子 if (cnt[idx] 3) { cnt[idx] - 3; bool ok formMelds(cnt, idx); cnt[idx] 3; if (ok) return true; } // 再试顺子 if (idx 27 idx % 9 7 cnt[idx 1] 0 cnt[idx 2] 0) { cnt[idx]--; cnt[idx 1]--; cnt[idx 2]--; bool ok formMelds(cnt, idx); cnt[idx]; cnt[idx 1]; cnt[idx 2]; if (ok) return true; } return false; } bool isHu(Tiles cnt) { for (int i 0; i 34; i) { if (cnt[i] 2) { cnt[i] - 2; // 尝试拿 i 做将牌 bool win formMelds(cnt, i); cnt[i] 2; if (win) return true; } } return false; }调用方需要注意isHu接收的是引用函数内虽然会回滚但为了防止某个边界情况下漏掉回滚更稳妥的调用方式是先复制手牌再判胡Tiles work hand; // hand 是 const Tiles if (isHu(work)) { /* 胡了 */ }C 实现不建议用std::unordered_mapint, int替代数组。34 个槽位的数组访问是 O(1) 且内存连续哈希表还要计算哈希、处理冲突对手牌计数这种固定小集合没有任何优势。递归时参数类型写成Tiles而不是Tiles否则每次递归调用都会触发一次完整数组拷贝单个拷贝不贵但乘上递归深度后性能差别明显。3.2 C# 实现int[] 引用语义与 Span 传参C# 的int[]本身是引用类型传给方法时只复制引用底层数组只有一个实例所以递归里的修改天然作用在同一个数组上。这里反而要小心调用方的数据被污染。C# 项目里我一般把核心判定函数设计成接收Spanint因为它既能接收int[]又能接收stackalloc出来的临时数组而且不引入额外分配。static bool FormMelds(Spanint cnt, int idx) { while (idx 34 cnt[idx] 0) idx; if (idx 34) return true; if (cnt[idx] 3) { cnt[idx] - 3; bool ok FormMelds(cnt, idx); cnt[idx] 3; if (ok) return true; } if (idx 27 idx % 9 7 cnt[idx 1] 0 cnt[idx 2] 0) { cnt[idx]--; cnt[idx 1]--; cnt[idx 2]--; bool ok FormMelds(cnt, idx); cnt[idx]; cnt[idx 1]; cnt[idx 2]; if (ok) return true; } return false; } static bool Hu(Spanint cnt) { for (int i 0; i cnt.Length; i) { if (cnt[i] 2) { cnt[i] - 2; bool win FormMelds(cnt, i); cnt[i] 2; if (win) return true; } } return false; }一个常见的坑是 C# 的in参数修饰符。如果项目代码里写bool Hu(in int[] cnt)语义是引用只读函数内不能修改数组内容和 C 的const Tiles很像但到了递归内部想回滚就不方便了。递归修改状态就用普通传参不要因为担心性能给数组加in数组本身传引用已经没复制数据。Unity 或旧 Mono 环境不支持SpanT时把函数签名改回int[]即可。SpanT是 ref struct不能用作类的字段或闭包捕获但递归方法传参没有任何问题。3.3 C 与 C# 传参方式对比语言传参方式底层行为恢复现场Cstd::arrayint,34传递引用无数据复制递归回滚Cstd::arrayint,34每次递归复制整个数组不需要回滚但慢C#int[]传递引用无数据复制递归回滚C#Spanint传递引用无数据复制递归回滚C#in int[]只读引用无法修改数据不适合直接做回滚写完这份对比就明白最不应该做的是在递归里保存一份副本用来恢复。副本逻辑看起来安全实际会让每个节点多一次数组遍历。保持“先改递归失败再改回来”的节奏代码既短也不会漏回滚。4. Lua 与 JS 写判胡1-based table、数组偏移和纯函数习惯4.1 Lua 的 1-based table数组偏移是移植第一大坑Lua 脚本语言里最常见的计数数据结构是 table但 table 的数组下标从 1 开始意味着 C 的索引 0 到 33 映射到 Lua 的 1 到 34。移植时如果只把循环范围从 34 改成 34忘了顺子边界判断里的取模逻辑就会出现诡异的误判。Lua 中万子的索引是 1-9条子 10-18筒子 19-27字牌 28-34顺子合法的起始位置变成(idx - 1) % 9 6。Lua 实现还要注意#操作符只对连续整数键可靠。手牌计数数组中间会出现值为 0 的槽位但槽位本身依然存在所以数组是“有洞”的#cnt可能返回 27 也可能返回 34取决于最后非空元素的位置。正确做法是把 34 写死为常量循环里用while idx 34不要依赖表长度。4.2 Lua 判胡完整实现while 跳过空档local TOTAL_TYPES 34 local function formMelds(cnt, idx) while idx TOTAL_TYPES and cnt[idx] 0 do idx idx 1 end if idx TOTAL_TYPES then return true end -- 曲子三张相同的牌 if cnt[idx] 3 then cnt[idx] cnt[idx] - 3 local ok formMelds(cnt, idx) cnt[idx] cnt[idx] 3 if ok then return true end end -- 顺子idx、idx1、idx2 -- idx 27 限制在数牌区(idx-1)%9 6 保证不跨花色 if idx 27 and (idx - 1) % 9 6 and cnt[idx 1] 0 and cnt[idx 2] 0 then cnt[idx] cnt[idx] - 1 cnt[idx 1] cnt[idx 1] - 1 cnt[idx 2] cnt[idx 2] - 1 local ok formMelds(cnt, idx) cnt[idx] cnt[idx] 1 cnt[idx 1] cnt[idx 1] 1 cnt[idx 2] cnt[idx 2] 1 if ok then return true end end return false end local function isHu(cnt) for i 1, TOTAL_TYPES do if cnt[i] 2 then cnt[i] cnt[i] - 2 local win formMelds(cnt, i) cnt[i] cnt[i] 2 if win then return true end end end return false endformMelds的idx参数要做两件事一是跳过值为 0 的空槽位二是把顺子检查限定在idx 2 34的有效区间。(idx - 1) % 9 6这行是上面 C 版本i % 9 7的 1-based 等价写法。比如 Lua 索引 9 是九万(9-1)%9 8大于 6不会进入顺子分支索引 10 是一条(10-1)%9 0可以组成一条、二条、三条的顺子。4.3 JS 写同一套算法函数式写法与调用方污染JS 数组本质是对象访问cnt[i]比 C 数组慢但 34 个元素规模完全感知不到。JS 版本在第 2 章的代码可以直接用真正需要讨论的是函数设计isHu会修改传入的cnt数组虽然函数结束后数组恢复到原状但调用方如果中途抛出异常会出现状态污染。稳妥做法是把isHu改成不修改外部状态的纯函数进入函数第一行复制数组function isHu(input) { const cnt input.slice(); // 浅拷贝一份函数内随便改 // 后续逻辑全部基于 cnt }input.slice()对 34 长度的数组来说消耗极小换来的是调用方不需要关心回滚是否正确这个取舍在写客户端工具和写测试用例时非常省心。JS 里的另一个坑是求手牌总和时用reduce习惯性写出回调函数如果判胡函数在 AI 搜索里被高频调用回调创建和遍历开销会被放大。遇到性能敏感路径直接 for 循环累加。Go 的写法介于两者之间[]int切片传递时只复制 slice header底层数组共享递归改完回滚与 C 一致。TypeScript 则完全复用 JS 代码只需要给参数加上number[]类型标注。4.4 四种语言核心差异速查语言索引起点顺子边界写法递归状态恢复C0i % 9 7引用 回滚C#0i % 9 7引用 回滚Lua1(idx - 1) % 9 6引用 回滚JS / TS0i % 9 7可用切片拷贝替代回滚Go0i % 9 7切片 回滚5. 听牌枚举与缓存把判胡函数变成听牌提示工具5.1 摸哪张能胡听牌枚举实现判胡函数只是基础棋牌客户端更常用的是“给 13 张手牌返回所有能胡的牌”。实现思路很简单对 34 种牌逐一尝试把这张牌加入手牌计数数组后调用判胡函数能胡就记录。这个操作在判胡函数保证“调用后数组复原”的前提下连拷贝都不用。function listeningTiles(hand) { const cnt new Array(34).fill(0); for (const t of hand) cnt[t]; const result []; for (let i 0; i 34; i) { if (cnt[i] 4) continue; // 手牌已经有 4 张摸不到这张牌 cnt[i] 1; // 模拟摸牌 if (isHu(cnt)) result.push(i); cnt[i] - 1; // 还原 } return result; }hand是 13 张牌的编码数组调用前已经通过cnt[t]转成计数数组。cnt[i] 4是听牌枚举里容易漏的判断牌山不会出现第 5 张枚举时必须跳过。如果需要更精确的提示可以再返回牌山里剩余张数即result.push({ tile: i, left: 4 - cnt[i] })这样“听了 3 张”和“只听 1 张”能在 UI 上直观区分。5.2 回归验证一份测试矩阵锁住多语言一致性胡牌算法改起来快但规则开关多回归测试必须覆盖平胡、七对、十三幺和无胡四种情况。下面这组用例可以直接作为四语言脚本的输入期望结果一致才算通过。用例手牌编码期望平胡0,1,2,3,4,5,6,7,8,9,9,9,4,4true七对0,0,1,1,2,2,3,3,4,4,5,5,6,6true十三幺0,8,9,17,18,26,27,28,29,30,31,32,33,27true无胡0,0,0,1,2,3,4,5,6,7,8,27,28,29false无胡用例的验证逻辑是只有 1 万能组成对子剩下的万子刚好拆成 123、456、789 三组面子但东、南、西三个单张字牌不能形成刻子或顺子因此必须返回 false。跑测试时把每个用例的编码数组分别灌进 C、C#、Lua、JS 的isHu四份结果保持一致才能放心地把算法嵌入到服务端和客户端两侧。最后提一个缓存优化代码里isHu每次修改后回滚同一个数组而听牌枚举会连续调用 34 次判胡如果对相同手牌反复判胡可以用cnt.join(,)生成字符串作为缓存键isHu开头检查缓存避免重复递归。本文还有配套的精品资源点击获取
返回列表