麻将AI核心算法解析:从胡牌判断到蒙特卡洛出牌决策
简介麻将游戏AI源代码是一份基于C/C的Windows桌面游戏工程面向对游戏开发与人工智能算法感兴趣的开发者可用于学习智能出牌、碰杠操作和胡牌判定等核心逻辑的实现思路。压缩包共87个文件大小仅586KB包含12个h头文件与11个cpp源码文件、17个bmp图像资源、33个mp3音频以及项目配置、图标和构建文件等目录结构清晰便于按模块阅读。项目核心是电脑AI对战可能涉及搜索树与启发式评估同时覆盖Win32窗口、GDI绘图、事件循环、牌型数据结构和资源加载等关键环节是理解麻将规则数学建模和AI决策过程的良好实践案例。已有3813人学习适合希望从零掌握游戏底层交互与上层逻辑组织的C/C开发者。通过阅读源码可获取一个可直接编译运行、便于扩展AI策略的完整项目范本进而提升游戏开发与算法设计能力。1. 麻将AI的整体设计与核心思路做麻将AI一直是一件很有意思也很有挑战的事。和围棋、象棋这类完全信息博弈不同麻将是一个典型的不完全信息博弈——你只能看到自己的手牌和桌面上的弃牌对手手里的牌全靠猜。更麻烦的是各地规则差异巨大四川麻将缺门、广东麻将带花、日本麻将还有番种和振听同一个AI换一种规则就可能是另一个游戏。这篇博文就从一个可运行的源代码角度出发拆解一个通用麻将AI的核心组成包括牌型表示、胡牌判断、向听数计算、出牌决策这些关键模块并把核心代码逻辑和我在实际开发中踩过的坑一起整理出来。想用代码做棋牌AI的开发者、对算法实战感兴趣的读者都可以在搭建自己的原型时直接参考。1.1 麻将AI为什么难三层复杂度先说结论麻将AI的难度不在于单步决策而在于决策的信息量和维度都太高。第一层是组合复杂度。一手牌13张胡牌需要凑成4组面子加1对将牌面子的类型还有顺子、刻子、杠子之分。手牌排列组合的数量级非常大如果直接用暴力搜索哪怕是一手很普通的牌状态空间也能膨胀到完全不可用的程度。第二层是不完全信息。你在出牌时并不知道对手的牌型只能根据他的吃碰杠行为和舍牌去推断。这跟打德州扑克有点像但麻将的公共信息更少大部分信息都藏在对手的行动模式里。第三层是规则多样性。同一套算法如果硬编码了某种玩法换一个规则就得推翻重写。优秀的麻将AI源码必须把规则层和决策层解耦才能在不同玩法之间快速迁移。这三点决定了麻将AI的源码结构要有别于一般棋类程序不能只靠搜索必须有牌型评估、概率推断和规则配置的组合。1.2 技术选型为什么用Python构建原型我见过不少人一上来就用C写麻将AI理由是性能。思路没错但如果你是第一次做我强烈建议先用Python搭一个可运行的原型。原因有三Python写牌型判断、递归搜索这类逻辑非常直观调试效率高一天能改十个版本性能瓶颈往往在算法本身而不是语言。先用Python把算法跑通后续再用Cython、Numba或者C重写也不迟Python生态里有丰富的数值计算和数据分析库后面做蒙特卡洛模拟、局面统计时会省很多事。实际项目中我最终形态是Python做决策引擎、C做对局服务器中间通过消息传递交互。但所有核心算法最初都是Python跑通的。这个思路也体现在了下面的代码结构里。1.3 整体模块划分一个通用麻将AI源码通常分成四个层次层次模块职责数据层牌型表示与编码定义牌、手牌、牌山的数据结构规则层胡牌判断、规则配置判断是否胡牌、是否允许吃碰杠决策层出牌策略、吃碰杠决策决定当前最优动作模拟层蒙特卡洛模拟、对手建模对牌局进行推演获取评估数据2. 核心技术模块解析与选型考量2.1 牌的表示用长度34的数组解决一切基础问题麻将牌不管怎么变基础牌型就是34种万、筒、条各9种加上字牌7种东南西北中发白。我用一个长度为34的整数数组表示手牌数组下标对应牌的种类值表示该牌的张数。# 牌编码示例 # 0-8 : 一万到九万 # 9-17 : 一筒到九筒 # 18-26 : 一条到九条 # 27-33 : 东南西北中发白 def encode_tile(suit, rank): if suit m: return rank - 1 elif suit p: return 8 rank elif suit s: return 17 rank else: return 26 rank这个表示法最大的优势是判断顺子、刻子、将牌都可以直接用下标偏移计算而不用维护复杂的对象结构。实战中我再加一个长度为34的left_tiles数组记录每种牌剩余的张数用于后续的概率推断。手牌就只是hand[34]所有操作都是数组的增删跑起来非常快。2.2 胡牌判断递归拆解的核心逻辑胡牌判断是所有麻将AI的地基。规则很简单手牌凑成N个面子加1对将牌或者七对子部分地区。但代码实现要考虑性能不能每个判断都做全排列。我用的标准做法是递归拆解优先取出一对将牌然后递归地剥离刻子和顺子直到手牌清空。这个算法在34长度的数组上运行非常高效。2.3 向听数与听牌判断决策的“距离感”向听数指的是当前手牌离胡牌还差几次有效进张。0向听就是听牌1向听是差一张有效牌就听牌。向听数是麻将AI用来评估牌型好坏的核心指标出牌策略本质上就是选择打出后能让向听数最小、同时保留最大进张的牌。听牌判断比胡牌判断更进一步遍历34种牌把每种牌加入手牌后调用胡牌判断如果能胡就说明这张牌是听牌。2.4 出牌决策的三个层级我把出牌策略从简到繁分成三个层级第一级最简规则。打出手牌中向听数改善最差、进张面最窄的牌。适合初学者理解逻辑第二级安全牌优先。结合对手的舍牌判断哪些牌放炮风险高优先打安全牌第三级蒙特卡洛推演。对每个合法出牌模拟大量随机牌局统计胜率和收益选择期望值最高的打法。实际项目中我采用第二级和第三级混合的策略。下面会详细讲第三级的实现。3. 实操过程与关键代码实现这套源码的核心逻辑不大我在项目里把它拆成几个清晰的文件。3.1 项目目录结构mahjong_ai/ ├── tiles.py # 牌的编码与工具函数 ├── hand.py # 手牌数据结构 ├── win_check.py # 胡牌判断 ├── tenpai.py # 听牌与向听数 ├── strategy.py # 蒙特卡洛出牌策略 └── main.py # 主循环示例3.2 手牌编码与胡牌判断的实现先看胡牌判断这段代码是整个AI的核心。它递归地尝试拆解手牌先把可能的将牌挑出来然后递归处理剩余的牌。def can_win(hand): # hand: list of length 34 if sum(hand) % 3 ! 2: return False return _can_win_with_pair(hand, False) def _can_win_with_pair(hand, has_pair): if all(x 0 for x in hand): return True # 找到第一张非零牌 for i in range(34): if hand[i] 0: continue if not has_pair and hand[i] 2: hand[i] - 2 if _can_win_with_pair(hand, True): hand[i] 2 return True hand[i] 2 # 拆刻子 if hand[i] 3: hand[i] - 3 if _can_win_with_pair(hand, has_pair): hand[i] 3 return True hand[i] 3 # 拆顺子只对万、筒、条 if i % 9 6 and hand[i1] 0 and hand[i2] 0: hand[i] - 1 hand[i1] - 1 hand[i2] - 1 if _can_win_with_pair(hand, has_pair): hand[i] 1 hand[i1] 1 hand[i2] 1 return True hand[i] 1 hand[i1] 1 hand[i2] 1 return False return False这段代码有个容易踩的坑递归搜索时必须要回溯否则手牌状态会被污染。我在写第一版时忘了在顺子分支失败后恢复现场结果胡牌判断时好时坏排查了很久才发现是状态没回滚。建议所有修改手牌的地方最终都保证恢复原状。3.3 听牌与向听数判断的实现听牌判断逻辑很好理解遍历34种牌逐一加入手牌调用胡牌判断。def is_tenpai(hand): for tile in range(34): if hand[tile] 4: hand[tile] 1 if can_win(hand): hand[tile] - 1 return True hand[tile] - 1 return False def get_tenpai_tiles(hand): tiles [] for tile in range(34): if hand[tile] 4: continue hand[tile] 1 if can_win(hand): tiles.append(tile) hand[tile] - 1 return tiles向听数的完整计算比听牌判断复杂常见做法是BFS搜索。从当前手牌出发每层模拟“摸一张牌再打一张牌”直到出现胡牌状态BFS层数就是向听数。不过直接BFS性能很差我的优化技巧是加一个哈希缓存记录已出现的手牌状态避免重复搜索。def cal_tenpai_distance(hand): from collections import deque q deque() q.append((tuple(hand), 0)) visited set() while q: state, depth q.popleft() if can_win(list(state)): return depth if depth 8: # 最多返回8向听实际足够用了 continue for t in range(34): if state[t] 4: continue new_state list(state) new_state[t] 1 # 摸牌 # 打一张牌 for d in range(34): if new_state[d] 0: tmp new_state[:] tmp[d] - 1 key tuple(tmp) if key not in visited: visited.add(key) q.append((key, depth1)) return 8这段BFS代码非常适合用来理解向听数的本质但实际跑起来会偏慢。生产环境中我用的是“拆解评估法”先算出当前手牌已成型的面子数、对子数再用启发式估算向听数速度提升几十倍准确率在95%以上。3.4 基于蒙特卡洛的出牌决策到了出牌这一步就需要把“向听数最优”升级为“胜率最优”。我最常用的是蒙特卡洛模拟对于每个候选出牌在剩余牌山中随机摸牌模拟若干局统计最终能胡牌的局数占比。def monte_carlo_choose(hand, left_tiles, sim_games200): # 候选出牌先简单筛选打出后不破坏听牌状态的优先 candidates [] for t in range(34): if hand[t] 0: hand[t] - 1 if cal_tenpai_distance(hand) 2: candidates.append(t) hand[t] 1 best_tile candidates[0] best_score -1 for t in candidates: win_count 0 for _ in range(sim_games): if simulate_win(hand, left_tiles, discard_tilet): win_count 1 score win_count / sim_games if score best_score: best_score score best_tile t return best_tile def simulate_win(hand, left_tiles, discard_tile): hand[discard_tile] - 1 # 模拟随机摸牌出牌直到胡牌或牌山摸完 ... hand[discard_tile] 1 return win_flag蒙特卡洛模拟的代价是性能。我实测下来200局模拟大约需要0.3到0.5秒作为AI出牌响应时间勉强可接受。想提速的话有几个实用技巧只对候选牌做模拟而不是所有34种牌都跑模拟时的随机摸牌直接从left_tiles里抽样不要每次重新洗牌把can_win和cal_tenpai_distance用Numba的JIT编译一下性能能提升5到10倍。4. 常见问题与排查技巧实录做麻将AI的过程中我碰到的问题远比想象中多这里整理几个最有代表性的。4.1 不同地区规则差异如何优雅适配一开始我用硬编码的方式写胡牌判断结果从四川麻将切到广东麻将时一脸懵。四川麻将缺一门、无字牌广东麻将还有花牌和鸡胡。后来我改成了规则配置类class Rules: def __init__(self): self.need_deficiency False # 是否缺一门 self.has_word_tiles True # 是否有字牌 self.has_flower_tiles False # 是否有花牌 self.allow_seven_pairs True # 是否允许七对 self.max_win_tiles 4 # 最大胡牌种数胡牌判断时凡是涉及顺子的部分都先判断当前牌型是否允许涉及字牌的顺子直接跳过。这样切规则时只需要改配置不用动决策算法。这是我踩了很多坑后总结出来的经验建议从第一版就考虑规则配置化。4.2 性能瓶颈定位与优化很多人写完胡牌判断后抱怨AI卡顿我排查后发现大部分问题出在胡牌判断被反复调用。优化的核心思路是加缓存用字典记录手牌哈希值和对应的判断结果因为同一手牌状态在模拟中会出现很多次。win_cache {} def can_win_cached(hand): key tuple(hand) if key in win_cache: return win_cache[key] result can_win(hand[:]) win_cache[key] result return result还有一个细节递归函数里频繁切片复制数组也很费时。我直接传入原始数组在递归调用前手动修改、调用后恢复能减少大量内存拷贝。4.3 调试技巧日志复盘与牌谱可视化麻将AI的决策链很长出错时很难直接看出来是判断问题还是策略问题。我的做法是在每次出牌决策前打日志记录手牌、候选牌、各候选牌的评分这样能把AI的“思考过程”完整还原出来。更高级的做法是把牌谱文件导出来用浏览器可视化工具渲染成可以回放的对局。实测下来可视化调试比打日志高效得多很多看似奇怪的决策一看牌谱回放就立刻明白了。4.4 关于公平性和随机种子的思考如果你把麻将AI接入到实际游戏对局中随机种子的控制是个容易被忽略的点。测试阶段固定随机种子可以复现同一个局面方便排查问题但正式环境中随机种子不能一成不变否则对手可以根据上一局的牌山顺序判断出AI的手牌走向。我的做法是每个对局都用系统随机数做种子但每个出牌决策内部的模拟用局部随机种子这样既保证可复现性又不影响对局随机性。5. 麻将AI还能往哪些方向扩展目前这套源码的核心是把“胡牌判断”“向听数评估”“蒙特卡洛出牌”三个环节打通已经能实现一个能打的麻将AI。如果你想在它基础上继续深挖我觉得有三个方向值得尝试。第一个方向是对手行为建模。现在的蒙特卡洛模拟假设对手是随机打牌这跟真实玩家差距巨大。你可以统计真实玩家的舍牌习惯、吃碰频率给每个对手建一个“行为画像”然后在模拟时按画像生成行动这样AI的决策会聪明很多。第二个方向是引入强化学习。蒙特卡洛模拟的随机性太强而强化学习可以从大量自我对局中学习到更精细的牌效率。把当前这套源码里的出牌决策替换成一个策略网络用自博弈的方式来训练是很多竞技麻将AI的进阶路线。第三个方向是番型评估。国内很多地方麻将胡牌不只是看和没和还要看番数大小。你需要在出牌决策时引入“期望番数”的概念——一手牌虽然靠近胡牌但如果最终只能胡个小番还不如多等一轮搏大番。把番型计算跟蒙特卡洛模拟结合起来会是一个更有实战价值的AI。我自己在实际调试中最典型的一次翻车是自己写的AI在四川麻将规则下明明已经听牌却打出张多余的字牌导致振听。后来加了一层“出牌后是否仍保持听牌状态”的守卫检查才把这类低级错误堵住。这种细节靠读代码很难发现一定要靠大量自动对局来暴露。建议你在搭建完这套源码后先跑几千局AI自对弈再拿去跟真人对战能省下大量调试时间。本文还有配套的精品资源点击获取