麻将AI实战:从向听数计算到防守决策的完整实现
2026/9/1 2:11:21 网站建设 项目流程

简介:这份麻将游戏AI源代码是一套基于C/C++与Windows API开发的Windows桌面游戏项目,能够在Windows环境下编译运行,完整包含电脑AI对战模块,面向希望从实际项目中学习游戏逻辑、搜索算法、数据结构和窗口程序设计的开发者。RAR压缩包共87个文件、586KB:11个cpp与12个h构成核心游戏规则与AI实现,17个bmp提供牌面及界面图像,33个mp3用于音效,另含sln/vcproj工程文件便于直接编译构建。目前已有3813人学习下载。通过阅读代码,可以理解麻将牌型判断、胡牌条件、碰杠操作等规则建模过程,也能看到AI如何结合深度优先、蒙特卡洛树搜索或启发式评估函数做出出牌决策,并掌握Win32消息循环、GDI绘图、资源加载、事件处理和常见数据结构的具体用法。这套源码规模紧凑、结构清晰,是学习经典棋牌类AI落地实践和C/C++游戏工程组织的合适样本。 写麻将AI之前,我先说个实话:很多人以为麻将AI就是“给每张牌打一个分,选最高的打出去”,真动手做才发现完全不是这么回事。麻将不是一个纯计算游戏,它是一个隐藏信息游戏——你看不到别人的手牌,只能根据牌河、副露和舍牌去反推。这也是为什么很多人照着教程写出来的“AI”一上场就被新手吊打:它只会顺着自己的手牌走,完全不管桌上发生了什么。

这篇文章想分享的是我从零实现一套麻将游戏AI源代码的完整思路。包括手牌编码、向听数计算、舍牌评估、副露判断、防守决策,以及如何用蒙特卡洛模拟做辅助验证。代码都以Python为例,偏向日麻规则(有宝牌、副露),但算法思路拿去做广东麻将、四川麻将、国标麻将都可以,只需要调整役种和牌型判断部分。

整个项目做完,我的感受是:麻将AI的核心价值不在某个算法多惊艳,而在于状态评估的完整程度。下面按我自己搭建时的顺序来讲,先地基后上层,讲清楚每一步为什么这么做。

1. 为什么麻将AI比棋类AI更麻烦——先理解问题本质

1.1 不完全信息博弈的特殊性

拿象棋、围棋举例,这类游戏属于完全信息博弈:棋盘上所有信息都是公开的,AI只需要在给定的局面下做最优搜索。麻将完全不是这个模型,每一家的手牌互相不可见,牌山里的剩余牌也不可知,每个人看到的世界都不一样。

所以麻将AI在底层逻辑上更接近德州扑克:你要根据有限的可观测信息去推断对手的牌型倾向,然后做概率意义上的最优决策。但这同时也意味着,没有一个“绝对正确”的AI——同样一手牌,在对手已经立直的情况下和对手还在乱打的情况下,最优舍牌完全不同。

这也是我在设计代码时强制自己遵守的第一条原则:决策函数必须接收完整的游戏状态,而不是只看着自己手牌做决定。

1.2 麻将AI需要覆盖的决策点

麻将的一局游戏,AI至少要处理四类决策:

  • 摸牌后:选择打哪一张(舍牌)
  • 别家舍牌后:判断要不要吃、碰、杠,还是直接过
  • 副露后:继续选择舍牌
  • 听牌时:判断要不要换听、要不要改听更安全的牌

其中舍牌决策频率最高,对手牌走向影响最大,是整套系统里最先要攻克的部分。而吃碰杠决策虽然触发频率低,但做错一次可能直接决定这局是赢还是放铳。

1.3 自底向上的搭建路线

我真正动手开始写的时候,发现网上能找到的麻将AI代码要么是纯规则硬编码,要么是直接上强化学习,中间层几乎没有。后来我确定了一条自底向上的路线:

  1. 先实现手牌编码和向听数计算(评估手牌的基础)
  2. 再做牌效优先的舍牌(“最强AI”的基线)
  3. 加入安全度评估(防守)
  4. 最后做副露判断和蒙特卡洛模拟辅助

这条路线的好处是每一层都可以单独验证。比如向听数算错了,后面所有的评估全是废的。所以每一步我都会写一个简单的测试脚本来验证正确性。

2. 向听数与手牌评估:整个AI的地基

2.1 手牌的编码方式

麻将牌一共34种:万子1-9、筒子1-9、条子1-9、风牌4种、三元牌3种。我用一个长度为34的整数数组表示手牌,数组下标对应牌种,值对应该牌的张数。

