你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。
石头的堆数可以是任意正整数,石头的总数也可以是任意正整数,这样就能打破先手必胜的局面了。比如有三堆石头 piles = [1, 100, 3]
,先手不管拿 1 还是 3,能够决定胜负的 100 都会被后手拿走,后手会获胜。
假设两人都很聪明,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。比如上面那个例子,先手能获得 4 分,后手会获得 100 分,你的算法应该返回 -96。
这样推广之后,这个问题算是一道 Hard 的动态规划问题了。博弈问题的难点在于,两个人要轮流进行选择,而且都贼精明,应该如何编程表示这个过程呢?
还是强调多次的套路,首先明确 dp 数组的含义,然后和股票买卖系列问题类似,只要找到「状态」和「选择」,一切就水到渠成了。
一、定义 dp 数组的含义
先看一下 dp 数组最终的样子:
以下是对 dp 数组含义的解释:
1 | dp[i][j].fir 表示,对于 piles[i...j] 这部分石头堆,先手能获得的最高分数。 |
求的答案是先手和后手最终分数之差,按照这个定义也就是 dp[0][n-1].fir - dp[0][n-1].sec
,即面对整个 piles,先手的最优得分和后手的最优得分之差。
二、状态转移方程
写状态转移方程很简单,首先要找到所有「状态」和每个状态可以做的「选择」,然后择优。
根据前面对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。
1 | dp[i][j][fir or sec] |
对于这个问题的每个状态,可以做的选择有两个:选择最左边的那堆石头,或者选择最右边的那堆石头。 可以这样穷举所有状态:
1 | n = piles.length |
上面的伪码是动态规划的一个大致的框架,股票系列问题中也有类似的伪码。这道题的难点在于,两人是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢?
根据对 dp 数组的定义,很容易解决这个难点,写出状态转移方程:
1 | dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec) |
根据 dp 数组的定义,也可以找出 base case,也就是最简单的情况:
1 | dp[i][j].fir = piles[i] |
这里需要注意一点, base case 是斜着的,而且推算 dp[i][j]
时需要用到 dp[i+1][j]
和dp[i][j-1]
:
所以说算法不能简单的一行一行遍历 dp 数组,而要斜着遍历数组:
说实话,斜着遍历二维数组说起来容易,还真不一定能想出来怎么实现,不信思考一下?这么巧妙的状态转移方程都列出来了,要是不会写代码实现,那真的很尴尬了。
三、代码实现
fir 和 sec 元组
1 | class Pair { |
然后直接把状态转移方程翻译成代码即可,可以注意一下斜着遍历数组的技巧:
1 | /* 返回游戏最后先手和后手的得分之差 */ |
注意到计算 dp[i][j]
只依赖其左边和下边的元素,所以说肯定有优化空间,转换成一维 dp,想象一下把二维平面压扁,也就是投影到一维。但是,一维 dp 比较复杂,可解释性很差,大家就不必浪费这个时间去理解了。
四、最后总结
本文给出了解决博弈问题的动态规划解法。博弈问题的前提一般都是在两个聪明人之间进行,编程描述这种游戏的一般方法是二维 dp 数组,数组中通过元组分别表示两人的最优决策。
之所以这样设计,是因为先手在做出选择之后,就成了后手,后手在对方做完选择后,就变成了先手。这种角色转换使得我们可以重用之前的结果,典型的动态规划标志。