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

发布时间:2026/10/26 8:19:05
麻将胡牌算法工程化:C++/C#/Lua/Go/JS五语言实现与优化

简介:这是一份面向游戏开发与算法学习者的麻将胡牌算法多语言实现合集,覆盖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-4),34 种牌共 136 bit,可以用多个 64 位整数表示。位运算在 C++ 和 Go 里能带来明显的性能优势,但可读性下降,调试成本高。我一般会先用计数数组把逻辑跑通,确认规则无误后,再针对性能瓶颈做位运算优化。

癞子牌(万能牌)的处理是另一个维度。常见做法是把癞子牌从手牌中抽离,记录数量,然后在递归过程中用癞子去补足面子或对子。这会增加递归的分支数,需要配合记忆化搜索来避免重复计算。

2.2 五种语言的数据结构映射

不同语言对同一套逻辑的表达方式不同,选错结构会让代码又慢又难维护。

C++ 里用std::array<int, 34>或std::vector<int>存计数,递归函数用引用传参避免拷贝。如果追求极致性能,可以用uint64_t位掩码加查表。C# 的int[]和Span<int>在栈上操作效率接近 C++,但要注意 GC 压力,避免在递归中分配数组。Lua 只有 table,用table存计数没问题,但递归深度受栈限制,需要把递归改成迭代或手动管理栈。Go 的切片和数组都可用,[34]int作为值类型传递会拷贝,建议用指针或切片。JavaScript 的Array和Int8Array都可以,Int8Array在频繁读写下更稳定,但要注意 Node.js 和浏览器环境的差异。

语言推荐结构递归方式癞子处理
C++std::array<int,34>引用传参抽离计数,递归补位
C#Span<int>/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 是数字牌且 i+2 < 34 且同花色: if count[i+1] > 0 && count[i+2] > 0: count[i]--; count[i+1]--; count[i+2]-- if canFormMelds(count, laiziCount): return true count[i]++; count[i+1]++; count[i+2]++ // 尝试用癞子补刻子或顺子 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 整除”的边界,因为癞子不能单独成面子,只能补足已有牌型。

提示:递归深度最大约为手牌数除以 3,14 张牌最多 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::array<int, 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::array<int, 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::array<int, 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 bit,34 种牌需要 136 bit,用三个uint64_t表示。递归时通过位运算增减,配合预计算的查表,单次判定可以压到微秒级。但位运算版本调试困难,建议先用数组版本验证规则。

3.2 C# 版本:Span 与递归的配合

C# 的实现思路和 C++ 一致,但要注意Span<T>不能作为递归参数跨 await 或装箱,这里用int[]加引用传递。

using System; class MahjongHu { // 牌型编码同 C++:0-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的Span<int>,但递归中不能直接用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.new(LuaJIT)优化。如果是在 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[first+1] > 0 && count[first+2] > 0 { if (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 } 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-4),34 种牌共 136 bit,用三个uint64_t表示。递归时通过位运算增减,避免数组索引。更激进的做法是预计算所有可能的胡牌牌型,存成哈希集合,判定时直接查表。

下面是一个 C++ 的位运算片段,展示如何把计数数组转成位掩码。

#include <cstdint> #include <array> // 把 count 数组转成三个 uint64_t void packCount(const std::array<int, 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 bit,21 种共 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_set<uint64_t>或排序数组里。判定时把当前手牌转成位掩码,直接查表。这个方案适合牌型固定的场景,但癞子会让组合数爆炸,需要额外处理。

验证方法:用随机生成的手牌对比数组版本和位运算版本的输出,确保结果一致。我一般会跑 10 万组随机数据,如果两个版本结果有差异,说明位运算的位宽或移位有问题。

我自己的习惯是:先用数组版本把规则跑通,写足够的测试用例,再考虑位运算优化。不要一上来就追求最快,规则正确比性能重要。希望帮到你。

本文还有配套的精品资源,点击获取