# 牌编码约定 # 0-8: 万子 1万-9万 # 9-17: 筒子 1筒-9筒 # 18-26: 条子 1条-9条 # 27-30: 东南西北 # 31-33: 白发中 hand = [0] * 34

这种编码方式比字符串表示更高效,做牌型拆解、遍历都方便。后面做副露判断时,副露的牌用单独的结构存储,手牌数组只维护手上的牌。

2.2 向听数的计算思路

向听数的定义是:还差几张有效牌就能听牌。它是所有麻将AI决策的基石。0向听就是已经听牌,1向听是摸一张有效牌就能听牌。

计算向听数最直观的方法是递归拆解。手牌的标准形是“4组面子+1组雀头”,其中面子是顺子或刻子。所以问题转化为:从手牌里能拆出多少个面子、多少个搭子、以及是否有一个雀头。

def count_shanten(hand): """ 计算标准形的向听数(简化版,未考虑七对子和国士无双) 返回0表示听牌,返回-1表示已经和牌 """ min_shanten = 8 # 标准形最大向听数是8 for pair_idx in range(34): if hand[pair_idx] >= 2: tmp = list(hand) tmp[pair_idx] -= 2 shanten = _dfs(tmp, 4, 0) # 需要凑4组面子 if shanten < min_shanten: min_shanten = shanten return min_shanten def _dfs(hand, need_melds, shanten): if need_melds == 0: # 剩下的牌全是孤张 return shanten + sum(hand) # 找到第一个有牌的索引 i = 0 while i < 34 and hand[i] == 0: i += 1 if i >= 34: return shanten + need_melds * 2 best = 8 # 尝试拆刻子 if hand[i] >= 3: tmp = list(hand) tmp[i] -= 3 best = min(best, _dfs(tmp, need_melds - 1, shanten)) # 尝试拆顺子 if i < 27 and i % 9 < 7 and hand[i+1] > 0 and hand[i+2] > 0: tmp = list(hand) tmp[i] -= 1; tmp[i+1] -= 1; tmp[i+2] -= 1 best = min(best, _dfs(tmp, need_melds - 1, shanten)) # 或者把这张牌当孤张,后续再优化 tmp = list(hand) tmp[i] -= 1 best = min(best, _dfs(tmp, need_melds, shanten + 1)) return best

这段代码是我早期验证用的简化版,递归深度不大,逻辑也好理解。但在真实项目中,我建议再加上七对子、国士无双的单独计算,它们在牌型特殊时向听数会明显低于标准形,不开这个分支会漏掉最优解。

另外我踩过一个坑:如果手牌是13张(正常摸牌后),副露过一次后手牌变成10张,再副露变成7张。副露后的向听数公式和标准形不同,因为手牌数量减少了。我的处理方式是单独写一个函数处理副露后的手牌,不再复用同一段代码。

2.3 有效牌数量与牌效评估

有向听数之后,就可以算“有效牌”了。有效牌的定义是:摸到哪几张牌能让手牌向听数下降。枚举手牌里还没摸到的34种牌,每种模拟摸一张,重算向听数,如果变低了就计入有效牌集合。

def effective_tiles(hand, visible_tiles): """ 计算当前手牌的有效牌及数量 visible_tiles: 已经出现的牌,用于排除 """ base = count_shanten(hand) result = [] for t in range(34): if hand[t] >= 4 - visible_tiles[t]: continue # 这种牌已经不可能再摸到 tmp = list(hand) tmp[t] += 1 if count_shanten(tmp) < base: result.append(t) return result

有效牌数量就是这个手牌的“牌效分数”。什么时候换听、什么时候拆搭子,本质上都是在比较不同方案的有效牌数量。这个指标虽然简单,却是后面所有复杂策略的原始输入。

3. 舍牌决策:从“牌效优先”到“攻守平衡”

3.1 牌效评分:打出某张后的进张数

基础舍牌逻辑是:枚举手牌里的每一张候选牌,模拟打出后,计算新手牌的有效牌数量,取有效牌数量最多的打法。

def choose_tile_naive(hand, visible_tiles): best_tile = None best_score = -1 for t in range(34): if hand[t] == 0: continue new_hand = list(hand) new_hand[t] -= 1 score = len(effective_tiles(new_hand, visible_tiles)) if score > best_score: best_score = score best_tile = t return best_tile

这套逻辑跑起来之后,AI就已经能在不考虑防守的情况下打出比较好的牌效了,比初级玩家强不少。但它有一个致命问题:完全不看别家打出来的牌,也不知道自己在什么风险局面下。

