麻将AI源码实战:从胡牌判定到出牌策略的完整拆解

发布时间:2026/9/2 6:21:33
麻将AI源码实战:从胡牌判定到出牌策略的完整拆解 简介这份麻将游戏AI源代码是基于C/C和Windows API实现的桌面游戏项目适合想学习游戏AI算法与桌面应用开发的中级开发者。程序包含牌型判断、碰杠逻辑、胡牌条件等完整麻将规则以及基于搜索策略或启发式评估的电脑AI可在Windows平台编译运行。资源包共87个文件其中包含11个cpp源码、12个h头文件以及17个bmp图片资源和33个mp3音频另有工程配置、图标及说明文档整体586KB结构紧凑。已有3813人浏览学习。对于希望了解C/C游戏开发全流程的读者可从源码中梳理窗口事件处理、图形资源加载、数据结构设计和AI决策逻辑并借助附带音频图片快速还原可运行环境是兼顾规则建模与AI对抗设计的实践素材。 如果你手里正好有一份“麻将游戏AI源代码”先别急着跑起来我建议你跟着我把它拆开看一遍。麻将AI这项目看起来就是“写个程序打麻将”但真正落地时你会发现它把牌型识别、递归搜索、概率模拟、决策系统全串在了一起。这篇文章会从源码结构、胡牌判定、听牌检索、出牌策略到常见坑位完整讲一遍。想自己写麻将AI或者想给开源项目做贡献的朋友可以当作一份操作手册来用。我本人是写Python出身所以这套代码里的核心部分都用Python实现后续也聊了怎么优化性能。你可以直接复制去跑也可以改成自己熟悉的语言。麻将规则版本很多我会按最通用的“万条筒字牌”来说明同时会提一下怎么适配四川麻将、广东麻将这类变体。1. 麻将AI到底是什么样的项目1.1 为什么选“麻将AI”当练手项目麻将和象棋、围棋有个特别大的区别它是“不完全信息博弈”。你只看得到自己的手牌其他三个人的手牌全是未知牌山也是随机的。这就导致麻将AI不能像棋类AI那样纯粹靠穷举搜索它得在“已知信息”和“概率推测”之间做权衡。这种特性让麻将AI非常适合练手因为它不要求你一开始就写一个碾压人类的算法而是先让你把“规则清楚、能正常出牌”这件事做好。另外麻将AI的模块边界非常清楚规则层管牌型决策层管出牌模拟层可以单独拎出来做蒙特卡洛实验。每一个模块都能独立测试出了问题也容易定位。这种工程结构对新手非常友好你做出来的不只是“一个AI”而是一套可以复用的棋牌逻辑库。1.2 一套能跑的AI源码该有哪些模块我见过不少麻将AI项目一上来就用强化学习结果连最基本的“能不能胡”都没算对后面全是在跑步机上练空腿。真正能落地的麻将AI源码通常包含下面几个模块模块职责关键输出牌型表示定义牌的种类、编号、手牌结构手牌数组、牌堆生成胡牌判断判断一手牌是否满足胡牌条件true/false听牌检索找出所有能让手牌胡牌的张子听牌列表出牌策略从手牌里选一张打出去动作索引吃碰杠决策处理吃、碰、杠等副露动作动作类型模拟器在虚拟牌山中进行推演胜率/得分统计这六个模块缺一不可。前三个是“静”的逻辑解决“会不会打”的问题后三个是“动”的策略解决“打得聪不聪明”的问题。下面我会按这个顺序逐个拆解。2. 胡牌判断与听牌算法先解决“会不会打”2.1 用递归拆解手牌胡牌判定的标准姿势麻将胡牌的标准规则本质上就是一句话将手牌拆成若干组“顺子”或“刻子”并且恰好留出一对“将牌”。听上去简单但用代码实现时很容易在递归里绕晕。我推荐用“固定长度数组 递归拆牌”的方案。用数组长度为34分别表示万、筒、条的1到9以及东南西北中发白。数组里存的是每个牌型的剩余张数。判断胡牌时优先取出一对将牌剩下的牌继续拆顺子和刻子。# tiles: list[int]长度34tiles[i]表示索引i对应牌型的数量 def can_win(tiles, has_pairFalse): # 找到第一张数量大于0的牌 start -1 for i in range(34): if tiles[i] 0: start i break if start -1: return has_pair # 所有牌都用完时必须已经有一对将 # 尝试把这张牌作为顺子的一部分 if start % 9 6 and start 2 34 and (start 1) // 9 start // 9 and (start 2) // 9 start // 9: if tiles[start 1] 0 and tiles[start 2] 0: tiles[start] - 1 tiles[start 1] - 1 tiles[start 2] - 1 if can_win(tiles, has_pair): return True tiles[start] 1 tiles[start 1] 1 tiles[start 2] 1 # 尝试把这张牌作为刻子的一部分 if tiles[start] 3: tiles[start] - 3 if can_win(tiles, has_pair): return True tiles[start] 3 # 如果没有将牌尝试把这张牌作为将牌 if not has_pair and tiles[start] 2: tiles[start] - 2 if can_win(tiles, True): return True tiles[start] 2 return False这段代码有个细节必须注意顺子不能跨花色。在代码里我用start % 9 6和后面的(start1)//9 start//9来保证三个索引属于同一种花色。如果不加这个判断“1万2筒3条”这种跨花色的假顺子就会混进去。2.2 听牌检索让你一眼看清所有胡牌张胡牌判定是基础听牌检索就是在这个基础上加一层循环。它会遍历所有34种牌把每种牌尝试加入手牌再判断能不能胡。如果加入后能胡这张牌就是一个“听牌张”。def get_ting_list(hand): ting [] for tile in range(34): if hand[tile] 4: continue # 牌山已经不可能再有第5张了 hand[tile] 1 if can_win(hand.copy(), False): ting.append(tile) hand[tile] - 1 return ting这里有一个容易犯错的地方很多新手直接用原数组hand传给can_win没有做copy()导致递归里修改了原数组听牌结果全乱。我的经验是在入口处统一用hand.copy()宁可多消耗一点内存也不能让状态污染。听牌检索最大的价值不只是给AI用你在做“AI助手”类工具时这个函数可以直接用来提示玩家“你现在听哪几张”。从源码结构上讲它属于“纯函数”输入一手牌返回一个列表测试起来非常方便。2.3 各地规则差异与特殊牌型处理上面这套逻辑是“平胡”的基础版但实战麻将还有七对、十三幺、清一色、龙七对这些特殊牌型。如果你想适配不同地区玩法源码里一定要预留扩展点。比较常见的做法是写一个is_special_win(hand)函数在can_win之前先判断特殊牌型。比如七对def is_qidui(hand): return all(v in (0, 2) for v in hand) and sum(1 for v in hand if v 2) 7四川麻将还要处理“缺一门”也就是胡牌时手里只能存在两种花色。这个判断也很简单遍历条、筒、万三组只要有两组数量不为0就说明“没有缺一门”不能胡。把这些规则做成策略类或者配置类方便在创建房间时切换规则。3. 出牌策略从“会打”到“打得聪明”3.1 向听数与进张评估AI的第一层直觉胡牌判断只是AI的“视力”出牌策略才是AI的“大脑”。一个最简单的出牌策略就是计算每张牌打出去之后的“向听数”或“进张数”。“向听数”指的是距离听牌还差几步。比如庄家起手14张牌正常是“1向听”或“2向听”离听牌越近越好。但向听数算法比较复杂需要反向搜索新手容易写成死循环。我更推荐先做“进张评估”对当前手牌尝试打出每一张候选牌计算打完之后下一手摸到哪些牌能让手牌的“组数将牌数”变多统计这些有效张的数量取数量最多的那张牌打出去。这个方案虽然“短视”但非常稳而且跑起来很快。代码实现时你可以复用前面的递归拆牌逻辑写一个evaluate_advance(hand)函数返回当前手牌的“牌型完整度”。然后用一个循环去比较所有候选出牌。3.2 蒙特卡洛模拟让AI在脑海中打一百局进张评估能解决大部分局面但它完全没有考虑牌山剩余牌分布和对手的牌。想要让AI再上一个台阶可以引入蒙特卡洛模拟。思路特别直白把当前手牌固定住从剩余牌山里随机抽牌补进来然后按照一个简单的随机策略打牌一直模拟到流局或胡牌。重复几百次统计每种出牌方案的胜率或得分。def monte_carlo_evaluate(hand, candidates, deck, trials200): scores {c: 0 for c in candidates} for _ in range(trials): for action in candidates: new_hand hand[:] new_hand.remove(action) # 打出候选牌 win simulate_single(new_hand, deck[:]) scores[action] 1 if win else 0 return max(scores, keyscores.get)这里要提醒一句真实源码中不能真的用hand.remove(action)这种线性操作而应该用“数量减1”的方式修改索引。上面的写法是为了让思路更清楚。模拟时你的随机策略越接近真实评估结果就越可信。我一般会让AI在模拟时也调用同一套出牌函数这样蒙特卡洛的“另一个自己”会打得比较合理而不是纯乱打。蒙特卡洛最明显的缺点是性能问题。出手牌选择可能有十几张每张牌模拟200局数据量不小。我的优化办法是先用进张评估筛掉明显很差的选择只对前3个候选动作做蒙特卡洛。这样既保留了概率推演的优势又不会让出牌卡顿。3.3 吃碰杠与防守决策别只顾着自己胡出牌策略只能决定“打什么”但麻将里还有“吃碰杠”这类主动动作。源码里通常把动作决策做成一个优先级列表如果能胡直接胡如果碰能让手牌向听数降低且不会破坏关键牌型则碰如果吃能让向听数降低且该副露不会暴露手牌信息则吃否则过。防守策略是新手做麻将AI最容易忽略的部分。真实的麻将不只是自己做牌还得防止对手胡牌。我实现防守时会记录每个玩家打出的所有牌根据“生张”和“熟张”给每张牌打一个危险分。如果出牌评估发现候选牌的危险分太高同时自己的牌型离听牌还很远AI会选择弃胡打一张安全牌。这里有个简单经验别人刚打过的牌是相对安全的因为能碰到的人可能已经碰了能胡的人也可能已经过了而从未出现在弃牌堆里的生张危险系数明显高。做源码时给每张牌维护一个“剩余张数”和“场弃数量”就够了。4. 源码结构与核心实现细节4.1 项目目录与模块职责我自己的实现用了一个轻量级目录结构分得很清楚mahjong_ai/ ├── config.py # 玩法配置是否缺一门最大番型等 ├── tiles.py # 牌型常量、手牌生成、打印 ├── win_check.py # 胡牌判断、听牌检索、特殊牌型 ├── strategy.py # 进张评估、危险牌判断、出牌主流程 ├── simulator.py # 蒙特卡洛模拟器 ├── game.py # 游戏流程管理AI与随机玩家对战 └── demo.py # 命令行demo跑一局完整麻将这个结构的好处是每个文件都只解决一个问题。win_check.py负责“能不能胡”strategy.py负责“打哪张”。以后你想把AI接到Web前端只需要替换game.py里的输入输出算法部分完全不用动。4.2 数据结构选型字符串、数组还是位运算很多初学者喜欢用字符串表示手牌比如123456789m...写起来直观但处理起来很痛苦。尤其是听牌判断和递归拆牌时字符串切割和替换的效率极低。我强烈建议用固定长度数组长度34每个位置存该牌型的剩余张数。这样判断是否有某张牌只需要看hand[tile] 0增加一张牌只需要hand[tile] 1整个手牌可以直接用元组传给lru_cache做缓存递归回溯时只需要在数组对应位置加回去不需要重建字符串。如果你追求极致性能哪怕是数组也还嫌慢。这时可以换成位运算用两个64位整数分别存储“有哪些牌”和“有几张牌”但可读性会急剧下降。我的建议是先用数组跑通逻辑只有在明确性能瓶颈之后再做位运算优化。4.3 性能优化让AI出牌不卡顿用Python写递归胡牌判断在34以内数量级的手牌上其实压力不大。但如果你在蒙特卡洛模拟里频繁调用递归开销就会变得很可观。我做过几次优化效果排序如下第一用functools.lru_cache缓存can_win的结果。前提是hand必须转成tuple因为列表不可哈希。缓存之后同一手牌的重复判断会直接命中。第二提前把“听牌列表”和“进张数”缓存到一个字典里。AI在游戏过程中经常对同一手牌做多次评估命中缓存后能省掉大量计算。第三限制蒙特卡洛的模拟次数。200局和2000局在大多数局面下的结论差不多但耗时会差一个量级。第四如果真要把AI做成服务器服务建议用PyPy运行Python代码或者把核心模块用Cython改写。我踩过一个坑在递归函数里用了全局列表结果回溯时忘记恢复状态导致后续每次can_win结果都错。所以优化时你要特别小心尽量让递归函数保持“无副作用”所有修改都能在返回前恢复。5. 本地复现与问题排查5.1 五分钟跑通Demo如果你已经拿到了这套源码最快跑通的方式是这样的装好Python 3.8以上版本不需要额外依赖先把win_check.py里的can_win和get_ting_list单独跑一遍用demo.py创建一个随机手牌打印听牌列表我提供一个最简单的手牌构造例子from win_check import get_ting_list # 手牌1万、2万、3万、4万、5万、6万、7万、8万、9万、中、中、中、白、白 hand [0] * 34 for i in range(0, 9): hand[i] 1 hand[27] 3 # 中 hand[28] 2 # 白 ting get_ting_list(hand) print(ting)如果输出里包含“红中”或“白板”对应的索引说明基础逻辑正常。跑通之后再去研究strategy.py里的出牌评估不然一上来就跑完整游戏出了问题根本不知道是该查递归、还是查策略。5.2 常见问题与解决方案速查表我在调试麻将AI时遇到过一堆奇奇怪怪的问题列成一张表给你参考现象可能原因解决办法can_win递归永远不结束递归时没有正确减少牌数或者回溯时没有恢复数量在函数入口打印状态检查每个分支是否都回溯听牌列表漏牌用了同一个列表对象传入递归没有copy调用can_win前使用hand.copy()顺子跨花色被误判没有限制start % 9 6顺子判断时增加同花色边界检查蒙特卡洛结果波动大模拟次数太少或者随机策略太差提高模拟次数并让模拟内部使用现有策略打牌出牌太慢界面卡顿每次出牌都完整跑蒙特卡洛先用进张评估筛出前3个候选再模拟特殊牌型被忽略没有提前判断七对/十三幺等在can_win之前调用is_special_win5.3 后续扩展方向源码跑通之后你可以按自己的兴趣往几个方向扩展。一个是把AI接到真实游戏平台做成自动打牌脚本或陪练助手这就需要在game.py里对接平台协议。另一个是引入强化学习用策略网络替代现在的规则评估初期可以用DQN打自己后面逐步加入对手建模。还有现在很流行用“大模型辅助开发”的方式你能让AI agent自己读代码、自动生成测试用例甚至在出牌策略里加入自然语言解释让玩家明白AI为什么这样打。我个人实际使用的体会是麻将AI项目最值得投入的不是算法本身而是把规则、测试、状态管理做扎实。很多同学一上来就想去搞深度学习结果连基础听牌判断都还有bug后面每一步都会被错误放大。先把“会打”这件事做对再去研究“打赢”这条路是走最快的。最后再分享一个小技巧开发时一定要给手牌写一个漂亮的打印函数每一轮把“手牌、候选动作、评分、听牌列表”全部打印出来否则你会在各种随机数里彻底迷路。本文还有配套的精品资源点击获取