个人博客:The Blog Of WaiterXiaoYY 欢迎来互相交流学习。
对动态规划的理解程度:★★◐☆☆
博弈类问题感觉也是一种脑脑急转弯的题,
博弈类题目其实都有非常巧妙的解法,
但我们学习还是以 稳 为准,不追求那些花里胡哨的做法,
今天,我们从石子游戏入手,去探究一下博弈类问题的奥妙。
石子游戏
亚历克斯和李用几堆石子在做游戏。偶数堆石子排成一行,每堆都有正整数颗石子 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) 是奇数。
解题思路
题目很长,你可以直接跳过上面,从这里开始读起,
你和你的朋友在一起玩游戏,有若干堆石子在你们面前,每堆石子的数量用 piles[i] 表示,每次只能从石子堆的两侧拿,你们都很精明,每次只能拿两侧的石子,然后看谁多谁赢,题目要求如果亚历克斯赢就返回true。
现在,我们为了能够获得博弈问题的解题套路,我们把这道题目进行变换一下,使的题目能够更具“一般性”,不用管题目中的亚历克斯和李,变换后其实就是一个先手和后手的问题,肯定有一个人先开始拿,有一个人后开始拿,这就是博弈问题的特点,变换后,我们也不管亚里斯赢不赢了,我们求先手和后手的石子数量的差,如果先手开始的到最后的石子数量大于后手的石子数量,就是先手赢,也是这道题的意思。
好,现在我们明确了题目,我们这时候要开始来分析一下解题思路,这种题我们可以用动态规划来做,动态规划的解题方法是是什么?
找出 状态 和 选择。动态规划的状态其实是很难判断的,判断出状态,然后列出状态转移方程,这也是动态规划中最难的部分。而选择就很简单了,因为不管先手还是后手,只能从上一个人选完剩下的石子堆的两侧进行选择,选择左侧还是右侧的石子就是选择。
选择我们确定了,状态呢?状态一下子是看不出来的,我们要深入到题目里面去,我们知道动态规划还有一个特点,就是求 最优解子问题,我们把所有求的最后的结果分解到子问题中去求解,
比如给一堆石子 :piles = [5, 3, 7, 1],索引从 1 到 4,
我们要求这堆石子的两人的石子数差,就是看子问题的石子数差,那问题来了,子问题是什么呢?
不急,我们再来思考一下,
假如,先手取了第一堆石子,那石子堆就只剩下了 piles=[3, 7, 1],这时就到了我们所谓的后手拿,后手也只能从两侧拿,如果拿了第二堆,那石子堆只剩下 piles=[7, 1],这时候又到我们的先手了,选择第三堆,那剩下就是后手拿了,最后先手的石子数量时 5 + 7,后手的数量 3 + 1,这次是先手胜,可能有同学注意到了,先手之所以能 5 + 7,是因为一开始选择了第一堆,那如果一开始选择的是最后一堆呢,结果可能就会有所变化,
所以通过这样具体分析后,子问题就出来了,当选手做出选择后,会出现两种情况,piles = [3, 7, 1], 或者是 piles = [5, 7, 3], 所以 每次选择都很重要,你一次糊涂选择会导致全盘皆输,但题目有要求,每个人都很精明,所以不可能会有糊涂选择,每次选择都是最佳的选择,而我们就是要找出这个最佳情况,那这个最佳是依靠什么判断出来呢?就是子问题,要看这次选择的是不是最佳,就看这次选择后的子问题的选择的情况,一直这样伸展下去,直到石子都被分完。
所以,这时候我们的状态就出来了,一个是石子堆的范围,就是从第几堆到第几堆的最佳情况,在 piles = [5, 3, 7, 1],要求第一堆到第四堆的最佳情况,就是求 选择完后的子问题,比如 [3, 7, 1],也就是第二堆到第四堆的最佳状况,第二个是当前选择的人,这两个状态就是我们用动态规划所需要展示出来的。
我们需要一个二维数组 dp[i][j], 用来表示第 i 堆石子到第 j 堆石子的最佳情况,还需要一个表示当前选择的人,我们用 first 和second 表示,像这样 dp[i][j].first,dp[i][j].second,表示第 i 堆石子到第 j 堆石子谁先开始选择,注意这里说的是 先开始,上面我们说的先手开始选后,那剩下的石子堆对于后手来说也是先开始,要理解这里。
接着讲状态和选择,转换成状态转移方程,
# 对于先手来说
dp[i][j].first = max(选择左侧的石子堆, 选择右侧的石子堆);
dp[i][j].first = max(piles[i] + dp[i + 1][j].second, piles[j] + dp[i][j - 1].second);
# 如果先手先选择了左侧的石子堆,那么剩下的石子堆范围就变成了[i+1, j],由后手来先开始选择
# 如果先手先选择了右侧的石子堆,那么剩下的石子堆范围就变成了[i, j + 1],由后手来先开始选择
# 最后取两者的最大值,也就是最佳情况
# 对于后手来说
if 先手选择了左侧:
dp[i][j].second = dp[i+1][j].first;
if 先手选择了右侧:
dp[i][j].second = dp[i][j + 1].first;
# 我是后手,我需要等先手先做出选择
# 如果先手选择了左边,那剩下的石子堆范围就变成了[i+1, j],这时候我变成了先手
# 如果先手选择了右边,那剩下的石子堆范围就变成了[i, j + 1],这时候我变成了先手
状态转移方程写出来了,接下来就是要确定我们的初始状态,
初始状态是i 和 j 相等,也就是当面前只有piles[i]这一堆石头的时候,
dp[i][j].first = piles[i];
dp[i][j].second = 0;
# 当只有一堆石头的时候,先手拿了,后手就没有了
初始状态,
在石子堆0~1中,(5,3)的子问题是(5,0)和(3,0),当先手选择了5,那后手只剩下了3,
对于石子堆0~2中,(10,5)的子问题是(5,3)和(7,3),根据状态转移方程,当先手选择了7,后手选择了5,只剩下3,所以先手是10,后手是5,
对于石子堆0~3,(12,4)的子问题是(10,5)和(4,7)分别对应选择右边和左边,,在选择左右两边中判断,发现当先先选择左边的5,剩下中后手先选能达到的最大是7,所以加起来是12,后手选的是4,
相信经过上面一大串啰嗦的分析,应该能对石子游戏有一定的认识了,在看代码之前,先解释一下first和second如何定义,我们可以弄一个类将其封装起来,在后面只需要创建它的实例就可以了,
class Pair {
//代表先手
int first;
//代表后手
int second;
Pair(int fisrt, int second) {
this.first = first;
this.second = second;
}
}
接下来我们看看代码吧。
代码
class Solution {
public boolean stoneGame(int[] piles) {
//石子堆的长度
int len = piles.length;
//创建pair的实例,同时初始化二维数组
Pair[][] dp = new Pair[len][len];
for(int i = 0; i < len; i++) {
for(int j = i; j < len; j++) {
dp[i][j] = new Pair(0, 0);
}
}
//将初始值填入
for(int i = 0; i < len; i++) {
dp[i][i].first = piles[i];
dp[i][i].second = 0;
}
//l代表的是石子堆的数量,从两堆开始
for(int l = 2; l <= len; l++) {
//i代表的是石子堆范围的左侧索引
for(int i = 0; i <= len - l; i++) {
//j代表的是石子堆范围的右侧索引
int j = i + l - 1;
//left表示选择左侧石子能达到的最佳状态
int left = piles[i] + dp[i + 1][j].second;
//右侧表示选择右侧石子能达到的最佳状态
int right = piles[j] + dp[i][j - 1].first;
//如果选择左侧大于选择右侧,则将在该范围的情况下先手的值更新为left,且后手的数量时等于在[i+1,j]范围中作为先手所能获得的最佳数量,否则反之
if(left > right) {
dp[i][j].first = left;
dp[i][j].second = dp[i + 1][j].first;
}
else {
dp[i][j].first = right;
dp[i][j].second = dp[i][j - 1].first;
}
}
}
//返回两者的差值
return dp[0][n - 1].first - dp[0][n - 1].second;
//本题的结果,比较是否先手会大,否则返回false
//return dp[0][len - 1].first > dp[0][len - 1].second;
}
}
整理于2020.4.15,有参考于labuladong大佬的讲解。