3.2 安全度评估:牌河的信号怎么用

这里引入安全度评估。核心思想是:牌河里出现过的牌相对安全,一张都没出现的生张很危险。我把舍牌的危险程度分成几档:

危险等级典型状态说明
0 - 安全现物(牌河里已有同类牌)对方不可能用这张牌和牌,除非单骑听牌
1 - 较安全早巡外字牌字牌很少被做成顺子,早巡打出的字牌安全度极高
2 - 中等筋牌(数字牌57、5和8之类)日麻里筋牌是相对安全的
3 - 危险无筋生张数牌最容易放铳的一类
4 - 极危险对手副露后的切牌附近的生张需要重点防守

在日麻里,防守时还需要考虑振听规则:立直者打过哪些牌,那些牌的筋、同巡都可以纳入安全度评估。具体逻辑我简化成了一个函数:

def tile_danger(tile, game_state): score = 0 # 现物绝对安全 if tile in game_state.discards: return 0 # 字牌:看场上出现张数 if tile >= 27: score += max(0, 3 - game_state.tile_count_on_table[tile]) else: # 数牌:无筋加2分,生张加1分 if not game_state.is_suji(tile): score += 2 if game_state.tile_count_on_table[tile] == 0: score += 1 # 对手副露附近的牌加1分 if game_state.near_meld_tiles(tile): score += 1 # 巡目越晚,危险分数越高 score += game_state.turn / 12 return score

3.3 综合评分公式

最终我的舍牌评分公式长这样:

final_score = tile_efficiency_score - danger_weight * danger_score

danger_weight 是一个可调参数,我一开始设成0.5,后面在测试对局里反复调。防守权重太高,AI变得太怂,明明该进攻的时候打保守牌,牌效直线下降;权重太低又变成无脑进攻,放铳率感人。最终在我的测试环境下调到0.8左右比较平衡。

这里有一个非常关键的判断:**要不要防守,取决于“我离和牌有多近”和“对手离和牌有多近”的对比。**听牌了但手牌很小,对手已经立直,果断弃和才是正解;自己已经听牌且牌很大,那即使有危险牌也可以搏一搏。我会先判断自己当前手牌的向听数,再结合场上的立直、副露情况决定要不要切换防守模式。

4. 吃碰杠的时机判断:当“动”则动,当“静”则静

4.1 副露后手牌结构的变化

吃碰杠(副露)对牌型影响很大:手牌减少一张,可调节空间变小;牌河信息暴露给三家,别人知道你在做什么牌。但副露的好处也很直接:减少一张手牌相当于少一张要打出去的牌,通常能加快听牌速度。

我的判断逻辑不搞什么玄学,先计算收益:

def should_chi_or_pon(game_state, action): # action 是吃/碰的具体牌型 # 模拟副露后的手牌 new_hand = simulate_meld(game_state.hand, action) new_shanten = count_shanten_after_meld(new_hand) old_shanten = count_shanten(game_state.hand) return new_shanten < old_shanten

如果吃碰后的向听数显著下降,比如从2向听变成1向听,AI倾向于副露;如果向听数没有变化,则考虑副露带来的额外收益(断幺、役牌、染手方向),否则不副露。

4.2 快速听牌 vs 手牌价值

这里有一个我调试中发现的坑:副露判断不能只看向听数,还要看剩余牌数。一局进行到后半段,快速听牌的价值远大于手牌的理论价值,但如果牌山里还有大量有效牌,保留门前清反而可能做出更大的牌。

所以最终的判断逻辑分成三档:

  • 副露后直接听牌:多数情况下副露,除非手牌有明确的役种方向冲突
  • 副露后向听数下降一位:评估手牌价值和剩余巡数,场况紧迫就副露
  • 副露后向听数不变:一般不副露,除非能凑出役种(比如吃出断幺)

4.3 风险控制:副露后防守能力大幅下降

副露还有一个隐性成本:手牌张数变少之后,防守时能选择的切牌范围变小了。所以你经常能看到日麻高手的对局里,明明可以早巡碰牌,却故意不碰,就是不想放弃后续的防守弹性。这个观点在我自己的AI里落地成了一条规则:当场上有两家以上明显在做牌或者立直时,副露的收益要乘以一个折扣系数

5. 代码架构与蒙特卡洛辅助:让AI可测试、可扩展

5.1 模块划分

整套代码我分成几个独立模块,方便单测和替换策略:

