数据结构与算法之深入解析“石子游戏II”的求解思路与算法示例

Posted Forever_wj

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法之深入解析“石子游戏II”的求解思路与算法示例相关的知识,希望对你有一定的参考价值。

一、题目描述

  • 亚历克斯和李继续他们的石子游戏,许多堆石子 排成一行,每堆都有正整数颗石子 piles[i],游戏以谁手中的石子最多来决出胜负。亚历克斯和李轮流进行,亚历克斯先开始,最初,M = 1。
  • 在每个玩家的回合中,该玩家可以拿走剩下的 前 X 堆的所有石子,其中 1 <= X <= 2M;然后,令 M = max(M, X),游戏一直持续到所有石子都被拿走。
  • 假设亚历克斯和李都发挥出最佳水平,返回亚历克斯可以得到的最大数量的石头。
  • 示例:
输入:piles = [2,7,9,4,4]
输出:10
解释:
如果亚历克斯在开始时拿走一堆石子,李拿走两堆,接着亚历克斯也拿走两堆,在这种情况下,亚历克斯可以拿到 2 + 4 + 4 = 10 颗石子。 
如果亚历克斯在开始时拿走两堆石子,那么李就可以拿走剩下全部三堆石子,在这种情况下,亚历克斯可以拿到 2 + 7 = 9 颗石子,所以我们返回更大的 10

二、求解思路

① 记忆化搜索

  • 总数 = 先手最大 + 后手最大;
  • 总数 = 先手选择数量 + 剩余先手最大 + 剩余后手最大;
  • 由1,2 => 先手最大 + 后手最大 == 先手选择数量 + 剩余先手最大 + 剩余后手最大;
  • 后手最大 == 剩余先手最大;
  • 由3,4 => 先手最大 == 先手选择数量 + 剩余后手最大;
  • 剩余后手最大 == 剩余数量 - 剩余先手最大;
  • 由5,6 => 先手最大 == 先手选择数量 + 剩余数量 - 剩余先手最大;
  • 先手选择数量 == pre[takeTo] - pre[index];
  • 剩余数量 == pre[pre.length - 1] - pre[takeTo];
  • 剩余先手最大 == stoneGameII(pre, takeTo, newm, dp);
  • 由7,8,9,10 => 先手最大 == pre[takeTo] - pre[index] + pre[pre.length - 1] - pre[takeTo] - stoneGameII(pre, takeTo, newm, dp);
  • 因此 先手最大 == - pre[index] + pre[pre.length - 1] - stoneGameII(pre, takeTo, newm, dp);
  • 结果就是先手最大中最大那个 max = Math.max(max, pre[pre.length - 1] - pre[index] - takeNum1);
  • int[] pre 为了快速的算出两段中间的累加和。
  • Java 示例:
lass Solution 
	public int stoneGameII(int[] piles) 
		int len = piles.length;
		int[] pre = new int[len + 1];//为了快速的算出两段中间的累加和
		for (int i = 0; i < len; i++) 
			pre[i + 1] = piles[i] + pre[i];
		
		int m = 1;
		Integer[][] dp = new Integer[len][len+1];
		return stoneGameII(pre, 0, m, dp);// 在剩余len堆中拿到最大
	

	private int stoneGameII(int[] pre, int index, int m, Integer[][] dp) //从index开始(index不可拿),当前先手能拿到的最大值是多少
		if (dp[index][m] != null)
			return dp[index][m];
		int rest = pre.length - 1 - index;
		if (2 * m >= rest) 
			return pre[pre.length - 1] - pre[index];
		
		int max = Integer.MIN_VALUE;
		int range = m * 2;
		for (int x = 1; x <= range; x++) //注意相互依赖 ,卡了个bug ,这块用m和newm如果用一个变量,错的很离谱
			if (x <= rest) 
				int takeTo = x + index;
				// int takeNum = pre[takeTo] - pre[index]; // 没用上
				int newm = Math.max(x, m);/// newm 和 range 不能同时用m 
				int takeNum1 = stoneGameII(pre, takeTo, newm, dp);/// ?
				// pre[pre.length - 1] - pre[takeTo] - takeNum1 + pre[takeTo] - pre[index];
				max = Math.max(max, pre[pre.length - 1] - pre[index] - takeNum1);
			
		
		dp[index][m] = max;
		return max;
	

