1. 博弈DP:当动态规划遇上策略对决
第一次接触博弈类动态规划问题是在一场编程比赛中,那道题要求两位玩家轮流从数组两端取数,最终比较双方总和。当时我用了整整两小时才想明白状态转移的逻辑,赛后查阅资料才发现这类问题有个专门的名字——博弈DP(Game Theory Dynamic Programming)。与传统DP不同,博弈DP的每个状态转移都包含着对手的最优决策,就像下棋时需要预判对手的应对策略。
博弈DP的核心在于模拟"决策-反决策"的交替过程。以经典的取石子游戏为例,假设有n堆石子,两位玩家轮流取走1到3颗石子,取走最后石子的人获胜。我们可以定义dp[i]为剩余i颗石子时的必胜状态(True/False)。转移方程为:
dp[i] = not (dp[i-1] and dp[i-2] and dp[i-3])这个方程的精妙之处在于,当前玩家能获胜的条件是存在至少一种取法(取1/2/3颗),使得对手在剩余石子情况下必败。这种"让对手处于必败态"的思想正是博弈DP的精华所在。
2. 博弈DP的三大核心要素解析
2.1 状态定义的双重视角
在普通DP中,状态通常表示单方的最优解。而博弈DP需要同时考虑双方:
- 对于Minimax类问题:状态需要记录当前玩家(Max或Min)
- 对于轮流取数问题:状态需要区分"先手优势"和"后手劣势"
- 对于不完全信息博弈:可能需要引入概率分布作为状态维度
以LeetCode 877.石子游戏为例:
# dp[i][j].first 表示在石子堆i...j区间先手能获得的最大分数 # dp[i][j].second 表示后手玩家能获得的最大分数 dp[i][j] = { 'first': max(piles[i] + dp[i+1][j].second, piles[j] + dp[i][j-1].second), 'second': min(dp[i+1][j].first, dp[i][j-1].first) # 对手会最小化你的收益 }2.2 转移方程中的对抗性思维
博弈DP的转移方程需要体现"对手会采取最优策略对抗你"这一特点。常见模式有:
- 极小化极大原则(Minimax):
dp[i] = max(当前选择 + min(对手后续选择))- 交替收益计算:
当前玩家收益 = 选择收益 + (总收益 - 对手最优收益)- 对称性转移:
dp[i][j] = !dp[i-1][j] || !dp[i][j-1] # 只要能让对手处于必败态即可2.3 终局条件与必胜态判定
博弈DP的边界条件往往决定了整个问题的解法走向:
- 尼姆游戏:异或和为0时处于必败态
- 取石子游戏:剩余数为0时上一位玩家获胜
- 棋盘类博弈:达到终止格局时立即返回胜负
关键技巧:在初始化阶段就标记出所有明显的必胜/必败态,这能大幅减少后续计算量。比如在硬币游戏中,当剩余金额等于某个面值时就是必胜态。
3. 经典博弈DP问题实战拆解
3.1 预测赢家问题(LeetCode 486)
给定分数数组,两位玩家轮流从两端取数,预测先手是否能获胜。解法示例:
def PredictTheWinner(nums): n = len(nums) dp = [[0]*n for _ in range(n)] for i in range(n-1, -1, -1): dp[i][i] = nums[i] for j in range(i+1, n): dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1]) return dp[0][n-1] >= 0这个解法的时间复杂度是O(n²),空间复杂度可以通过滚动数组优化到O(n)。核心思想是计算当前选择与对手后续选择的差值最大化。
3.2 石子游戏VII(LeetCode 1686)
这道题加入了差值最大化的变体,我们需要分别计算Alice和Bob的最优策略:
def stoneGameVII(stones): prefix = [0]*(len(stones)+1) for i in range(len(stones)): prefix[i+1] = prefix[i] + stones[i] dp = [[0]*len(stones) for _ in range(len(stones))] for l in range(1, len(stones)): for i in range(len(stones)-l): j = i + l total = prefix[j+1] - prefix[i] dp[i][j] = max(total - stones[i] - dp[i+1][j], total - stones[j] - dp[i][j-1]) return dp[0][-1]3.3 井字棋终极判定(LeetCode 794)
虽然井字棋可以用简单的规则判断,但用博弈DP可以扩展到N×N的情况:
def canWin(board, player): # 检查当前玩家是否能立即获胜 def is_win(b, p): n = len(b) # 检查行、列 for i in range(n): if all(b[i][j] == p for j in range(n)): return True if all(b[j][i] == p for j in range(n)): return True # 检查对角线 if all(b[i][i] == p for i in range(n)): return True if all(b[i][n-1-i] == p for i in range(n)): return True return False # 尝试每个空位 for i in range(3): for j in range(3): if board[i][j] == ' ': board[i][j] = player if is_win(board, player) or not canWin(board, 'O' if player == 'X' else 'X'): board[i][j] = ' ' # 回溯 return True board[i][j] = ' ' # 回溯 return False4. 博弈DP的优化技巧与常见陷阱
4.1 记忆化搜索的实战应用
对于状态空间复杂的问题,记忆化搜索往往比表格法更直观。以预测赢家为例的记忆化实现:
from functools import lru_cache def PredictTheWinner(nums): @lru_cache(maxsize=None) def dfs(i, j): if i == j: return nums[i] pick_i = nums[i] - dfs(i+1, j) pick_j = nums[j] - dfs(i, j-1) return max(pick_i, pick_j) return dfs(0, len(nums)-1) >= 0这种方法虽然时间复杂度相同,但在实际运行中可能更快,因为它避免了计算不必要的子状态。
4.2 状态压缩与维度削减
当状态包含多个维度时,可以考虑:
- 对称性压缩:如dp[i][j]和dp[j][i]可能对称
- 滚动数组:只保留必要的几层状态
- 位压缩:对于布尔状态可以用位掩码
示例:在取石子游戏中,如果每次能取1/3/4颗石子,可以观察到dp[i]只依赖于dp[i-1],dp[i-3],dp[i-4],因此只需要维护最近5个状态:
def canWin(n): if n < 0: return False dp = [False] * 5 for i in range(1, n+1): dp[i%5] = not (dp[(i-1)%5] and dp[(i-3)%5] and dp[(i-4)%5]) return dp[n%5]4.3 常见错误与调试技巧
循环顺序错误:博弈DP通常需要逆向或斜向遍历
- 区间DP一般先枚举长度,再枚举起点
- 逆向DP从终局开始倒推
状态转移遗漏对手策略:
# 错误写法:没有考虑对手的最优选择 dp[i][j] = max(piles[i] + dp[i+1][j], piles[j] + dp[i][j-1]) # 正确写法: dp[i][j] = max(piles[i] + min(dp[i+2][j], dp[i+1][j-1]), piles[j] + min(dp[i][j-2], dp[i+1][j-1]))终止条件不完整:必须覆盖所有游戏结束的情况
调试建议:从小规模测试用例开始,打印出完整的DP表格,检查每个状态的转移是否符合博弈逻辑。特别关注边界状态(如剩余1/2个元素时)的计算是否正确。
5. 博弈DP的进阶应用与扩展
5.1 组合博弈与SG函数
SG函数(Sprague-Grundy)是解决组合博弈问题的强大工具。对于每个游戏状态,定义其SG值为:
SG(x) = mex{SG(y) | y是x的后继状态}其中mex表示最小排除值(最小的非负整数不在集合中)。多个独立游戏的组合SG值为各游戏SG值的异或和。
以取石子游戏为例的SG函数实现:
def sg_function(max_pick, n): sg = [0]*(n+1) for i in range(1, n+1): moves = set() for pick in range(1, max_pick+1): if i >= pick: moves.add(sg[i - pick]) # 计算mex mex = 0 while mex in moves: mex += 1 sg[i] = mex return sg5.2 不完全信息博弈的处理
当游戏存在隐藏信息时,可以引入概率分布到状态中。以卡牌游戏为例:
# dp[player][cards_remaining][score] = win_probability def cardGame(deck): n = len(deck) memo = {} def dfs(turn, start, score1, score2): if start >= n: return score1 > score2 key = (turn, start, score1, score2) if key in memo: return memo[key] if turn == 0: # 玩家1的回合 res = max( dfs(1, start+1, score1+deck[start], score2), dfs(1, start+1, score1+deck[-1], score2) ) else: # 玩家2的回合 res = min( dfs(0, start+1, score1, score2+deck[start]), dfs(0, start+1, score1, score2+deck[-1]) ) memo[key] = res return res return dfs(0, 0, 0, 0)5.3 多人博弈的扩展
当游戏玩家超过两人时,状态需要增加维度记录各玩家信息。以三人取石子为例:
def threePlayerGame(piles): n = len(piles) # dp[i][j][k] 表示当前玩家(0/1/2)在区间i...j时的最大得分差 dp = [[[0]*3 for _ in range(n)] for __ in range(n)] for i in range(n-1, -1, -1): for j in range(i, n): if i == j: for k in range(3): dp[i][j][k] = piles[i] else: # 当前玩家选择左端 pick_left = piles[i] - dp[i+1][j][(k+1)%3] # 当前玩家选择右端 pick_right = piles[j] - dp[i][j-1][(k+1)%3] dp[i][j][k] = max(pick_left, pick_right) return dp[0][n-1][0] > 0在实际编码比赛中遇到博弈DP问题时,我的经验是先画出小规模案例的状态转移图,明确必胜态和必败态的判定条件。特别注意游戏规则的细节——比如是否允许平局、玩家行动顺序、得分计算方式等,这些都会直接影响状态设计和转移方程。