[DP003] 石子游戏

161 阅读6分钟

石子游戏

本文正在参加「Java主题月 - Java开发实战」,详情查看:juejin.cn/post/696719…

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

877. 石子游戏

难度中等250

亚历克斯和李用几堆石子在做游戏。偶数堆石子排成一行,每堆都有正整数颗石子 piles[i]

游戏以谁手中的石子最多来决出胜负。石子的总数是奇数,所以没有平局。

亚历克斯和李轮流进行,亚历克斯先开始。 每回合,玩家从行的开始或结束处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中石子最多的玩家获胜。

假设亚历克斯和李都发挥出最佳水平,当亚历克斯赢得比赛时返回 true ,当李赢得比赛时返回 false

示例:

输入:[5,3,4,5]
输出:true
解释:
亚历克斯先开始,只能拿前 5 颗或后 5 颗石子 。
假设他取了前 5 颗,这一行就变成了 [3,4,5] 。
如果李拿走前 3 颗,那么剩下的是 [4,5],亚历克斯拿走后 5 颗赢得 10 分。
如果李拿走后 5 颗,那么剩下的是 [3,4],亚历克斯拿走后 4 颗赢得 9 分。
这表明,取前 5 颗石子对亚历克斯来说是一个胜利的举动,所以我们返回 true 。

提示:

  1. 2 <= piles.length <= 500
  2. piles.length 是偶数。
  3. 1 <= piles[i] <= 500
  4. sum(piles) 是奇数。

方法签名

    public boolean stoneGame(int[] piles) {

    }

first try

根据题意可以得知:

  • 每次是一定要取的
  • 而且只能从头尾取一个

因此可以得知某一次取的大小就为:

f(x) = max(left,right)

就可以取当次的结果值,同时将取得的那份去掉。

由此写出第一个提交:

    public static boolean stoneGame(int[] piles) {
        int alex = 0,lee = 0;
        int left = 0,right = piles.length-1;
        boolean alexTime = true;
        while(left <= right){
            int cur = 0;
            if(piles[left] >= piles[right]){
                cur = piles[left];
                left ++;
            }else{
                cur = piles[right];
                right--;
            }
            if(alexTime){
                alex+=cur;
            }else{
                lee+=cur;
            }
            alexTime = !alexTime;
        }
        return alex>lee;
    }

结果:回答错误

输入:

[3,7,2,3]

输出:

false

预期结果:

true

second try

此时需要注意到:

  • 取了某一个值后,原来遮盖的下一个值就暴露出来了。

    例如上述的3,7,2,3:

    • 如果第一次取了左边的3,那么lee就可以取7,那么不能算是最优的结果。
  • 而且我们在计算中,提前决定了走的方向,也就是说我们每次都取当前最优的解作为全局最优解的一部分,而事实上这样子取数是不对的。

这里采用的解法跟DP也没啥关系,我们需要重新拟定计算规则。

递归

由于最终解不清楚,那么我们先采用递归的方法来解释一下这个题目。

假设:

  • 选左边为F(L),选右边为F(R),结果为F(total)

那么:

  • 我们每次的选择事实上都是下一步的结果汇总
  • 只要有一种选择能获胜,那么结果就为胜

基于这个思路,我们拟定出下面的递归:

  • 我们使用整数值来代表目前alex比lee多多少石头

  • 无论是左右哪一边的选择,我们都选择返回最大的就可以(符合只要**“赢了一次就是赢”**)

假设x为第i次递归的状态,f(x)为第i次结果,那么:

f(x) = Max(left(x),right(x))

我们将状态x规定为:

  • 总数
  • 最左索引
  • 最右索引

做出如下解答:

    public static boolean stoneGame(int[] piles) {
        return rec(1,0,0,piles.length-1,piles) > 0;
    }

    public static int rec(int turn,int total,int l,int r,int[] piles){
        if(l >= r){
            return total;
        }
        int nowChoose = (turn %2) == 1?1:-1;
        int left = rec(turn+1,total+nowChoose*piles[l],l+1,r,piles);
        int right = rec(turn+1,total+nowChoose*piles[r],l,r-1,piles);
        return Math.max(left,right);
    }

