题目回顾——预测赢家
题目描述:给定一个整型数组arr,代表数值不同的纸牌排成一条线。 玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。
暴力递归尝试
思路分析:俩玩家都绝顶聪明,整个游戏可以分成先手和后手两个过程
- 先手:先手玩家拿完一张牌后就变成后手了,所以该玩家肯定会拿对自己最有利的牌
- 后手:后手玩家等对方拿完牌就变成先手了,但对方留下的肯定是最差的情况
public class CardsInLine {
public static int win1(int[] arr){
if (arr == null || arr.length == 0) {
return 0;
}
return Math.max(f(arr, 0, arr.length - 1), s(arr, 0, arr.length - 1));
}
/**
* 先手,在[i, j]范围上开始拿牌,返回最终的分数
*/
public static int f(int[] arr, int i, int j) {
// Base case: 只剩下一张牌了,我先手,必须拿
if (i == j) {
return arr[i];
}
// 拿走最左边或最右边的牌,我变为后手,取最大值
return Math.max(arr[i] + s(arr, i + 1, j), arr[j] + s(arr, i, j - 1));
}
/**
* 后手,在[i, j]范围上拿牌,返回最终的分数
*/
public static int s(int[] arr, int i, int j) {
// Base case: 只剩下一张牌了,我后手,不能拿,返回0
if (i == j) {
return 0;
}
// 对方拿走最左或最右边的牌,我变为先手,取最小值(因为对方会留给我最不利的情况)
return Math.min(f(arr, i + 1, j), f(arr, i, j - 1));
}
}
记忆化搜索
显然我们需要两张表,分别记录先手和后手在对应范围上得到的分数
/**
* 记忆化搜索
*/
public static int win2(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
// i 和 j 的范围都是0 ~ arr.length
int[][][] dp = new int[arr.length][arr.length][2];
for (int i = 0; i < dp.length; i++) {
for (int j = 0; j < dp[i].length; j++) {
dp[i][j][0] = -1;
dp[i][j][1] = -2;
}
}
return Math.max(f1(arr, 0, arr.length - 1), s1(arr, 0, arr.length - 1));
}
public static int f2(int[] arr, int i, int j, int[][][] dp) {
if (dp[i][j][0] != -1) {
return dp[i][j][0];
}
if (i == j) {
dp[i][j][0] = arr[i];
} else {
dp[i][j][0] = Math.max(arr[i] + s2(arr, i + 1, j, dp), arr[j] + s2(arr, i, j - 1, dp));
}
return dp[i][j][0];
}
public static int s2(int[] arr, int i, int j, int[][][] dp) {
if (dp[i][j][1] != -1) {
return dp[i][j][1];
}
if (i == j) {
dp[i][j][1] = 0;
} else {
dp[i][j][1] = Math.min(f2(arr, i + 1, j, dp), f2(arr, i, j - 1, dp));
}
return dp[i][j][1];
}
严格表结构动态规划
表格依赖关系分析:
-
首先一定有
i <= j
,所以表格的左下半部分用不到,可以不用管 -
对于先手:
i == j
时,先手表格f[i][j]
对应位置的值就是arr[i]
- 否则,
f[i][j] = min(arr[i] + s[i + 1, j], arr[j] + s[i, j - 1])
,可以看到f[i][j]
依赖**后手表**下边格子s[i + 1, j]
和左边格子s[i, j - 1]
,所以需要按照左下到右上的填写方式
-
对于后手:
i == j
时,后手表格s[i][j]
对应位置的值就是0- 否则,
s[i][j] = min(arr[i] + f[i + 1, j], arr[j] + f[i, j - 1])
,可以看到s[i][j]
依赖先手表下边格子f[i + 1, j]
和左边格子f[i, j - 1]
,所以需要按照左下到右上的填写方式
-
由上可知,先手表和后手表是相互依赖的,但是两张表的对角线都是可以直接填的,所以可以从对角线开始向右上角填。
/**
* 严格表结构动态规划
*/
public static int win3(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int n = arr.length;
int[][][] dp = new int[n][n][2];
for (int i = 0; i < n; i++) {
dp[i][i][0] = arr[i];
dp[i][i][1] = 0;
}
int col = 1;
// 对角线开始位置row行col列
while (col < n) {
int i = 0;
int j = col;
while (i < n && j < n) {
dp[i][j][0] = Math.max(arr[i] + dp[i + 1][j][1], arr[j] + dp[i][j - 1][1]);
dp[i][j][1] = Math.min(dp[i + 1][j][0], dp[i][j - 1][0]);
i++;
j++;
}
col++;
}
return Math.max(dp[0][n - 1][0], dp[0][n - 1][1]);
}
2.8.3 三变量DP题目——象棋马走日
题目描述:在中国象棋棋盘(10 × 9)上建立坐标系(x9 y10),马从[0, 0]
出发去往[x, y]
,但马一定要走k
步,一共有多少种方法?
暴力递归
-
首先马到达终点
[x, y]
的前一步,其实已经确定了,就是[x, y]
周围的八个点
-
所以我们需要知道🐴通过跳
k - 1
步到达这8个点各有多少方法,然后进一步相加就是问题的答案。 -
当然,这8个点也有可能在棋盘外,这样我们视为不能到达,即方法数为0
-
在代码实现过程中,我们是从
[x, y]
到[0, 0]
,这样的话就不用一直记录目标位置了,统一都是[0, 0]
public static int getWays(int x, int y, int k) {
return process(x, y, k);
}
public static int process(int x, int y, int rest) {
if (x < 0 || x > 8 || y < 0 || y > 9) {
// 边界外,无法到达目标点
return 0;
}
if (rest == 0) {
// 已经走完 k 步了,不能再动了
return (x == 0 && y == 0) ? 1 : 0;
}
// 要到达的位置不越界,也有步数可以跳
return process(x - 1, y + 2, rest - 1)
+ process(x + 1, y + 2, rest - 1)
+ process(x + 2, y + 1, rest - 1)
+ process(x + 2, y - 1, rest - 1)
+ process(x + 1, y - 2, rest - 1)
+ process(x - 1, y - 2, rest - 1)
+ process(x - 2, y - 1, rest - 1)
+ process(x - 2, y + 1, rest - 1);
}
从暴力递归可以看到,变量有三个,要到达的位置x, y
,剩余的步数rest
,所以可以使用一个三维数组来存储对应x, y, rest
的值,记忆化搜索比较简单,我们直接来看严格表结构的动态规划
严格表结构的动态规划
- 首先,对于超过三维数组范围的点,肯定无法到达目标点,所以方法为0(可以理解为一个长方体浸泡在0的海洋里)
- 对于
rest = 0
的层,已经不能再移动了,所以只有点[0, 0]
处的值为1,其他都是0 - 对于
rest > 0
且不越界的点,上面我们已经分析过,它依赖8个可能去的点,而且这八个点都在rest - 1
层上,也就是rest
层依赖rest - 1
层的点,而rest = 0
的层已经确定了,所以我们可以从低到高依次填写,最终要得到[x, y, rest]
的值
/**
* 严格表结构动态规划
*/
public static int dpWays(int x, int y, int step) {
if (x < 0 || x > 8 || y < 0 || y > 9 || step < 0) {
// 边界外,无法到达目标点
return 0;
}
int[][][] dp = new int[9][10][step + 1];
dp[0][0][0] = 1;
for (int h = 1; h <= step; h++) {
for (int r = 0; r < 9; r++) {
for (int c = 0; c < 10; c++) {
dp[r][c][h] += getValue(dp, r - 1, c + 2, h - 1);
dp[r][c][h] += getValue(dp, r + 1, c + 2, h - 1);
dp[r][c][h] += getValue(dp, r + 2, c + 1, h - 1);
dp[r][c][h] += getValue(dp, r + 2, c - 1, h - 1);
dp[r][c][h] += getValue(dp, r + 1, c - 2, h - 1);
dp[r][c][h] += getValue(dp, r - 1, c - 2, h - 1);
dp[r][c][h] += getValue(dp, r - 2, c - 1, h - 1);
dp[r][c][h] += getValue(dp, r - 2, c + 1, h - 1);
}
}
}
return dp[x][y][step];
}
/**
* 越界判断
*/
public static int getValue(int[][][] dp, int row, int col, int step) {
if (row < 0 || row > 8 || col < 0 || col > 9) {
// 边界外,无法到达目标点
return 0;
}
return dp[row][col][step];
}
2.8.4 零钱兑换
题目描述:给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。计算并返回可以凑够总金额的方法总数。(可以认为每种硬币的数量是无限的)
解题思路:
暴力递归
- 可以利用从左往右的套路进行尝试,设立两个变量:从
index
硬币开始,利用index
之后的硬币凑够rest
块钱,返回总方法数 - 由于每个硬币数量是无限的,所以我们需要考虑
index
硬币使用多少个,即要计算[index, rest - coins[index] * num]
,但要保证rest - coins[index] * num >= 0
- 可以发现即使我们考虑的变量有三个,但实际上硬币个数的不同转化为了
rest
的不同
/**
* 暴力递归方法
* @param coins 所有硬币的面值,无重复
* @param amount 要凑够的金额
* @return 总方法数
*/
public static int ways1(int[] coins, int amount) {
return process(coins, 0, amount);
}
/**
* 从index硬币开始,利用index之后的硬币凑够rest块钱,返回总方法数
*/
public static int process(int[] coins, int index, int rest) {
if (index == coins.length) {
return rest == 0 ? 1 : 0;
}
// coin[index] 0枚、1枚 ... 不要超过 rest 的钱数
int ways = 0;
// num 是coin[index]使用的枚数
for (int num = 0; coins[index] * num <= rest; num++) {
ways += process(coins, index + 1, rest - coins[index] * num);
}
return ways;
}
可以看到递归函数的变量一种有两个index, rest
,记忆化搜索的版本就比较容易了,直接用一张二维表存储所有[index, rest]
的值即可,下面直接来说严格表结构的动态规划
严格表结构的动态规划
- 先来看两个变量的范围:
index
:0 ~ coins.lengthrest
:0 ~ amount
- 所以二维表的大小为
arr[coins.length][amount]
- 考虑 base case:
index = coins.length
时,已经没有可考虑的硬币了,所以只有rest = 0
的位置是1,其他位置都是0 index < coins.length
的情况下,可以看到是通过一个for循环枚举index
号硬币使用多少枚,然后把总的方法数相加并返回。而当前index
号之前硬币的枚数确定了,接下来要确定index + 1
之后的硬币使用的枚数,所以dp[index][rest]
的值依赖dp[index + 1][...]
(即index + 1
号硬币取各个枚数的情况)
/**
* 严格表结构动态规划
*/
public static int ways2(int[] coins, int amount) {
if (coins == null || coins.length == 0) {
return 0;
}
int n = coins.length;
int[][] dp = new int[n + 1][amount + 1];
dp[n][0] = 1;
for (int index = n - 1; index >= 0; index--) {
for (int rest = 0; rest <= amount; rest++) {
// num 是coin[index]使用的枚数
for (int num = 0; coins[index] * num <= rest; num++) {
dp[index][rest] += dp[index + 1][rest - coins[index] * num];
}
}
}
return dp[0][amount];
}
来分析一下时间复杂度:
- 首先是遍历整张表的复杂度: O ( N ∗ a m o u n t ) O(N * amount) O(N∗amount)
- 计算每个格子的时候,我们进行了一个枚举每个硬币使用多少个的操作,最糟糕的情况就是硬币面值为1的情况,这时候我们需要枚举最多 a m o u n t amount amount 次
- 所以总复杂度为 O ( N ∗ a m o u n t 2 ) O(N * amount^2) O(N∗amount2)
可以看到,其实枚举这个操作还是比较耗时的,遍历整张表这个过程是必要的,那枚举这个过程是否可以再优化呢?答案是可以
优化后的动态规划
画图分析枚举的整个过程,我们假定coin[index] = 3, rest = 200
:
可以看到dp[index][rest]
依赖dp[index][rest], dp[index][rest - 3], dp[index][rest - 6], ...
等等,从右到左每次都增加一枚硬币coin[index]
,但我们可以发现,其实该行每一个格子再计算时都是如此(从右到左,-3、-3、…),所以其实,前面的格子 dp[index][197]
已经帮我们算好了 dp[index][rest - 3] + dp[index][rest - 6] + ...
的值我们没有必要再加一遍了。
/**
* 优化后的动态规划
*/
public static int ways3(int[] coins, int amount) {
if (coins == null || coins.length == 0) {
return 0;
}
int n = coins.length;
int[][] dp = new int[n + 1][amount + 1];
dp[n][0] = 1;
for (int index = n - 1; index >= 0; index--) {
for (int rest = 0; rest <= amount; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest - coins[index] >= 0) {
dp[index][rest] += dp[index][rest - coins[index]];
}
}
}
return dp[0][amount];
}
这种优化方式叫做斜率优化:当填表的时候,存在枚举行为,我们就可以利用观察的方式,看看邻近的位置是否可以替代枚举行为
2.9.5 动态规划总结
对于一个dp题目,解题套路:
- 首先,需要找到一个尝试的版本,即暴力递归的版本
- 尝试的方式可以是从左往右(从右往左)尝试、范围尝试等等(这两个是比较常见的尝试模型,基本可以搞定七成以上的题目)
- 暴力递归转记忆化搜索
- 这个过程比较容易,就是分析递归函数有几个变量,然后决定用几维数组来存储搜索结果
- 记忆化搜索转严格表结构动态规划
- 首先,考虑 Base case 的情况,把能直接得到值的位置先填到表格里
- 分析递归过程,搞清楚表格之间的依赖关系,从而确定表格的填写顺序
- 优化填写过程
- 遍历表格是必须的,无法优化;如果表格在填写时,不能直接利用简单运算得出结果,而是需要枚举、遍历等操作,这时就需要考虑优化的可能性
真正做题的时候,就会发现最难的还是尝试这一步,先不说能不能找出尝试的版本(),我们如何确定一个尝试是好的尝试还是坏的尝试呢?
- 单可变参数维度
- 一个参数为整型的变化范围肯定要比参数为整型数组的变化范围少,所以尽量选择维度少的参数
- 可变参数个数
- 可变参数个数决定了表结构的维度,所以可变参数个数越少越好