1872. 石子游戏 VIII
这道题官网有很多很好的解释,这里结合我自己的思考介绍是怎么一步步从最原始的解法再到优化出 的解法的过程。也可以理解为是对转移方程
的一个补充说明
比较关键的一步是要发现出“前缀和”这个重点。
通常来说,石子游戏系列都可以通过自己“画"出一棵树来看是怎么从上而下得到结果的。但是如果这里我们直接从最原始的输入来画的话,就会得到下面这种图。输入:。
上图是遍历出每一轮的选择的结果。其中,正方形代表的是轮到Alice选,圆形代表的是轮到Bob选。但是,注意到Alice和Bob都想作出对自己最优的选择。假设最终的结果的选择是。 那么当Alice面对输入的时候,从上图可知,他一共有4个选择,而且他最终肯定是选择:
同理,轮到Bob选的时候他也会选的结果从而最优。
//之所以是-(-2)是因为上面讨论的,Bob选的数在最终结果中是以减法的形式来作用的
很明显,这是个递归的问题。每个问题都要基于子问题的结果才能算出来,因此从直觉上来说要用自下而上的方法来算,当然,自下而上的递归一般可以用记忆化递归来优化。从以上的分析中不难看出,子问题有一个变量是当前的回合是Alice还是Bob选。如果是Alice,那就是加上选的数0否则是减去选的数。表示Alice还是Bob可以用一个变量,当时表示当前回合轮到Alice。表示轮到Bob。但是,难点就是另一个变量是怎么选比较好呢?每一个子问题都是一个数组,从上图的树中虽然可以看到有很多重复的输入,但是用什么来表述一个输入的数组呢?????
揭晓答案了:就是回归到文中开始说的“前缀和”。从观察中可以看出,无论是Alice还是Bob。选的数其实都是前缀和。因此,首先算出前缀和。然后可以画出以下的选择树
所以表示问题维度的另外一个因素就出来了,就是前缀和数组的下标!
方法一:最符合思维的自上而下+记忆化
// 直接这个Stack Overflow
public int stoneGameVIII(int[] stones) {
int[] prefixSum = new int[stones.length];
prefixSum[0] = stones[0];
for (int i = 1; i < prefixSum.length; i++) {
prefixSum[i] = prefixSum[i - 1] + stones[i];
}
Integer[][] mem = new Integer[stones.length + 1][2];
mem[stones.length - 1][0] = prefixSum[stones.length - 1];
mem[stones.length - 1][1] = -prefixSum[stones.length - 1];
Arrays.fill(mem[stones.length], 0);
return solve(prefixSum, mem, 1, 0);
}
private int solve(int[] prefixSum, Integer[][] mem, int start, int turn) {
if (mem[start][turn] != null) {
return mem[start][turn];
}
int result = turn == 0 ? Integer.MIN_VALUE : Integer.MAX_VALUE;
for (int i = start; i < prefixSum.length; i++) {
if (turn == 0) {
result = Math.max(result, prefixSum[i] + solve(prefixSum, mem, i + 1, 1));
} else {
result = Math.min(result, -prefixSum[i] + solve(prefixSum, mem, i + 1, 0));
}
}
mem[start][turn] = result;
return result;
}
说明:参数start表示当前选择的人从前缀和数组的第几个数开始选,turn表示当前是谁的回合。0是Alice,1是Bob。所以最终结果就是
上面的代码比较符合人的思维,自上而下地作出选择。但可惜StackOverflow错误。。。
方法二:从后推到前的迭代方法+记忆化搜素
public int stoneGameVIII(int[] stones) {
int[] prefixSum = new int[stones.length];
prefixSum[0] = stones[0];
for (int i = 1; i < prefixSum.length; i++) {
prefixSum[i] = prefixSum[i - 1] + stones[i];
}
Integer[][] mem = new Integer[stones.length + 1][2];
mem[stones.length - 1][0] = prefixSum[stones.length - 1];
mem[stones.length - 1][1] = -prefixSum[stones.length - 1];
Arrays.fill(mem[stones.length], 0);
for (int i = stones.length - 2; i >= 1; i--) {
int zeroResult = Integer.MIN_VALUE, oneResult = Integer.MAX_VALUE;
for (int j = i; j < stones.length; j++) {
zeroResult = Math.max(zeroResult, prefixSum[j] + mem[j + 1][1]);
oneResult = Math.min(oneResult, -prefixSum[j] + mem[j + 1][0]);
}
mem[i][0] = zeroResult;
mem[i][1] = oneResult;
}
return mem[1][0];
}
方法二是对方法一的改进。可惜,很明显复杂度是。所以也是超时了。
方法三:优化嵌套循环
观察的计算过程:
对比一下 的计算过程:
不难发现 实际上就等于:
并且结合上面的等式,可以推出
于是,上面的等式可以化简为:
可以看到,第二个表示当前回合是轮到Alice还是Bob的维度可以去掉了。最终就是官网上给出的转移方程:
到此,推导过程完成了!
见代码:
public int stoneGameVIII(int[] stones) {
int[] prefixSum = new int[stones.length];
prefixSum[0] = stones[0];
for (int i = 1; i < prefixSum.length; i++) {
prefixSum[i] = prefixSum[i - 1] + stones[i];
}
Integer[][] mem = new Integer[stones.length + 1][2];
mem[stones.length - 1][0] = prefixSum[stones.length - 1];
mem[stones.length - 1][1] = -prefixSum[stones.length - 1];
Arrays.fill(mem[stones.length], 0);
for (int i = stones.length - 2; i >= 1; i--) {
int zeroResult = Integer.MIN_VALUE, oneResult = Integer.MAX_VALUE;
zeroResult = Math.max(prefixSum[i] + mem[i + 1][1], mem[i + 1][0]);
oneResult = Math.min(-prefixSum[i] + mem[i + 1][0], mem[i + 1][1]);
mem[i][0] = zeroResult;
mem[i][1] = oneResult;
}
return mem[1][0];
}
方法四:一维转移方程
public int stoneGameVIII(int[] stones) {
int[] prefixSum = new int[stones.length];
prefixSum[0] = stones[0];
for (int i = 1; i < prefixSum.length; i++) {
prefixSum[i] = prefixSum[i - 1] + stones[i];
}
Integer[] mem = new Integer[stones.length + 1];
mem[stones.length - 1] = prefixSum[stones.length - 1];
for (int i = stones.length - 2; i >= 1; i--) {
mem[i] = Math.max(prefixSum[i] - mem[i + 1], mem[i + 1]);
}
return mem[1];
}