最近在练习动态规划,所以今天继续来看leetcode上一道dp相关的问题。其实是一道常规题,但是可以通过分析得到一个不常规的解法。由于这篇文章是动态规划算法相关的,所以不常规的解法就顺便提一提,具体证明就略去了,官方题解很清晰,这里只说说如何用动态规划的思想做出整道题。
1. 题目描述
我稍微地总结一下这个题目的要点:
-
石头堆整体是有顺序的,每次取石头只能取当前石头堆最左或最右的石头
-
两个人都会发挥出最佳水平,可以理解成两个人智商都很高,能预判到全程如何取石子,会让自己最后的得分最高
2. 解题思路
面对一个动态规划问题,该如何判断它是一个dp相关问题,又该如何进行思考呢?其实还是跟昨天那一套一样的步骤:
-
该问题是否有子问题?更进一步地,在程序执行过程中,会不会产生重叠子问题?
-
你已经判断出该问题是一个dp问题,那该如何进行状态定义呢?
-
定义好状态之后,状态转移方程该如何书写呢?
值得一提的是,第二步在有一些题目是比较困难的,一个问题可能有多种状态定义方式(leetcode887-鸡蛋掉落),也有可能你压根想不到该如何定义状态(leetcode1240-铺瓷砖)。
接下来我将按照以上三个步骤,对此题进行解读:
A. 子问题的判断:
想象一下,你现在就是Alex或者Lee,先手Alex会拿走一个石子,石头堆会发生这样的变化:
看到这个过程,相信很容易就能理解这个问题是如何将大问题拆解成子问题,其实在进行游戏的过程,石子堆数就一直在减少。相当于piles数组大小一直在减小,直到basecase。
子问题的转换,已经解释清楚了。那么这个问题是否存在重叠子问题呢?其实一般都是存在的,不然也没有必要用动态规划思想去做了。
在这道题中,要达到只剩下索引为1, 2两个石子堆的状态(姑且称之为状态A),有几种游戏进行方式呢?很明显,有两种:先拿掉0再拿掉3,与先拿掉3再拿掉0;在编程的过程中,如果不对状态A下的相关值进行记录,在暴力搜索的过程中,就会对这个状态进行重复计算。
所以,此题也是存在重叠子问题的。
B. 状态定义:
首先,状态定义方式在上面解释子问题存在性的过程中,就已经提示得很明显了。在图--F1中,左边和右边可以看成两个状态,观察这两个状态的不同之处?左边有四堆石头,索引从0-3;右边是三堆石头,索引从1-3。
如果我一开始取掉的不是第一堆石头,而是最后一堆石头呢?那右边的状态就会变成,三堆石头,索引从0-2。
相信已经很明显了,我们可以使用一个二维数组来代表状态,第一维表示最左边的石头堆的索引,第二维表示最右边的石头堆的索引。
dp[a][b] // 表示当前剩下石头堆范围是,索引:[a, b]
那在这个状态下,我们要对其取什么值,才能正确地求解出这个问题呢?这个时候就需要看题目分析了。
题目要我们返回bool值,但是如果我们用true或false作为dp[a][b]的取值的话,状态转移方程几乎无法推导。所以我们把问题稍微变一下。
题目不是要求最后结果是Alex赢还是Lee赢吗?要判断最终谁赢,很简单。只需要看最后谁得分高就行了。那么很明显了,我们对dp[a][b]取当前状态下最后Alex可以得到的最高分就行了(因为Alex几乎不会犯错,在最好情况下最后取得最高分,就是题目给定的条件)。
dp[a][b] = 在剩余石子堆为[a, b]的状态下,最后Alex可以取得的最高分
C. 状态转移方程:
状态转移方程的书写,在这道题目中,其实很简单,还是图--F1,就很明显地提示了我们状态转移的过程。这里我简单地画一下状态转移树:
需要注意的是,dp[a][b]表示的是Alex取的时候,剩余石子为[a, b]之间的最高得分,当Alex取完之后,下一次轮到Lee,需要等Lee取完之后,才是Alex的下一个状态。所以整个递归树需要经过两次状态变化。
看着递归树,我们也就可以很容易地写出状态转移方程了(注意:Alex只在第1层 -> 第2层会得分,从第2层 -> 第3层是Lee得分)。
dp[a][b] = Math.max(
Math.max(dp[a+2][b], dp[a+1][b-1]) + piles[a],
Math.max(dp[a+1][b-1], dp[a][b-2]) + piles[b]
);
ps:其实在提交完这个题目获得ac之后我就没管了,但是我一开始的状态转移方程没有考虑到Alex和Lee轮流取的问题,所以一开始提交的答案是错误的,但是由于这道题的特殊性获得了ac。这么一看,写文章其实还有利于自己纠错,对问题理解得更加深刻。
D. 循环开始处的分析:
其实之前文章《Leetcode-808分汤》就已经讨论过这个问题了,但是那个问题中很容易,但是如果按照我这种状态转移方程的定义方式,没有掌握正确的技巧,要想清楚其实还是挺麻烦的。
但是也正如之前文章中说的,这种二维的dp都可以用一张表格,清晰地分析出我们要从哪里开始循环!
上表中画X的部分,表示在整个遍历过程中,我们没必要对这些位置的元素进行计算,想一下是不是这样的?
- 当a > b时候,很明显,区间[a, b]没有意义
- 因为题目告诉我们,给出的石子堆数总是偶数(题目提示2),所以每一次轮到Alex取石头的时候,剩余石子堆数总是偶数,所以像[0, 2]这种包含(0,1,2)三个元素的区间,我们就没必要考虑了,在循环过程中,可以直接跳过。当然也就包含[a, a]这样的区间了,所以对角线上的元素我们都没必要进行计算!
再想想basecase,如果给我们只有两个石子堆的话?那Alex每次只需要取出最大的那个元素就好了,对应到表中就是 (1,2),(2,3),...这些位置
再看看状态转移方程:
dp[a][b] = Math.max(
Math.max(dp[a+2][b], dp[a+1][b-1]) + piles[a],
Math.max(dp[a+1][b-1], dp[a][b-2]) + piles[b]
);
每个新状态的更新,在行方向都需要 {a + 2, a + 1, a}等位置的元素,对应到表中就是a行及a行之下,所以在遍历行时我们应该从n - 4的位置,从大往小遍历。同理,列的方向是从左到右,即从小到大遍历列。
其实,从图我们也可以很直观地得到这个结果,你看我们的base case是对角线上面一斜行,所以我们应该遍历右上部分的元素,先确定整体位置,再确定具体遍历方向就可以了。
下面是我个人的实现的代码,供参考:
class Solution {
public boolean stoneGame(int[] piles) {
int n = piles.length;
if(n == 2) return true;
int[][] dp = new int[n][n];
int sum = piles[0];
for(int i = 0; i < n - 1; i ++){
dp[i][i + 1] = Math.max(piles[i], piles[i + 1]);
sum += piles[i + 1];
}
for(int a = n - 4; a >= 0; a -= 2)
for(int b = a + 3; b <= n - 1; b += 2)
dp[a][b] = Math.max(Math.max(dp[a+2][b], dp[a+1][b-1]) + piles[a],
Math.max(dp[a+1][b-1], dp[a][b-2]) + piles[b]);
return 2 * dp[0][n - 1] > sum;
}
}
需要注意的是,最后我们需要返回Alex是赢还是输,所以只要确定 Alex的最高得分是否高于总分的一半就可以了。
结语: 到此,关于这道题我想说的就说完了。值得一提的是,你可以用数学证明出来,这个游戏不论怎么进行,只要满足题意的条件,最终都会是Alex赢。这也是这道题为什么特殊,有意思的原因。有兴趣的朋友请自行研究,本文只从动态规划的角度分析这个问题。