简介:这是一份面向游戏开发与算法学习者的多语言麻将胡牌算法合集,覆盖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. 五语言麻将胡牌算法包:从 check_eye_table 到能跑起来的胡牌判定
做棋牌游戏后端的人多半遇到过这个场景:产品说“加个麻将玩法”,你打开代码仓库发现胡牌判定逻辑散落在三四个文件里,C++ 写了一半,Lua 那边又抄了一份,改一条规则要同步五个地方。这个压缩包解决的正是这件事——它把 C++、C#、Lua、Go、JavaScript 五种语言实现的麻将胡牌算法放在一起,核心围绕check_eye_table系列查表结构展开,配套完整的牌型判断、将牌(眼)识别和胡牌组合搜索。适合三类人:正在做麻将/棋牌项目的后端或客户端开发、需要跨语言移植胡牌逻辑的工程师、以及想拿一个真实算法案例来对比五种语言写法的学习者。标签里虽然带了 java,但包里实际是另外五种语言,别被误导。下面按“这是什么 → 怎么用 → 坑在哪”的顺序拆开讲。
2. 胡牌算法的核心结构:check_eye_table 与递归拆解怎么配合
2.1 胡牌判定的本质是一次带约束的组合搜索
麻将胡牌的标准模型是:手牌 14 张(或 13 张听牌),要拆成 4 组面子加 1 对将牌。面子可以是顺子(同花色连续三张)或刻子(三张相同),将牌是两张相同的牌。七对子、十三幺这类特殊牌型是额外分支。朴素做法是枚举所有拆分方式,但 14 张牌的组合数在极端情况下会爆炸,所以工程上普遍用“计数数组 + 递归回溯 + 剪枝”来做。
这个包里check_eye_table的命名透露了设计思路:eye 指将牌(对子),table 指查表。常见做法是预先算好“去掉将牌后剩余牌能否全部拆成面子”的查表结果,把最耗时的部分缓存下来。check_eye_table_0到check_eye_table_4这五个表,通常对应不同花色或不同牌张数量的索引维度——具体维度划分要看源码里的数组定义,不同实现会有差异。
理解这一点很关键:胡牌算法不是“判断有没有对子”这么简单,而是“在确定将牌之后,剩下的牌能不能被完全消化成面子”。查表法把“能不能消化”这个子问题预计算,运行时只做查表加少量递归,性能提升明显。
2.2 五种语言的实现差异与选型理由
为什么同一个算法要写五遍?因为棋牌项目的部署环境差异极大。服务器端常用 C++ 或 Go 追求性能,客户端逻辑用 Lua 嵌入(很多游戏引擎用 Lua 做热更新),Web 端或 H5 小游戏用 JavaScript,而 C# 多见于 Unity 项目或上位机工具。
| 语言 | 典型使用场景 | 数据结构选择 | 性能定位 |
|---|---|---|---|
| C++ | 高并发游戏服务器 | vector / array + 位运算 | 最高,适合万级 QPS |
| Go | 云原生后端、并发服务 | slice + map | 高,goroutine 天然适配多桌并发 |
| C# | Unity 客户端、工具链 | List + LINQ | 中高,开发效率好 |
| Lua | 游戏逻辑热更新层 | table | 中,嵌入宿主语言 |
| JavaScript | H5 小游戏、Node 服务 | Array + Object | 中,V8 优化后够用 |
选型时不要只看性能。如果项目已经在用 Lua 做游戏逻辑,硬塞一个 C++ 胡牌模块进去,跨语言调用和内存管理的成本可能比算法本身还高。我一般建议:服务器用 Go 或 C++,客户端逻辑跟主逻辑语言保持一致,工具和验证脚本用 JavaScript 或 C# 快速写。
2.3 从手牌数组到胡牌结果的最小调用链
以最常见的流程为例,一次完整的胡牌判定包含这几步:
# 伪代码:胡牌判定主流程(对应各语言实现的通用逻辑) def check_hu(hand_tiles): # hand_tiles: 长度34的计数数组,索引0-33对应万条筒字牌 # 第一步:尝试每一种可能的将牌 for eye in range(34): if hand_tiles[eye] >= 2: hand_tiles[eye] -= 2 # 取出将牌 if can_form_melds(hand_tiles): # 剩余牌能否全拆成面子 hand_tiles[eye] += 2 # 还原,不影响后续判断 return True hand_tiles[eye] += 2 # 第二步:检查七对子等特殊牌型 if check_seven_pairs(hand_tiles): return True return False def can_form_melds(tiles): # 找到第一张有牌的索引 for i in range(34): if tiles[i] == 0: continue # 尝试刻子 if tiles[i] >= 3: tiles[i] -= 3 if can_form_melds(tiles): tiles[i] += 3 return True tiles[i] += 3 # 尝试顺子(注意花色边界,不能跨花色连) if is_valid_sequence_start(i) and tiles[i+1] > 0 and tiles[i+2] > 0: tiles[i] -= 1; tiles[i+1] -= 1; tiles[i+2] -= 1 if can_form_melds(tiles): tiles[i] += 1; tiles[i+1] += 1; tiles[i+2] += 1 return True tiles[i] += 1; tiles[i+1] += 1; tiles[i+2] += 1 return False # 第一张牌无法处理,直接失败 return True这段逻辑说明几个参数要点:hand_tiles用长度 34 的计数数组而不是原始牌列表,是因为计数数组天然去重且索引即牌 ID,查表时可以直接用索引定位。is_valid_sequence_start必须处理花色边界——万子 0-8、条子 9-17、筒子 18-26、字牌 27-33,顺子只能在同花色内成立,字牌不能组顺子。can_form_melds里“找到第一张有牌的就必须处理掉”是剪枝核心,避免无意义的排列组合。
check_eye_table系列在这里的作用是替换can_form_melds的递归调用:把去掉将牌后的计数数组编码成一个整数索引,直接查表得到布尔结果。表的大小取决于编码方式,常见的是按花色分段编码或整体哈希。
3. 把算法跑起来:五种语言的编译、调用与验证步骤
3.1 C++ 与 C# 的编译接入
C++ 版本通常是一个头文件加源文件,或者直接一个.cpp。接入时先确认编译器标准,STL 容器和位运算在 C++11 以上都没问题。
# C++ 编译(假设主文件为 mahjong_hu.cpp) g++ -std=c++17 -O2 -o mahjong_hu mahjong_hu.cpp # 运行验证 ./mahjong_hu参数说明:-std=c++17保证结构化绑定和if constexpr可用(如果源码用了的话),-O2对递归回溯的性能影响很大,别用-O0测性能。如果源码依赖特定头文件路径,加-I指定。
C# 版本如果是单个.cs文件,用dotnet script或建一个控制台项目:
# 建控制台项目并替换 Program.cs dotnet new console -n MahjongHu cd MahjongHu # 把算法文件复制进来,确保 namespace 一致 dotnet runC# 里如果用 LINQ 做牌型分组,注意GroupBy的延迟执行特性——在递归里反复调用可能产生意外开销,必要时先.ToList()物化。
3.2 Lua、Go、JavaScript 的快速验证
Lua 版本适合直接嵌入现有游戏逻辑,验证时用lua命令行:
-- test_hu.lua local hu = require("mahjong_hu") -- 假设模块名为 mahjong_hu -- 构造一副听牌:1万2万3万 4万5万6万 7万8万9万 东东东 南南 local hand = {1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,2} print(hu.check(hand)) -- 期望输出 true注意 Lua 的 table 索引从 1 开始,而算法内部如果用 0 基索引,转换时容易差一位。这是移植时最常见的翻车点。
Go 版本验证:
# 初始化模块并运行 go mod init mahjong go run main.goGo 的 slice 是引用类型,递归函数里对切片的修改会影响外层,如果算法依赖“修改后还原”的模式,要确认源码是否用了值拷贝或显式还原。JavaScript 版本在 Node 下直接node mahjong_hu.js即可,浏览器端则把算法挂到全局或模块导出。
3.3 用固定测试用例验证五种实现结果一致
跨语言移植最大的风险是“看起来都对,结果不一致”。建议准备一组覆盖边界的手牌用例,五种语言跑同一组输入,比对输出。
| 用例编号 | 手牌构成 | 期望结果 | 考察点 |
|---|---|---|---|
| T01 | 标准 4 面子 + 1 将 | 胡 | 基础路径 |
| T02 | 七对子 | 胡 | 特殊牌型 |
| T03 | 差一张不成面子 | 不胡 | 剪枝正确性 |
| T04 | 字牌刻子 + 顺子混合 | 胡 | 花色边界 |
| T05 | 空手牌 / 非法长度 | 不胡或报错 | 错误处理 |
跑完这组用例,五种语言结果一致,才能说明移植没引入逻辑偏差。如果某个语言结果不同,优先查索引基数和花色边界。
4. 避坑与排查:胡牌算法移植中最容易翻车的五个点
4.1 现象:C++ 能胡的牌,Lua 判定不胡
原因:Lua table 从 1 开始索引,而 C++ 数组从 0 开始。如果直接把 C++ 的计数数组逻辑搬到 Lua,牌 ID 整体偏移一位,顺子判断的i+1、i+2全部错位。
解决:在 Lua 入口处统一做一次索引转换,或者在算法内部全部用 0 基但 table 预留一个占位元素。我一般会在 Lua 模块顶部加一行注释标明索引起点,避免后来人踩坑。
4.2 现象:递归深度过大导致栈溢出
原因:can_form_melds在极端牌型下递归层数接近牌张数,某些语言默认栈空间小(如 Lua 的 C 栈限制),14 张牌全是对子或刻子时递归较深。
解决:把递归改成显式栈的迭代版本,或者用查表法把大部分子问题预计算掉。check_eye_table本身就是这个思路的产物——能查表就别递归。
4.3 现象:七对子被误判为普通胡牌或漏判
原因:七对子的判定和普通胡牌是两条独立路径。有些实现先跑普通胡牌,失败后才查七对子,但如果普通胡牌路径里修改了手牌计数没有还原,七对子检查就会拿到脏数据。
解决:确保每条判定路径都在副本上操作,或者严格配对“修改-还原”。测试用例里必须包含七对子,且要测“既是七对子又能拆成普通胡”的牌型(如 11223344556677),确认返回结果符合规则预期。
4.4 现象:Go 版本并发调用时结果错乱
原因:Go 的 slice 是引用传递,如果多个 goroutine 共享同一个手牌 slice 并同时修改,数据竞争会导致结果不可预测。
解决:每个 goroutine 传入手牌副本,或者算法内部第一件事就是拷贝一份计数数组。别图省内存直接传原 slice,棋牌服务器上这种 bug 极难复现和定位。
4.5 现象:JavaScript 版本在浏览器和 Node 下结果不同
原因:JavaScript 的数组方法如sort默认按字符串排序,[1,11,2].sort()得到[1,11,2]而不是[1,2,11]。如果算法某处依赖排序后的顺序,就会出问题。
解决:所有排序显式传比较函数(a,b) => a-b。另外注意parseInt和Number的差异,牌 ID 转换时统一用Number。
5. 进阶技巧:用查表法把胡牌判定压到微秒级
check_eye_table的真正价值在于把“去掉将牌后能否全拆成面子”这个子问题从递归变成 O(1) 查表。具体做法是:把 34 位计数数组按花色分段编码成一个整数,每段用固定位宽表示该花色每张牌的数量,然后把这个整数作为索引去查预计算好的布尔表。
编码方式决定了表的大小。如果每张牌用 2 bit 表示数量(0-3),34 张牌需要 68 bit,超出 64 位整数范围。所以常见做法是分花色编码:万子 9 张每张 2 bit 共 18 bit,条子 18 bit,筒子 18 bit,字牌 7 张每张 2 bit 共 14 bit,合计 68 bit,仍然超。实际工程中会进一步压缩,比如字牌单独处理,或者用哈希表代替数组查表。
我一般会先用递归版本跑通逻辑,再用脚本生成查表数据,最后替换。生成表的脚本本身可以用 JavaScript 或 Python 写,跑一次生成静态数组,嵌入到各语言实现里。这样五种语言共享同一份表数据,结果一致性有保障。
验证查表法是否正确,不能只测胡牌用例,还要测“不胡”的用例。因为查表法最容易出的错是索引碰撞——不同的牌型编码到了同一个索引,导致本该不胡的牌被误判为胡。测试集里要有足够多的“差一张”用例,覆盖各种花色组合。
从那以后我每次移植胡牌算法,都强制先跑一遍跨语言一致性测试,再谈性能优化。希望帮到你。
本文还有配套的精品资源,点击获取