【动态规划】博弈问题

博弈问题

简介

具有竞争或对抗性质的行为称为博弈行为,在这类行为中,参加斗争或竞争的各方各自具有不同的目标或利益。博弈论是二人在平等的对局中各自利用对方的策略变换自己的对抗策略,达到取胜的目的。

为了达到各自的目标和利益,各方必须考虑对手的各种可能的行动方案,并力图选取对自己最为有利或最为合理的方案,比如日常生活中的下棋,打牌等。博弈论就是研究博弈行为中斗争各方是否存在着最合理的行为方案,以及如何找到这个合理的行为方案的数学理论和方法。

应用

应用1:Leetcode.486

题目

486. 预测赢家

方法一:动态规划(自底向上)

解题思路

\(dp[i][j]\) 表示从子数组 \(nums[i], \cdots, nums[j]\) 中做选择,当前玩家与另一个玩家的分数差值的最大值

同时,我们假设数组 \(nums\) 的长度为 \(n\) ,因为两位选手是轮流选择的,我们只关心最终的状态,也就是 \(dp[0][n - 1]\) 的状态,所以,我们只需要通过状态转移记录每次选择的方案就行了。

\(dp[0][n - 1]\) 对应的状态,就是数组 \(nums\) 的初始状态,即对应先手玩家的分数。

因此,当所有的状态枚举完了之后,如果 \(dp[0][n - 1]\) 的值大于零,那么当前玩家(先手的玩家)的分数大于另一个玩家的得分,则先手赢,否则,后手赢。

边界条件

显然,\(i\), \(j\)需要满足:

\[\begin{aligned} &0 \le i \le n - 1 \\ &0 \le j \le n - 1 \end{aligned} \]

并且,当 \(i = j\) 时,即子数组的长度为 \(1\) 时,当前玩家只能选择这个数字,两人的分数差值的最大值就是当前的数字,即

\[dp[i][i] = nums[i], \quad 0 \le i \le n -1 \]

也就是说,如果只有一个数字,先手的玩家始终会成为赢家。

状态转移

假设子数组中的元素大于等于两个时,那么,状态 \(dp[i][j]\) 对应的子数组中的元素可以表示为:

\[nums[i], \ nums[i + 1], \ \cdots, \ nums[j - 1], \ nums[j] \]

此时,对于当前玩家,他有两种选择,他可以选择 \(nums[i]\) 或者 \(nums[j]\)

  • 如果当前玩家选择 \(nums[i]\),那么,当前两者分数的差值,就是当前选择的数字 \(nums[i]\) 减去上一个状态的差值,即 \(nums[i] - dp[i + 1][j]\);
  • 如果当前玩家选择 \(nums[j]\),那么,当前两者分数的差值,就是当前选择的数字 \(nums[j]\) 减去上一个状态的差值,即 \(nums[j] - dp[i][j - 1]\);

在两种方案中,当前玩家一定会选择最优的方案,使得自己的分数最大化,即上述两种选择之中的最大值,因此,状态转移方程:

\[dp[i][j] = \max(nums[i] - dp[i + 1][j], \ nums[j] - dp[i][j - 1]), \quad 0 \le i \lt j \le n - 1 \]

这里,我们不用考虑某个位置是谁在做选择,因为每个位置,对应的玩家都会做最优选择。

我们只需要考虑最终状态状态 \(dp[0][n - 1]\) 就行了,因为只有先手的玩家会对应状态 \(dp[0][n - 1]\)

注意,枚举 \(i\)\(j\) 的时候,需要注意枚举的方向,需要从边界条件(只有单个元素的时候)开始,依次增加子数组的长度。

代码实现

  • 逆序枚举:
class Solution {
    public boolean predictTheWinner(int[] nums) {
        int n = nums.length;
        int [][] dp = new int [n][n];
        for (int i = 0; i < n; i++) {
            dp[i][i] = nums[i];
        }

        for (int i = n - 2; i >= 0; i--) {
            for (int j = i + 1; j < n; j++) {
                dp[i][j] = Math.max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
            }
        }
        return dp[0][n - 1] >= 0;
    }
}
  • 顺序枚举也是一样的,代码实现如下:
