上一篇文章 几道智力题 中讨论到一个有趣的「石头游戏」,经过题目的限制条件,这个游戏是先手必胜的。可是智力题终究是智力题,真正的算法问题确定不会是投机取巧能搞定的。因此,本文就借石头游戏来说讲「假设两我的都足够聪明,最后谁会获胜」这一类问题该如何用动态规划算法解决。python
博弈类问题的套路都差很少,下文举例讲解,其核心思路是在二维 dp 的基础上使用元组分别存储两我的的博弈结果。掌握了这个技巧之后,别人再问你什么俩海盗分宝石,俩人拿硬币的问题,你就告诉别人:我懒得想,直接给你写个算法算一下得了。git
咱们「石头游戏」改的更具备通常性:算法
你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。大家轮流拿石头,一次拿一堆,可是只能拿走最左边或者最右边的石头堆。全部石头被拿完后,谁拥有的石头多,谁获胜。编程
石头的堆数能够是任意正整数,石头的总数也能够是任意正整数,这样就能打破先手必胜的局面了。好比有三堆石头 piles = [1, 100, 3]
,先手无论拿 1 仍是 3,可以决定胜负的 100 都会被后手拿走,后手会获胜。数组
假设两人都很聪明,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。好比上面那个例子,先手能得到 4 分,后手会得到 100 分,你的算法应该返回 -96。app
这样推广以后,这个问题算是一道 Hard 的动态规划问题了。博弈问题的难点在于,两我的要轮流进行选择,并且都贼精明,应该如何编程表示这个过程呢?框架
仍是强调屡次的套路,首先明确 dp 数组的含义,而后和股票买卖系列问题相似,只要找到「状态」和「选择」,一切就水到渠成了。ide
PS:我认真写了 100 多篇原创,手把手刷 200 道力扣题目,所有发布在 labuladong的算法小抄,持续更新。建议收藏,按照个人文章顺序刷题,掌握各类算法套路后投再入题海就如鱼得水了。学习
定义 dp 数组的含义是颇有技术含量的,同一问题可能有多种定义方法,不一样的定义会引出不一样的状态转移方程,不过只要逻辑没有问题,最终都能获得相同的答案。优化
我建议不要迷恋那些看起来很牛逼,代码很短小的奇技淫巧,最好是稳一点,采起可解释性最好,最容易推广的设计思路。本文就给出一种博弈问题的通用设计框架。
介绍 dp 数组的含义以前,咱们先看一下 dp 数组最终的样子:
下文讲解时,认为元组是包含 first 和 second 属性的一个类,并且为了节省篇幅,将这两个属性简写为 fir 和 sec。好比按上图的数据,咱们说 dp[1][3].fir = 10
,dp[0][1].sec = 3
。
先回答几个读者可能提出的问题:
这个二维 dp table 中存储的是元组,怎么编程表示呢?这个 dp table 有一半根本没用上,怎么优化?很简单,都不要管,先把解题的思路想明白了再谈也不迟。
如下是对 dp 数组含义的解释:
dp[i][j].fir 表示,对于 piles[i...j] 这部分石头堆,先手能得到的最高分数。 dp[i][j].sec 表示,对于 piles[i...j] 这部分石头堆,后手能得到的最高分数。 举例理解一下,假设 piles = [3, 9, 1, 2],索引从 0 开始 dp[0][1].fir = 9 意味着:面对石头堆 [3, 9],先手最终可以得到 9 分。 dp[1][3].sec = 2 意味着:面对石头堆 [9, 1, 2],后手最终可以得到 2 分。
咱们想求的答案是先手和后手最终分数之差,按照这个定义也就是 dp[0][n-1].fir - dp[0][n-1].sec
,即面对整个 piles,先手的最优得分和后手的最优得分之差。
写状态转移方程很简单,首先要找到全部「状态」和每一个状态能够作的「选择」,而后择优。
根据前面对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。
dp[i][j][fir or sec] 其中: 0 <= i < piles.length i <= j < piles.length
对于这个问题的每一个状态,能够作的选择有两个:选择最左边的那堆石头,或者选择最右边的那堆石头。 咱们能够这样穷举全部状态:
n = piles.length for 0 <= i < n: for j <= i < n: for who in {fir, sec}: dp[i][j][who] = max(left, right)
上面的伪码是动态规划的一个大体的框架,股票系列问题中也有相似的伪码。这道题的难点在于,两人是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢?
根据咱们对 dp 数组的定义,很容易解决这个难点,写出状态转移方程:
dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec) dp[i][j].fir = max( 选择最左边的石头堆 , 选择最右边的石头堆 ) # 解释:我做为先手,面对 piles[i...j] 时,有两种选择: # 要么我选择最左边的那一堆石头,而后面对 piles[i+1...j] # 可是此时轮到对方,至关于我变成了后手; # 要么我选择最右边的那一堆石头,而后面对 piles[i...j-1] # 可是此时轮到对方,至关于我变成了后手。 if 先手选择左边: dp[i][j].sec = dp[i+1][j].fir if 先手选择右边: dp[i][j].sec = dp[i][j-1].fir # 解释:我做为后手,要等先手先选择,有两种状况: # 若是先手选择了最左边那堆,给我剩下了 piles[i+1...j] # 此时轮到我,我变成了先手; # 若是先手选择了最右边那堆,给我剩下了 piles[i...j-1] # 此时轮到我,我变成了先手。
根据 dp 数组的定义,咱们也能够找出 base case,也就是最简单的状况:
dp[i][j].fir = piles[i] dp[i][j].sec = 0 其中 0 <= i == j < n # 解释:i 和 j 相等就是说面前只有一堆石头 piles[i] # 那么显然先手的得分为 piles[i] # 后手没有石头拿了,得分为 0
这里须要注意一点,咱们发现 base case 是斜着的,并且咱们推算 dp[i][j] 时须要用到 dp[i+1][j] 和 dp[i][j-1]:
因此说算法不能简单的一行一行遍历 dp 数组,而要斜着或者倒着遍历数组:
PS:我认真写了 100 多篇原创,手把手刷 200 道力扣题目,所有发布在 labuladong的算法小抄,持续更新。建议收藏,按照个人文章顺序刷题,掌握各类算法套路后投再入题海就如鱼得水了。
如何实现这个 fir 和 sec 元组呢,你能够用 python,自带元组类型;或者使用 C++ 的 pair 容器;或者用一个三维数组 dp[n][n][2]
,最后一个维度就至关于元组;或者咱们本身写一个 Pair 类:
class Pair { int fir, sec; Pair(int fir, int sec) { this.fir = fir; this.sec = sec; } }
而后直接把咱们的状态转移方程翻译成代码便可,注意咱们要倒着遍历数组:
/* 返回游戏最后先手和后手的得分之差 */ int stoneGame(int[] piles) { /* 返回游戏最后先手和后手的得分之差 */ int stoneGame(int[] piles) { int n = piles.length; // 初始化 dp 数组 Pair[][] dp = new Pair[n][n]; for (int i = 0; i < n; i++) for (int j = i; j < n; j++) dp[i][j] = new Pair(0, 0); // 填入 base case for (int i = 0; i < n; i++) { dp[i][i].fir = piles[i]; dp[i][i].sec = 0; } // 斜着遍历数组 for (int i = n - 2; i >= 0; i--) { for (int j = i + 1; j < n; j++) { int j = l + i - 1; // 先手选择最左边或最右边的分数 int left = piles[i] + dp[i+1][j].sec; int right = piles[j] + dp[i][j-1].sec; // 套用状态转移方程 // 先手确定会选择更大的结果,后手的选择随之改变 if (left > right) { dp[i][j].fir = left; dp[i][j].sec = dp[i+1][j].fir; } else { dp[i][j].fir = right; dp[i][j].sec = dp[i][j-1].fir; } } } Pair res = dp[0][n-1]; return res.fir - res.sec; } }
动态规划解法,若是没有状态转移方程指导,绝对是一头雾水,可是根据前面的详细解释,读者应该能够清晰理解这一大段代码的含义。
并且,注意到计算 dp[i][j]
只依赖其左边和下边的元素,因此说确定有优化空间,转换成一维 dp,想象一下把二维平面压扁,也就是投影到一维。可是,一维 dp 比较复杂,可解释性不好,你们就没必要浪费这个时间去理解了。
本文给出了解决博弈问题的动态规划解法。博弈问题的前提通常都是在两个聪明人之间进行,编程描述这种游戏的通常方法是二维 dp 数组,数组中经过元组分别表示两人的最优决策。
之因此这样设计,是由于先手在作出选择以后,就成了后手,后手在对方作完选择后,就变成了先手。这种角色转换使得咱们能够重用以前的结果,典型的动态规划标志。
读到这里的朋友应该能理解算法解决博弈问题的套路了。学习算法,必定要注重算法的模板框架,而不是一些看起来牛逼的思路,也不要奢求上来就写一个最优的解法。不要舍不得多用空间,不要过早尝试优化,不要害怕多维数组。dp 数组就是存储信息避免重复计算的,随便用,直到咱满意为止。
Reference:
这篇文章参考了 YouTube 视频 https://www.youtube.com/watch?v=WxpIHvsu1RI