☰
斗地主AI决策从能出牌到会出牌:手牌拆解与蒙特卡洛搜索实战
2026/10/13 15:48:00 网站建设 项目流程

1. 从"能出牌"到"会出牌":斗地主AI的决策分水岭

很多人第一次做斗地主AI,都会掉进同一个坑:把规则写完了,牌也能出出去了,但打起来像个刚学会规则的新手——手里攥着炸弹舍不得用,该拆牌的时候死抱着顺子不放,队友只剩一张牌了还在那慢悠悠地出单张。这个差距,就是"能出牌"和"会出牌"之间的鸿沟。

我前后写过三版斗地主AI,第一版纯规则引擎,第二版加了蒙特卡洛搜索,第三版做了轻量化的策略网络。每一版都踩了不同的坑,也让我逐渐摸清楚了一件事:斗地主AI的核心难点不在于规则实现,而在于不完全信息下的决策建模。你永远看不到对手的手牌,只能通过出牌历史去推断,这跟围棋、象棋那种完全信息博弈完全是两个物种。

这篇文章面向的是有一定编程基础、想动手做一个能打的斗地主AI的开发者。我会从牌型拆解、手牌评估、搜索算法、策略优化几个层面,把我在实际项目中积累的经验和踩过的坑都摊开来讲。不会只给你一个"用蒙特卡洛搜索就行了"的空壳结论,而是把每一步为什么这么做、参数怎么调、哪里容易翻车都说清楚。

先说一个反直觉的结论:斗地主AI里最难的环节不是搜索,而是手牌拆分。你把一手牌拆成最优的出牌组合,这件事本身就近似于一个NP难问题。很多人搜索算法写得花里胡哨,结果手牌拆得稀烂,搜索空间全是垃圾分支,效果自然好不了。

下面我会按照实际开发中遇到的决策链路,从底层到上层逐步展开。

2. 手牌拆解:一切决策的地基

2.1 为什么手牌拆解比搜索更关键

斗地主里你拿到17张牌,可能的出牌组合数量是天文数字。但真正合理的出牌路径其实不多。手牌拆解要解决的问题是:给定一手牌和一个出牌权,找出"把牌出完所需的最少手数"以及对应的出牌方案。

为什么这个重要?因为你的搜索算法需要评估每个候选出牌的好坏,而评估的核心指标之一就是"出完这手牌还需要几轮"。如果你连手牌都拆不明白,搜索就是在瞎猜。

我第一版AI就是吃了这个亏。当时搜索深度设了6层,每层展开所有合法出牌,结果AI打起来毫无章法。后来我才意识到,问题出在候选出牌的质量上——一手17张牌,合法出牌可能有几十种,但其中大部分是"垃圾走法",比如拆散对子去出单张、把顺子拆成散牌。这些走法不应该进入搜索空间。

2.2 贪心拆解与回溯修正

最直接的手牌拆解方法是贪心:优先提取火箭、炸弹、长顺子、连对、飞机这些"大牌型",剩下的按对子、单张处理。但贪心有个致命问题——局部最优不等于全局最优。

举个例子:手牌是 3-4-5-6-7-8-9,外加一对10。贪心会先把3到9的顺子抽出来,剩下对10。这看起来没问题。但如果手牌是 3-4-5-6-7-8-9-J-Q-K,贪心抽3-9顺子后剩下J-Q-K,也是顺子,总共两手出完。但如果抽4-8顺子,剩下3和9-J-Q-K,反而变成三手。所以贪心的顺序很关键。

我的做法是:先做贪心拆解得到一个上界,然后用回溯搜索尝试不同的拆解顺序,看能否减少总手数。具体来说,每次从当前手牌中选一个牌型抽出,递归处理剩余牌,记录最少手数。为了控制搜索规模,加两个剪枝:

  • 如果当前手数已经超过已知最优解,直接剪掉
  • 优先尝试"大牌型"(长度更长的顺子、连对、飞机),因为它们消耗的牌多,通常更优
def min_hands(cards, memo=None): if not cards: return 0 key = tuple(sorted(cards)) if memo is None: memo = {} if key in memo: return memo[key] best = float('inf') for combo in generate_combos(cards): remaining = remove_cards(cards, combo) best = min(best, 1 + min_hands(remaining, memo)) memo[key] = best return best

这个递归在17张牌的情况下,配合记忆化,实测能在毫秒级跑完。关键是要对generate_combos做限制——只生成"合理"的牌型,比如顺子长度不超过12、飞机不超过3组等。

2.3 拆牌中的经验规则

纯数学最优拆解有时候不符合实战需要。比如你手里有 3-4-5-6-7 和一对2,数学上可能拆成顺子加对2是两手,但实战中对手可能在你出顺子的时候直接炸掉,你的对2反而成了累赘。所以拆牌要结合局势:

  • 手牌少时优先保大牌:残局阶段,一张2的价值远高于一个顺子
  • 有队友时考虑配合:如果队友手牌少,拆牌要倾向于能"送牌"的组合
  • 地主手牌多时优先消耗:地主17张牌,农民要尽量出得快,拆牌可以激进一些

