首页 / 资讯中心 / 文章详情

麻将胡牌算法工程化:C++/C#/Lua/Go/JS五语言实现与优化

麻将胡牌算法工程化:C++/C#/Lua/Go/JS五语言实现与优化 ★ FEATURED ARTICLE
简介这是一份面向游戏开发与算法学习者的麻将胡牌算法多语言实现合集覆盖C、C#、Lua、Go、JavaScript五种语言适合想对比不同语言在相同逻辑下设计差异的中高级开发者也可作为棋牌类项目判断胡牌、计算番数的参考实现。压缩包共334个文件约18.87MB以cpp、cs、lua、go、js源码为主辅以h头文件、tbl查表数据、config配置、makefile与sln工程文件以及check_table、check_eye_table、check_feng_table等预生成查表模块便于直接编译运行与二次改造。资源围绕牌型判断、番数计算、合法性检查、性能优化与异常处理展开读者可从中获取顺子刻子杠对子的组合识别思路、查表加速方案及多语言工程组织方式。目前已有496人学习下载适合需要快速搭建胡牌判定模块或研究算法跨语言移植的开发者参考。1. 麻将胡牌算法从手牌到胡牌判定的工程化拆解打麻将时玩家最关心的瞬间是“我这手牌到底胡没胡”。把这个判断交给程序就是麻将胡牌算法要解决的核心问题。它接收一组手牌通常是 13 张或 14 张输出是否构成合法胡牌牌型。听起来简单但真正落地时会遇到几个绕不开的工程问题不同麻将变体的牌型规则不同、癞子牌让判定分支爆炸、性能要求从单次判断到百万次枚举不等。这也是为什么同一个需求用 C、C#、Lua、Go、JavaScript 写出来的实现差异很大——语言特性直接决定了数据结构选型和递归策略。这篇文章面向需要把胡牌判定嵌入游戏服务端、客户端或 AI 搜索模块的开发者把五种语言的实现路径、参数边界和踩坑点讲清楚让你拿到就能改、改完就能跑。2. 胡牌判定的数学模型与数据结构选型2.1 从牌型规则到可计算模型麻将胡牌的基本形态是“4 组面子 1 对将牌”面子可以是顺子同花色连续三张或刻子三张相同。特殊牌型如七对、十三幺需要单独处理。把规则翻译成计算模型核心是把手牌表示成一个可快速增减和查询的结构。最常见的做法是用计数数组。以万、条、筒各 9 种牌加上风牌和箭牌总共 34 种牌型用一个长度为 34 的整型数组count[34]表示每种牌的数量。判断胡牌时递归地尝试取出将牌、刻子、顺子直到所有牌归零。这个模型的好处是状态紧凑、递归时只需修改数组元素不需要频繁创建新对象。另一种做法是用位运算压缩。每种牌用 4 个 bit 表示数量0-434 种牌共 136 bit可以用多个 64 位整数表示。位运算在 C 和 Go 里能带来明显的性能优势但可读性下降调试成本高。我一般会先用计数数组把逻辑跑通确认规则无误后再针对性能瓶颈做位运算优化。癞子牌万能牌的处理是另一个维度。常见做法是把癞子牌从手牌中抽离记录数量然后在递归过程中用癞子去补足面子或对子。这会增加递归的分支数需要配合记忆化搜索来避免重复计算。2.2 五种语言的数据结构映射不同语言对同一套逻辑的表达方式不同选错结构会让代码又慢又难维护。C 里用std::arrayint, 34或std::vectorint存计数递归函数用引用传参避免拷贝。如果追求极致性能可以用uint64_t位掩码加查表。C# 的int[]和Spanint在栈上操作效率接近 C但要注意 GC 压力避免在递归中分配数组。Lua 只有 table用table存计数没问题但递归深度受栈限制需要把递归改成迭代或手动管理栈。Go 的切片和数组都可用[34]int作为值类型传递会拷贝建议用指针或切片。JavaScript 的Array和Int8Array都可以Int8Array在频繁读写下更稳定但要注意 Node.js 和浏览器环境的差异。语言推荐结构递归方式癞子处理Cstd::arrayint,34引用传参抽离计数递归补位C#Spanint/int[]引用传参同上Luatable手动栈或尾递归同上Go[]int/*[34]int切片传参同上JSInt8Array闭包或显式栈同上选型时还要考虑调用频率。如果胡牌判定在服务端每秒被调用上万次C 或 Go 的位运算方案更合适如果是客户端单次判断Lua 或 JS 的计数数组足够。不要一上来就追求最快先保证规则正确再根据 profiling 结果优化。2.3 递归判定的最小实现框架不管用什么语言胡牌判定的核心递归逻辑是相似的。下面用伪代码描述这个框架后续各语言实现都围绕它展开。function canHu(count, laiziCount): // 尝试所有可能的将牌 for i in 0..33: if count[i] 2: count[i] - 2 if canFormMelds(count, laiziCount): count[i] 2 return true count[i] 2 // 没有将牌时尝试用癞子当将牌 if laiziCount 2: if canFormMelds(count, laiziCount - 2): return true return false function canFormMelds(count, laiziCount): // 找到第一张非零牌 for i in 0..33: if count[i] 0: break if 所有牌为零: return laiziCount % 3 0 // 剩余癞子必须能组成面子 // 尝试刻子 if count[i] 3: count[i] - 3 if canFormMelds(count, laiziCount): return true count[i] 3 // 尝试顺子仅限数字牌 if 是数字牌且 i2 34 且同花色: if count[i1] 0 count[i2] 0: count[i]--; count[i1]--; count[i2]-- if canFormMelds(count, laiziCount): return true count[i]; count[i1]; count[i2] // 尝试用癞子补刻子或顺子 if laiziCount 1: // 补刻子 if count[i] 2: count[i] - 2 if canFormMelds(count, laiziCount - 1): return true count[i] 2 // 补顺子 if 是数字牌且同花色: // 缺一张的情况 ... return false这个框架的关键点将牌和面子的处理分离癞子在面子阶段补位。实际实现时canFormMelds需要处理“剩余癞子数必须能被 3 整除”的边界因为癞子不能单独成面子只能补足已有牌型。提示递归深度最大约为手牌数除以 314 张牌最多 5 层不会导致栈溢出。但如果加入癞子分支分支数会显著增加建议加记忆化。3. C 与 C# 实现从计数数组到位运算优化3.1 C 版本计数数组加剪枝C 的优势在于对内存和递归的精细控制。下面是一个可编译的最小实现处理无癞子的标准胡牌判定。#include array #include cstring #include iostream // 牌型编码0-8 万9-17 条18-26 筒27-33 字牌 // 字牌不参与顺子判定 bool canFormMelds(std::arrayint, 34 count) { int first -1; for (int i 0; i 34; i) { if (count[i] 0) { first i; break; } } if (first -1) return true; // 所有牌用完 // 尝试刻子 if (count[first] 3) { count[first] - 3; if (canFormMelds(count)) { count[first] 3; return true; } count[first] 3; } // 尝试顺子仅数字牌且不跨花色 if (first 27) { int suit first / 9; if (first % 9 6 count[first 1] 0 count[first 2] 0 (first 2) / 9 suit) { count[first]--; count[first 1]--; count[first 2]--; if (canFormMelds(count)) { count[first]; count[first 1]; count[first 2]; return true; } count[first]; count[first 1]; count[first 2]; } } return false; } bool canHu(std::arrayint, 34 count) { // 尝试将牌 for (int i 0; i 34; i) { if (count[i] 2) { count[i] - 2; if (canFormMelds(count)) { count[i] 2; return true; } count[i] 2; } } return false; } int main() { std::arrayint, 34 hand{}; // 示例1万1万1万 2万3万4万 5万6万7万 8万8万8万 9万9万 hand[0] 3; hand[1] 1; hand[2] 1; hand[3] 1; hand[4] 1; hand[5] 1; hand[6] 1; hand[7] 3; hand[8] 2; std::cout (canHu(hand) ? HU : NOT HU) std::endl; return 0; }逻辑说明canFormMelds每次找到最小的非零牌优先尝试刻子再尝试顺子。顺子判定里加了花色检查防止 8 万 9 万 1 条这种跨花色误判。canHu遍历所有可能的将牌对每种将牌调用面子判定。参数说明count数组长度固定 34索引映射为 0-8 万、9-17 条、18-26 筒、27-33 字牌。字牌不参与顺子所以first 27才进入顺子分支。如果要做癞子版本需要额外传入laiziCount并在面子判定里增加补位逻辑。性能优化方向把std::array换成uint64_t位掩码每种牌 4 bit34 种牌需要 136 bit用三个uint64_t表示。递归时通过位运算增减配合预计算的查表单次判定可以压到微秒级。但位运算版本调试困难建议先用数组版本验证规则。3.2 C# 版本Span 与递归的配合C# 的实现思路和 C 一致但要注意SpanT不能作为递归参数跨 await 或装箱这里用int[]加引用传递。using System; class MahjongHu { // 牌型编码同 C0-8 万9-17 条18-26 筒27-33 字牌 static bool CanFormMelds(int[] count) { int first -1; for (int i 0; i 34; i) { if (count[i] 0) { first i; break; } } if (first -1) return true; // 刻子 if (count[first] 3) { count[first] - 3; if (CanFormMelds(count)) { count[first] 3; return true; } count[first] 3; } // 顺子 if (first 27) { int suit first / 9; if (first % 9 6 count[first 1] 0 count[first 2] 0 (first 2) / 9 suit) { count[first]--; count[first 1]--; count[first 2]--; if (CanFormMelds(count)) { count[first]; count[first 1]; count[first 2]; return true; } count[first]; count[first 1]; count[first 2]; } } return false; } public static bool CanHu(int[] hand) { int[] count new int[34]; foreach (int tile in hand) count[tile]; for (int i 0; i 34; i) { if (count[i] 2) { count[i] - 2; if (CanFormMelds(count)) { count[i] 2; return true; } count[i] 2; } } return false; } static void Main() { // 示例手牌用 0-33 编码 int[] hand { 0,0,0, 1,2,3, 4,5,6, 7,7,7, 8,8 }; Console.WriteLine(CanHu(hand) ? HU : NOT HU); } }逻辑说明C# 版本把计数数组的构建放在CanHu里接收原始手牌编码。递归部分和 C 完全一致。注意count数组在递归中被修改和恢复这是回溯的标准写法。参数说明hand是手牌编码数组长度应为 14或 13 加摸牌。count长度 34索引含义同 C。如果要做上位机集成可以把CanHu封装成静态方法通过管道通信或 HTTP 接口暴露给主程序。C# 的性能陷阱递归中不要用 LINQ 或 foreach 遍历数组会有额外开销。用 for 循环和数组索引。如果调用频率高考虑把int[]换成stackalloc的Spanint但递归中不能直接用Span需要改成迭代或手动栈。3.3 两种语言的调试与验证方法写完判定逻辑后必须用已知胡牌和非胡牌的手牌验证。我一般会准备三组测试数据标准胡牌4 面子1 将、七对、十三幺以及对应的非胡牌变体。C 可以用assert或简单的main函数输出结果C# 用Debug.Assert或单元测试。验证时重点检查边界字牌顺子误判、跨花色顺子、癞子数量不足、将牌重复使用。一个常见的翻车点是顺子判定里忘了检查(first 2) / 9 suit导致 8 万 9 万 1 条被当成顺子。这个 bug 在测试数据覆盖不全时很难发现建议专门构造跨花色的边界用例。注意C 里std::array作为参数传递会拷贝递归中应传引用。上面的canFormMelds接收的是引用但canHu接收的是值这是为了在canHu里修改副本而不影响调用方。如果手牌数据很大可以改成指针或引用加手动恢复。4. Lua、Go、JavaScript 实现脚本与并发场景的取舍4.1 Lua 版本table 计数与尾递归优化Lua 在游戏客户端和嵌入式环境里很常见但递归深度和性能是短板。下面是一个可运行的 Lua 实现用 table 存计数。-- 牌型编码1-9 万11-19 条21-29 筒31-37 字牌 -- 为方便这里用 1-34 的连续索引 local function canFormMelds(count) local first nil for i 1, 34 do if count[i] 0 then first i; break end end if not first then return true end -- 刻子 if count[first] 3 then count[first] count[first] - 3 if canFormMelds(count) then count[first] count[first] 3 return true end count[first] count[first] 3 end -- 顺子仅数字牌1-7 范围内且不跨花色 if first 27 then local suit math.floor((first - 1) / 9) local pos (first - 1) % 9 if pos 6 and count[first 1] 0 and count[first 2] 0 then local suit2 math.floor((first 1) / 9) if suit2 suit then count[first] count[first] - 1 count[first 1] count[first 1] - 1 count[first 2] count[first 2] - 1 if canFormMelds(count) then count[first] count[first] 1 count[first 1] count[first 1] 1 count[first 2] count[first 2] 1 return true end count[first] count[first] 1 count[first 1] count[first 1] 1 count[first 2] count[first 2] 1 end end end return false end local function canHu(hand) local count {} for i 1, 34 do count[i] 0 end for _, tile in ipairs(hand) do count[tile] count[tile] 1 end for i 1, 34 do if count[i] 2 then count[i] count[i] - 2 if canFormMelds(count) then count[i] count[i] 2 return true end count[i] count[i] 2 end end return false end -- 测试 local hand {1,1,1, 2,3,4, 5,6,7, 8,8,8, 9,9} print(canHu(hand) and HU or NOT HU)逻辑说明Lua 的 table 索引从 1 开始所以牌型编码调整为 1-34。顺子判定里用math.floor计算花色和位置确保不跨花色。递归部分和 C 一致。参数说明hand是手牌数组元素为 1-34 的整数。count是长度为 34 的 table初始化为 0。如果要做癞子需要额外传入laiziCount并在canFormMelds里增加补位分支。Lua 的性能陷阱递归调用在 Lua 里开销较大如果判定频率高建议改成迭代加显式栈。另外Lua 的 table 在频繁增减时可能触发 rehash可以用预分配的数组或table.newLuaJIT优化。如果是在 Logitech 或 NodeMCU 环境里跑还要注意栈大小限制必要时把递归改成尾递归或循环。4.2 Go 版本切片传参与并发安全Go 的切片在递归中传递的是引用修改会影响原数组需要手动恢复。下面是一个可编译的 Go 实现。package main import fmt // 牌型编码0-8 万9-17 条18-26 筒27-33 字牌 func canFormMelds(count []int) bool { first : -1 for i : 0; i 34; i { if count[i] 0 { first i break } } if first -1 { return true } // 刻子 if count[first] 3 { count[first] - 3 if canFormMelds(count) { count[first] 3 return true } count[first] 3 } // 顺子 if first 27 { suit : first / 9 if first%9 6 count[first1] 0 count[first2] 0 { if (first2)/9 suit { count[first]-- count[first1]-- count[first2]-- if canFormMelds(count) { count[first] count[first1] count[first2] return true } count[first] count[first1] count[first2] } } } return false } func CanHu(hand []int) bool { count : make([]int, 34) for _, tile : range hand { count[tile] } for i : 0; i 34; i { if count[i] 2 { count[i] - 2 if canFormMelds(count) { count[i] 2 return true } count[i] 2 } } return false } func main() { hand : []int{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 7, 7, 8, 8} if CanHu(hand) { fmt.Println(HU) } else { fmt.Println(NOT HU) } }逻辑说明Go 版本用切片传递计数数组递归中直接修改切片元素。CanHu里创建count切片并填充然后遍历将牌。参数说明hand是手牌编码切片元素 0-33。count长度 34。如果要在并发环境里调用每次调用创建独立的count切片避免共享状态。Go 的切片在递归中传递不会拷贝底层数组所以恢复操作必须成对出现。Go 的性能优势编译型语言递归开销比 Lua 和 JS 小。如果要做批量判定可以用 goroutine 并行处理多组手牌但要注意count切片不能共享。另外Go 的[34]int数组作为值传递会拷贝用切片更合适。4.3 JavaScript 版本Int8Array 与浏览器兼容JavaScript 在网页麻将游戏和 Node.js 服务端都有应用。下面是一个用Int8Array的实现。// 牌型编码0-8 万9-17 条18-26 筒27-33 字牌 function canFormMelds(count) { let first -1; for (let i 0; i 34; i) { if (count[i] 0) { first i; break; } } if (first -1) return true; // 刻子 if (count[first] 3) { count[first] - 3; if (canFormMelds(count)) { count[first] 3; return true; } count[first] 3; } // 顺子 if (first 27) { const suit Math.floor(first / 9); if (first % 9 6 count[first 1] 0 count[first 2] 0) { if (Math.floor((first 2) / 9) suit) { count[first]--; count[first 1]--; count[first 2]--; if (canFormMelds(count)) { count[first]; count[first 1]; count[first 2]; return true; } count[first]; count[first 1]; count[first 2]; } } } return false; } function canHu(hand) { const count new Int8Array(34); for (const tile of hand) count[tile]; for (let i 0; i 34; i) { if (count[i] 2) { count[i] - 2; if (canFormMelds(count)) { count[i] 2; return true; } count[i] 2; } } return false; } // 测试 const hand [0,0,0, 1,2,3, 4,5,6, 7,7,7, 8,8]; console.log(canHu(hand) ? HU : NOT HU);逻辑说明JS 版本用Int8Array存计数递归中直接修改。canHu接收手牌数组构建计数后遍历将牌。参数说明hand是手牌编码数组元素 0-33。count是Int8Array长度 34。如果要在浏览器里用注意Int8Array的兼容性很好但递归深度受调用栈限制14 张牌最多 5 层不会溢出。JS 的性能陷阱递归中不要用Array的forEach或map用for循环。如果判定频率高可以把递归改成迭代加显式栈或者用 Web Worker 避免阻塞主线程。Node.js 环境下可以用Buffer替代Int8Array但可读性下降。4.4 三种脚本语言的选型建议Lua 适合嵌入游戏客户端和 C 引擎配合Go 适合服务端高并发场景JavaScript 适合网页游戏和快速原型。选型时不要只看语言热度要看运行环境和调用频率。如果只是单次判断三种语言都能胜任如果要批量枚举Go 和 C 更合适。提示Lua 调用 DLL 或 C# 管道通信时胡牌判定可以作为独立模块暴露避免跨语言递归。Go 的智能合约场景里胡牌判定可以写成纯函数方便测试和验证。5. 避坑与排查胡牌判定里最容易翻车的五个点5.1 顺子跨花色导致误判现象手牌里有 8 万、9 万、1 条程序判定为顺子输出胡牌。原因顺子判定只检查了索引连续没有检查花色是否相同。解决在顺子分支里加花色比较确保(first 2) / 9 first / 9。这个坑在测试数据覆盖不全时很难发现建议专门构造跨花色用例。5.2 癞子数量未参与面子判定现象手牌有癞子但程序只按普通牌判定导致本可胡牌的牌型被判为不胡。原因癞子被抽离后没有在递归中补位。解决在canFormMelds里增加癞子补刻子和补顺子的分支并确保剩余癞子数能被 3 整除。注意癞子不能单独成面子只能补足已有牌型。5.3 递归中数组未恢复导致状态污染现象第一次判定返回 true 后第二次判定结果错误。原因递归中修改了计数数组但没有恢复导致后续调用看到脏数据。解决每次修改后必须成对恢复或者在递归入口拷贝数组。C 里用引用传参时尤其要注意Go 的切片和 JS 的Int8Array也是引用传递。5.4 将牌重复使用现象手牌里只有一对将牌但程序在面子判定中又用了这对牌导致误判。原因canHu里减去将牌后canFormMelds没有正确识别剩余牌。解决确保将牌减去后面子判定从剩余牌里找第一张非零牌不会重复使用已减去的将牌。如果有多对将牌候选遍历所有可能。5.5 性能瓶颈在递归分支爆炸现象加入癞子后单次判定耗时从微秒级涨到毫秒级。原因癞子补位增加了大量分支递归树膨胀。解决加记忆化搜索缓存已计算过的计数状态或者用位运算压缩状态减少递归深度。如果调用频率极高考虑预计算所有胡牌牌型用哈希表查询。注意排查时先用无癞子的标准牌型验证基础逻辑再逐步加入癞子、七对、十三幺等特殊牌型。每加一个规则都要补充对应的测试用例。6. 进阶技巧用位运算和查表把判定压到微秒级当胡牌判定被集成到 AI 搜索或实时对战服务里单次毫秒级延迟会累积成秒级卡顿。这时候需要把计数数组换成位运算配合预计算查表。核心思路每种牌用 4 bit 表示数量0-434 种牌共 136 bit用三个uint64_t表示。递归时通过位运算增减避免数组索引。更激进的做法是预计算所有可能的胡牌牌型存成哈希集合判定时直接查表。下面是一个 C 的位运算片段展示如何把计数数组转成位掩码。#include cstdint #include array // 把 count 数组转成三个 uint64_t void packCount(const std::arrayint, 34 count, uint64_t lo, uint64_t mid, uint64_t hi) { lo mid hi 0; for (int i 0; i 21; i) { lo | (uint64_t)count[i] (i * 3); // 每种牌 3 bit21 种共 63 bit } for (int i 21; i 34; i) { int shift (i - 21) * 3; if (shift 64) mid | (uint64_t)count[i] shift; else hi | (uint64_t)count[i] (shift - 64); } }逻辑说明这里用 3 bit 存每种牌的数量0-4 够用21 种牌占 63 bit放在lo里剩余 13 种牌放在mid和hi里。递归时通过位运算增减比数组索引快。但位运算版本调试困难建议先用数组版本验证规则再逐步替换。参数说明count是长度为 34 的计数数组lo、mid、hi是输出的位掩码。每种牌占 3 bit所以 21 种牌需要 63 bit刚好放进一个uint64_t。剩余 13 种牌需要 39 bit分到mid和hi。查表方案预计算所有胡牌牌型的位掩码存到std::unordered_setuint64_t或排序数组里。判定时把当前手牌转成位掩码直接查表。这个方案适合牌型固定的场景但癞子会让组合数爆炸需要额外处理。验证方法用随机生成的手牌对比数组版本和位运算版本的输出确保结果一致。我一般会跑 10 万组随机数据如果两个版本结果有差异说明位运算的位宽或移位有问题。我自己的习惯是先用数组版本把规则跑通写足够的测试用例再考虑位运算优化。不要一上来就追求最快规则正确比性能重要。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站