石子游戏
本文正在参加「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 。
提示:
2 <= piles.length <= 500piles.length是偶数。1 <= piles[i] <= 500sum(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%的用户