尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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::vectorCard。这样写可读性是好但胡牌判定要做大量「这张牌还剩几张」「这个顺子能不能凑」的查询对象数组的缓存局部性极差一次判定动辄几百微秒。麻将的核心操作本质是计数不是对象操作。常见做法是把 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::arrayuint8_t, TILE_KINDS; // 从「牌 id 列表」转成计数数组牌 id 范围 0~135 HandCount to_count(const std::vectorint 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 计数数组的三种变体与选型实际项目里我会根据用途选不同变体别一套结构打天下结构用途内存特点arrayuint8_t,34手牌计数34 B最常用判定主力uint64_t位掩码是否持有某牌8 B快速查「有没有」arrayuint8_t,34 副露列表完整手牌状态变长副露单独存不混进计数副露吃碰杠千万不要直接减进计数数组就完事否则你分不清「这张牌是手里的还是碰出来的」番型判定会错。我的习惯是手牌计数只存暗牌副露用一个std::vectorMeld单独维护Meld里记录类型和牌种。enum class MeldType { Chi, Pon, Kan, AnKan }; struct Meld { MeldType type; int tile; // 起始牌种下标 bool from_self; // 是否自己摸的暗杠/自摸相关 };参数说明tile对吃来说记最小那张比如 234 万记 12万的下标判定顺子时按tile, tile1, tile2展开。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_thas_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[i1] 0 cnt[i2] 0) { cnt[i]--; cnt[i1]--; cnt[i2]--; if (can_form_melds(cnt, need - 1)) { cnt[i]; cnt[i1]; cnt[i2]; return true; } cnt[i]; cnt[i1]; cnt[i2]; } 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 递归的性能陷阱与记忆化上面这段代码在单次判定时够用但如果你要批量算「打出某张牌后能不能听」会调用成千上万次纯递归会重复计算大量相同子状态。血泪经验手牌计数数组本身就是完美的记忆化 key34 字节可以直接哈希。#include unordered_map #include string std::unordered_mapstd::string, bool memo; bool can_form_melds_memo(HandCount cnt, int need) { if (need 0) return true; std::string key(reinterpret_castchar*(cnt.data()), TILE_KINDS); key.push_back(static_castchar(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::arrayuint8_t,35做 key 配自定义哈希或者把计数数组编码成一个uint64_t每种牌 2 bit34 种需要 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::vectorint waiting_tiles(const HandCount hand) { std::vectorint 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的并行策略就有价值了。C17 起execution提供了std::execution::par配合算法库能自动多线程#include execution #include algorithm #include numeric // 计算「打出某张牌后」的听牌张数返回 (牌种, 听牌数) 列表 std::vectorstd::pairint,int discard_analysis(const HandCount hand) { std::vectorint candidates; for (int t 0; t TILE_KINDS; t) if (hand[t] 0) candidates.push_back(t); std::vectorstd::pairint,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-ltbbMSVC 原生支持Clang 视版本而定。如果你的环境不支持退回std::thread手动分块别硬上。4.3 听牌结果的正确性验证并行化最容易出的 bug 是「结果对但顺序乱」或「共享状态被踩」。验证方法很简单写一个串行版本对同一手牌跑 1000 次比对结果集合是否一致。// 串行参考实现用于对拍 std::vectorstd::pairint,int discard_analysis_serial(const HandCount hand) { std::vectorstd::pairint,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[i1] 0 cnt[i2] 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-fsanitizethread跑一遍能直接定位。5.5 牌 id 映射写错导致整副牌错位现象所有判定都「差一点」比如总是少一张牌。原因id / 4的映射和实际牌的排列对不上比如把风牌箭牌的顺序搞反。解决写一个单元测试构造 136 张牌验证每种牌恰好 4 张、映射后计数总和为 136。这个测试五分鐘能写完能省你几小时 debug。6. 把判定器做成可复用模块接口设计与单测习惯走到这里核心逻辑已经能跑了。但要让这套东西真正可复用接口设计比算法更值得花时间。我一般会把判定器封装成一个类对外只暴露三个方法is_win、waiting_tiles、discard_analysis内部状态记忆化表、副露列表全部私有。这样换规则、加番型时调用方代码一行不用改。class MahjongJudge { public: explicit MahjongJudge(std::vectorMeld 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::vectorint waiting_tiles(const HandCount hand) const { std::vectorint 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::vectorMeld melds_; mutable std::unordered_mapstd::string, bool memo_; };逻辑说明memo_用mutable是为了在const方法里也能写缓存。melds_构造时传入判定时结合暗牌一起算。这个接口的好处是你后面接 AI、接 UI、接网络对战都只依赖这三个方法内部怎么优化都不影响外部。验证习惯上我强烈建议给判定器配一组「黄金测试用例」标准胡、七对、十三幺、听牌、假胡各来几个写死期望结果。每次改判定逻辑先跑这组测试绿了再提交。麻将规则的边界太多靠人肉记忆迟早翻车。我自己维护的那组用例有 40 多条覆盖了各种顺子边界、副露组合、特殊牌型跑一遍不到一秒但帮我挡掉了无数次回归。最后一个具体技巧如果你要算「向听数」离胡牌还差几步别用 BFS 硬搜用「面子数 搭子数」的启发式评估配合查表能快几十倍。这个展开又是另一篇的体量了先把胡牌和听牌这两块打磨扎实向听数自然水到渠成。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

