金罐游戏的两种动态规划解法

问题描述

金罐游戏中有两个玩家,A和B,所有的金罐排成一排,每个罐子里都有一些金币, 玩家可以看到每个金罐中有多少硬币。A和B两个玩家交替轮流打开金罐,但是必须从一排的某一端开始挑选,玩家可以从一排罐的任一端挑选一个罐打开。 获胜者是最后拥有更多硬币的玩家。 我们是A玩家,问如何才能使A 收集的硬币数量最大。
假设 B 也是按照“最佳”策略玩,并且 A 开始游戏。

示例:[4,6,2,3]
拿取顺序

金罐中金币个数(已排列)AB
4, 6, 2, 33
4, 6, 24
6, 26
22
9 coins6 coins

类似的问题:
Leetcode 486题 预测赢家

给你一个整数数组 nums 。玩家 1 和玩家 2 基于这个数组设计了一个游戏。

玩家 1 和玩家 2 轮流进行自己的回合,玩家 1 先手。开始时,两个玩家的初始分值都是 0 。每一回合,玩家从数组的任意一端取一个数字(即,nums[0] 或 nums[nums.length - 1]),取到的数字将会从数组中移除(数组长度减 1 )。玩家选中的数字将会加到他的得分上。当数组中没有剩余数字可取时,游戏结束。

如果玩家 1 能成为赢家,返回 true 。如果两个玩家得分相等,同样认为玩家 1 是游戏的赢家,也返回 true 。你可以假设每个玩家的玩法都会使他的分数最大化。

解题思路

Step1:穷举

首先,不考虑双方需要采取的策略,穷举所有的可能。红色为A能获得金币数,蓝色为B能获得的金币数。此时,理论上来说A最多能拿到10个。
不考虑最优策略时的可能

但考虑双方都采取最优策略后,“A能拿到10个金币”这种可能实际上并不会发生。下面我们自底向上进行分析。

Step2:从len(pots)=1到len(pots)=4

首先,如果子列中仅有1个元素,当前玩家只能选择该元素。

如果子列中有2个元素,当前玩家一定选择较大的。例如,子列为[2,3]时,A一定选择3。

在这里插入图片描述

如果子列中有3个元素,B做出选择后,A能得到的金币数也就确定了。例如,子列为[6,2,3]时,B选6(左边),剩余[2,3],那么A接下来一定会选3,B选2;如果B选3,剩余[6,2],那么A接下来一定会选6,B选2。

即,B最多能拿到的金币数为 m a x ( 6 + ( 剩余总金币 − A 得到的 ) , 3 + ( 剩余总金币 − A 得到的 ) ) ) max(6+(剩余总金币-A得到的),3+(剩余总金币-A得到的))) max(6+(剩余总金币A得到的)3+(剩余总金币A得到的)))。而 ( 剩余总金币 − A 得到的 ) (剩余总金币-A得到的) (剩余总金币A得到的),实际上就是B第二次选择时能得到金币数。
最终我们得到 m a x ( 6 + 2 , 3 + 2 ) = 8 max(6+2,3+2)=8 max(6+2,3+2)=8,选左边。

在这里插入图片描述

如果子列中有4个元素,计算过程与子列中有3个元素类似。
m a x ( 4 + ( 剩余总金币 − B 得到的 ) , 3 + ( 剩余总金币 − B 得到的 ) ) ) = m a x ( 4 + 3 , 3 + 6 ) = 9 max(4+(剩余总金币-B得到的),3+(剩余总金币-B得到的))) \\=max(4+3,3+6) \\=9 max(4+(剩余总金币B得到的)3+(剩余总金币B得到的)))=max(4+3,3+6)=9

在这里插入图片描述

Step3: 拓展,更一般化的表述

那么现在,假设[4,6,2,3]是更大的金罐序列pots的一部分,pots[i]=4,pots[j]=3,len(pots)=n

在这里插入图片描述

如图所示,每当我们选择左边的罐子,i右移一格;每当我们选择右边的罐子,j左移一格。
在这里插入图片描述

