麻将胡牌算法递归实现:C++/C#/Lua/JS多语言落地指南
2026/9/12 5:12:05 网站建设 项目流程

简介:这份压缩包汇集了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、i+1、i+2 三张牌 // i 小于 27 保证是数牌,i % 9 < 7 保证 i+2 不跨到下一花色 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 = 6,6 + 2 = 8 正好是九万,合法;索引 7 是八万,7 % 9 = 7,7 + 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::array<int, 34>配合递归函数引用传参。数组只有 34 个 int,但递归深度通常在 10 到 20 层之间,如果每一层递归都复制整个数组,操作次数会放大一个量级,所以递归函数内部直接修改传入的数组,回溯时再回滚。这个“修改 + 回滚”的口诀在 C++ 面试里也常被问到,很多候选人能写出递归,却忘了在递归失败后恢复现场,导致下一次尝试基于脏数据判断。

#include <array> using Tiles = std::array<int, 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_map<int, int>替代数组。34 个槽位的数组访问是 O(1) 且内存连续,哈希表还要计算哈希、处理冲突,对手牌计数这种固定小集合没有任何优势。递归时参数类型写成Tiles&而不是Tiles,否则每次递归调用都会触发一次完整数组拷贝,单个拷贝不贵,但乘上递归深度后性能差别明显。

3.2 C# 实现:int[] 引用语义与 Span 传参

C# 的int[]本身是引用类型,传给方法时只复制引用,底层数组只有一个实例,所以递归里的修改天然作用在同一个数组上。这里反而要小心调用方的数据被污染。C# 项目里我一般把核心判定函数设计成接收Span<int>,因为它既能接收int[],又能接收stackalloc出来的临时数组,而且不引入额外分配。

static bool FormMelds(Span<int> 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(Span<int> 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 环境不支持Span<T>时,把函数签名改回int[]即可。Span<T>是 ref struct,不能用作类的字段或闭包捕获,但递归方法传参没有任何问题。

3.3 C++ 与 C# 传参方式对比

语言传参方式底层行为恢复现场
C++std::array<int,34>&传递引用,无数据复制递归回滚
C++std::array<int,34>每次递归复制整个数组不需要回滚,但慢
C#int[]传递引用,无数据复制递归回滚
C#Span<int>传递引用,无数据复制递归回滚
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、idx+1、idx+2 -- 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 end

formMeldsidx参数要做两件事:一是跳过值为 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 四种语言核心差异速查

语言索引起点顺子边界写法递归状态恢复
C++0i % 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开头检查缓存,避免重复递归。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询