class Solution {
    public boolean predictTheWinner(int[] nums) {
        int n = nums.length;
        int [][] dp = new int [n][n];
        for (int i = 0; i < n; i++) {
            dp[i][i] = nums[i];
        }

        for (int j = 1; j < n; j++) {
            for (int i = j - 1; i >= 0; i--) {
                dp[i][j] = Math.max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
            }
        }
        return dp[0][n - 1] >= 0;
    }
}

枚举的方向并不重要,核心思想在于:从边界条件开始,依次顺序枚举完所有的子数组即可。

方法二:动态规划(自顶向下)

解题思路

这里我们利用分治的思想:通过定义一个带返回值的递归函数,将问题分解为子问题(子树),通过递归推导出答案。

这里,我们定义一个play(nums, left, right, memory)函数,表示当从子数组 \(nums[left]\) , \(\cdots\), \(nums[right]\) 中做选择时,当前玩家与另一个玩家的分数差值的最大值,其中,\(memory\) 用于记录已经搜索过的结果,避免重复计算。

递归的终止条件就是子数组长度为零时,即 \(left \gt right\),两者分数的差值为零。

代码实现

class Solution:
    def PredictTheWinner(self, nums: List[int]) -> bool:
        n = len(nums)
        memory = [ [None for _ in range(n) ]  for _ in range(n)]
        return self.play(nums, 0, n - 1, memory) >= 0

    def play(self, nums: List[int], left: int, right: int, memory: List[List[int]]) -> int:
        if left > right:
            return 0

        if memory[left][right] is not None:
            return memory[left][right]

        plan_a = nums[left] - self.play(nums, left + 1, right, memory)
        plan_b = nums[right] - self.play(nums, left, right - 1, memory)

        result = max(plan_a, plan_b)
        memory[left][right] = result

        return result

方法三:动态规划

解题思路

这里我们定一个了一个对象 \(Score\),它有两个属性\(first\)\(second\),分别表示先手和后手玩家的分数,用于辅助记录状态转移过程。

\(dp[i][j]\) 表示从子数组 \(nums[i] , \cdots, nums[j]\) 中做选择,两个玩家可以获取的分数的最大值,即:

  • \(dp[i][j].first\) 表示先手玩家的最大分数;
  • \(dp[i][j].second\) 表示后手玩家的最大分数。

因此,当所有的状态枚举完了之后,如果 \(dp[0][n - 1].first\) 的值大于\(dp[0][n - 1].second\),则先手赢,否则,后手赢。

边界条件

显然,\(i\), \(j\)需要满足:

\[\begin{aligned} &0 \le i \le n - 1 \\ &0 \le j \le n - 1 \end{aligned} \]

并且,当 \(i = j\) 时,即子数组的长度为 \(1\) 时,先手玩家只能选择这个数字,那么先手玩家的分数就是\(nums[i]\),后手玩家的分数就是零,即

\[dp[i][i] = Score(nums[i], 0), \quad 0 \le i \le n -1 \]

也就是说,如果只有一个数字,先手的玩家始终会成为赢家。

状态转移

假设子数组中的元素大于等于两个时,那么,状态 \(dp[i][j]\) 对应的子数组中的元素可以表示为:

\[nums[i], \ nums[i + 1], \ \cdots, \ nums[j - 1], \ nums[j] \]

此时,对于这一轮选择,当前玩家就是先手玩家,他有两种选择,他可以选择 \(nums[i]\) 或者 \(nums[j]\)

  • 如果当前玩家选择 \(nums[i]\),那么:

    • 他可以获取的大分数 \(y_1\),就是上一个状态的他分数 \(dp[i + 1][j].second\) 加上当前的数字 \(nums[i]\) ,即 \(y_1 = nums[i] + dp[i + 1][j].second\)
    • 另一个玩家的分数保持不变,即还是上一个状态的分数 \(dp[i + 1][j].first\)
  • 如果当前玩家选择 \(nums[j]\),那么:

    • 他可以获取的大分数 \(y_2\),就是上一个状态的他分数 \(dp[i][j - 1].second\) 加上当前的数字 \(nums[i]\) ,即 \(y_2 = nums[i] + dp[i][j - 1].second\)
    • 另一个玩家的分数保持不变,即还是上一个状态的分数 \(dp[i][j - 1].first\)