基于YOLOv8的电子元器件检测系统开发与优化

基于YOLOv8的电子元器件检测系统开发与优化

1. 项目概述:电子元器件目标检测系统这个项目实现了一个基于YOLOv8的电子元器件检测系统,包含完整的训练、推理和可视化界面。我在工业质检领域做过类似项目,这类系统能显著提升生产线上的元器件分拣效率。传统人工检测每小时最多处理200-300…

📅 2026/10/26 8:19:57
工业级知识检索优化:动态权重与分层索引实践

工业级知识检索优化:动态权重与分层索引实践

1. 项目背景与核心挑战在工业级知识检索场景中,响应延迟直接影响用户体验和系统吞吐量。我们遇到的实际案例显示:当检索延迟超过150ms时,用户会话放弃率会显著上升23%。传统RAG(Retrieval-Augmented Generation)方案在…

📅 2026/10/26 8:19:57
AI Agent走出屏幕囚笼:Muse登顶与开源SDK背后的硬件控制之路

AI Agent走出屏幕囚笼:Muse登顶与开源SDK背后的硬件控制之路

Muse 登顶 App Store 那天,我朋友圈里最热闹的讨论不是“又一个大模型应用爆了”,而是一连串让我有点意外的问题:有人问 macOS 上下载的文件到底存在哪,有人搜“meta muse 怎么装”,还有人开始认真研究 AI Agent 的主流…

📅 2026/10/26 8:14:57
MORE NEWS

更多资讯

📰

MATLAB男女声音识别实战:特征提取与分类器全流程

简介:面向音频处理与机器学习初学者的MATLAB男声女声识别项目,基于GUI图形界面完成性别分类。资源共五个文件,包含三段分别用于训练和测试的男女声音频、一个GUI布局文件以及一份完整M源码,压缩包仅1.42MB,轻量易部署。…

📰

claude-seo 图片 SEO 优化完整指南:从 on-page 审计、格式转换到 IPTC 元数据与 AI 图片合规

【免费下载链接】claude-seo Universal SEO skill for Claude Code. 26 sub-skills 19 sub-agents covering technical SEO, E-E-A-T, schema, GEO/AEO, agent readiness (Lighthouse Agentic Browsing, WebMCP, llms.txt), backlinks, local SEO, e-commerce, international …

📰

Claude SEO 项目 hreflang 技能实战指南:国际 SEO 审核、标签生成与多语言内容一致性审计

【免费下载链接】claude-seo Universal SEO skill for Claude Code. 26 sub-skills 19 sub-agents covering technical SEO, E-E-A-T, schema, GEO/AEO, agent readiness (Lighthouse Agentic Browsing, WebMCP, llms.txt), backlinks, local SEO, e-commerce, international …

📰

微生物群落组装机制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

静态时序分析:线负载模型的选择机制

相关阅读 静态时序分析https://blog.csdn.net/weixin_45791458/category_12567571.html 线负载模型及其选择 线负载模型仅在Design Compiler线负载模式(非拓扑模式)下时使用,它估算了导线长度和扇出对网线的电阻、电容和面积的影响&#xff…

📰

STM32F1嵌入式入门实战:DHT11温湿度采集与串口通信

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