Nim 游戏 、⽯头游戏1、石头游戏2
一:Nim 游戏
你和你的朋友,两个人一起玩 Nim 游戏:桌子上有一堆石头,每次你们轮流拿掉 1 - 3 块石头。 拿掉最后一块石头的人就是获胜者。你作为先手。
你们是聪明人,每一步都是最优解。 编写一个函数,来判断你是否可以在给定石头数量的情况下赢得游戏。
示例:
输入: 4
输出: false
解释: 如果堆中有 4 块石头,那么你永远不会赢得比赛;
因为无论你拿走 1 块、2 块 还是 3 块石头,最后一块石头总是会被你的朋友拿走
解题思路:
我们解决这种问题的思路⼀般都是反着思考:
1 . 如果我能赢, 那么最后轮到我取⽯⼦的时候必须要剩下 1~3 颗⽯⼦, 这样 我才能⼀把拿完。
2.如何营造这样的⼀个局⾯呢? 显然, 如果对⼿拿的时候只剩 4 颗⽯⼦, 那么⽆论他怎么拿, 总会剩下 1~3 颗⽯⼦, 我就能赢。
3.如何逼迫对⼿⾯对 4 颗⽯⼦呢? 要想办法, 让我选择的时候还有 5~7 颗⽯⼦, 这样的话我就有把握让对⽅不得不⾯对 4 颗⽯⼦。
4.如何营造 5 ~ 7 颗⽯⼦的局⾯呢? 让对⼿⾯对 8 颗⽯⼦, ⽆论他怎么拿, 都会给我剩下 5~7 颗, 我就能赢。
5.这样⼀直循环下去, 我们发现只要踩到 4 的倍数, 就落⼊了圈套, 永远逃不出 4 的倍数, ⽽且⼀定会输。 所以这道题的解法⾮常简单:
bool canWinNim(int n) {
// 如果上来就踩到 4 的倍数, 那就认输吧
// 否则, 可以把对⽅控制在 4 的倍数, 必胜
return n % 4 != 0;
}
二:⽯头游戏
亚历克斯和李用几堆石子在做游戏。偶数堆石子排成一行,每堆都有正整数颗石子 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 <= 500
- piles.length 是偶数。
- 1 <= piles[i] <= 500
- sum(piles) 是奇数。
强调双⽅都很聪明的原因, 算法也是求最优决策过程下你是否能赢。这道题⼜涉及到两⼈的博弈, 也可以⽤动态规划算法暴⼒试, ⽐较⿇烦。 但我们只要对规则深⼊思考, 就会⼤惊失⾊: 只要你⾜够聪明, 你是必胜⽆疑的, 因为你是先⼿。
boolean stoneGame(int[] piles) {
return true;
}
这是为什么呢, 因为题⽬有两个条件很重要: ⼀是⽯头总共有偶数堆, ⽯头的总数是奇数。
这两个看似增加游戏公平性的条件, 反⽽使该游戏成为了⼀个割⾲菜游戏。
我们以 piles=[2, 1, 9, 5] 讲解, 假设这四堆⽯头从左到
右的索引分别是 1, 2, 3, 4。
- 如果我们把这四堆⽯头按索引的奇偶分为两组, 即第 1、 3 堆和第 2、 4 堆,
- 那么这两组⽯头的数量⼀定不同, 也就是说⼀堆多⼀堆少。 因为⽯头的总数是奇数, 不能被平分。
- ⽽作为第⼀个拿⽯头的⼈, 你可以控制⾃⼰拿到所有偶数堆, 或者所有的奇数堆。
- 你最开始可以选择第 1 堆或第 4 堆。 如果你想要偶数堆, 你就拿第 4 堆, 这样留给对⼿的选择只有第 1、 3 堆, 他不管怎么拿, 第 2 堆⼜会暴露出来,你就可以拿。
- 同理, 如果你想拿奇数堆, 你就拿第 1 堆, 留给对⼿的只有第2、 4 堆, 他不管怎么拿, 第 3 堆⼜给你暴露出来了。
- 也就是说, 你可以在第⼀步就观察好, 奇数堆的⽯头总数多, 还是偶数堆的⽯头总数多, 然后步步为营, 就⼀切尽在掌控之中了。
三、石头游戏2
亚历克斯和李继续他们的石子游戏。许多堆石子 排成一行,每堆都有正整数颗石子 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 <= piles.length <= 100
- 1 <= piles[i] <= 10 ^ 4
解题思路:
定义:
- stones:与题目中 piles 意思保持一致;
- n:stone个数;
- score[i, j]:当前玩家从 stones[i] 开始,且 M = j 时,该玩家所能得到的最高分数;
- sum[i]:从stones[i]开始,剩下石头分数之和(即,stones[i] + … + stones[n - 1]);
考虑:
- 如何使得当前玩家所能获得的分数score[i, j]最高?取1个?取2个?… 取 2 * j 个?
- 当前玩家拿1个stone时,另一个玩家接下来所能获得的最高分数为score[i + 1, max(j, 1)];
- 当前玩家拿2个stone时,另一个玩家接下来所能获得的最高分数为score[i + 2, max(j, 2)];
- …
- 当前玩家拿2*j个stone时,另一个玩家接下来所能获得的最高分数为score[i + 2 * j, max(j, 2 * j)];
- 因为sum[i]是一定的,只需要使另一个玩家接下来所能获得的最高分数最小,即可保证当前玩家所得到的分数score[i, j]最大
基于上述考虑,得到递归方程如下:
score[i,j]=sum[i] − min(socre[i + 1,max(j,1)],...,score[i + 2 ∗ j,max(j,2 ∗ j)])
进一步考虑,递归终止条件:
因为所有stone的分数都为正数;当 n - i <= 2 * j 时(即,当前玩家可以直接拿下剩下的所有stone),则当前玩家所得最高分 score[i, j] 直接为剩下所有stone分数之和(即,score[i, j] = sum[i]);
完整递归方程如下:
回到题目
- 一场游戏,Alex所能获得的最高分为
score[0,1]
; - 对应的,Lee所获得的分数为
sum[0]−score[0,1]
; 谁获得的分数高,谁赢
;
为了可以在常数时间内计算到sum[i],我们需要提前计算好后缀和数组;
当n = 8时,示例如下:
正如前面所说,当 2 * j > n - i 时,当前玩家可以拿走剩下的所有stone,因此这里 j 的上限,我们取 n 的一半向上取整;
为了计算score[0, 1],我们需要知道score[1, 1],score[2, 2],所以计算时我们采用从下到上(从左到右或者从右到左都行)的计算顺序;
方法一:DP 函数
class Solution {
public:
int n = 0;
//表示从i开始取M个获取的最大价值
int* vec;
//备忘录
int mem[105][105];
//dp函数
int dp(int s, int M)
{
//如果在备忘录中直接在备忘录中返回,避免重复计算
if(mem[s][M] != 0)
return mem[s][M];
//如果s >= n代表下标已经越界,相当于piles越界
if(s >= n)
return 0;
//如果发现 s + 2 * M >= n 说明玩家可以一次取完,直接返回
if(s + 2 * M >= n)
{
return vec[s];
}
int ans = 0;
for(int i = 1; i <= 2 * M; i++)
{
//当前的最大价值为,当前剩余价值 - 下一个状态的最大价值。
ans = max(ans, vec[s] - dp(s + i, max(i, M)));
}
//记忆化搜索
mem[s][M] = ans;
return ans;
}
int stoneGameII(vector<int>& piles) {
n = piles.size();
//初始化相关操作
memset(mem, 0, sizeof(mem));
vec = new int[n];
//从 后往前计算每个 i 位置前 的所有石头之和
int sum = 0;
for(int i = n - 1; i >= 0; i--) {
sum += piles[i];
vec[i] = sum;
}
//递归调用dp函数求结果,0:代表玩家从piles的0下标处开始可以获得的最大石头数量 ;1:代表m的初始值为1
return dp(0,1);
}
};
方法二:DP table
class Solution {
public:
int stoneGameII(vector<int>& piles) {
int len = piles.size();
//用来从前往后计算每个 i 位置的所有石头之和
int sum = 0;
// dp[i][j]表示当前是第i波,m = j,玩家所能获得的最大石头的数量;
vector<vector<int>>dp(len + 1, vector<int>(len + 1, 0));
//注意要反向遍历
for(int i = len - 1; i >= 0; i--)
{
sum += piles[i];// 表示当前所剩下的所有棋子的和
for(int M = 1; M <= len; M++)
{
//代表玩家可以一次取完剩下的所有石头,直接放到dp中,继续循环测试
if(i + 2 * M >= len)
{
//直接把剩下的所有石头保存在dp数组中
dp[i][M] = sum;
continue;
}
//用循环来枚举所有的情况,取其中玩家可以获得最大石头数量的一个结果保存
//x代表每次取的堆数
//i + x <= len 是为了防止越界
//x <= 2 * M 代表每次可以取的石头的堆数范围
for(int x = 1; i + x <= len && x <= 2 * M; x++)
{
//每次选取其中结果最大 的来保存
dp[i][M] = max(dp[i][M], sum - dp[i + x][max(M, x)]);
}
}
}
return dp[0][1];
}
};