mahjong/ ├── core/ │ ├── tiles.py # 牌编码、牌组判断 │ ├── hand.py # 手牌类、副露结构 │ └── shanten.py # 向听数计算 ├── agent/ │ ├── base.py # AI接口定义 │ ├── efficiency.py # 牌效优先AI │ ├── defensive.py # 带防守的AI │ └── mcts.py # 蒙特卡洛模拟辅助 ├── game/ │ ├── table.py # 牌桌状态、回合流转 │ └── simulator.py # 对局模拟器 └── tests/ ├── test_shanten.py └── test_effective.py

这种结构的最大好处是每个AI策略都可以单独跑对局对比胜率,换策略不需要动游戏框架。

5.2 对局模拟器的作用

对局模拟器是整个项目中最被低估的部分。没有它,你只能靠真人打牌去测试AI,效率极低。模拟器要模拟从洗牌、发牌到打牌的全过程,并且支持两个AI互相竞技。我一开始只写了随机AI作为baseline,后来加入牌效AI、防守AI,每次改完代码跑几千局对局,看统计结果。

# 对局模拟核心循环 def play_game(ai1, ai2, ai3, ai4): table = Table() while not table.game_over(): current = table.current_player() tile = current.agent.choose_action(table.public_state()) # ...

5.3 蒙特卡洛模拟辅助决策

单纯靠公式评分的AI有一个问题:公式是拍脑袋定的,不同局面下最优权重不一样。我后来给AI加了一层蒙特卡洛模拟:对候选的舍牌方案,各自随机模拟N次后续摸牌过程,统计最终的和牌率和期望得分,用这个统计结果辅助决策。

def simulate_discard(game_state, tile, n=200): wins = 0 for _ in range(n): sim_state = copy.deepcopy(game_state) sim_state.discard(tile) while not sim_state.game_over(): sim_state.step_random() if sim_state.winner() == 0: wins += 1 return wins / n

这个方法虽然有效,但我提醒一句:它很吃算力。每手牌做200次模拟,每次模拟几十步,步数多了之后对局速度肉眼可见地变慢。我在项目里只对“牌效分数很接近”的候选牌做模拟,而不是对每张候选牌都跑。

6. 实测数据与调参心得:AI真实对局的那些坑

6.1 用胜率和放铳率说话

我先后实现了三个版本:

版本描述胜率(4人局)平均放铳数/局
v1纯牌效AI23%0.42
v2牌效+基础防守26%0.31
v3牌效+防守+蒙特卡洛辅助27%0.28

v1的问题非常典型:它只知道做自己的牌,对手一旦立直,它照样往枪口上撞。加进防守逻辑后,放铳率立刻下来了,胜率也上去了。这说明防守带来的收益比盲目进攻高得多

6.2 容易踩的坑

第一个坑是向听数计算里的递归层数。Python默认递归深度是1000,复杂局面加上七对子、国士无双分支后,递归深度很容易飙升。解决办法是改成迭代式栈,或者直接提高递归上限,但提高上限后要注意栈溢出风险。

第二个坑是副露后的手牌数量。我早期直接在原来的向听数函数上套,导致副露后向听数永远算不对。后来副露后的手牌用单独的类表示,长度是10或7,而不是13。

第三个坑是防守逻辑太激进。v2版本我一度把安全度权重调到1.5,AI经常在牌效极好的局面选择打安全牌,把自己拖崩。后来我加入了“自己和牌收益”的权衡逻辑,当自己已经听牌且手牌足够大时,对危险牌的容忍度提升。

6.3 调参经验

调参与其说是技术,不如说是在打麻将。我每次改完参数,都会跑500局对局看统计,不凭感觉拍板。有一个经验可以分享:进攻还是防守的阈值,跟规则有直接关系。在日麻规则下,防守权重可以给高一点,因为放铳的惩罚很大;但在有些地方的麻将规则里,放铳惩罚相对小,那就应该更偏向进攻。所以做AI之前一定要先想明白目标规则下的得分结构。

我自己现在跑这套代码,日常训练已经可以让AI稳定打赢普通玩家。下一步的打算是尝试把向听数和有效牌相关的网络结构接进来,用自对弈生成的数据训练一个价值网络,替换掉现在手工调的权重。

最后分享一个我已经踩过多次的坑:不要在还没有对局模拟器的时候就开始调AI参数。没有模拟器,你根本没法在短时间内验证一次改动到底是变强了还是变弱了。先把模拟器写好,让AI自己和自己打,后续所有优化才谈得上有效率。

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

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

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

立即咨询