这些规则没法用纯搜索表达,需要在拆解结果上做一层后处理。我的做法是给每种拆解方案打一个"局势分",综合手数、大牌保留数量、单张数量等指标,选综合最优的。

3. 蒙特卡洛搜索在斗地主里的正确打开方式

3.1 为什么直接用MCTS会翻车

蒙特卡洛树搜索(MCTS)在围棋上大放异彩,但直接搬到斗地主上效果很差。原因有三个:

第一,斗地主的状态空间太大。围棋每一步有几百个选择,斗地主虽然每步选择少一些,但隐藏信息(对手手牌)让状态空间爆炸。你没法像围棋那样做均匀采样。

第二,随机模拟的质量太低。围棋里随机走子还能看出个大概趋势,斗地主里随机出牌完全是灾难——你随机拆牌、随机出炸弹,模拟结果毫无参考价值。

第三,斗地主是三人博弈,不是零和二人博弈。农民之间有合作关系,MCTS的UCT公式需要调整。

我第二版AI就是直接套了标准MCTS,结果AI打起来像个疯子,该配合的时候不配合,该炸的时候不炸。后来我做了三个关键改动才让它正常起来。

3.2 确定性化:把不完全信息变成完全信息

这是斗地主AI最核心的技巧。既然看不到对手的牌,那就根据已知信息推断对手可能的牌,然后采样出一组具体的对手手牌,在这个"确定化"的局面下做搜索。

具体做法:

  1. 记录所有已出的牌,得到剩余未知牌集合
  2. 根据对手的出牌历史,推断他们可能的手牌结构(比如对手一直出单张,说明他单张多;对手pass了很多轮,说明他手里可能有大牌但缺小牌)
  3. 从剩余未知牌中随机分配,生成一组符合推断的对手手牌
  4. 在这个确定化局面上做搜索
  5. 重复多次,取平均结果

这里的关键是第2步的推断质量。我的做法是维护一个"手牌概率模型":每张未知牌属于某个对手的概率,根据出牌历史动态更新。比如对手出了3-4-5-6-7顺子,那么他手里有8和9的概率就降低了(因为如果有8或9,可能会出更长的顺子)。

def determinize(unknown_cards, opponent_history, num_samples=50): samples = [] for _ in range(num_samples): # 根据历史推断每张牌属于各对手的概率 probs = infer_card_distribution(unknown_cards, opponent_history) # 按概率采样分配 sample = sample_assignment(unknown_cards, probs) samples.append(sample) return samples

实测下来,50次采样配合合理的推断模型,效果已经相当不错。采样次数再多,边际收益递减明显。

3.3 模拟策略:用规则引擎代替随机走子

标准MCTS用随机模拟,但斗地主里必须用启发式模拟。我的做法是:在模拟阶段,用一套轻量级规则引擎来出牌,而不是随机出。

这套规则引擎不需要很强,但要做到几点:

  • 能出牌时优先出小牌,保留大牌
  • 队友出牌时,如果队友可能赢,不压队友
  • 对手快出完时,优先出大牌压制
  • 炸弹留到关键时候用

这套规则引擎本身就能打赢随机AI,用它做模拟,MCTS的评估质量会高很多。我实测过,用规则引擎模拟比随机模拟的胜率高出30%以上。

3.4 三人博弈的搜索调整

斗地主是三人博弈,MCTS的UCT公式需要调整。标准UCT是:

UCT = Q/N + c * sqrt(ln(N_parent) / N)

在三人博弈里,Q值不能简单用胜负表示,因为农民赢和地主赢的意义不同。我的做法是:

  • 如果AI是地主,Q值 = 1(赢)或 0(输)
  • 如果AI是农民,Q值 = 1(农民赢)或 0(农民输),但队友的表现也影响Q值——如果队友快出完了,即使AI这轮没出好,Q值也应该高一些

具体实现时,我给Q值加了一个"队友进度"的修正项:

def evaluate_state(state, ai_role): if ai_role == 'landlord': return 1.0 if state.winner == 'landlord' else 0.0 else: if state.winner == 'farmer': # 队友手牌越少,贡献越大 teammate_cards = state.farmer_cards[state.teammate_id] return 1.0 + (17 - len(teammate_cards)) * 0.02 else: return 0.0

这个修正项让AI在队友快赢的时候更倾向于配合,而不是自己抢着出牌。

4. 策略网络:让AI学会"直觉"

4.1 为什么需要策略网络

MCTS的问题是慢。每次决策要跑几十次模拟,每次模拟要跑完整局,在实时对战中延迟很高。策略网络的作用是快速给出候选出牌的优先级,让MCTS只需要在少数几个高质量候选上做深度搜索。

我第三版AI加了一个轻量级策略网络,输入是当前手牌和出牌历史,输出是每种合法出牌的胜率估计。网络结构很简单:几层全连接加注意力机制,参数量控制在10万以内,推理时间在毫秒级。

4.2 特征工程:把牌局变成向量