因此,对于当前的这一轮选择,当前玩家一定会选择对自己有利的方案,即选择 \(y_1\)\(y_2\) 较大的方案,那么

\[dp[i][j]=\begin{cases} Score(nums[i] + dp[i + 1][j].second, \ dp[i + 1][j].first), & y_1 \gt y_2 \\ Score(nums[i] + dp[i][j - 1].second, \ dp[i][j - 1].first), & y_1 \le y_2 \end{cases} \]

注意,对于当前的这一轮选择,当前玩家就是先手玩家,显然,在上一轮他就是后手玩家

代码实现

class Score(object):
    def __init__(self, first: int = 0, second: int = 0):
        self.first = first  # 先手玩家获得的分数
        self.second = second  # 后手玩家获取的分数

    def diff(self):
        return self.first - self.second


class Solution:
    def PredictTheWinner(self, nums: List[int]) -> bool:
        n = len(nums)
        dp = [ [Score() for _ in range(n)]  for _ in range(n)]

        for i in range(n):
            dp[i][i] = Score(nums[i], 0)

        for i in range(n - 2, -1, -1):
            for j in range(i + 1, n):
                plan_a = nums[i] + dp[i + 1][j].second
                plan_b = nums[j] + dp[i][j - 1].second
                # 先手玩家选择对其有利的方案,后手玩家的分数不变,还是上一个状态的分数
                if plan_a > plan_b:
                    dp[i][j].first = plan_a
                    dp[i][j].second = dp[i + 1][j].first
                else:
                    dp[i][j].first = plan_b
                    dp[i][j].second = dp[i][j - 1].first

        return dp[0][n - 1].diff() >= 0

应用2:Leetcode.877

题目

877. 石子游戏

分析

这道题可以直接套用前面的结论,这里就不再重复分析了,我们直接给出代码实现。

另外,这道题可以通过数学结论,直接返回结果,即当数组长度为偶数时,先手玩家必获胜。由于数学解法不具有通用性,就不在此深入分析了。

感兴趣的话,可以参考力扣官方题解:石子游戏

代码实现

class Solution:
    def stoneGame(self, piles: List[int]) -> bool:
        n = len(piles)
        dp = [ [0] * n for _ in range(n)]
        for i in range(n):
            dp[i][i] = piles[i]
        # 枚举所有的状态
        for i in range(n - 2, -1, -1):
            for j in range(i + 1, n):
                dp[i][j] = max(piles[i] - dp[i + 1][j], piles[j] - dp[i][j - 1])
        return dp[0][n - 1] >= 0
class Score(object):
    def __init__(self, first: int = 0, second: int = 0):
        self.first = first  # 先手玩家获得的分数
        self.second = second  # 后手玩家获取的分数

    def diff(self):
        return self.first - self.second

class Solution:
    def stoneGame(self, piles: List[int]) -> bool:
        n = len(piles)
        dp = [ [Score() for _ in range(n)]  for _ in range(n)]

        for i in range(n):
            dp[i][i] = Score(piles[i], 0)

        for i in range(n - 2, -1, -1):
            for j in range(i + 1, n):
                plan_a = piles[i] + dp[i + 1][j].second
                plan_b = piles[j] + dp[i][j - 1].second
                # 先手玩家选择对其有利的方案,后手玩家的分数不变,还是上一个状态的分数
                if plan_a > plan_b:
                    dp[i][j].first = plan_a
                    dp[i][j].second = dp[i + 1][j].first
                else:
                    dp[i][j].first = plan_b
                    dp[i][j].second = dp[i][j - 1].first

        return dp[0][n - 1].diff() >= 0

参考:

posted @ 2023-01-12 17:39  LARRY1024  阅读(85)  评论(0编辑  收藏  举报