② 动态规划

  • 本题很明显要用记忆化搜索或者动态规划来求解,如果直接使用动态规划的话,我们要想清楚有哪些子状态需要存储。
  • 首先一定要存储的是取到某一个位置时,已经得到的最大值或者后面能得到的最大值,但是光有位置是不够的,相同的位置有不同数量的堆可以取,所以我们还需存储当前的 M 值。
  • 由于本题中的状态是从后往前递推的,如:假如最后只剩一堆,一定能算出来最佳方案,但是剩很多堆时不好算(依赖后面的状态),所以选择从后往前递推。
  • 有了思路之后,就能很方便地定义 dp 数组:
    • dp[i][j] 表示剩余 [i : len - 1] 堆时,M = j 的情况下,先取的人能获得的最多石子数;
    • i + 2M >= len, dp[i][M] = sum[i : len - 1],剩下的堆数能够直接全部取走,那么最优的情况就是剩下的石子总和;
    • i + 2M < len, dp[i][M] = max(dp[i][M], sum[i : len - 1] - dp[i + x][max(M, x)]), 其中 1 <= x <= 2M,剩下的堆数不能全部取走,那么最优情况就是让下一个人取的更少。对于所有的 x 取值,下一个人从 x 开始取起,M 变为 max(M, x),所以下一个人能取 dp[i + x][max(M, x)],最多能取 sum[i : len - 1] - dp[i + x][max(M, x)]。
  • 对于 piles = [2,7,9,4,4],可以得到下图所示的 dp 数组,结果为 dp[0][1]:

  • Java 示例:
import java.util.Arrays;

public class Solution 

    // 动态规划(后缀数组)

    public int stoneGameII(int[] piles) 
        int len = piles.length;

        // 后缀和
        int[] suffixSum = new int[len + 1];
        suffixSum[len] = 0;

        // 区间 [i..len - 1] 的后缀和 = suffixSum[i]
        // 区间 [i..j] 的后缀和 suffixSum[i] - suffixSum[j - 1]
        for (int i = len - 1; i >= 0; i--) 
            suffixSum[i] = suffixSum[i + 1] + piles[i];
        

        // dp[i][j] 表示:区间 piles[i..len - 1] 里取出 j 堆石子,当前先手能够获得的分数(注意:不是相对分数)
        int[][] dp = new int[len][len + 1];
        for (int i = len - 1; i >= 0; i--) 
            // 枚举 M:从取左边 1 堆石头,到 len 堆石头
            for (int M = 1; M <= len; M++) 
                // 至少要取 1 堆,i + 2 * M >= len 说明 [i, len - 1] 这个区间里所有的石头都可以拿走
                if (i + 2 * M >= len) 
                    dp[i][M] = suffixSum[i];
                    continue;
                
                
                // 枚举 X,此时 i + 2 * M < len,X <= 2 * M 保证了 i + X < len
                for (int X = 1; X <= 2 * M; X++) 
                    // 这里 X 可以理解为下标偏移,也可以理解为选取的石子堆数量
                    dp[i][M] = Math.max(dp[i][M], suffixSum[i] - dp[i + X][Math.max(X, M)]);
                
            
        
        return dp[0][1];
    