策略网络的输入特征设计很关键。我用的是以下几类特征:

  • 手牌特征:每种牌的数量(0-4),共15维(3到2加大小王)
  • 已出牌特征:每种牌已经出了多少张,15维
  • 出牌历史特征:最近10轮出牌的类型和大小,编码成向量
  • 角色特征:AI是地主还是农民,队友手牌数量
  • 轮次特征:当前是第几轮,剩余牌数

这些特征拼起来大概100维左右,不算大,但足够表达当前局势。

4.3 训练数据的生成

策略网络的训练数据来自MCTS的自对弈。具体流程:

  1. 用当前策略网络加MCTS跑自对弈,生成大量牌局
  2. 记录每个决策点的状态和最终胜负
  3. 用胜负作为标签,训练策略网络预测胜率
  4. 用新网络替换旧网络,重复

这个过程就是AlphaZero的简化版。我实测下来,跑几万局自对弈后,策略网络的预测准确率能到70%左右,配合MCTS搜索,整体胜率比纯MCTS高出15%以上。

训练时有个坑要注意:数据要平衡。地主赢和农民赢的局数要差不多,否则网络会偏向预测多数类。我的做法是每次训练时对少数类做重采样。

4.4 网络推理的工程优化

策略网络虽然小,但在实时对战里,推理速度还是很关键。我做了几个优化:

  • 批量推理:把MCTS里多个叶子节点的评估请求攒成一批,一次性推理
  • 量化:把float32量化成int8,速度提升2倍多,精度损失很小
  • 缓存:相同状态的推理结果缓存起来,避免重复计算

这些优化做完,单次决策的延迟从50ms降到了10ms以内,完全满足实时对战需求。

5. 实战中的坑与调优经验

5.1 炸弹的使用时机

这是我在实战中调了很久的一个点。早期AI要么舍不得用炸弹,要么一有炸弹就乱炸。后来我总结了一个原则:炸弹的价值取决于局势,不取决于炸弹本身。

具体来说:

  • 如果对手快出完了(手牌少于5张),炸弹该用就用
  • 如果自己是地主且手牌还多,炸弹要留着压制农民的配合
  • 如果队友快赢了,炸弹留着防止对手翻盘
  • 如果对手刚出了一个大牌型(比如飞机),炸弹可以考虑跟

我在代码里给炸弹加了一个"紧急度"评分,综合对手手牌数、队友手牌数、当前轮次等因素,紧急度高就炸,低就留。

5.2 队友配合的微妙之处

农民之间的配合是斗地主AI最难的环节。我踩过的坑包括:

  • AI队友出了单张3,AI手里有4但不出,结果对手直接出2拿回出牌权
  • AI队友只剩一张牌了,AI还在那出对子,不给队友送牌
  • AI队友明显在憋大招,AI却把队友需要的牌型拆了

解决这些问题的关键是建立队友意图模型。我的做法是:根据队友的出牌历史,推断他想要什么牌型。比如队友连续出单张,说明他单张多,AI就应该尽量出单张给他送牌;队友pass了很多轮,说明他手里可能有大牌,AI就应该出小牌让他接。

这个模型不需要很精确,但要有。我加了这个模型后,农民配合的胜率提升了20%左右。

5.3 残局处理的特殊性

残局(手牌少于5张)和开局中局的策略完全不同。残局里,每一张牌的价值都被放大,搜索深度可以更深,但候选出牌要更精确。

我的做法是:残局阶段切换到专门的残局求解器,用完全搜索(因为状态空间小)找出必胜或最优走法。这个求解器不需要MCTS,直接递归搜索就行,速度快且准确。

残局求解器的一个关键优化是对称性剪枝。比如手里有对3和对4,出对3和出对4在大多数情况下是等价的,只需要搜索一个。

5.4 性能与效果的平衡

最后说一个工程上的经验:不要追求极致效果而忽略性能。我见过有人用深度神经网络加超大MCTS,效果确实好,但每步决策要几秒钟,根本没法实战。

我的建议是:

  • 策略网络控制在10万参数以内
  • MCTS模拟次数控制在100次以内
  • 单次决策延迟控制在50ms以内
  • 用缓存和批量推理优化性能

在这个约束下,AI的胜率已经能超过大部分普通玩家。再往上提升,边际收益递减明显,不如把工程做好。

# 一个简单的决策流程示例 def decide_move(state, ai_role): # 1. 残局用完全搜索 if len(state.my_cards) <= 5: return solve_endgame(state) # 2. 生成候选出牌 candidates = generate_combos(state.my_cards) # 3. 策略网络快速筛选 if policy_net: scores = policy_net.predict(state, candidates) candidates = top_k(candidates, scores, k=10) # 4. MCTS深度搜索 best_move = mcts_search(state, candidates, num_simulations=100) return best_move

这套流程跑下来,AI在普通牌局里的表现已经相当稳定。当然,遇到高手还是会被虐,但至少不会犯低级错误了。后续如果想继续提升,可以考虑加入对手建模(预测对手手牌分布)和更精细的残局库。不过那是另一个话题了,先把基础打牢再说。

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

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

立即咨询