资讯动态

斗地主AI智能出牌算法:从零实现非完全信息博弈决策系统

发布时间:2026/10/13 17:20:18 来源:尧图企业网站定制
1. 从零拆解一个斗地主AI为什么值得做难点在哪斗地主这个游戏规则简单到三分钟就能学会但想写出一个真正能打的AI难度远超大多数人的预期。我前后花了将近四个月时间从最开始的随机出牌到后来能稳定压制大部分普通玩家中间踩了无数坑。这篇文章就把整个智能出牌算法的设计思路、核心实现和调优经验完整地分享出来。先说清楚这个项目到底在做什么核心目标是让程序在斗地主对局中根据当前手牌、已出牌记录、剩余牌面信息和角色定位地主或农民自动做出胜率最高的出牌决策。它不是一个简单的规则引擎而是一套融合了搜索、评估和概率推断的完整决策系统。适合谁来参考如果你有基本的编程能力了解Python或者任何一种面向对象语言同时对博弈类AI感兴趣那这篇文章基本可以带你走完全程。如果你已经做过棋类AI那斗地主的非完全信息博弈特性会让你觉得很有意思——象棋围棋是双方都能看到全部局面斗地主不是你永远不知道对手手里攥着什么牌。1.1 斗地主AI和棋类AI的本质区别很多人第一反应是拿斗地主AI和象棋AI做类比觉得无非就是换个游戏规则。但实际做起来会发现两者的难度维度完全不同。棋类AI的核心是搜索——Alpha-Beta剪枝、蒙特卡洛树搜索这些方法在完全信息环境下非常有效因为你可以精确地推演每一步之后局面的变化。但斗地主不一样你看不到对手的手牌你面对的是一个非完全信息博弈问题。这意味着你没办法直接搜索出一棵完整的博弈树因为树的很多分支对你来说是未知的。那怎么办业界常用的思路是信息集搜索加上概率推断。简单说就是我不知道对手具体有什么牌但我可以根据已经打出的牌和对手的出牌行为推断出他手里可能有哪些牌的概率分布然后在这个概率分布上做期望值最大化的决策。这个思路听起来简单但实际落地时会遇到几个很棘手的问题。第一概率推断的准确性严重依赖对手行为模型的精度第二即使推断出了概率分布搜索空间依然巨大第三斗地主的合作博弈特性——两个农民之间需要配合这让决策逻辑更加复杂。1.2 为什么选择这套技术方案在动手之前我调研了几种主流方案最终选择了一条规则引擎蒙特卡洛模拟手牌评估函数的混合路线。这里说说我的选型逻辑。纯规则引擎的方案优点是实现快、可控性强但缺点是死板。你很难穷举所有情况一旦遇到规则没覆盖到的局面AI就会做出很蠢的决策。我早期版本就是纯规则测试的时候发现AI经常在残局阶段把一手好牌打烂。纯深度学习的方案理论上上限最高但需要大量的对局数据和训练资源。对于个人项目来说数据获取和训练成本都太高而且训练出来的模型可解释性差出了问题很难排查。混合方案的好处在于规则引擎负责处理确定性高的常规决策蒙特卡洛模拟负责处理需要搜索的复杂局面评估函数负责给每个候选出牌打分。三者结合既有规则的可控性又有搜索的深度还有评估函数的灵活性。具体来说整个决策流程是这样的首先根据当前局面生成所有合法的出牌组合然后对每个候选出牌用蒙特卡洛模拟推演若干步估算胜率同时用评估函数对出牌后的手牌质量进行打分最后综合胜率估算和手牌评估选出最优出牌这个流程的关键在于蒙特卡洛模拟的效率和评估函数的准确性。后面我会详细展开这两部分。注意不要一上来就追求完美。我建议先用纯规则跑通整个流程确保游戏逻辑没问题然后再逐步替换各个模块。一开始就搞复杂架构调试起来会让你怀疑人生。2. 手牌数据结构与出牌组合生成这是整个项目的地基。如果手牌的数据结构设计得不好后面的搜索和评估都会受到影响。我在这部分重构了三次最终才找到一个比较满意的方案。2.1 手牌表示为什么用计数数组而不是列表最开始我用的是简单的列表结构每张牌就是一个对象包含花色和点数。但很快发现这样不行——斗地主里牌型判断非常频繁每次都要遍历列表去统计点数分布效率太低。后来改成了计数数组的方案。斗地主的牌面一共15种点数3到2加上小王和大王我用一个长度为15的数组来表示手牌数组的索引对应点数值对应这张牌有几张。# 点数映射30, 41, 52, ..., K10, A11, 212, 小王13, 大王14 # 例如手牌 [3,3,3,4,5,5,大王] 表示为 hand [3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]这个设计的优势非常明显。判断有没有炸弹只需要看数组里有没有值等于4的元素判断有没有顺子只需要检查连续五个索引的值是否都大于0。所有牌型判断都变成了数组操作速度极快。但计数数组也有一个缺点丢失了花色信息。不过斗地主里花色本来就不重要所以这个缺点可以忽略。2.2 牌型枚举如何高效生成所有合法出牌给定一手牌生成所有可能的出牌组合这个操作的复杂度直接决定了AI的思考速度。我一开始用的是递归枚举结果发现手牌多的时候比如17张牌起手枚举出来的组合数量非常恐怖。后来我做了几个优化。第一按牌型分类枚举而不是盲目递归。斗地主的牌型就那么几种单张、对子、三张、三带一、三带二、顺子、连对、飞机、炸弹、火箭。每种牌型单独枚举互不干扰。第二剪枝。比如枚举顺子的时候如果当前起点开始连不成五个就直接跳过不需要继续尝试。第三缓存。对于相同的手牌状态出牌组合是可以复用的。我用了一个哈希表来缓存已经计算过的结果。def enumerate_plays(hand, last_playNone): 枚举所有合法出牌如果last_play不为空只返回能压过的牌型 plays [] # 单张 for i in range(15): if hand[i] 0: play [0]*15 play[i] 1 if last_play is None or can_beat(play, last_play): plays.append(play) # 对子 for i in range(15): if hand[i] 2: play [0]*15 play[i] 2 if last_play is None or can_beat(play, last_play): plays.append(play) # ... 其他牌型类似 return plays实际代码会比这个复杂得多因为要处理三带一、三带二、飞机带翅膀这些组合牌型。但核心思路就是这样分类枚举逐个判断能压就加。2.3 牌型比较规则的精确定义牌型比较看起来简单但实际写代码的时候很容易出bug。我整理了一份完整的比较规则这里分享出来。牌型比较方式特殊情况单张点数大的赢大王小王2AK...3对子点数大的赢同上三张点数大的赢同上三带一三张部分点数大的赢带的单张不影响比较三带二三张部分点数大的赢带的对子不影响比较顺子起始点数和长度都相同才能比长度不同不能比连对起始点数和长度都相同才能比同上飞机起始点数和长度都相同才能比带翅膀的规则另算炸弹点数大的赢炸弹可以压任何非炸弹牌型火箭最大压一切提示顺子、连对、飞机这三种牌型比较的时候必须长度一致。比如6-7-8-9-10的顺子不能和7-8-9-10-J的顺子比大小因为起始点数不同也不能和6-7-8-9-10-J的顺子比因为长度不同。这个规则在代码里一定要严格实现否则会出现非法出牌。3. 蒙特卡洛模拟让AI学会推演未来这是整个AI最核心的部分。简单说蒙特卡洛模拟就是让AI在脑子里把牌局模拟很多遍每遍都随机分配未知的牌给对手然后按照某种策略打完最后统计胜率。胜率高的出牌就是好出牌。3.1 模拟的基本流程一次完整的蒙特卡洛模拟包含以下步骤确定未知牌根据当前局面计算出所有还没出现的牌随机分配把未知牌随机分配给其他玩家使得每个玩家的手牌数量符合当前局面快速推演用简化的策略把牌局打完记录胜负重复多次重复上述过程N次统计每个候选出牌的胜率这里的关键是快速推演。如果每次推演都用完整的决策逻辑那速度会慢到无法接受。所以推演阶段必须用简化策略比如能出就出出最小的或者优先出能压过上家的牌。def mcts_play(hand, last_play, unknown_cards, num_simulations200): 蒙特卡洛模拟选择出牌 candidate_plays enumerate_plays(hand, last_play) if not candidate_plays: return None # 要不起 win_counts {i: 0 for i in range(len(candidate_plays))} for _ in range(num_simulations): # 随机分配未知牌 opponent_hands random_distribute(unknown_cards) for i, play in enumerate(candidate_plays): # 模拟出牌后的局面 new_hand subtract(hand, play) result simulate_game(new_hand, opponent_hands, play) if result win: win_counts[i] 1 # 选择胜率最高的出牌 best_idx max(win_counts, keywin_counts.get) return candidate_plays[best_idx]这个伪代码展示了核心逻辑但实际实现要复杂得多。比如random_distribute需要考虑对手的手牌数量约束simulate_game需要处理三个玩家的轮流出牌逻辑。3.2 模拟次数的选择速度与精度的平衡模拟次数直接决定了AI的强度和思考时间。我做过一组对比测试模拟次数平均决策时间对普通玩家胜率对中级玩家胜率500.1秒62%48%2000.4秒71%55%5001.0秒76%61%10002.1秒78%64%20004.3秒79%65%从数据可以看出模拟次数从50提升到500胜率提升非常明显但从500提升到2000胜率只提升了3个百分点而决策时间翻了四倍。所以500次是一个比较合适的平衡点。当然这个数字不是固定的。在残局阶段手牌少模拟速度快可以适当增加次数在开局阶段手牌多模拟速度慢可以适当减少次数。我后来实现了一个自适应模拟次数的机制根据当前手牌数量和局面复杂度动态调整。3.3 随机分配的优化让模拟更接近真实最初的随机分配是完全均匀的每张未知牌分配给每个玩家的概率相同。但这样有个问题对手的出牌行为会透露信息。比如对手一直不出牌那他手里很可能没有大牌对手主动出炸弹那他手里很可能还有别的强牌。我后来加入了一个基于出牌历史的权重调整机制。具体来说如果某个对手在之前的轮次中表现得很保守那他在模拟中被分配到小牌的概率就会增加如果他表现得很激进那他被分配到大牌的概率就会增加。这个调整看起来简单但效果非常明显。加入权重调整后AI在中局的决策准确率提升了大约8个百分点。实操心得权重调整的幅度不要太大否则会过拟合。我一开始把权重调得很激进结果AI变得非常极端要么过度保守要么过度激进。后来把权重调整幅度控制在±20%以内效果就比较稳定了。4. 手牌评估函数给每手牌打个分蒙特卡洛模拟虽然强大但它有个致命缺点慢。特别是在开局阶段手牌多、未知牌多模拟出来的结果方差很大需要很多次模拟才能收敛。这时候就需要一个快速的评估函数来辅助决策。4.1 评估函数的维度设计一个好的手牌评估函数应该考虑哪些维度我总结了以下几个大牌数量2、小王、大王这些牌越多控制力越强炸弹数量炸弹是改变局势的关键一个炸弹往往能扭转乾坤牌型完整度手牌能不能组成顺子、连对、飞机直接影响出牌效率单张数量单张越多出牌越被动手牌长度手牌越少越接近胜利我给每个维度分配了不同的权重最终形成一个综合评分。这个评分不是绝对的而是用来比较不同出牌方案的优劣。def evaluate_hand(hand, role): 评估手牌质量返回一个分数 score 0 # 大牌加分 score hand[12] * 3 # 2 score hand[13] * 4 # 小王 score hand[14] * 5 # 大王 # 炸弹加分 for i in range(13): if hand[i] 4: score 10 # 单张扣分 singles sum(1 for i in range(15) if hand[i] 1) score - singles * 2 # 手牌长度扣分越少越好 total_cards sum(hand) score - total_cards * 0.5 # 角色调整 if role landlord: score 5 # 地主有额外优势 return score这个评估函数看起来简单但实际调参花了我很多时间。每个维度的权重都需要通过大量对局来验证和调整。4.2 动态权重调整不同阶段用不同标准开局、中局、残局手牌评估的标准应该是不一样的。开局阶段手牌长度不重要重要的是牌型结构和控制力残局阶段手牌长度就是一切谁能先出完谁就赢。所以我实现了一个动态权重调整机制。根据当前手牌数量自动调整各个维度的权重。阶段手牌数量大牌权重炸弹权重单张权重长度权重开局121.01.01.00.3中局6-121.21.51.20.8残局60.82.00.52.0这个表格是我经过大量测试后总结出来的经验值。残局阶段炸弹权重特别高因为一个炸弹往往能直接决定胜负长度权重也高因为手牌少意味着接近胜利。4.3 农民配合的评估不只是看自己的牌斗地主最复杂的地方在于农民之间的配合。两个农民是队友需要协同作战。但问题是AI怎么知道队友想干什么我的做法是根据队友的出牌行为推断他的意图。如果队友主动出小牌说明他想让你接过去如果队友出大牌压你说明他手里有更好的牌型想自己走。在评估函数里我加入了一个配合度的维度。具体来说如果当前出牌能让队友更容易走掉那这个出牌的评分就会提高。def evaluate_cooperation(play, teammate_history, hand): 评估出牌的配合度 score 0 # 如果队友之前出过小牌说明他在让牌 if teammate_history and teammate_history[-1] small: # 出大牌压住对手给队友创造机会 if is_big_play(play): score 5 # 如果队友手牌很少优先让他走 if teammate_card_count 5: # 出小牌让队友有机会接 if is_small_play(play): score 8 return score这个配合逻辑在实际对局中效果很好。AI不再是单打独斗而是会主动给队友创造机会。5. 完整决策流程与实战调优前面几部分分别讲了各个模块这一部分把它们串起来讲完整的决策流程以及我在实战调优中积累的经验。5.1 决策流程的完整实现一次完整的出牌决策包含以下步骤局面分析统计当前手牌、已出牌、剩余牌、各玩家手牌数量候选生成枚举所有合法出牌快速筛选用评估函数给每个候选出牌打分筛掉明显不合理的蒙特卡洛模拟对剩下的候选出牌进行模拟估算胜率综合决策结合评估分数和模拟胜率选出最优出牌这个流程的关键在于快速筛选这一步。如果候选出牌太多蒙特卡洛模拟会非常慢。所以先用评估函数筛掉一批只对最有希望的几个候选进行模拟。def decide_play(game_state): 完整的出牌决策流程 hand game_state.my_hand last_play game_state.last_play unknown game_state.unknown_cards # 1. 生成候选 candidates enumerate_plays(hand, last_play) if not candidates: return None # 2. 快速筛选 scored [(c, evaluate_hand(subtract(hand, c), game_state.role)) for c in candidates] scored.sort(keylambda x: x[1], reverseTrue) top_candidates [c for c, _ in scored[:5]] # 只保留前5个 # 3. 蒙特卡洛模拟 best_play None best_winrate -1 for play in top_candidates: winrate simulate(hand, play, unknown, game_state) if winrate best_winrate: best_winrate winrate best_play play return best_play这个流程在实际运行中平均决策时间在0.5秒左右对于大多数应用场景来说是可以接受的。5.2 实战调优那些只有踩过坑才知道的事调优阶段是最耗时间的但也是收获最大的。这里分享几个我踩过的坑和对应的解决方案。坑一AI在优势局过于保守。早期版本中AI在手牌明显占优的情况下反而不敢出牌总是想等更好的机会。结果被对手慢慢磨死。后来我在评估函数里加入了优势加速机制当AI的评估分数明显高于对手时主动出牌会有额外加分鼓励AI尽快结束战斗。坑二AI在劣势局直接摆烂。另一个极端是AI发现胜率很低的时候会随机出牌完全放弃。这显然不对因为斗地主翻盘的机会很多。我加入了永不放弃机制即使胜率很低也要选择最优出牌因为对手可能会犯错。坑三模拟结果方差太大。蒙特卡洛模拟的一个固有问题是方差大特别是模拟次数少的时候。有时候同一个局面两次模拟的结果可能完全相反。我用了两个方法来缓解一是增加模拟次数二是用方差修正——如果某个出牌的模拟结果方差很大就降低它的权重。坑四对手行为模型太简单。最初的模拟中对手的出牌策略是固定的简单规则。但真实玩家的出牌风格千差万别。我后来加入了一个对手风格识别模块根据对手的历史出牌判断他是激进型还是保守型然后在模拟中使用对应的策略。注意调优是一个持续的过程。不要指望一次调参就能达到理想效果。我的经验是每次只调整一个参数然后跑1000局测试看胜率变化。如果胜率提升就保留如果下降就回滚。这样虽然慢但每一步都是扎实的。5.3 常见问题速查表在实际开发和调试过程中我整理了一份常见问题速查表这里分享出来希望能帮你少走弯路。问题现象可能原因排查方法解决方案AI不出牌候选生成逻辑有bug打印候选列表检查牌型枚举代码AI出非法牌牌型比较规则错误对比规则表修正比较函数AI决策特别慢模拟次数过多打印决策耗时降低模拟次数或优化代码AI胜率突然下降评估函数权重失衡回滚最近改动重新调参AI在残局表现差残局评估权重不对单独测试残局调整动态权重AI不配合队友配合度评估缺失检查配合逻辑加入配合度维度模拟结果不稳定方差过大增加模拟次数加入方差修正AI被特定策略克制对手模型太单一分析对局记录增加对手风格识别这张表里的每一个问题我都实际遇到过也花了不少时间解决。希望这份经验能帮你节省一些时间。6. 性能优化与工程实践算法设计得再好如果跑不起来也是白搭。这一部分讲讲我在性能优化和工程实践方面的经验。6.1 性能瓶颈分析与优化整个系统的性能瓶颈主要在两个地方牌型枚举和蒙特卡洛模拟。牌型枚举的优化我前面已经提到了一些比如分类枚举、剪枝、缓存。这里再补充一个技巧预计算。对于常见的手牌状态可以预先计算出所有出牌组合存到哈希表里。这样运行时直接查表速度极快。蒙特卡洛模拟的优化核心是减少每次模拟的开销。我做了以下几个优化简化推演策略推演阶段不用完整的决策逻辑只用简单的能出就出策略提前终止如果某个出牌的模拟胜率已经明显低于当前最优就提前终止模拟并行计算用多线程同时模拟多个候选出牌这些优化加起来让整体决策速度提升了大约3倍。6.2 代码架构与模块划分项目做大了之后代码组织就很重要。我最终的架构是这样的project/ ├── core/ │ ├── card.py # 牌的定义和基本操作 │ ├── hand.py # 手牌数据结构 │ ├── play.py # 牌型定义和比较 │ └── game.py # 游戏状态管理 ├── ai/ │ ├── enumerator.py # 出牌枚举 │ ├── evaluator.py # 手牌评估 │ ├── simulator.py # 蒙特卡洛模拟 │ └── decision.py # 决策流程 ├── utils/ │ ├── cache.py # 缓存工具 │ └── logger.py # 日志工具 └── tests/ ├── test_play.py # 牌型测试 ├── test_ai.py # AI测试 └── benchmark.py # 性能测试这个架构的好处是模块清晰职责分明。每个模块都可以单独测试和优化不会互相干扰。6.3 测试与评估方法怎么知道AI到底强不强我用了三种测试方法方法一自我对弈。让AI和自己打统计胜率。如果胜率接近50%说明AI的决策比较均衡如果明显偏离50%说明某个角色地主或农民的决策逻辑有问题。方法二规则对手测试。让AI和不同水平的规则对手打统计胜率。这能直观地看出AI的强度水平。方法三人类玩家测试。找几个朋友和AI打收集反馈。这能发现一些程序测试发现不了的问题比如AI的出牌风格是否合理、是否容易被针对。实操心得自我对弈的时候要注意如果AI的某个策略有缺陷自我对弈可能发现不了因为双方都有同样的缺陷。所以一定要结合规则对手测试和人类玩家测试。7. 后续扩展方向这个项目做到现在基本功能已经比较完善了但还有很多可以扩展的方向。方向一深度学习。用神经网络替代手工设计的评估函数通过大量对局数据训练模型。这需要大量的计算资源和数据但上限更高。方向二在线学习。让AI在实战中不断学习根据对手的出牌风格动态调整策略。这需要设计一个有效的在线学习机制。方向三多模态输入。除了牌面信息还可以考虑对手的表情、出牌速度等信息这些在真实对局中都是重要的判断依据。方向四可解释性。目前的AI决策过程是一个黑盒如果能输出决策理由比如我出这张牌是因为...会更有意思。我个人在实际操作中的体会是斗地主AI最难的地方不是算法本身而是如何平衡搜索深度和计算速度。蒙特卡洛模拟虽然强大但太慢了评估函数虽然快但不够准确。找到两者之间的平衡点是这个项目最大的挑战。我最后的方案是开局阶段以评估函数为主中局阶段两者结合残局阶段以蒙特卡洛模拟为主。这个策略在实际对局中效果不错胜率能稳定在70%以上。最后再分享一个小技巧如果你也想做类似的项目建议先从残局开始。残局手牌少搜索空间小容易验证算法是否正确。等残局调好了再逐步扩展到中局和开局。这样循序渐进比一上来就搞完整对局要容易得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