题目地址:
https://leetcode.com/problems/predict-the-winner/
给定一个非负整数数组。设计一个两人的游戏,比如甲乙两个人,甲可以从数组左右两端点取一个分数,然后乙接着拿,直到拿完为止。如果甲总分高于等于乙,就返回true,否则返回false。甲是先手方。
思路是这样的:首先如果总共只有一个数字,或有偶数个数字,那么先手必胜。证明如下:
只有一个数字时显然先手必胜;
如果有偶数个数字,我们把整个数组从左向右数第奇数个数都染成红色,第偶数个数都染成蓝色。如果红色数字的和大于等于蓝色数字的和,那么每轮先手都去取红色的数字,就可以必胜;反之每轮都取蓝色的数字就必胜。因为按照这种策略,先手可以保证自己取的数字都是一种颜色,而让对手都取另一种颜色。这样先手就可以稳操胜券。
接下来考虑一般情况:
如果只剩两个数字了,那么结论可以很轻易的得到。如果多于两个数字,那么就枚举先手方拿左右端这两种情况,这样就把大问题化为了更小规模的问题。但困难之处在于,仅仅知道小规模问题谁胜谁负,不足以知道大规模问题谁胜谁负,于是想到我们可以把谁胜谁负转化为分差。如果我们知道了
n
n
n规模的问题中,先手方比后手方的分差
s
n
s_n
sn,那么对于规模
n
+
1
n+1
n+1的问题,先手方比后手方的分差就是先手方拿一个分数,再减去规模为
n
n
n的问题中先手方和后手方的分差(此时先后手对调了)。最后只需要返回分差是否非负即可。
法1:记忆化搜索。代码如下:
public class Solution {
public boolean PredictTheWinner(int[] nums) {
// 如果只有一个数字,或者有偶数个数字,返回true,先手必胜
if (nums.length == 1 || (nums.length & 1) == 0) {
return true;
}
// f[i][j]记录对于数组nums[i,...,j],先手方与后手方的分差
// 做记忆化,防止重复搜索
int[][] f = new int[nums.length][nums.length];
return dfs(nums, 0, nums.length - 1, f) >= 0;
}
private int dfs(int[] nums, int i, int j, int[][] f) {
// 递归出口
if (j - i == 1) {
f[i][j] = Math.abs(nums[i] - nums[j]);
return f[i][j];
}
// 如果f里有记忆,直接调取记忆;否则接着暴搜
// score1是先手选了数组左端的情况下,后手与先手的分差
int score1 = f[i + 1][j] != 0 ? f[i + 1][j] : dfs(nums, i + 1, j, f);
// score2是先手选了数组右端的情况下,后手与先手的分差
int score2 = f[i][j - 1] != 0 ? f[i][j - 1] : dfs(nums, i, j - 1, f);
// 返回之前做一下记忆
f[i][j] = Math.max(nums[i] - score1, nums[j] - score2);
return f[i][j];
}
}
时空复杂度 O ( n 2 ) O(n^2) O(n2)。时间复杂度的分析可以这么看,在暴搜那个二维矩阵的时候,每搜到叶子节点回溯的时候,一路上都会去做记忆化,所以这个二维矩阵每个entry最多被搜一次。
法2:动态规划。本质上和记忆化搜索是一样的。只需要注意更新顺序,一定要以已知来更新未知。代码如下:
public class Solution {
public boolean PredictTheWinner(int[] nums) {
if (nums.length == 1 || (nums.length & 1) == 0) {
return true;
}
int[][] dp = new int[nums.length - 1][nums.length];
for (int j = 1; j < nums.length; j++) {
for (int i = j - 1; i >= 0; i--) {
if (j - i == 1) {
dp[i][j] = Math.abs(nums[i] - nums[j]);
} else {
dp[i][j] = Math.max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
}
}
}
return dp[0][nums.length - 1] >= 0;
}
}
时空复杂度一样。
接下来考虑优化空间复杂度,直观的想法是只用一维数组,重用数组覆盖掉以后不用的数值。我们想象某时刻我们在更新矩阵的第 i i i行第 j j j列,这个数字只依赖与第 i i i行第 j − 1 j-1 j−1列,以及第 i + 1 i+1 i+1行第 j j j列的数,所以我们可以按行滚动更新,具体的意思是,我们初始化一维数组,代表某一行,然后新行就用这个旧的行来更新,在更新行的时候, j j j需要从左向右更新。在空间优化的时候,决定更新顺序的关键,是一定要搞清楚当前值是由更新后的值算出来的,还是更新前的值算出来的,只有这样才能保证答案正确。代码如下:
public class Solution {
public boolean PredictTheWinner(int[] nums) {
if (nums.length == 1 || (nums.length & 1) == 0) {
return true;
}
int[] dp = new int[nums.length];
for (int i = nums.length - 2; i >= 0; i--) {
for (int j = i + 1; j < nums.length; j++) {
dp[j] = Math.max(nums[i] - dp[j], nums[j] - dp[j - 1]);
}
}
return dp[nums.length - 1] >= 0;
}
}
时间复杂度一样,空间 O ( n ) O(n) O(n)。
注意:
首先一个自然的问题是,可不可以不用int数组,而用布尔数组。答案是不可以,因为小规模问题的胜负不足以决定大规模问题的胜负。一个很简单的反例是,
(
1
,
3
,
1
)
(1,3,1)
(1,3,1)和
(
1
,
0
,
1
)
(1,0,1)
(1,0,1),前者甲必败,后者甲必胜。如果布尔数组可以解决问题的话,当只剩两个数的时候,布尔数组都记录为真(因为两个数的时候甲必胜),那么在更新到三个数的时候,仅仅靠端点的数值和两个数时的胜败,无法推断出三个数的时候的胜败。比如上面两个反例,端点都是
1
1
1,但最后胜负却完全不同。所以必须用int数组引入分差来做。
第二个自然的问题是,能不能像数组长度为偶数一样,算奇数位和偶数位的数字和来判断。答案也是不可以。虽然长度为偶数时甲必胜,但上面的那个必胜策略并不能保证赢的分差最大。比如,如果甲取了第一个数,接下来算一下奇偶位的数之和的差的绝对值(已经取过的数不计入),这是乙用必胜策略打败甲时的分差。这个分差如果不够大,使得甲取的那个数减去这个分差仍然非负,就会得到甲胜的错误的结论。
具体的反例是: ( 0 , 0 , 7 , 6 , 5 , 6 , 1 ) (0,0,7,6,5,6,1) (0,0,7,6,5,6,1)。我们先来递推一下分差:
i↓ j→ | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 7 | 1 | 4 | 2 | -1 |
1 | 0 | 0 | 7 | -1 | 6 | 0 | 1 |
2 | 0 | 0 | 7 | 1 | 6 | 2 | 1 |
3 | 0 | 0 | 0 | 6 | 1 | 5 | 6 |
4 | 0 | 0 | 0 | 0 | 5 | 1 | 0 |
5 | 0 | 0 | 0 | 0 | 0 | 6 | 5 |
6 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
如果甲先取 0 0 0,那么乙的最优策略的分差是 1 1 1;如果甲先取 1 1 1,那么乙的最优策略的分差是 2 2 2。而如果算一下甲取 1 1 1后,乙的必胜策略的分差是 0 + 7 + 5 − 0 − 6 − 6 = 0 0+7+5-0-6-6=0 0+7+5−0−6−6=0,并不是最优的 2 2 2,这就会得到甲胜的错误的结果。
所以如果数组长度为奇数的话,必须用动态规划来递推。