③ 记忆化递归

  • 对博弈类问题比较生疏的时候,可以画图去理解「博弈」的过程,在下面这张图上没有办法很详细地展示整个思维过程,因此简单说一下思路:

  • 思路分析:
    • 这样设计状态的(很重要,这样的状态设计会给转移带来一定方便):当前做决策的人,拿石子的时候得分为正,留给下一轮队手选择的时候,得分为负,因此定义的分数是 相对分数;
    • 这一轮可以选择的石子堆数,与上一轮相关,因此需要设置一个参数 M(和题目中的 M 意思一样),表示当前可以选择的石子堆数;
    • 从叶子结点开始向上每一步进行选择和比较,这样的做法才叫做「假设亚历克斯和李都发挥出最佳水平」,即每个人都让自己的利益最大化,等价于「让他人利益最小化」,这是因为石子的总数是固定的。
  • 由于拿的都是左边的连续的石子堆,需要先计算一下前缀和,然后计算区间和;
  • 当前区间(或者说由左边界 begin 决定的剩下的区间,右边界一定是 len - 1)里的石子堆数小于等于 2M 的时候,全部拿走,是使得自己获利最多的做法;
  • 否则的话,就需要枚举拿 1 堆、2 堆、3 堆的时候,选出一个当对手利益最大化的时候,自己利益最大化的选择;
  • 由于 memo 或者说 dfs 定义的是「相对分数」,因此输出的时候,还要做一个小的转化。
  • Java 示例:
public class Solution 

    // 一开始把这道题和区间 dp 联系在一起,是不对的

    public int stoneGameII(int[] piles) 
        int len = piles.length;
        int[][] memo = new int[len][len + 1];

        // [i, j] 的前缀和 preSum[j + 1] - preSum[i]
        int[] preSum = new int[len + 1];
        for (int i = 0; i < len; i++) 
            preSum[i + 1] = preSum[i] + piles[i];
        
//        x + y = preSum[len];
//        x - y = res;
        int res = dfs(piles, 0, 1, preSum, memo);
        // 由于得到的是相对分数,需要转换成为绝对分数
        return (preSum[len] + res) / 2;
    

    /**
     * @param piles
     * @param begin 定义石子堆的起始下标,即在 [start, len - 1] 这个区间里取石子
     * @param M     当前先手可以拿 [1, 2 * M] 堆石子(如果石子数够的话)
     * @param memo
     * @return 当前玩家在区间 [start, len - 1] 这个区间里取石子,得到的「相对分数」
     */
    private int dfs(int[] piles, int begin, int M, int[] preSum, int[][] memo) 
        int len = piles.length;
        if (begin >= len) 
            return 0;
        

        if (memo[begin][M] != 0) 
            return memo[begin][M];
        
        // 当前区间 [begin, len - 1] 的元素个数 len - begin <= 2M 的时候,
        // 全部拿走是利益最大的,这是因为 1 <= piles[i] <= 10 ^ 4
        if (len - begin <= 2 * M) 
            memo[begin][M] = preSum[len] - preSum[begin];
            return preSum[len] - preSum[begin];
        

        // 走到这里,可以取的石子堆数 1 <= X <= 2M
        // 区间 [begin, j] 的长度 j - begin + 1 >= 2 * M
        int minLen = Math.min(2 * M, len - begin);
        // 这个初始化很重要,因为有可能是负分,所以不能初始化为 0
        int res = Integer.MIN_VALUE;
        for (int X = 1; X <= minLen; X++) 
            // 区间 [begin, begin + X - 1] 的前缀和 = preSum[begin + X] - preSum[begin - 1]
            int chooseLeft = preSum[begin + X] - preSum[begin];
            res = Math.max(res, chooseLeft - dfs(piles, begin + X, Math.max(M, X), preSum, memo));
        
        memo[begin][M] = res;
        return res;
    

    public static void main(String[] args) 
        Solution solution = new Solution();
        int[] piles = new int[]2, 7, 9, 4, 4;
        int res = solution.stoneGameII(piles);
        System.out.println(res);
    

以上是关于数据结构与算法之深入解析“石子游戏II”的求解思路与算法示例的主要内容,如果未能解决你的问题,请参考以下文章

数据结构与算法之深入解析“股票的最大利润”的求解思路与算法示例

数据结构与算法之深入解析“安装栅栏”的求解思路与算法示例

数据结构与算法之深入解析“最长连续序列”的求解思路与算法示例

数据结构与算法之深入解析“路径总和”的求解思路与算法示例

数据结构与算法之深入解析“斐波那契数”的求解思路与算法示例

数据结构与算法之深入解析“连续整数求和”的求解思路与算法示例