假设 d p [ i ] [ j ] dp[i][j] dp[i][j]表示当金罐子序列从第 i 个到第 j 个时,当前操作的玩家(假设为A)可以从这些金罐中获得的最大金币数。
如果A取第i个罐子,则对方从剩下的金罐中获得的最大金币数为 d p [ i + 1 ] [ j ] dp[i+1][j] dp[i+1][j]
此时A获得的金币为: p o t s [ i ] + ( t o t a l [ i ] [ j − 1 ] − d p [ i ] [ j − 1 ] ) pots[i]+(total[i][j-1]- dp[i][j-1]) pots[i]+(total[i][j1]dp[i][j1])
接下来B进行选择,两种选择分别导向A得到 d p [ i + 2 ] [ j ] dp[i+2][j] dp[i+2][j],或 d p [ i + 1 ] [ j − 1 ] dp[i+1][j−1] dp[i+1][j1],B一定是选择让A最少的,即 m i n ( d p [ i + 2 ] [ j ] , d p [ i + 1 ] [ j − 1 ] ) min(dp[i+2][j],dp[i+1][j−1]) min(dp[i+2][j],dp[i+1][j1])
即,此时A获得的金币为:
p o t s [ i ] + m i n ( d p [ i + 2 ] [ j ] , d p [ i + 1 ] [ j − 1 ] ) pots[i]+min(dp[i+2][j],dp[i+1][j−1]) pots[i]+min(dp[i+2][j],dp[i+1][j1])

同理,如果A取第j个罐子,A获得的金币为:
p o t s [ j ] + m i n ( d p [ i + 1 ] [ j − 1 ] , d p [ i ] [ j − 2 ] ) pots[j]+min(dp[i+1][j−1],dp[i][j−2]) pots[j]+min(dp[i+1][j1],dp[i][j2])