结果:

执行结果:

超出时间限制

最后执行的输入:

[7,7,12,16,41,48,41,48,11,9,34,2,44,30,27,12,11,39,31,8,23,11,47,25,15,23,4,17,11,50,16,50,3

至少说明思路是正确的了,接下来我们要对这种思路方法进行算法上的优化。

DP

这个时候我们需要先找出需要优化的地方:

  • 我们在取下一步的值的时候,进行了多次计算,这些计算的值是否能用数据进行固化,节省递归的调用?

我们此处将所有的记录都记载下来:

dp[i] [j]

i,j代表:当石块处于i和j之间时,取得的绝对值最大值。那么根据上面的递归公式,我们可以得出dp公式:

dp[i] [j] = Math.max(pile[i] - dp[i+1] [j],pile[j] - dp[i] [j-1])

根据这个dp数组,初始化结束后我们只需要判断dp[0] [piles.length-1]大小即可。

至此我们获取了dp公式,那么我们接下来要决定计算的步骤。

  • 由于上一步的结果,是根据下一步的结果来的,且:
    • 我们的i ,j ,隐含着 i < j 的含义
  • 因此i = 0 代表着我们回到了问题最初的情况:选0到j处的最大值。我们要根据后面的数据,来计算前面的所得。
  • 因此,我们要从后面开始进行循环。

最后,我们需要将dp数组进行预初始化,作为已知条件填入dp数组中。

  • 根据我们已知的条件,当i = j 的时候,dp[i] [j] 就只能取piles[i]上的那堆石头,因此

    dp[i] [i] = piles[i]

将上述的条件通过代码描述如下:

    public static boolean stoneGame(int[] piles) {
        int[][] dp = new int[piles.length][piles.length];
        for(int i = 0;i< piles.length;i++){
            dp[i][i] = piles[i];
        }
        for(int i = piles.length-1;i>=0;i--){
            for(int j=i+1 ;j <piles.length ; j++){
                dp[i][j] = Math.max(piles[i]-dp[i+1][j],piles[j]-dp[i][j-1]);
            }
        }
        return dp[0][piles.length-1]>0;
    }

结果:

执行用时:6 ms, 在所有 Java 提交中击败了47.78%的用户

内存消耗:39.1 MB, 在所有 Java 提交中击败了30.12%的用户

滚动数组

根据我们已知的方法,dp是可以通过滚动数组的方式,来节省dp数组所占空间的,那么这个地方是否能做同样的空间优化,或者说:

  • 将数组大小缩小,来实现空间优化?

在dp公式中:

dp[i][j] = Math.max(piles[i]-dp[i+1][j],piles[j]-dp[i][j-1]);

事实上计算过程中:

  • 数据只和上下两层的i 有关

取结果

  • 我们只取dp[0] [piles.length-1]

因此,对于最后的dp结果,我们只关心dp[0],且:

  • 在每次循环i固定的时候,我们都会对从 i 到 j 的数进行重新计算,这意味着:
    • 在i之前的数值,在该层的计算中是没用的
    • 在下一次计算时,我们会把上一层i之前的数据覆盖掉,而且不使用这个数据

因此,实际上我们只要维护 一层的数组数据即可,因此将dp[] [] 节省为 dp[]

dp数组的含义是:

  • 在当前 i 对应的位置,我们取 i 到 j 之间的石子的结果为多少。
public static boolean stoneGame2(int[] piles) {
    int[] dp = new int[piles.length];
    for (int i = 0; i < piles.length; i++) {
        dp[i] = piles[i];
    }

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

结果:

执行用时:5 ms, 在所有 Java 提交中击败了48.76%的用户

内存消耗:36.1 MB, 在所有 Java 提交中击败了78.74%的用户