ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

动态规划解斗地主:从状态压缩到博弈树搜索的算法实战

动态规划解斗地主:从状态压缩到博弈树搜索的算法实战 1. 项目概述从“斗地主”到“简单DP”的思维跃迁“斗地主”这个游戏大家都不陌生三张底牌十七张手牌炸弹、顺子、对子组合起来与对手斗智斗勇。但今天我们要聊的“斗地主”和你手机里那个可能不太一样。这是一个经典的算法问题通常出现在各大在线编程竞赛和面试题库中它的全称往往是“斗地主简单DP版”。这里的DP就是动态规划Dynamic Programming的缩写。我第一次接触这个问题是在准备一场算法比赛的时候。题目描述很简单给定一副去掉大小王的扑克牌共n张牌牌面为1到13对应A, 2, 3, ..., K每种牌最多4张。你和两个对手地主和另一个农民进行游戏你需要计算出在已知所有牌面的情况下你作为农民一方有可能获胜的初始手牌组合有多少种或者计算你在最优出牌策略下的最大得分。这个“有可能”和“最优”就是DP大显身手的地方。为什么“斗地主”能和DP扯上关系因为出牌的过程本质上是一个状态转移的过程。你的手牌状态拥有哪些牌、各有多少张随着你打出顺子、对子、炸弹而改变对手的出牌也会限制你的可行动作。我们需要一个高效的方法来遍历所有可能的出牌序列并找到那个致胜的或得分最高的路径。暴力搜索牌稍微多一点状态空间就爆炸了。这时候DP通过定义状态、找到状态转移方程、并用递推或记忆化搜索的方式避免了大量重复计算让求解成为可能。这个项目适合所有对算法感兴趣的朋友无论你是正在啃《算法导论》的学生还是想提升编程思维、应对技术面试的开发者。通过拆解“斗地主简单DP”你不仅能深入理解动态规划的核心思想——最优子结构和重叠子问题更能学会如何将一个复杂的、看似无从下手的现实问题抽象成一个清晰的数学模型并用代码实现它。这其中的思维训练价值远大于解出这一道题本身。2. 核心思路拆解如何用DP“打”斗地主面对“斗地主”这个问题直接上手写代码肯定会一头雾水。我们得先把它“翻译”成计算机能理解或者说能高效处理的语言。这个过程就是建模。DP解题万变不离其宗核心就三步定义状态、确立转移方程、确定边界条件。我们一步步来。2.1 状态定义把一手牌“压缩”成一个数字在斗地主中什么是“状态”最直观的就是你当前手里还剩下哪些牌。但是如果我们用数组hand[1..13]来表示1到13每种牌剩余的张数0到4这个状态空间太大了直接作为DP数组的下标不现实。这里就需要用到DP中一个非常经典的思想状态压缩。注意到每种牌最多只有4张我们可以用进制转换的思路来编码。一个常见的技巧是使用5进制。为什么是5因为每种牌的数量有0、1、2、3、4共5种可能。我们可以把一个状态State定义为一个13位的5进制数。第i位从低位到高位对应牌面i的值v就表示牌面为i的牌还剩v张。例如初始手牌如果是{1,1,1,2,2,3,4,5}假设n8那么状态编码为牌面1有3张位值3牌面2有2张位值2牌面3有1张位值1牌面4有1张位值1牌面5有1张位值1其余为0。这个状态对应的5进制数就是(0...0 1 1 1 2 3)从高位13到低位1我们可以把它转换成一个十进制整数S作为DP数组的下标。这样一来任何一个特定的手牌组合都唯一对应一个整数S。所有可能的手牌状态总数理论上最多是5^13这是一个很大的数约122亿但实际游戏中手牌总数n是有限的比如20张很多高位5进制数根本不会出现牌的总张数超标实际需要处理的状态数会少很多。我们可以用记忆化搜索Memoization来避免枚举所有状态只计算访问到的状态。所以我们的DP状态可以初步定义为dp[S]它表示当手牌状态为S时当前玩家可能是你也可能是对手回合取决于设计的某种最优值比如是否能赢或者最大得分。2.2 状态转移模拟出牌的过程定义了状态接下来就是最关键的一步状态如何转移这对应着游戏中的“出牌”动作。从状态S出发当前玩家可以选择不出牌要不起或者出一种合法的牌型。出牌后手牌状态会从S变为S。S就是将S中对应牌的张数减去所出牌型消耗的张数后得到的新编码。那么dp[S]的值怎么从后续状态dp[S]推导出来呢这取决于我们想求什么以及游戏规则。情景一求必胜性博弈DP如果问题是“判断先手是否必胜”这就是一个典型的博弈论问题可以用DP求解。我们定义dp[S]为布尔值表示在当前手牌状态S下当前行动方是否必胜。转移如果当前玩家存在至少一种出牌方式使得出牌后到达的状态S是对方必败的即dp[S] false那么当前玩家在这个状态S下就是必胜的dp[S] true。边界当状态S表示手牌已经出完空手时上一轮出牌的玩家获胜。所以对于“空手”状态它的意义是“轮到对方出牌但对方没牌了”那么当前行动方即刚出完牌的那一方就赢了。在递归中我们可以这样处理如果当前玩家无牌可出或者选择不出则轮到对方行动。因此dp[空手]需要根据回合制来小心定义。更常用的方法是采用递归函数win(S)表示当前手牌为S的玩家是否能赢。他会尝试所有出牌如果存在一种出牌让对手win(S)为假则自己赢。情景二求最大得分如果每出一次牌都有得分比如出单张得1分对子得2分顺子得5分等问题变为“在牌出完时获得最高分”。我们定义dp[S]为从状态S开始当前玩家能获得的最大分数或双方最优博弈下的己方分数。转移dp[S] max( 不出牌的得分 , max_{所有合法出牌类型t} ( score(t) dp[S] ) )。这里score(t)是出牌型t的即时得分dp[S]是出完牌t后在剩余手牌状态S下能获得的最大分数注意出完牌后可能轮到对方出如果是双方交替则dp[S]表示的是对方最优行动下你的后续得分此时可能需要用总分数 - 对方得分的思路即零和博弈。情景三简化版单玩家最优出牌序列很多“简单DP”版本的斗地主实际上做了极大简化忽略对手或者将对手的出牌视为固定的、已知的约束例如上一轮对手出了某个牌型你必须出更大的同牌型或炸弹。此时问题退化为一个单玩家的“如何将手牌拆分成若干合法牌型使得某个目标最优”的问题。这更像一个复杂的背包问题。例如求最少出牌次数打完所有手牌。定义dp[S]为打完状态S的手牌所需的最少出牌次数。转移dp[S] min( 1 dp[S] )对所有合法出牌型tS为出牌t后的状态。边界dp[空手] 0。在“简单DP”的语境下最常见的是第三种情景的变体。因为它剥离了博弈的复杂性更专注于DP状态设计和转移本身。2.3 合法牌型枚举与预处理无论哪种情景状态转移都需要枚举从当前状态S下所有可能的合法出牌。斗地主的牌型多样单张、对子、三张、三带一、三带二、顺子至少5张连续单张、连对至少3个连续对子、飞机至少2个连续三张、炸弹四张相同、火箭王炸但本题通常无王等。在状态S下我们需要根据当前手牌的各牌数量快速生成所有能出的牌型。直接在递归中实时计算效率很低。一个标准的优化方法是预处理。我们可以预先计算出所有可能的牌型模板。例如所有可能的顺子从起点i开始长度L5直到iL-113且途中每张牌的数量至少为1。所有可能的连对从起点i开始连续的对子数M3且途中每张牌的数量至少为2。所有可能的飞机带翅膀或不带从起点i开始连续的三张数N2且途中每张牌的数量至少为3。对于每个模板它本质上是一个“消耗向量”指明了需要从哪些牌面各减去多少张。在状态转移时我们检查当前状态S解码出的牌数量数组是否包含这个“消耗向量”。如果包含就可以打出这个牌型从而得到新状态S。预处理将这些牌型模板存储为列表。在DP过程中枚举当前状态下的合法出牌就变成了遍历这个模板列表并用当前手牌数量去匹配。对于单张、对子、三张、炸弹等简单牌型可以直接检查数量数组无需模板。注意这里有一个关键细节就是“三带一”和“三带二”中的“带”所带的牌可以是任意不同的单张或对子只要手牌里有就行。这增加了枚举的复杂性。一种处理方法是先确定“三张”的部分即连续或不连续的三张组合然后对于每个这样的组合再枚举手牌中剩余牌里所有可能的单张或对子来“带”。这相当于两层枚举。在简单DP中有时会规定“带”的牌必须是除了那三张以外另外的牌不能是那三张牌中抽出一张来“带”即不能是四张相同的牌拆成三带一。3. 算法实现细节与优化技巧理解了思路我们来看看如何把它变成代码。我会以“求解单玩家最少出牌次数”这个经典简化版为例因为它的目标明确状态定义清晰非常适合作为DP入门“斗地主”问题的第一站。3.1 状态表示与编解码我们选择用状态压缩和记忆化搜索来实现。为什么用记忆化搜索而不是递推因为状态空间不是规整的线性结构从初始状态开始出牌路径是树状发散的记忆化搜索递归缓存更符合直觉也更容易编码。首先我们需要在手牌数量数组和状态整数之间进行转换。# 假设牌面1到13用长度为14的数组cnt[0..13]表示数量cnt[0]不用 def encode(cnt): 将cnt数组编码为一个整数状态 state 0 for i in range(1, 14): # 从牌面1到13 state state * 5 cnt[i] # 5进制 return state def decode(state, cnt): 将整数状态解码到cnt数组中 # 先清空cnt for i in range(1, 14): cnt[i] 0 s state for i in range(13, 0, -1): # 从牌面13到1逆序解码 cnt[i] s % 5 s // 5这里cnt数组是传引用的在解码时复用避免频繁创建新数组。encode和decode是状态处理的基础函数。3.2 牌型模板的生成接下来我们预先计算所有可能的牌型。一个牌型可以用一个长度为14的“消耗”数组pattern表示pattern[i]表示需要消耗牌面i的张数。# 存储所有牌型模板 patterns [] # 1. 单张 for i in range(1, 14): p [0] * 14 p[i] 1 patterns.append(p) # 2. 对子 for i in range(1, 14): p [0] * 14 p[i] 2 patterns.append(p) # 3. 三张 for i in range(1, 14): p [0] * 14 p[i] 3 patterns.append(p) # 4. 三带一核心是找到一个三张再找一个单张 for i in range(1, 14): # 三张的牌面 for j in range(1, 14): # 带的单张牌面 if i j: continue # 不能是同一张牌即不能是四张拆开有些规则允许这里按常见不允许处理 p [0] * 14 p[i] 3 p[j] 1 patterns.append(p) # 5. 三带二核心是找到一个三张再找一个对子 for i in range(1, 14): for j in range(1, 14): if i j: continue p [0] * 14 p[i] 3 p[j] 2 patterns.append(p) # 6. 顺子 (单顺) for start in range(1, 10): # 顺子起点最大到99,10,J,Q,K for length in range(5, 14 - start 1): # 长度从5到最大可能 p [0] * 14 for k in range(length): p[start k] 1 patterns.append(p) # 7. 连对 (双顺) for start in range(1, 12): # 连对起点最大到12Q,K,A? 这里A是1通常顺子到A结束连对同理 for pair_count in range(3, (14 - start) // 1 1): # 至少3个连续对子 p [0] * 14 for k in range(pair_count): p[start k] 2 patterns.append(p) # 8. 飞机 (三顺) 不带翅膀 for start in range(1, 13): for triple_count in range(2, (14 - start) // 1 1): # 至少2个连续三张 p [0] * 14 for k in range(triple_count): p[start k] 3 patterns.append(p) # 9. 飞机带单翅连续N个三张带N个单张 for start in range(1, 13): for triple_count in range(2, (14 - start) // 1 1): # 生成核心飞机部分 base_pattern [0]*14 for k in range(triple_count): base_pattern[start k] 3 # 需要带triple_count个单张这些单张必须从非核心牌中选 # 这里无法预先确定具体带哪几张所以不能生成一个固定的pattern。 # 需要在DFS中动态组合。因此我们只把“纯飞机”加入模板“飞机带翅”在搜索时特殊处理。 # 我们先把纯飞机加入 patterns.append(base_pattern.copy()) # “飞机带翅”的模板我们暂时不生成留在搜索时处理。 # 10. 飞机带双翅连续N个三张带N个对子同理不在预处理中固定 # 11. 炸弹 for i in range(1, 14): p [0] * 14 p[i] 4 patterns.append(p) # 注意火箭王炸本题没有。实操心得预处理牌型时像“飞机带翅膀”这种组合多变的牌型生成固定模板非常困难且数量爆炸。一个更实用的方法是在DFS搜索状态S时先识别出所有可能的“纯飞机”连续三张然后对于每个纯飞机再在当前手牌剩余部分扣除飞机核心后中搜索能否找到足够数量的单张或对子来“带”。这属于搜索过程中的局部枚举虽然增加了一些复杂度但避免了模板数量膨胀。上面的代码只将“纯飞机”加入了模板列表。3.3 记忆化搜索的实现现在实现核心的DFS记忆化搜索函数dfs(state)它返回打完状态state所代表的手牌所需的最少出牌次数。from functools import lru_cache # 全局变量存储预处理好的简单牌型模板 SIMPLE_PATTERNS patterns # 即上面生成的patterns但不包含需要动态组合的“飞机带翅” lru_cache(maxsizeNone) def dfs(state): if state 0: return 0 # 没有牌了需要0次出牌 cnt [0] * 14 decode(state, cnt) # 解码当前状态到手牌数组cnt # 初始值设为无穷大表示还没找到方案 ans float(inf) # 枚举所有预处理的简单牌型 for pattern in SIMPLE_PATTERNS: # 检查当前手牌cnt是否包含pattern can_play True for i in range(1, 14): if cnt[i] pattern[i]: can_play False break if not can_play: continue # 如果可以出则计算出牌后的新状态 new_cnt cnt.copy() for i in range(1, 14): new_cnt[i] - pattern[i] new_state encode(new_cnt) # 递归求解并更新答案 ans min(ans, 1 dfs(new_state)) # 特殊处理飞机带单翅 # 思路先找出所有连续的“三张”区间飞机核心 for start in range(1, 13): for length in range(2, 14 - start 1): # 飞机长度至少为2 # 检查从start开始长度为length的区间是否每张牌都有至少3张 core_ok True for k in range(length): if cnt[start k] 3: core_ok False break if not core_ok: continue # 找到了一个飞机核心消耗掉这些三张 new_cnt cnt.copy() for k in range(length): new_cnt[start k] - 3 # 现在需要带 length 个单张。从剩余牌中选出 length 个不同的单张牌面不同 # 这是一个组合选择问题可以用DFS搜索剩余牌中所有的单张选择方式。 remaining_singles [] for i in range(1, 14): # 注意飞机核心消耗的牌其剩余张数可能还有比如原来有4张用了3张还剩1张这1张也可以作为“带”的牌。 # 所以这里应该用 new_cnt[i]即扣除核心后剩余的数量来判断。 # 但为了简单我们通常要求“带”的牌必须是完全独立的牌即原cnt[i] 1 且不属于核心消耗。 # 更严格的检查如果这张牌在核心区间内那么它被消耗了3张后如果原来有4张则还剩1张这1张是可以带的。 # 我们这里采用更简单的常见规则“带”的牌不能是构成飞机的那几张牌本身即使有剩余。 # 所以我们用原始的cnt来判断如果i在核心区间内则要求cnt[i] 3才有额外的牌可带。 # 为了简化代码我们采用另一种常见规则“带”的牌可以是任意牌只要数量够。 # 我们直接使用扣除核心后的 new_cnt 来寻找单张。 pass # 由于实现组合搜索代码较长这里暂不展开。我们可以用递归或迭代来枚举从剩余牌中选length个单张的所有方式。 # 假设我们有一个辅助函数 enumerate_singles(new_cnt, need)能返回所有选择need个单张的方案列表每个方案是一个消耗数组 # 那么 # for single_pattern in enumerate_singles(new_cnt, length): # final_new_cnt new_cnt 减去 single_pattern # new_state encode(final_new_cnt) # ans min(ans, 1 dfs(new_state)) # 特殊处理飞机带双翅类似需要找对子 # 如果以上所有出牌方式都尝试了ans还是无穷大说明这个状态无法通过出牌减少 # 实际上因为单张总是可以出的所以不会出现这种情况。但为了安全可以返回一个很大的数。 # 不过更可能的是我们漏掉了一种情况不出牌要不起。在这个单玩家问题中不存在“要不起”必须出完。 # 所以最终ans一定会被更新。 return ans if ans ! float(inf) else 0 # 实际上不会返回0除非state0 # 辅助函数枚举从当前手牌cnt中选出need个单张的所有可能牌面可重复但每次消耗1张 def enumerate_single_selections(cnt, need): 返回一个列表每个元素是一个pattern数组表示一种选择need个单张的方式 # 这是一个组合问题可以用回溯法实现。 # 由于篇幅这里给出一个简化版本只考虑选择need个单张不考虑它们是否来自同一牌面即可以选多个同牌面的单张只要该牌面数量够。 # 更精确的枚举需要递归或动态规划生成所有组合。 patterns_list [] # 我们用一个递归函数来实现 def backtrack(idx, current_pattern, selected): # idx: 当前考虑到的牌面索引 (1-13) # current_pattern: 当前的消耗数组 # selected: 已选择的单张总数 if selected need: patterns_list.append(current_pattern.copy()) return if idx 13: return # 对于牌面idx最多可以选 min(cnt[idx], need-selected) 张作为单张 max_take min(cnt[idx], need - selected) for take in range(max_take 1): current_pattern[idx] take backtrack(idx 1, current_pattern, selected take) current_pattern[idx] 0 # 回溯 backtrack(1, [0]*14, 0) return patterns_list上面的代码框架展示了核心逻辑但“飞机带翅膀”的完整枚举比较复杂。在实际竞赛或面试中如果遇到此类问题通常会对牌型进行进一步的限制和简化以降低编码复杂度。例如规定“飞机”只能不带翅膀或者“带”的牌必须严格是另外的牌且不能重复。3.4 初始状态与答案获取假设输入给你一个初始的手牌数量数组init_cnt[1..13]那么初始状态就是start_state encode(init_cnt)。调用dfs(start_state)得到的就是最少出牌次数。def min_play_times(init_cnt): start_state encode(init_cnt) return dfs(start_state) # 示例手牌为 [3,1,1,1,1,1,1,1,1,1,1,1,1] 即3个A其他牌各1张。 init_cnt [0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # 索引0不用1对应A print(min_play_times(init_cnt))4. 性能优化与剪枝策略直接按上述方法实现对于牌数较多比如17张以上的情况可能会因为状态太多或牌型枚举太慢而超时。我们需要一些优化策略。4.1 状态压缩的进一步优化我们用了5进制状态范围是0 ~ 5^13-1。但很多状态是无效的牌总数不对。我们可以使用哈希表字典来代替数组存储DP值只存储实际访问到的状态。Python的lru_cache自动帮我们做了这件事。4.2 出牌顺序的剪枝出牌顺序不影响最终的最少出牌次数。因此我们可以规定一个出牌顺序避免重复计算本质相同的出牌序列。一个常见的剪枝是优先出牌数多的牌型。例如有炸弹就先考虑出炸弹有长顺子就先考虑出长顺子。因为先出大牌型能更快减少手牌数量也符合直觉。在DFS中我们可以对牌型模板按照“消耗牌的张数”从大到小排序优先枚举消耗牌多的牌型。# 在预处理patterns后进行排序 SIMPLE_PATTERNS.sort(keylambda p: -sum(p)) # 按消耗总张数降序排列4.3 等效状态合并有些状态虽然编码不同但通过“牌面重命名”是等效的。例如手牌{1,1,2,2}和手牌{3,3,4,4}在出牌策略上是完全同构的都是两个对子。如果问题只关心出牌次数那么它们的最优值是一样的。我们可以设计一个标准化函数将状态映射到一个规范形式。例如按照牌的数量进行排序将数量相同的牌面视为一类。但这会引入额外的映射开销在状态数不是极端多的情况下可能得不偿失。4.4 贪心预判与可行性剪枝在DFS进入一个状态后可以先计算一个“理论最小出牌次数”的下界。例如手牌总张数除以最长的牌型长度比如炸弹4张得到一个乐观估计。如果当前ans当前找到的最佳值已经小于等于这个下界就可以提前剪枝。另一个更强的下界是不考虑牌型组合只看牌的数量分布。我们可以用DP或贪心快速计算一个近似解。例如先出所有炸弹、三张、对子、单张这个出牌次数一定大于等于最优解。可以用这个值作为初始ans进行全局剪枝。def lower_bound(cnt): 计算状态cnt的一个出牌次数下界贪心 plays 0 # 出炸弹 for i in range(1, 14): while cnt[i] 4: plays 1 cnt[i] - 4 # 出三张 for i in range(1, 14): while cnt[i] 3: plays 1 cnt[i] - 3 # 出对子 for i in range(1, 14): while cnt[i] 2: plays 1 cnt[i] - 2 # 出单张 plays sum(cnt[1:]) return plays在dfs(state)开始时先计算当前状态的lower_bound如果lower_bound current_best则直接返回lower_bound或一个很大的值不再深入搜索。4.5 预处理合法出牌列表在dfs中每次都要遍历所有SIMPLE_PATTERNS并检查是否可出这很耗时。我们可以为每个状态S预处理出它的合法出牌列表并缓存起来。但状态很多缓存这个列表可能内存爆炸。一个折中方法是在DFS中根据当前cnt数组动态生成合法出牌而不是遍历所有模板。例如检测顺子时只需要扫描cnt数组找连续段即可。5. 常见问题与调试技巧实现这样一个复杂的DP搜索调试是不可避免的。下面是一些常见坑点和调试方法。5.1 状态编码/解码错误这是最隐蔽的bug。务必写单元测试验证encode和decode函数是互逆的。def test_encode_decode(): cnt [0]*14 for i in range(1, 14): cnt[i] i % 5 # 随便赋一些值 state encode(cnt) cnt2 [0]*14 decode(state, cnt2) assert cnt cnt2, f编码解码错误: {cnt} vs {cnt2} print(编码解码测试通过)5.2 牌型枚举遗漏或重复特别是“飞机带翅膀”这种复杂牌型很容易漏掉某些合法组合或者枚举出不合法的组合比如“带”的牌和飞机核心牌有重叠。务必用多个小型测试用例验证。例如手牌为{3,3,3,4,4,4,5,6}应该能识别出“飞机3,4带单翅5,6”。手牌为{3,3,3,4,4,4,5,5}应该能识别出“飞机3,4带对翅5,5”。手牌为{3,3,3,3,4,4,4,5}注意这里有四个3。那么“飞机3,4带单翅5”是合法的吗这取决于规则。如果规则要求“带”的牌必须完全独立那么用掉3个3之后还剩1个3这个3不能作为“带”的牌因为它是飞机核心牌面。所以只能带5。代码逻辑必须正确处理这一点。5.3 递归深度与栈溢出手牌最多20张但出牌序列可能很长。Python的递归默认深度约1000层对于这个问题通常够用。但如果担心可以改用迭代加深搜索或BFS状态压缩不过记忆化搜索写起来最直观。也可以使用sys.setrecursionlimit(1000000)提高递归深度限制。5.4 性能瓶颈定位如果程序太慢可以用Python的cProfile模块进行性能分析。import cProfile cProfile.run(min_play_times(init_cnt), sorttime)查看哪些函数耗时最多。通常是dfs调用次数过多或者牌型枚举太慢。这时就需要加强剪枝或者优化牌型枚举的逻辑比如用位运算加速状态检查和转移。5.5 特殊边界情况空手状态dfs(0)必须返回 0。无法出牌的状态理论上不存在因为至少可以出单张。但如果你在枚举牌型时错误地限制了某些条件可能导致无牌可出。这时ans会保持无穷大最终返回一个错误值。务必确保单张牌型在枚举列表中。牌数很多但牌型很少比如所有牌都是单张没有对子、顺子等。这时算法应该退化为一手牌有多少张就需要出多少次。用这个简单案例验证。6. 从“简单DP”到更复杂的博弈场景我们上面讨论的“简单DP”版本实际上回避了斗地主最核心的博弈特性对手的存在。在真实的斗地主AI中DP会复杂好几个数量级。6.1 引入对手模型双人零和博弈假设我们只考虑你和地主两人另一个农民视为固定盟友或忽略问题就变成了一个双人零和博弈。状态需要包含当前手牌状态你的牌和地主的牌当前出牌权以及上一轮出的牌用于限制本轮出牌类型和大小。此时状态S可以编码为(my_state, opponent_state, turn, last_play)。turn表示轮到谁出牌last_play编码了上一轮出的牌型或None表示新回合可以任意出。dp[S]可以表示在当前状态下当前行动方的胜率或期望得分。状态转移时当前玩家需要枚举所有能压住上一轮牌的合法出牌或者任意牌如果上一轮为None。然后轮到对方行动。这是一个典型的极小化极大搜索Minimax结合Alpha-Beta剪枝和记忆化就是博弈AI的基础。6.2 蒙特卡洛树搜索MCTS的引入对于斗地主这样分支因子巨大、状态空间浩瀚的游戏完整的DP即遍历整个博弈树是不可能的。职业的斗地主AI如腾讯“欢乐斗地主”中的AI会采用更高级的算法如蒙特卡洛树搜索MCTS。MCTS不尝试遍历所有可能而是通过随机模拟Rollout来评估某个走法的优劣。它包含四个步骤选择Selection、扩展Expansion、模拟Simulation、回溯Backpropagation。通过多次迭代逐渐将搜索资源集中在更有希望的行棋路线上。在MCTS框架下每个节点代表一个游戏状态边的选择基于UCB1公式平衡探索与利用。模拟阶段通常使用快速的随机出牌策略直到终局得到一个胜负结果。最终选择访问次数最多或胜率最高的动作作为实际出牌。6.3 与神经网络结合近年来AlphaGo的成功展示了深度学习与搜索结合的巨大威力。对于斗地主我们可以用神经网络来价值网络Value Network直接评估一个状态的胜率代替MCTS中耗时的随机模拟。策略网络Policy Network给出在某个状态下各个合法动作的概率分布用于指导MCTS的选择和扩展阶段使其更偏向人类专家的出牌风格。训练这样的网络需要大量的自我对弈数据。2019年腾讯AI Lab和康奈尔大学等就曾推出过“DouZero”斗地主AI它使用深度强化学习特别是Q-learning的变体在模拟环境中训练达到了人类高手水平。从“简单DP”到“MCTS神经网络”我们看到的是解决复杂问题的方法论演进从精确但受限的搜索到基于采样的近似搜索再到学习与搜索的融合。理解了这个脉络再回头看“斗地主简单DP”它不仅仅是一道算法题更是通往更广阔AI世界的一块重要基石。它训练了我们抽象建模、状态设计和搜索优化的基本功而这些基本功正是应对更复杂智能挑战的必备武器。
返回列表