那么A是取第 i 还是第 j 个罐子?当然是较大的那个选择。即:
m a x ( p o t s [ i ] + m i n ( d p [ i + 2 ] [ j ] , d p [ i + 1 ] [ j − 1 ] ) , p o t s [ j ] + m i n ( d p [ i + 1 ] [ j − 1 ] , d p [ i ] [ j − 2 ] ) max(pots[i]+min(dp[i+2][j],dp[i+1][j−1]), \\ pots[j]+min(dp[i+1][j−1],dp[i][j−2]) max(pots[i]+min(dp[i+2][j],dp[i+1][j1]),pots[j]+min(dp[i+1][j1],dp[i][j2])
这就是我们的状态转移方程。
很明显,至少有三个罐子时,这个方程才行得通。即边界条件为:
边界条件:
i = j i=j i=j(一个罐子): d p [ i ] [ i ] = p o t s [ i ] dp[i][i]=pots[i] dp[i][i]=pots[i]
i + 1 = j i+1=j i+1=j(两个罐子): d p [ i ] [ j ] = m a x ( p o t s [ i ] , p o t s [ j ] ) dp[i][j]=max(pots[i],pots[j]) dp[i][j]=max(pots[i],pots[j])

Step4:算法实现

很明显 d p [ i ] [ j ] dp[i][j] dp[i][j]可以用一个 n × n n×n n×n的二维数组表示。计算过程就是先把pots[i]填充到对角线(边界条件1),再依次比较相邻两个元素的较大值填充到次对角线(边界条件2),接着依次填充右上方的矩阵。时间复杂度和空间复杂度都为 O ( n 2 ) O(n^2) O(n2)。通过三角矩阵的压缩存储可以降低存储空间占用( n 2 → n ( n + 1 ) 2 n^2→\frac{n(n+1)}{2} n22n(n+1)),但空间复杂度依然为 O ( n 2 ) O(n^2) O(n2)

在这里插入图片描述
只计算能得到的最大金币数:

def max_coins(pots):
    n = len(pots)
    # 创建一个 n x n 的二维列表,初始化为 0
    dp = [[0] * n for _ in range(n)]
    
    # 处理只有一个金罐的情况
    for i in range(n):
        dp[i][i] = pots[i]
    
    # 处理有两个金罐的情况
    for i in range(n - 1):
        dp[i][i + 1] = max(pots[i], pots[i + 1])
    
    # 使用动态规划填充 dp 表
    for length in range(3, n + 1):  # 从长度为 3 到 n
        for i in range(n - length + 1):
            j = i + length - 1
            # 计算两种情况,选择最优解
            pick_first = pots[i] + min(dp[i + 2][j] if i + 2 <= j else 0,
                                       dp[i + 1][j - 1] if i + 1 <= j - 1 else 0)
            pick_last = pots[j] + min(dp[i + 1][j - 1] if i + 1 <= j - 1 else 0,
                                      dp[i][j - 2] if i <= j - 2 else 0)
            dp[i][j] = max(pick_first, pick_last)
    
    # 最终结果存储在 dp[0][n-1] 中
    return dp[0][n - 1]

# 示例
pots = [4, 6, 2, 3]
print(max_coins(pots))  # 输出应为玩家 A 可以获得的最大硬币数

记录选择:

def max_coins_with_tracking(pots):
    n = len(pots)
    dp = [[0] * n for _ in range(n)]
    # 用于记录每一步的选择路径
    pick = [[None] * n for _ in range(n)]
    
    for i in range(n):
        dp[i][i] = pots[i]
        pick[i][i] = (i, 'take only pot')

    for i in range(n - 1):
        if pots[i] > pots[i + 1]:
            pick[i][i + 1] = (i, 'take leftmost pot')
        else:
            pick[i][i + 1] = (i + 1, 'take rightmost pot')
        dp[i][i + 1] = max(pots[i], pots[i + 1])
    
    for length in range(3, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            
            left_option = min(dp[i + 2][j] if i + 2 <= j else 0, dp[i + 1][j - 1] if i + 1 <= j - 1 else 0)
            right_option = min(dp[i + 1][j - 1] if i + 1 <= j - 1 else 0, dp[i][j - 2] if i <= j - 2 else 0)
            pick_first = pots[i] + left_option
            pick_last = pots[j] + right_option
            
            if pick_first > pick_last:
                dp[i][j] = pick_first
                pick[i][j] = (i, 'take leftmost pot')
            else:
                dp[i][j] = pick_last
                pick[i][j] = (j, 'take rightmost pot')

    # 回溯选择路径
    choices = []
    i, j = 0, n - 1
    while i <= j:
        choice_index, choice_description = pick[i][j]
        choices.append((choice_index, choice_description))
        if choice_description == 'take leftmost pot':
            i += 1
        else:
            j -= 1

    return dp[0][n - 1], choices,pick

# 示例
pots = [4, 6, 2, 3]
max_coins, choices ,pick= max_coins_with_tracking(pots)
print("Maximum coins:", max_coins)
print("Choices:")
for index, choice in choices:
    print(f"Pot {index + 1} with {pots[index]} coins: {choice}")

另一种解题思路

另一种思路来自于CSND上“很咸的飞鱼”的这篇帖子:金罐游戏
这个解法,将区间 [ i , j ] [ i , j ] [i,j]的罐子中先手玩家与后手玩家最终得到的金币数量的差值设为 d p [ i ] [ j ] dp[i][j] dp[i][j],动态规划方程为:
d p [ i ] [ j ] = m a x ( c o i n s [ i ] − d p [ i + 1 ] [ j ] , c o i n s [ j ] − d p [ i ] [ j − 1 ] ) \mathrm{dp[i][j]=max(coins[i]-dp[i+1][j],coins[j]-dp[i][j-1])} dp[i][j]=max(coins[i]dp[i+1][j],coins[j]dp[i][j1])
这种解法不直接计算得到的最大金币数,而是从博弈的角度,在零和游戏里拉大分差。因为在这个游戏里,A拿的多B就拿得少,一个玩家的收益就是另一个玩家的损失。
相应的,动态方程的含义为:
最终相比于对方的分差 = 选择 i / j 能得到的分数 − 这样选择后对方会获得多少优势 最终相比于对方的分差= \\选择i/j能得到的分数-这样选择后对方会获得多少优势 最终相比于对方的分差=选择i/j能得到的分数这样选择后对方会获得多少优势
例如:
对于[2,3],A的选择为max(2-3,3-2)=1,所以选右边,得到3个金币,比B多1。
对于[6,2,3],B选左边,则得到6个金币,而这样选择的话,接下来A会比B多1个金币,最终分差为6-1=5,B比A多5个;同理,选右边,则为3-4=-1,B比A多-1个,也就是比A少1个。故max(6-1,3-4)=5

在这里插入图片描述
最大化两者间的差距(这里设置的 d p [ i ] [ j ] dp[i][j] dp[i][j])和最大化收益(得到的金币数,也就是下图的 A m a x A_{max} Amax)是可以相互转化的。

A m a x = A m a x ′ + c o i n ( i / j ) A_{max}=A'_{max}+coin(i/j) Amax=Amax+coin(i/j)
d p [ i ] [ j ] = A m a x − B m a x = A m a x ′ + c o i n ( i / j ) − B m a x = − ( B m a x − A ′ m a x ) + c o i n ( i / j ) = c o i n ( i / j ) − d p [ i + 1 ] [ j ] / d p [ i ] [ j − 1 ] \\dp[i][j]=A_{max}-B_{max} \\=A'_{max}+coin(i/j)-Bmax \\=-(Bmax-A'max)+coin(i/j) \\=coin(i/j)-dp[i+1][j]/dp[i][j-1] dp[i][j]=AmaxBmax=Amax+coin(i/j)Bmax=(BmaxAmax)+coin(i/j)=coin(i/j)dp[i+1][j]/dp[i][j1]
注:A’是第二轮时A的选择,也就是(第一轮)A选择→B选择→(第二轮)A选择→…

该方法的代码实现、效率等,详见原文。

  • 29
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值