news 2026/10/26 8:19:55

C++实现麻将胡牌与听牌核心逻辑:数据结构、递归优化与并行计算

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++实现麻将胡牌与听牌核心逻辑:数据结构、递归优化与并行计算

简介:这是一份面向C++初学者与游戏开发爱好者的麻将游戏完整源码工程,基于Visual C++与MFC构建,适合用来学习Windows桌面游戏开发流程与面向对象编程思想。压缩包共135个文件,约3.86MB,包含9个cpp源文件与10个头文件承载核心逻辑,89个wav音频与5个bmp位图、2个ico图标构成音效与界面素材,另有dsw、dsp、opt等工程配置文件和麻将介绍文本,以及可直接运行的exe程序。项目覆盖洗牌、胡牌判断、电脑出牌思考等算法实现,并借助MFC完成对话框与图形界面交互,读者可从中理解Visual Studio项目管理、资源打包与文本读取等实用技能。目前已有295人学习下载,适合作为课程设计或自学练手参考。

1. 从一份 CPP-Mahjong-game.rar 说起:麻将逻辑到底难在哪

很多人第一次拿到CPP-Mahjong-game.rar这种命名,第一反应是「一个 C++ 写的麻将小游戏,能有多难」。真动手拆开写一遍,你会发现难点根本不在画牌面,而在胡牌判定、听牌计算、番型识别这三块纯逻辑。麻将的规则组合爆炸:一副牌 136 张,光是标准胡牌型(4 面子 + 1 将)的枚举空间就够让暴力递归卡死,再加上七对、十三幺、各种地方番型,判定函数写不好就是指数级灾难。

这篇笔记面向两类人:一是想用 C++ 从零实现一套可复用的麻将核心逻辑(不依赖任何引擎,纯 STL 就能跑)的开发者;二是手里已经有一份CPP-Mahjong-game源码、想读懂它为什么这么设计、想改规则或接 AI 的人。我会把「牌怎么表示、胡牌怎么判、听牌怎么算、性能怎么压」这条链路拆开讲,每一步都给能直接抄的代码和参数。C++ 在这里的价值不是「快」这么笼统,而是std::vector的紧凑存储、位运算的状态压缩、以及std::execution并行策略在批量听牌计算上的实际收益。读完你应该能自己搭出一个判定正确、单次胡牌判定在微秒级的核心模块。

2. 牌型数据结构:用 C++ vector 还是定长数组,先想清楚

2.1 为什么麻将牌不适合用字符串或对象数组表示

新手最容易翻车的地方,是把每张牌写成一个Card类,字段有花色、点数、名字,然后手里捏一个std::vector<Card>。这样写可读性是好,但胡牌判定要做大量「这张牌还剩几张」「这个顺子能不能凑」的查询,对象数组的缓存局部性极差,一次判定动辄几百微秒。麻将的核心操作本质是计数,不是对象操作。

常见做法是把 34 种牌(万筒条各 9 张 + 风牌 4 种 + 箭牌 3 种)映射成下标 0~33,用一个长度 34 的计数数组表示手牌。这个数组就是你的「黑匣子」状态,所有判定都基于它。下面是我一般会用的表示:

#include <array> #include <cstdint> // 牌下标约定: // 0-8 万 (1万~9万) // 9-17 筒 // 18-26 条 // 27-30 东南西北 // 31-33 中发白 constexpr int TILE_KINDS = 34; using HandCount = std::array<uint8_t, TILE_KINDS>; // 从「牌 id 列表」转成计数数组,牌 id 范围 0~135 HandCount to_count(const std::vector<int>& tiles) { HandCount cnt{}; for (int id : tiles) { cnt[id / 4]++; // 每种牌 4 张,id/4 得到种类下标 } return cnt; }

逻辑说明:id / 4这个映射是关键,它把 136 张实体牌压成 34 个种类计数。参数上uint8_t足够,因为每种牌最多 4 张,不会溢出。这样一次胡牌判定只需要遍历 34 个字节,全部能塞进一个 cache line,比对象数组快一个数量级。

2.2 计数数组的三种变体与选型

实际项目里我会根据用途选不同变体,别一套结构打天下:

结构用途内存特点
array<uint8_t,34>手牌计数34 B最常用,判定主力
uint64_t位掩码是否持有某牌8 B快速查「有没有」
array<uint8_t,34>+ 副露列表完整手牌状态变长副露单独存,不混进计数

副露(吃碰杠)千万不要直接减进计数数组就完事,否则你分不清「这张牌是手里的还是碰出来的」,番型判定会错。我的习惯是手牌计数只存暗牌,副露用一个std::vector<Meld>单独维护,Meld里记录类型和牌种。

enum class MeldType { Chi, Pon, Kan, AnKan }; struct Meld { MeldType type; int tile; // 起始牌种下标 bool from_self; // 是否自己摸的(暗杠/自摸相关) };

参数说明:tile对吃来说记最小那张,比如 234 万记 1(2万的下标),判定顺子时按tile, tile+1, tile+2展开。from_self影响杠上开花、抢杠等番型,别省这个字段,后期加规则时你会感谢自己。

2.3 用位运算做「快速存在性」查询

有些判定只需要知道「有没有某张牌」,不需要数量。这时用位掩码比遍历数组快:

uint64_t to_mask(const HandCount& cnt) { uint64_t m = 0; for (int i = 0; i < TILE_KINDS; ++i) if (cnt[i]) m |= (1ULL << i); return m; } // 判断是否持有某张牌 inline bool has_tile(uint64_t mask, int tile) { return mask & (1ULL << tile); }

逻辑说明:34 位刚好塞进一个uint64_t,has_tile是一条位与指令,比cnt[tile] > 0还快,而且掩码可以整体拷贝、比较。注意别用这个掩码替代计数数组做胡牌判定,它丢掉了数量信息,只能做存在性判断。

3. 胡牌判定:递归拆面子为什么容易翻车,怎么改

3.1 标准胡牌型的递归拆解思路

标准胡牌是「4 个面子 + 1 对将」,面子可以是顺子(同花色连续三张)或刻子(三张相同)。最直观的写法是递归:从最小的牌开始,尝试把它作为将、顺子起点、刻子起点,逐层剥离。

// 判断去掉将之后能否拆成 4 个面子 bool can_form_melds(HandCount& cnt, int need) { if (need == 0) return true; // 找到第一张还有剩余的牌 int i = 0; while (i < TILE_KINDS && cnt[i] == 0) ++i; if (i == TILE_KINDS) return false; // 尝试刻子 if (cnt[i] >= 3) { cnt[i] -= 3; if (can_form_melds(cnt, need - 1)) { cnt[i] += 3; return true; } cnt[i] += 3; } // 尝试顺子(只有数牌能组顺子,且 i 不能跨花色) if (i < 27 && (i % 9) <= 6 && cnt[i+1] > 0 && cnt[i+2] > 0) { cnt[i]--; cnt[i+1]--; cnt[i+2]--; if (can_form_melds(cnt, need - 1)) { cnt[i]++; cnt[i+1]++; cnt[i+2]++; return true; } cnt[i]++; cnt[i+1]++; cnt[i+2]++; } return false; } bool is_standard_win(const HandCount& hand) { HandCount cnt = hand; for (int i = 0; i < TILE_KINDS; ++i) { if (cnt[i] >= 2) { cnt[i] -= 2; // 尝试把 i 当将 if (can_form_melds(cnt, 4)) { cnt[i] += 2; return true; } cnt[i] += 2; } } return false; }

逻辑说明:can_form_melds每次剥离一个面子,need从 4 递减到 0。关键剪枝是「永远从最小的非零牌开始拆」——因为最小的那张牌必须被用掉,它要么做刻子,要么做顺子的起点,不可能做顺子的中间或末尾(比它小的牌已经处理完了)。这个不变量让递归分支数大幅下降。

参数说明:i % 9 <= 6是防止顺子跨花色,比如 8万、9万、1筒 不能组成顺子。i < 27限定只有数牌能组顺子,风牌箭牌只能做刻子。

3.2 递归的性能陷阱与记忆化

上面这段代码在单次判定时够用,但如果你要批量算「打出某张牌后能不能听」,会调用成千上万次,纯递归会重复计算大量相同子状态。血泪经验:手牌计数数组本身就是完美的记忆化 key,34 字节可以直接哈希。

#include <unordered_map> #include <string> std::unordered_map<std::string, bool> memo; bool can_form_melds_memo(HandCount& cnt, int need) { if (need == 0) return true; std::string key(reinterpret_cast<char*>(cnt.data()), TILE_KINDS); key.push_back(static_cast<char>(need)); auto it = memo.find(key); if (it != memo.end()) return it->second; bool result = false; int i = 0; while (i < TILE_KINDS && cnt[i] == 0) ++i; if (i == TILE_KINDS) { memo[key] = false; return false; } // ... 同上的刻子/顺子尝试,命中时 result = true memo[key] = result; return result; }

逻辑说明:把 34 字节计数 + 1 字节need拼成字符串当 key。这样写简单,但字符串哈希有开销。追求极致的话,用std::array<uint8_t,35>做 key 配自定义哈希,或者把计数数组编码成一个uint64_t(每种牌 2 bit,34 种需要 68 bit,超了,所以得用两个uint64_t或直接上std::bitset)。

提示:记忆化表在多局游戏间要清空,否则会串局。我一般把它做成判定器的成员变量,每局开始memo.clear()。

3.3 七对、十三幺等特殊牌型的独立判定

特殊牌型不要硬塞进标准递归,单独写判定更快也更清晰:

bool is_seven_pairs(const HandCount& cnt) { int pairs = 0, singles = 0; for (int i = 0; i < TILE_KINDS; ++i) { if (cnt[i] == 2) pairs++; else if (cnt[i] == 4) pairs += 2; // 四张算两对 else if (cnt[i] != 0) singles++; } return pairs == 7 && singles == 0; } bool is_thirteen_orphans(const HandCount& cnt) { static const int orphans[13] = {0,8,9,17,18,26,27,28,29,30,31,32,33}; int kinds = 0, has_pair = 0; for (int t : orphans) { if (cnt[t] == 0) return false; if (cnt[t] >= 2) has_pair = 1; kinds++; } // 十三种幺九牌齐全 + 其中一种成对 int total = 0; for (int i = 0; i < TILE_KINDS; ++i) total += cnt[i]; return kinds == 13 && has_pair && total == 14; }

逻辑说明:七对判定里,四张相同算两对是很多地方规则认可的,别漏。十三幺先检查 13 种特定牌是否齐全,再检查是否有且仅有一对。参数上orphans数组是固定的 13 个下标,写死比运行时算快。

4. 听牌计算:从暴力枚举到 std::execution 并行

4.1 听牌的本质是「加一张能不能胡」

听牌计算就是:对手牌 13 张,枚举所有可能的 34 种牌,每种加一张看是否构成胡牌。听起来简单,但如果你还要算「打哪张能听、听几张」,组合数就上来了。最朴素的写法:

std::vector<int> waiting_tiles(const HandCount& hand) { std::vector<int> result; HandCount cnt = hand; for (int t = 0; t < TILE_KINDS; ++t) { if (cnt[t] >= 4) continue; // 这种牌已经没了 cnt[t]++; if (is_win(cnt)) result.push_back(t); cnt[t]--; } return result; }

逻辑说明:cnt[t] >= 4是必须的剪枝,一种牌总共 4 张,手里已有 4 张就不可能再摸到。is_win内部先判特殊牌型再判标准型。这个函数对手牌 13 张调用一次,最多 34 次is_win,单次判定微秒级,整体几十微秒,够用。

4.2 用 std::execution 并行批量计算

真正吃性能的场景是 AI 决策:枚举「打出手里每一张牌」×「每种打法的听牌」,一次决策要算十几次听牌。这时std::execution的并行策略就有价值了。C++17 起<execution>提供了std::execution::par,配合算法库能自动多线程:

#include <execution> #include <algorithm> #include <numeric> // 计算「打出某张牌后」的听牌张数,返回 (牌种, 听牌数) 列表 std::vector<std::pair<int,int>> discard_analysis(const HandCount& hand) { std::vector<int> candidates; for (int t = 0; t < TILE_KINDS; ++t) if (hand[t] > 0) candidates.push_back(t); std::vector<std::pair<int,int>> results(candidates.size()); std::transform(std::execution::par, candidates.begin(), candidates.end(), results.begin(), [&hand](int discard) { HandCount cnt = hand; cnt[discard]--; auto waits = waiting_tiles(cnt); // 听牌张数 = 每种听牌的剩余张数之和 int count = 0; for (int w : waits) count += (4 - cnt[w]); return std::make_pair(discard, count); }); return results; }

逻辑说明:std::transform配std::execution::par会把candidates上的映射并行化,每个线程独立算一种打法的听牌。注意 lambda 里cnt是值拷贝,不能捕获引用后共享修改,否则数据竞争。参数上4 - cnt[w]是剩余张数,因为cnt是打出后的手牌,还没算别人手里的,这是理论最大听牌数。

注意:std::execution::par需要编译器支持。GCC 需要链接 TBB(-ltbb),MSVC 原生支持,Clang 视版本而定。如果你的环境不支持,退回std::thread手动分块,别硬上。

4.3 听牌结果的正确性验证

并行化最容易出的 bug 是「结果对但顺序乱」或「共享状态被踩」。验证方法很简单:写一个串行版本,对同一手牌跑 1000 次,比对结果集合是否一致。

// 串行参考实现,用于对拍 std::vector<std::pair<int,int>> discard_analysis_serial(const HandCount& hand) { std::vector<std::pair<int,int>> results; for (int t = 0; t < TILE_KINDS; ++t) { if (hand[t] == 0) continue; HandCount cnt = hand; cnt[t]--; auto waits = waiting_tiles(cnt); int count = 0; for (int w : waits) count += (4 - cnt[w]); results.emplace_back(t, count); } return results; }

逻辑说明:对拍时把两个结果按first排序后比较,别直接比 vector,因为并行版顺序不保证。这个习惯能帮你抓出 90% 的并行 bug。

5. 避坑与排查:麻将逻辑里最容易翻车的 5 个点

5.1 顺子跨花色判定漏了边界

现象:is_standard_win偶尔把 8万9万1筒 判成顺子,导致假胡。原因:只判断了cnt[i+1] > 0 && cnt[i+2] > 0,没限制i在花色内的位置。解决:加(i % 9) <= 6,确保顺子起点不越过 9 的边界。这个坑我在三个不同项目里都见过,属于经典翻车点。

5.2 副露混进计数数组导致番型错乱

现象:碰了 3 条之后,手牌计数里 3 条是 0,但算番时「门前清」判定错误。原因:把副露直接减进计数数组,丢失了「这是副露」的信息。解决:手牌计数只存暗牌,副露单独用Meld列表维护,算番时分别查两处。

5.3 记忆化表跨局未清空

现象:第二局开始后,某些明明能胡的牌判成不能胡。原因:memo是全局或静态的,上一局的子状态残留。解决:把memo作为判定器成员,每局开始清空;或者给 key 加上局 id。后悔药没有,只能靠纪律。

5.4 std::execution 并行下的数据竞争

现象:并行听牌计算结果偶尔少一项,或程序偶发崩溃。原因:lambda 捕获了引用,多个线程同时改同一个HandCount。解决:lambda 内所有可变状态都值拷贝,只读的hand可以按引用捕获。用 ThreadSanitizer(-fsanitize=thread)跑一遍能直接定位。

5.5 牌 id 映射写错导致整副牌错位

现象:所有判定都「差一点」,比如总是少一张牌。原因:id / 4的映射和实际牌的排列对不上,比如把风牌箭牌的顺序搞反。解决:写一个单元测试,构造 136 张牌,验证每种牌恰好 4 张、映射后计数总和为 136。这个测试五分鐘能写完,能省你几小时 debug。

6. 把判定器做成可复用模块:接口设计与单测习惯

走到这里,核心逻辑已经能跑了。但要让这套东西真正可复用,接口设计比算法更值得花时间。我一般会把判定器封装成一个类,对外只暴露三个方法:is_win、waiting_tiles、discard_analysis,内部状态(记忆化表、副露列表)全部私有。这样换规则、加番型时,调用方代码一行不用改。

class MahjongJudge { public: explicit MahjongJudge(std::vector<Meld> melds = {}) : melds_(std::move(melds)) {} bool is_win(const HandCount& hand) const { if (is_seven_pairs(hand) || is_thirteen_orphans(hand)) return true; return is_standard_win(hand); } std::vector<int> waiting_tiles(const HandCount& hand) const { std::vector<int> result; HandCount cnt = hand; for (int t = 0; t < TILE_KINDS; ++t) { if (cnt[t] >= 4) continue; cnt[t]++; if (is_win(cnt)) result.push_back(t); cnt[t]--; } return result; } private: std::vector<Meld> melds_; mutable std::unordered_map<std::string, bool> memo_; };

逻辑说明:memo_用mutable是为了在const方法里也能写缓存。melds_构造时传入,判定时结合暗牌一起算。这个接口的好处是,你后面接 AI、接 UI、接网络对战,都只依赖这三个方法,内部怎么优化都不影响外部。

验证习惯上,我强烈建议给判定器配一组「黄金测试用例」:标准胡、七对、十三幺、听牌、假胡各来几个,写死期望结果。每次改判定逻辑先跑这组测试,绿了再提交。麻将规则的边界太多,靠人肉记忆迟早翻车。我自己维护的那组用例有 40 多条,覆盖了各种顺子边界、副露组合、特殊牌型,跑一遍不到一秒,但帮我挡掉了无数次回归。

最后一个具体技巧:如果你要算「向听数」(离胡牌还差几步),别用 BFS 硬搜,用「面子数 + 搭子数」的启发式评估,配合查表能快几十倍。这个展开又是另一篇的体量了,先把胡牌和听牌这两块打磨扎实,向听数自然水到渠成。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 8:19:54

Raft共识算法:分布式系统一致性的核心机制

1. 为什么我们需要共识算法&#xff1f;想象一下你和几个朋友要决定今晚去哪吃饭。如果大家同时喊出不同的餐厅名字&#xff0c;最后可能谁也听不清谁。分布式系统里的节点就像这些朋友&#xff0c;需要一种方法来对某个值达成一致——这就是共识算法要解决的问题。在分布式系统…

作者头像 李华
网站建设 2026/10/26 8:19:53

基于YOLOv8的电子元器件智能检测系统开发实践

1. 项目概述&#xff1a;电子元器件目标检测系统这个基于YOLOv8的电子元器件检测系统&#xff0c;本质上是一个将工业质检流程智能化的解决方案。我在电子制造行业做了多年自动化项目&#xff0c;深知产线上人工目检的效率痛点——工人需要在高倍显微镜下逐个检查0402甚至0201封…

作者头像 李华
网站建设 2026/10/26 8:18:34

浏览器渲染流程与重排重绘优化指南

1. 浏览器渲染流程基础认知当我们在地址栏输入网址按下回车后&#xff0c;浏览器究竟是如何把代码变成屏幕上绚丽画面的&#xff1f;这个问题困扰过无数前端开发者。要理解重排&#xff08;Reflow&#xff09;和重绘&#xff08;Repaint&#xff09;的本质区别&#xff0c;我们…

作者头像 李华
网站建设 2026/10/26 8:16:59

蒙特卡洛搜索树解中国象棋残局:120关“乌江自刎”实战与优化

简介&#xff1a;这份资源是基于蒙特卡洛搜索树&#xff08;MCTS&#xff09;实现中国象棋残局解法的C源码工程&#xff0c;面向计算机、人工智能及相关专业的在校学生、教师与企业开发者&#xff0c;可用于毕业设计、课程设计、大作业或项目立项演示&#xff0c;帮助理解MCTS在…

作者头像 李华
网站建设 2026/10/26 8:16:33

HAR文件在接口自动化测试中的应用与实践

1. HAR文件与接口自动化测试的天然契合第一次接触HAR文件是在三年前的一个性能测试项目&#xff0c;当时需要分析页面加载过程中的所有网络请求。打开那个.json格式的文件时&#xff0c;我突然意识到——这不就是现成的接口测试数据集吗&#xff1f;每个请求的URL、参数、头部信…

作者头像 李华