1.思路流程
暴力递归到动态规划的思路流程:
(1)从尝试方法进行递归优化:try(递归)
(2)记忆搜索动态规划:记搜(dp)
(3)严格表结构的动态规划:严格表结构(dp)
在某些问题中,记忆搜索动态规划和严格表结构的动态规划的时间复杂度近似。
(4)精确严格表结构的动态规划:严格表结构精版(dp)
严格表建立流程:
(1)分析可变参数变化范围,两个可变参数就是二维表,三个可变参数就是三维表;
(2)标出你要计算的终止位置
(3)根据basecase标出不用计算直接出答案的位置
(4)推普遍位置是如何依赖其它关系的
(5)定出严格表从哪些格子推出到哪些格子的顺序,将try(递归)得到的东西改动态规划表实现拿值的过程,进行替代
现在用一些题目来讲明以上的流程。
2.机器人位置问题
给定四个整数 N、S、E、K:
-
N:一共有N个位置,分别编号为1、2、3、......、N。 -
S:机器人的起始位置,范围为1~N。 -
E:机器人的最终目标位置,范围为1~N。 -
K:机器人必须行走的步数。
机器人每次只能向左或向右移动一个位置,不能停留在原地,并且必须遵守边界条件:
-
机器人处于位置1时,只能向右移动到位置2。
-
机器人处于位置N时,只能向左移动到位置N-1。
-
机器人处于中间位置时,可以选择向左或向右移动。
问题:机器人必须恰好走K步,从S到达E,共有多少种不同的走法?(参考leetcode62和70,754,2400)
例如,N=5,S=2,E=4,K=4。
符合条件的路径共有4条:
(1) 2 → 3 → 4 → 5 → 4
(2) 2 → 3 → 4 → 3 → 4
(3) 2 → 3 → 2 → 3 → 4
(4) 2 → 1 → 2 → 3 → 4
最终结果:4种方法
下面按照四个阶段进行优化:
暴力递归
↓
记忆化搜索动态规划
↓
严格表结构动态规划
↓
严格表结构精简版(空间压缩)
2.1 方法一:暴力递归(try)
首先,采用从左到右的尝试方法,让机器人每次选择向左或向右移动,直到剩余步数为0。
定义递归函数:
f1(int N, int E, int rest, int cur)
其中:
N:总位置数量,固定参数
E:目标位置,固定参数
rest:机器人还需要走多少步
cur:机器人当前所在位置
返回值:
从cur出发,恰好走rest步到达E的方法数。
由于 N 和 E 在整个递归过程中保持不变,因此我们只需要关注 rest 和 cur 两个变化的参数。
(1)递归终止条件
如果机器人已经走完要求的步数,即 rest=0,则判断当前是否到达目标位置。
if (rest == 0) {
return cur == E ? 1 : 0;
}
如果 cur==E,说明找到了一条合法路径,返回1;否则返回0。
(2)递归尝试过程
根据当前位置分为三种情况:
java
情况一:cur=1
机器人只能向右移动。
f(rest,1) = f(rest-1,2)
情况二:cur=N
机器人只能向左移动。
f(rest,N) = f(rest-1,N-1)
情况三:1<cur<N
机器人可以向左或者向右移动。
f(rest,cur)= f(rest-1,cur-1)+ f(rest-1,cur+1)
(3)递归展开过程
例如,N=5,S=2,E=4,K=4。
初始状态是:
f(4,2)
表示:
当前位置为2,剩余4步,
最终需要到达位置4。
展开递归树:
f(4,2)
/ \
/ \
f(3,1) f(3,3)
| / \
| / \
f(2,2) f(2,2) f(2,4)
/ \ / \ / \
... ... ... ... ... ...
观察发现:f(2,2) 被重复计算了两次。继续递归下去,还会出现大量相同的子问题。这些子问题具有相同的 rest 和 cur,因此它们的计算结果必然相同。但是暴力递归每次遇到这些状态时,都会重新展开递归树,导致大量重复计算。
(4)时间复杂度
每次递归最多分裂成两个子问题,而递归深度最大为K,因此递归树可以看成一棵高度为K的二叉树。最坏情况下,时间复杂度为:O(2^K)},递归调用栈的空间复杂度为:O(K)
因此,暴力递归能够解决问题,但当K较大时,执行效率较低。
2.2 方法二:记忆化搜索动态规划(记搜DP)
通过前面的递归树可以发现,递归过程中存在大量重复状态。例如:
f(4,2)
|
├── f(3,1)
| |
| └── f(2,2)
|
└── f(3,3)
|
├── f(2,2)
|
└── f(2,4)
其中,f(2,2) 无论通过哪一条路径进入,返回值都相同。因此,可以建立一张二维表,将已经计算过的状态保存起来。当某个状态再次出现时,直接从表中读取结果,不再重复递归。
(1)建立记忆化搜索表
因为递归过程中变化的参数只有:
rest:0~K
cur:1~N
所以建立:
int[][] dp = new int[K + 1][N + 1];
其中:dprestcur表示:从cur位置出发,还需要走rest步到达E的方法数。
将所有位置初始化为 -1,表示该状态还没有计算过。
for (int i = 0; i <= K; i++) {
for (int j = 0; j <= N; j++) {
dp[i][j] = -1;
}
}
(2)记忆化搜索过程
当程序进入 f2() 时,首先检查缓存:
if (dp[rest][cur] != -1) {
return dp[rest][cur];
}
如果之前已经计算过该状态,就直接返回。否则继续进行递归,并将最终结果记录在表中。
例如:
dp[rest][cur] =
f2(N, E, rest - 1, cur - 1, dp)
+ f2(N, E, rest - 1, cur + 1, dp);
这样每个状态最多只会真正计算一次。
(3)记忆化搜索的优化效果
在暴力递归中:
f(2,2)
第一次出现:
展开递归,计算结果。
第二次出现:
再次展开递归,重新计算。
而在记忆化搜索中:
f(2,2)
第一次出现:
展开递归,计算结果并存入dp。
第二次出现:
直接读取dp[2][2]。
第三次出现:
仍然直接读取dp[2][2]。
此时,重复计算被消除。整个问题最多存在:(K+1) N 个不同的有效状态,因此时间复杂度降低为:O(KN)。空间复杂度为:O(KN)} 。此外,还需要最多 O(K) 的递归调用栈空间。
需要注意的是,记忆化搜索仍然保留递归过程,只是为递归增加了缓存。
2.3 方法三:严格表结构动态规划(严格表结构DP)
通过记忆化搜索,我们已经发现,机器人位置问题的计算结果只依赖两个变量:
rest:剩余步数
cur:当前位置
因此,可以直接建立二维DP表,而不再依赖递归函数。
(1)建立DP表
例如:
N=5,S=2,E=4,K=4
建立:
int[][] dp = new int[K + 1][N + 1];
计算完成的DP表如下:
目标位置 E=4
|-------------|---|-----|---|---|---|
| rest \ cur | 1 | 2 | 3 | 4 | 5 |
| 0 | 0 | 0 | 0 | 1 | 0 |
| 1 | 0 | 0 | 1 | 0 | 1 |
| 2 | 0 | 1 | 0 | 2 | 0 |
| 3 | 1 | 0 | 3 | 0 | 2 |
| 4 | 0 | 4 ✓ | 0 | 5 | 0 |
列表示当前位置cur,行表示剩余步数rest。最终取dp42=4。
(2)确定初始状态
当 rest=0 时,机器人已经不能继续移动。
因此:
如果cur=E:
dp[0][cur] = 1
如果cur≠E:
dp[0][cur] = 0
本题目标位置为4,因此:
dp[0][4] = 1;
对应第0行:
cur: 1 2 3 4 5
rest=0:0 0 0 1 0
(3)分析表结构的依赖关系
由于每走一步,剩余步数都会减少1,因此当前状态依赖上一行。
对于左边界位置1:
dp[rest][1] = dp[rest-1][2]
对于右边界位置N:
dp[rest][N] = dp[rest-1][N-1]
对于中间位置:
dp[rest][cur]
= dp[rest-1][cur-1]
+ dp[rest-1][cur+1]
可以发现:
-
第一列依赖上一行的右上角。
-
最后一列依赖上一行的左上角。
-
中间位置依赖上一行的左上角与右上角之和。
因此,只需要从上往下依次填写DP表。
(4)逐行填写DP表
第一行:
rest=0:
0 0 0 1 0
第二行:
rest=1:
dp[1][3] = dp[0][2] + dp[0][4]
= 0 + 1
= 1
dp[1][5] = dp[0][4]
= 1
结果:
0 0 1 0 1
第三行:
rest=2:
dp[2][2] = dp[1][1] + dp[1][3]
= 0 + 1
= 1
dp[2][4] = dp[1][3] + dp[1][5]
= 1 + 1
= 2
结果:
0 1 0 2 0
第四行:
rest=3:
dp[3][1] = dp[2][2] = 1
dp[3][3] = dp[2][2] + dp[2][4]
= 1 + 2
= 3
dp[3][5] = dp[2][4] = 2
结果:
1 0 3 0 2
第五行:
rest=4:
dp[4][2] = dp[3][1] + dp[3][3]
= 1 + 3
= 4
dp[4][4] = dp[3][3] + dp[3][5]
= 3 + 2
= 5
结果:
0 4 0 5 0
最终机器人从位置2出发,恰好走4步到达位置4的方法数为:
dp[4][2] = 4;
(5)严格表结构DP的特点
严格表结构动态规划不再使用递归,而是根据递归关系确定DP表中每个位置的依赖关系,然后按照正确顺序填表。
由于有K+1行、N个有效位置,每个状态的计算只需要常数次操作,因此:
时间复杂度:O(KN)
空间复杂度:O(KN)
与记忆化搜索相比,两者时间复杂度相同,但严格表结构DP不需要递归调用栈。
2.4 方法四:严格表结构精简版动态规划(空间压缩DP)
观察第三种方法的状态转移方程:
dp[rest][cur]
= dp[rest-1][cur-1]
+ dp[rest-1][cur+1]
我们发现,当前行的计算只依赖上一行,与更早的行没有关系。
例如:计算rest=4时:只需要rest=3的数据。计算rest=3时:只需要rest=2的数据。
因此,没有必要保留整个二维DP表,只需要保存当前行和上一行。
(1)使用两个一维数组
原来:
int[][] dp = new int[K + 1][N + 1];
现在:
int[] dp = new int[N + 1];
int[] next = new int[N + 1];
其中:
dp:上一行的状态
next:当前正在计算的状态
初始状态仍然为:
dp[E] = 1;
(2)空间压缩的计算过程
以 N=5,E=4,K=4 为例。
初始化:rest=0
dp:
位置 1 2 3 4 5
数值 0 0 0 1 0
第一轮:rest=1
根据dp计算next:
next:
位置 1 2 3 4 5
数值 0 0 1 0 1
计算结束后:
dp = next
第二轮:rest=2
根据上一轮dp计算next:
next:
位置 1 2 3 4 5
数值 0 1 0 2 0
计算结束后:
dp = next
第三轮:rest=3
next:
位置 1 2 3 4 5
数值 1 0 3 0 2
计算结束后:
dp = next
第四轮:rest=4
next:
位置 1 2 3 4 5
数值 0 4 0 5 0
计算结束后:
dp = next
最终:
dp[S] = dp[2] = 4
(3)精简版状态转移
虽然不再使用二维数组,但状态转移方程完全不变。
for (int rest = 1; rest <= K; rest++) {
int[] next = new int[N + 1];
for (int cur = 1; cur <= N; cur++) {
if (cur == 1) {
next[cur] = dp[2];
} else if (cur == N) {
next[cur] = dp[N - 1];
} else {
next[cur] = dp[cur - 1] + dp[cur + 1];
}
}
dp = next;
}
需要注意,不能在没有额外处理的情况下直接覆盖原来的 dp[cur]。因为当前行的状态计算需要使用上一行的数据。如果一边计算一边覆盖原数组,就可能破坏后面状态所依赖的旧数据。所以这里使用 next 保存当前行,计算完成后再令 dp=next。
(4)复杂度分析
每一轮仍然需要计算N个位置,一共计算K轮。因此时间复杂度仍然为:O(KN)}
但是只需要保存两个长度为N+1的一维数组。所以空间复杂度降低为:O(N)}
第四种方法没有改变第三种方法的递推关系,只是利用相邻行之间的依赖关系,减少了不必要的历史状态存储。
2.5 代码和总结对比
代码如下:
java
package Class014;
public class Code_RobotWalk {
public static int walkWays1(int N,int E,int S,int K){
return f1(N,E,K,S);
}
//一共是1~N这么多位置,固定参数
//最终的目标是E,固定参数
//当前在cur位置
//还剩rest步需要走
//返回从cur出发,恰好走rest步到达E的方法数
public static int f1(int N,int E,int rest,int cur){
if (rest==0){
//来到终止位置,记录1个路径;没有来到终止位置,0个路径
return cur==E?1:0;
}
//res>0有路可走
//当前位置是1,只能从1->2,rest-1
if (cur==1){
return f1(N,E,rest-1,2);
}
//当前位置是N,要走,rest-1
if (cur==N){
return f1(N,E,rest-1,N-1);
}
//机器人来达的是中间位置,可以往左一步,或者往右一步。
return f1(N,E,rest-1,cur-1)+f1(N,E,rest-1,cur+1);
}
// 方法二:记忆化搜索
public static int walkWays2(int N,int E,int S,int K){
int[][] dp=new int[K+1][N+1];
for (int i=0;i<=K;i++){
for (int j=0;j<=N;j++){
dp[i][j]=-1;
}
}
return f2(N,E,K,S,dp);
}
//一共是1~N这么多位置,固定参数
//最终的目标是E,固定参数
//当前在cur位置
//还剩rest步需要走
//返回方法数
public static int f2(int N,int E,int rest,int cur,int[][]dp){
//如果之前算过,那么就从缓存里面取
if (dp[rest][cur]!=-1){
return dp[rest][cur];
}
//缓存没有命中的情况
if (rest==0){
//来到终止位置,记录1个路径;没有来到终止位置,0个路径
dp[rest][cur]=cur==E?1:0;
return dp[rest][cur];
}
//res>0有路可走
//当前位置是1,只能从1->2,rest-1
if (cur==1){
dp[rest][cur]=f2(N,E,rest-1,2,dp);
}
//当前位置是N,要走,rest-1
else if (cur==N){
dp[rest][cur]=f2(N,E,rest-1,N-1,dp);
}
//机器人来达的是中间位置,可以往左一步,或者往右一步。
else {
dp[rest][cur]=f2(N,E,rest-1,cur-1,dp)+f2(N,E,rest-1,cur+1,dp);
}
return dp[rest][cur];
}
// 方法三:严格表结构动态规划
// P:目标位置E
// M:起始位置S
// K:必须走的步数
public static int dpWay(int N,int P,int M,int K){
// dp[rest][cur]:
// 从cur出发,剩余rest步到达P的方法数
int[][]dp=new int[K+1][N+1];//dp[...][0]废了不用
// 剩余0步时,只有已经到达P才有1种方法
dp[0][P]=1;
// 从第1行开始,依次填写到第K行
for (int rest=1;rest<=K;rest++){
for (int cur=1;cur<=N;cur++){
if (cur==1){
// 左边界,只能向右
dp[rest][cur]=dp[rest-1][2];
}else if (cur==N){
// 右边界,只能向左
dp[rest][cur]=dp[rest-1][N-1];
}else {
// 中间位置,左右方法数相加
dp[rest][cur]=dp[rest-1][cur-1]+dp[rest-1][cur+1];
}
}
}
// 从M出发,走K步,到达P
return dp[K][M];
}
// 方法四:严格表结构精简版(空间压缩)
// 保持原来的状态转移方程不变
// 只保留上一行和当前行
public static int dpWayFine(int N,int P,int M,int K){
//dp[cur]保存上一行的结果
int[] dp=new int[N+1];
//对应原二维表的dp[0][P]=1
dp[P]=1;
for (int rest=1;rest<=K;rest++){
//当前行
int[] next=new int[N+1];
for (int cur=1;cur<=N;cur++){
//左边界
if (cur==1){
next[cur]=dp[2];
}
//右边界
else if (cur==N){
next[cur]=dp[N-1];
}
//中间位置
else {
next[cur]=dp[cur-1]+dp[cur+1];
}
}
//当前行成为下一轮的上一行
dp=next;
}
return dp[M];
}
public static void main(String [] args){
//每组:N,S,E,K,预期结果
int[][]tests={
{5, 2, 4, 4, 4},
{5, 2, 4, 2, 1},
{5, 2, 4, 3, 0},
{5, 2, 2, 0, 1},
{5, 2, 4, 0, 0},
{5, 1, 5, 4, 1},
{2, 1, 2, 3, 1}
};
for (int[] t:tests){
int N=t[0];
int S=t[1];
int E=t[2];
int K=t[3];
int expected=t[4];
int ans1=walkWays1(N,E,S,K);
int ans2=walkWays2(N,E,S,K);
int ans3=dpWay(N,E,S,K);
int ans4=dpWayFine(N,E,S,K);
System.out.println("N="+N+",S="+S+",E="+E+",K="+K);
System.out.println("暴力递归:" + ans1);
System.out.println("记忆化搜索:" + ans2);
System.out.println("严格表结构DP:" + ans3);
System.out.println("精简版DP:" + ans4);
if (ans1==expected && ans1==ans2 && ans2==ans3 && ans3==ans4){
System.out.println("测试提过");
}else {
throw new AssertionError("四种方法结果不一致");
}
System.out.println("-------------------------");
}
}
}
运行结果:
java
N=5,S=2,E=4,K=4
暴力递归:4
记忆化搜索:4
严格表结构DP:4
精简版DP:4
测试提过
-------------------------
N=5,S=2,E=4,K=2
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
N=5,S=2,E=4,K=3
暴力递归:0
记忆化搜索:0
严格表结构DP:0
精简版DP:0
测试提过
-------------------------
N=5,S=2,E=2,K=0
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
N=5,S=2,E=4,K=0
暴力递归:0
记忆化搜索:0
严格表结构DP:0
精简版DP:0
测试提过
-------------------------
N=5,S=1,E=5,K=4
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
N=2,S=1,E=2,K=3
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
四种方法对比:
| 方法 | 核心思想 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
暴力递归 walkWays1() |
每一步尝试左右移动,递归枚举所有路径 | 最坏O(2^K) | O(K) |
记忆化搜索 walkWays2() |
将已计算的递归状态记录到二维表,避免重复计算 | O(KN) | O(KN) |
严格表结构DP dpWay() |
根据递归依赖关系,自底向上填写完整二维表 | O(KN) | O(KN) |
严格表结构精简版 dpWayFine() |
当前行只依赖上一行,用滚动数组压缩空间 | O(KN) | O(N) |
3.01背包问题
leetcode322零钱兑换。
2,7,3,5,3,......正数序列,每个代表一枚硬币,aim=10,问最少要多少枚硬币?
思路:
java
3. 01背包问题:最少硬币数量
题目:给定正整数数组arr,每个元素代表一枚硬币,每枚硬币最多只能使用一次。给定目标金额aim,求凑出aim所需的最少硬币数量,无法凑出则返回-1。
例如:arr=[2,7,3,5,3],aim=10。
可以选择7+3=10(2枚)、2+3+5=10(3枚)、2+5+3=10(3枚),因此最优结果为2枚硬币。
一、方法一:暴力递归(try)
1. 定义递归状态
process1(arr,index,rest):使用arr[index...]中的硬币,凑出剩余金额rest所需要的最少硬币数量。
其中index表示当前考虑的硬币位置,rest表示还需要凑出的金额。返回-1表示无法凑出,返回0表示已经凑够,返回正数表示需要的最少硬币数量。
2. 递归终止条件
(1)rest<0:选择硬币后金额超过目标,返回-1。
(2)rest==0:已经凑够目标金额,不需要更多硬币,返回0。
(3)index==arr.length且rest>0:全部硬币考虑完毕,但仍未凑够金额,返回-1。
3. 每个位置的两种选择
第一种:不选择当前硬币,剩余金额不变,直接考虑下一枚硬币。
p1=process1(arr,index+1,rest)
第二种:选择当前硬币,剩余金额减少arr[index],再考虑下一枚硬币。
p2Next=process1(arr,index+1,rest-arr[index])
因为已经选择了当前硬币,所以在p2Next有效时,实际需要的硬币数量为p2Next+1。
4. 递归结果的合并
(1)p1==-1且p2Next==-1:两种选择都无法凑出,返回-1。
(2)p1==-1且p2Next有效:只能选择当前硬币,返回p2Next+1。
(3)p1有效且p2Next==-1:只能不选择当前硬币,返回p1。
(4)两种选择都有效:返回Math.min(p1,p2Next+1)。
5. 具体计算过程
arr=[2,7,3,5,3],aim=10,初始调用process1(arr,0,10),当前考虑的硬币为2。
f(0,10)
/ \
不选择2 选择2
/ \
f(1,10) f(1,8)
(1)计算左侧f(1,10),当前硬币为7。
不选择7:进入f(2,10),剩余硬币为[3,5,3]。这些硬币无法凑出10,所以f(2,10)=-1。
选择7:进入f(2,3),剩余硬币为[3,5,3]。只需要再选择一枚3,例如选择index=2的硬币3,进入f(3,0)=0,因此f(2,3)=0+1=1。
综合两种选择:不选择7的分支无效,选择7的分支需要1+1=2枚,所以f(1,10)=2,对应7+3=10。
(2)计算右侧f(1,8),当前硬币为7。
不选择7:进入f(2,8),剩余硬币为[3,5,3],可以选择3+5=8,所以f(2,8)=2。
选择7:进入f(2,1),剩余硬币为[3,5,3],无法凑出1,所以f(2,1)=-1。
因此f(1,8)=2,因为只有不选择7的分支有效。
(3)回到初始状态f(0,10)。
不选择2:p1=f(1,10)=2。
选择2:p2Next=f(1,8)=2,算上当前硬币2,实际需要2+1=3枚。
最终:f(0,10)=min(2,3)=2。
6. 时间复杂度
每枚硬币都有"选择"和"不选择"两种情况,递归树深度最大为N,因此最坏时间复杂度为O(2^N),递归调用栈空间复杂度为O(N)。
二、方法二:记忆化搜索动态规划(记搜DP)
1. 分析重复子问题
暴力递归中真正变化的参数只有index和rest。如果两个递归状态的index、rest相同,那么它们对应的剩余问题完全相同,可以直接复用计算结果。
例如arr=[2,7,3,5,3],aim=10,以下两条路径会到达同一个状态:
第一条路径:不选择2 → 选择7 → 不选择index=2的3 → 不选择5,此时index=4、rest=3。
第二条路径:选择2 → 不选择7 → 不选择index=2的3 → 选择5,此时index=4、rest=3。
两条路径最终都需要计算f(4,3)。此时只剩最后一枚3,选择它后进入f(5,0)=0,因此f(4,3)=1。
暴力递归会重复计算f(4,3),而记忆化搜索只计算第一次,随后直接读取缓存结果。
2. 建立二维缓存表
int[][] dp=new int[N+1][aim+1];
dp[index][rest]表示使用arr[index...]中的硬币,凑出rest所需要的最少硬币数量。
状态约定:-2表示尚未计算,-1表示已经计算但无法凑出,0表示已经凑够,正数表示最少硬币数量。初始化时将整个dp数组填充为-2。
3. 记忆化搜索的执行过程
以process2(arr,4,3,dp)为例:
第一次访问时,dp[4][3]=-2,说明尚未计算,需要继续递归。
不选择最后一枚3:f(5,3)=-1。
选择最后一枚3:f(5,0)=0,算上当前硬币得到0+1=1。
因此将dp[4][3]=1,并返回1。
第二次访问process2(arr,4,3,dp)时,发现dp[4][3]=1,直接返回1,不再展开递归。
4. 整体递归计算
从process2(arr,0,10,dp)开始,每次进入递归状态时,先检查dp[index][rest]。
若结果不等于-2,说明该状态已经计算过,直接返回;否则按照"选择"和"不选择"两种情况递归,并将最优结果写入dp[index][rest]。
以后再次遇到相同状态时,直接读取缓存。最终dp[0][10]=2,返回2。
5. 时间复杂度
index的取值范围为0~N,rest的有效取值范围为0~aim,因此最多存在(N+1)*(aim+1)个状态。
每个状态最多真正计算一次,每次计算只需要常数次运算,所以时间复杂度为O(N*aim),二维缓存空间复杂度为O(N*aim),另外需要O(N)的递归调用栈。
三、方法三:严格表结构动态规划(dp)
1. 从递归结构推导表结构
根据前面两种方法,process(arr,index,rest)存在两种选择:
不选择当前硬币:p1=dp[index+1][rest]。
选择当前硬币:p2Next=dp[index+1][rest-arr[index]],实际硬币数量为p2Next+1。
因此,dp[index][rest]依赖下一行的两个位置。如果两条路径都有效,则:
dp[index][rest]
=min(dp[index+1][rest],
dp[index+1][rest-arr[index]]+1)
如果只有一条路径有效,则采用有效路径的结果;如果两条路径均不可达,则dp[index][rest]=-1。
2. 确定DP表的初始状态
arr=[2,7,3,5,3],aim=10,N=5。
建立int[][] dp=new int[6][11],其中行index为0~5,列rest为0~10。
首先填写第0列:dp[index][0]=0,因为剩余金额为0时不需要任何硬币。
然后填写最后一行:dp[N][rest]=-1(rest>0),因为已经没有剩余硬币,无法凑出正数金额。
3. 分析表格填写方向
dp[index][rest]依赖dp[index+1][rest]和dp[index+1][rest-arr[index]],因此当前行只依赖下一行。
填写方向必须从最后一行向上,即index=N-1,N-2,...,0。同一行中的状态互不依赖,因此rest可以从1递增到aim。
4. 逐行填写DP表
(1)第5行:index=5,没有剩余硬币。
rest: 0 1 2 3 4 5 6 7 8 9 10
dp[5]: 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
(2)第4行:index=4,当前硬币为3。
计算dp[4][3]:不选择3,dp[5][3]=-1;选择3,dp[5][0]+1=1,所以dp[4][3]=1。
dp[4]: 0 -1 -1 1 -1 -1 -1 -1 -1 -1 -1
(3)第3行:index=3,当前硬币为5,剩余硬币为[5,3]。
计算dp[3][5]:不选择5,dp[4][5]=-1;选择5,dp[4][0]+1=1,所以dp[3][5]=1。
计算dp[3][8]:不选择5,dp[4][8]=-1;选择5,dp[4][3]+1=1+1=2,所以dp[3][8]=2。
dp[3]: 0 -1 -1 1 -1 1 -1 -1 2 -1 -1
(4)第2行:index=2,当前硬币为3,剩余硬币为[3,5,3]。
计算dp[2][3]:不选择3,dp[3][3]=1;选择3,dp[3][0]+1=1,所以dp[2][3]=min(1,1)=1。
计算dp[2][6]:不选择3,dp[3][6]=-1;选择3,dp[3][3]+1=1+1=2,所以dp[2][6]=2。
计算dp[2][8]:不选择3,dp[3][8]=2;选择3,dp[3][5]+1=1+1=2,所以dp[2][8]=min(2,2)=2。
dp[2]: 0 -1 -1 1 -1 1 2 -1 2 -1 -1
(5)第1行:index=1,当前硬币为7,剩余硬币为[7,3,5,3]。
计算dp[1][7]:不选择7,dp[2][7]=-1;选择7,dp[2][0]+1=1,所以dp[1][7]=1。
计算dp[1][10]:不选择7,dp[2][10]=-1;选择7,dp[2][3]+1=1+1=2,所以dp[1][10]=2。
dp[1]: 0 -1 -1 1 -1 1 2 1 2 -1 2
(6)第0行:index=0,当前硬币为2,剩余硬币为[2,7,3,5,3]。
计算dp[0][10]:不选择2,dp[1][10]=2;选择2,dp[1][8]+1=2+1=3。
两条路径都有效,因此dp[0][10]=min(2,3)=2。
dp[0]: 0 -1 1 1 -1 1 2 1 2 2 2
5. 最终DP表
rest
index 0 1 2 3 4 5 6 7 8 9 10
------------------------------------------------
5 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
4 0 -1 -1 1 -1 -1 -1 -1 -1 -1 -1
3 0 -1 -1 1 -1 1 -1 -1 2 -1 -1
2 0 -1 -1 1 -1 1 2 -1 2 -1 -1
1 0 -1 -1 1 -1 1 2 1 2 -1 2
0 0 -1 1 1 -1 1 2 1 2 2 2
------------------------------------------------
最终取dp[0][aim],即dp[0][10]=2。
6. 时间复杂度
DP表共有(N+1)*(aim+1)个状态,每个状态只依赖下一行的两个位置,使用常数时间即可完成计算。
因此,时间复杂度为O(N*aim),空间复杂度为O(N*aim)。
代码:
java
package Class014;
import java.util.Arrays;
public class Code_CoinsMin {
public static int minCoins1(int[]arr,int aim){
return process1(arr,0,aim);
}
//arr[index.....]组成出rest这么多钱,最少的硬币数量返回
public static int process1(int[] arr,int index,int rest){
if (rest<0){
return -1;
}
if (rest==0){
return 0;
}
//rest>0
if (index==arr.length){
return -1;
}
//rest>0 且也有硬币
//第一种情况是不要当前硬币,用剩下的硬币搞定
//第二种是用当前硬币,接着用剩下的硬币搞定
int p1=process1(arr,index+1,rest);
int p2Next=process1(arr,index+1,rest-arr[index]);
if(p1==-1&&p2Next==-1){
return -1;
}else {
if (p1==-1){
return p2Next+1;
}
if (p2Next==-1){
return p1;
}
return Math.min(p1,p2Next+1);
}
}
public static int minCoins2(int[]arr,int aim){
int[][] dp=new int[arr.length+1][aim+1];
for (int i=0;i<=arr.length;i++){
for (int j=0;j<=aim;j++){
dp[i][j]=-2;
}
}
return process2(arr,0,aim,dp);
}
//arr[index.....]组成出rest这么多钱,最少的硬币数量返回
public static int process2(int[] arr,int index,int rest,int[][] dp){
//此步可以视为缓存区域
//由于无法表示-1,这些都是无效区域,故将表中低于0的位置全部标为-1
if (rest<0){
return -1;
}
if (dp[index][rest]!=-2){
return dp[index][rest];
}
if (rest==0){
dp[index][rest]=0;
} else if (index==arr.length){
//rest>0
dp[index][rest]=-1;
}else {
int p1=process2(arr,index+1,rest,dp);
int p2Next=process2(arr,index+1,rest-arr[index],dp);
if(p1==-1&&p2Next==-1){
dp[index][rest]=-1;
}else {
if (p1==-1){
dp[index][rest]=p2Next+1;
} else if (p2Next==-1){
dp[index][rest]= p1;
}
else {
dp[index][rest]=Math.min(p1,p2Next+1);
}
}
}
return dp[index][rest];
}
public static int minCoins3(int[]arr,int aim){
int N=arr.length;
int[][] dp=new int[N+1][aim+1];
for (int row=0;row<=N;row++){
dp[row][0]=0;
}
for (int col=1;col<=aim;col++){
dp[N][col]=-1;
}
for (int index=N-1;index>=0;index--){
for (int rest=1;rest<=aim;rest++){
int p1=dp[index+1][rest];
int p2Next=-1;
if (rest-arr[index]>=0){
p2Next=dp[index+1][rest-arr[index]];
}
if(p1==-1&&p2Next==-1){
dp[index][rest]= -1;
}else {
if (p1==-1){
dp[index][rest]= p2Next+1;
}else if (p2Next==-1){
dp[index][rest]= p1;
}else {
dp[index][rest]= Math.min(p1,p2Next+1);
}
}
}
}
return dp[0][aim];
}
//for test
public static int[] generateRandomArray(int len,int max){
int[] arr=new int[(int) (Math.random()*len)+1];
for (int i=0;i<arr.length;i++){
arr[i]=(int) (Math.random()*max)+1;
}
return arr;
}
// 固定用例校验
public static void check(int[] arr, int aim, int expected) {
int ans1 = minCoins1(arr, aim);
int ans2 = minCoins2(arr, aim);
int ans3 = minCoins3(arr, aim);
System.out.println("arr=" + Arrays.toString(arr)
+ ", aim=" + aim
+ " | 递归=" + ans1
+ " 记搜=" + ans2
+ " DP=" + ans3
);
if (ans1 != expected
|| ans2 != expected
|| ans3 != expected) {
throw new AssertionError("测试失败");
}
}
public static void main(String[] args) {
System.out.println("===== 固定测试 =====");
check(new int[]{2, 7, 3, 5, 3}, 10, 2);
check(new int[]{5, 2, 3}, 5, 1);
check(new int[]{2, 5}, 6, -1);
check(new int[]{3, 3, 3}, 6, 2);
check(new int[]{2, 2, 2}, 4, 2);
check(new int[]{1, 2, 4}, 7, 3);
check(new int[]{1, 2, 4}, 8, -1);
check(new int[]{}, 0, 0);
check(new int[]{}, 5, -1);
System.out.println("\n===== 随机交叉测试 =====");
int len = 10;
int max = 10;
int testTime = 10000;
for (int i = 0; i < testTime; i++) {
int[] arr = generateRandomArray(len, max);
int aim = (int) (Math.random() * 3 * max) + max;
int ans1 = minCoins1(arr, aim);
int ans2 = minCoins2(arr, aim);
int ans3 = minCoins3(arr, aim);
// 任意两个答案不同,都报告错误
if (ans1 != ans2 || ans2 != ans3) {
System.out.println("ooops!");
System.out.println(Arrays.toString(arr));
System.out.println("aim=" + aim);
System.out.println(ans1 + ", " + ans2 + ", " + ans3);
return;
}
}
System.out.println("通过 " + testTime + " 组随机测试");
}
}
运行结果:
java
===== 固定测试 =====
arr=[2, 7, 3, 5, 3], aim=10 | 递归=2 记搜=2 DP=2
arr=[5, 2, 3], aim=5 | 递归=1 记搜=1 DP=1
arr=[2, 5], aim=6 | 递归=-1 记搜=-1 DP=-1
arr=[3, 3, 3], aim=6 | 递归=2 记搜=2 DP=2
arr=[2, 2, 2], aim=4 | 递归=2 记搜=2 DP=2
arr=[1, 2, 4], aim=7 | 递归=3 记搜=3 DP=3
arr=[1, 2, 4], aim=8 | 递归=-1 记搜=-1 DP=-1
arr=[], aim=0 | 递归=0 记搜=0 DP=0
arr=[], aim=5 | 递归=-1 记搜=-1 DP=-1
===== 随机交叉测试 =====
通过 10000 组随机测试
三种方法的复杂度总结
设 N=arr.length,A=aim。
| 方法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
暴力递归 minCoins1() |
最坏 O(2^N) | O(N) |
记忆化搜索 minCoins2() |
O(NA) | O(NA) |
严格表结构DP minCoins3() |
O(NA) | O(NA) |
4.两端抽牌问题
给定一个整型数组 arr,代表数值不同的纸牌排成一条线。玩家 A 和玩家 B 依次拿走每张纸牌,规定玩家 A 先拿,玩家 B 后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家 A 和玩家 B 都绝顶聪明。请返回最后获胜者的分数。(leetcode486,1423)
【举例】
arr = 1, 2, 100, 4。
开始时,玩家 A 只能拿走 1 或 4。如果开始时玩家 A 拿走 1,则排列变为 2, 100, 4,接下来玩家 B 可以拿走 2 或 4,然后继续轮到玩家 A......
如果开始时玩家 A 拿走 4,则排列变为 1, 2, 100,接下来玩家 B 可以拿走 1 或 100,然后继续轮到玩家 A......
玩家 A 作为绝顶聪明的人不会先拿 4,因为拿 4 之后,玩家 B 将拿走 100。所以玩家 A 会先拿 1,让排列变为 2, 100, 4,接下来玩家 B 不管怎么选,100 都会被玩家 A 拿走。玩家 A 会获胜,分数为 101。所以返回 101。
arr = 1, 100, 2。
开始时,玩家 A 不管拿 1 还是 2,玩家 B 作为绝顶聪明的人,都会把 100 拿走。玩家 B 会获胜,分数为 100。所以返回 100。
详细的分析见【leetcode】(八)暴力递归。在那一章里,我们设置了一个arrL.....R数组,接着设置了一个int f(arr,L,R)函数进行分析。具体代码如下:
java
package Class014;
public class Code_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));
}
//先手函数
//当前该你拿,arr[i,,j]
//返回你的最好分数
public static int f(int[] arr,int i,int j){
if(i==j){
return arr[j];
}
//i..j
return Math.max(arr[i]+s(arr,i+1,j),arr[j]+s(arr,i,j-1));
}
//后手函数
//当前不该你拿,是对方在arr[i..j]范围上拿
//返回你的最好分数
public static int s(int[]arr,int i,int j){
if(i==j){
return 0;
}
//i..j
return Math.min(f(arr,i+1,j),f(arr,i,j-1));
}
public static int dp(int[] arr){
if (arr==null|| arr.length==0){
return 0;
}
int[][]f=new int[arr.length][arr.length];
int[][]s=new int[arr.length][arr.length];
for(int j=0;j<arr.length;j++){
f[j][j]=arr[j];
for(int i=j-1;i>=0;i--){
f[i][j]=Math.max(arr[i]+s[i+1][j],arr[j]+s[i][j-1]);
s[i][j]=Math.min(f[i+1][j],f[i][j-1]);
}
}
return Math.max(f[0][arr.length-1],s[0][arr.length-1]);
}
public static void main(String []args){
int[]arr1={1,9,10};
System.out.println("列表:1,9,10");
System.out.println(win1(arr1));
System.out.println(dp(arr1));
// A是先手,B是后手
int A=f(arr1,0,arr1.length-1);
int B=s(arr1,0,arr1.length-1);
System.out.println("玩家A得分:"+A);
System.out.println("玩家B得分:"+B);
if(A>B){
System.out.println("玩家A获胜");
}else if(A<B){
System.out.println("玩家B获胜");
}else{
System.out.println("双方平局");
}
int[]arr2={1,9,2,4,9,13,12};
System.out.println("列表:1,9,2,4,9,13,12");
System.out.println(win1(arr2));
System.out.println(dp(arr2));
// A是先手,B是后手
int A1=f(arr2,0,arr2.length-1);
int B1=s(arr2,0,arr2.length-1);
System.out.println("玩家A得分:"+A1);
System.out.println("玩家B得分:"+B1);
if(A1>B1){
System.out.println("玩家A获胜");
}else if(A1<B1){
System.out.println("玩家B获胜");
}else{
System.out.println("双方平局");
}
int[]arr3={1,9,9,1};
System.out.println("列表:1,9,9,1");
System.out.println(win1(arr3));
System.out.println(dp(arr3));
// A是先手,B是后手
int A2=f(arr3,0,arr3.length-1);
int B2=s(arr3,0,arr3.length-1);
System.out.println("玩家A得分:"+A2);
System.out.println("玩家B得分:"+B2);
if(A2>B2){
System.out.println("玩家A获胜");
}else if(A2<B2){
System.out.println("玩家B获胜");
}else{
System.out.println("双方平局");
}
}
}
运行结果:
java
列表:1,9,10
11
11
玩家A得分:11
玩家B得分:9
玩家A获胜
列表:1,9,2,4,9,13,12
26
26
玩家A得分:24
玩家B得分:26
玩家B获胜
列表:1,9,9,1
10
10
玩家A得分:10
玩家B得分:10
双方平局
那么如何从暴力递归到动态规划?现在来展示一下代码的思路。
首先构造两张表,一张为f表,另一张为s表,对于arr3,100,4,50,f表和s表构造如下
java
arr[3,100,4,50]
0 1 2 3
f
0 1 2 3 ->i
0 *
1
2
3
|
i
s
0 1 2 3 ->j
0 *
1
2
3
|
j
由于递归尝试模型,左下半区域一概无效:
java
arr[3,100,4,50]
0 1 2 3
f
0 1 2 3 ->i
0 *
1 x
2 x x
3 x x x
|
i
s
0 1 2 3 ->j
0 *
1 x
2 x x
3 x x x
|
j
由于f函数中if(i==j){ return arrj; },s函数中if(i==j){ return 0; }所以对角线上的点可以直接填写值:
java
arr[3,100,4,50]
0 1 2 3
f
0 1 2 3 ->i
0 3 *
1 x 100
2 x x 4
3 x x x 50
|
i
s
0 1 2 3 ->j
0 0 *
1 x 0
2 x x 0
3 x x x 0
|
j
由于f函数中的上三角的区域依赖s函数的上三角区域:Math.max(arri+s(arr,i+1,j),arrj+s(arr,i,j-1));由于s函数中的上三角的区域依赖f函数的上三角区域:Math.min(f(arr,i+1,j),f(arr,i,j-1))。
而f函数矩阵中的上三角区域的某一个点的值,是由s函数矩阵中上三角区域的左边与下方的点的值决定的;而s函数矩阵中的上三角区域的某一个点的值,是由f函数矩阵中上三角区域的左边与下方的点的值决定的。比方说s函数矩阵中的(0,1)点的值是由f函数矩阵中的(0,0)和(1,1)决定的,对应的,f函数矩阵中的(0,1)点的值是由s函数矩阵中的(0,0)和(1,1)决定的。也就是说,s矩阵的上三角对角线能够推f矩阵的倒数第二条上三角对角线,f矩阵的上三角对角线能够推s矩阵的倒数第二条上三角对角线;s矩阵的倒数第二条上三角对角线能够推f矩阵的倒数第三条上三角对角线,f矩阵的倒数第二条上三角对角线能够推s矩阵的倒数第三条上三角对角线;以此类推,直到推出矩阵的顶端(0,3)。
最后,能够推出动态规划的矩阵:
java
arr[3,100,4,50]
f
0 1 2 3 ->j
0 3 100 7 150
1 x 100 100 104
2 x x 4 50
3 x x x 50
|
i
s
0 1 2 3 ->j
0 0 3 100 7
1 x 0 4 50
2 x x 0 4
3 x x x 0
|
i
最终结果:
f[0][3] = 150
s[0][3] = 7
获胜者分数 = max(150,7) = 150
玩家A获胜。
5.象棋问题(马走日)
马初始在(0,0)位置,对应(x,y),给定(a,b)是马最终要去的位置,但是马一定要跳k步,问有多少种跳法。
java
y
|
9
8
7
6
5
4
3
2
1
0
0 1 2 3 4 5 6 7 8 ->x
马初始在(0,0)位置,对应(x,y),给定(a,b)是马最终要去的位置,但是马一定要跳k步,问有多少种跳法。
分析,采用倒推的方法,也就是从(x,y)到(0,0),每一步都有8种走法。
java
y
|
9
8
7
6
5 a a
4 a a
3 p
2 a a
1 a a
0
0 1 2 3 4 5 6 7 8 ->x
比如,从(0,0)出发,,必须走8步,现在走了7步,到达a这个点
那么对于p(x,y)这个目标,再走一步就能到达,那么a的位置有多少种可能?
由于马走日的特性,我们发现有8种可能性。
举例:
java
y
|
9
8 a a
7 a a
6 p
|
5 a | a
|
4 a | a
|
3 |
|
2 |
|
1 |
|
0 |
0 1 2 3 4 5 6 7 8 ->x
从(0,0)出发,必须走10步,到达(6,6)这个位置,那么有多少种走法?
分别对应(x-1,y+2)(x+1,y+2)(x+2,y+1)(x+2,y-1)
(x+1,y-2)(x-1,y-2)(x-2,y-1)(x-2,y+1)
也就是说:
第0层:h=0,初始化起点。
第1层:h=1,根据第0层计算。
第2层:h=2,根据第1层计算。
第3层:h=3,根据第2层计算。
......
第K层:h=K,根据第K-1层计算。
最后建立一张小三维表来说明:
java
y
|
5 a a
4 a a
3 p
2 a | a
1 a | a
0 |
0 1 2 3 4 5 ->x
目标是(3,3),当k=3时,f(3,3,3),倒数一步为f(2,5,2),f(1,4,2)
那么如何从(0,0)实现两步就达到f(2,5,2)或f(1,4,2)?
最后建立了一个三维立方体,如图所示:

注意:这里只用两步就能实现。
示例代码:
java
package Class014;
public class Code_HorseJump {
public static int getWay(int x,int y,int k){
return process(x,y,k);
}
//跳过了多少步
//x,y在的位置
//从(0,0)出发跳必须跳step步之后,来到(x,y)位置的方法数有多少种
public static int process(int x,int y,int step){
if (x<0||x>8||y<0||y>9){
return 0;
}
if (step==0){//已经没有步数,跳完了,不能再动了
return (x==0&&y==0)?1:0;
}
//要到达的位置不越界,也有步数可以跳
return process(x-1,y+2,step-1)
+process(x+1,y+2,step-1)
+process(x+2,y+1,step-1)
+process(x+2,y-1,step-1)
+process(x+1,y-2,step-1)
+process(x-1,y-2,step-1)
+process(x-2,y-1,step-1)
+process(x-2,y+1,step-1);
}
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];
}
//为了检验枚举的顺序性对答案毫无影响,只是层之间存在依赖关系,设置dpWays01函数
public static int dpWays01(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=8;r>=0;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];
}
//相当于一个全是零的立方体,被分成许多层(step是层数)许多step平面堆叠成立方体(x,y,step)
public static int getValue(int[][][] dp,int row,int col,int step){
//越界就累加0
if (row<0||row>8||col<0||col>9){
return 0;
}
//不越界就累加它原来的值
return dp[row][col][step];
}
public static void main(String [] args){
int x=7;
int y=7;
int step=10;
System.out.println(getWay(x,y,step));
System.out.println(dpWays(x,y,step));
System.out.println(dpWays01(x,y,step));
}
}
运行结果:
java
515813
515813
515813
方法对比:
| 方法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
暴力递归 process() |
最坏 O(8^K) | O(K) |
严格表结构DP dpWays() |
O(9*10*K*8) | O(9*10*(K+1)) |
改变遍历顺序 dpWays01() |
与 dpWays() 相同 |
与 dpWays() 相同 |
6.死亡越界
给定一个 N×M 的二维网格,Bob初始位于 (i,j)。每一步可以等概率地向上、下、左、右移动一格,共移动K步。
如果Bob在移动过程中越过网格边界,则立即死亡。求Bob完成K步后仍然存活的概率。
例如:N=10,M=10,i=3,j=2,K=5,最终存活概率为 945/1024。
代码:
java
package Class014;
public class Code_BobDie {
//现在bob在row行,col列
//区域是N*M的
//如果bob在1走rest步的过程中2,都没有出圈,那么在走完的时候,bob就获得一个生存点
//返回bob的生存点数
public static int live(int N,int M,int row,int col,int rest){
if (row<0||row>=N||col<0||col>=M){
return 0;
}
if (rest==0){
return 1;
}
int point=0;
point+=live(N,M,row+1,col,rest-1);
point+=live(N,M,row-1,col,rest-1);
point+=live(N,M,row,col-1,rest-1);
point+=live(N,M,row,col+1,rest-1);
return point;
}
public static String bob1(int N,int M,int i,int j,int K){
//所有可能的场景
long all=(long) Math.pow(4,K);
//存活下来的场景
long live=process(N,M,i,j,K);
//计算最大公约数,便于huaj
long gcd=gcd(all,live);
return String.valueOf((live/gcd)+"/"+(all/gcd));
}
//N*M的区域,Bob在(row,col)位置,走rest步之后,获得的生存方法数
public static long process (int N,int M,int row,int col,int rest){
if (row<0||row==N||col<0||col==M){
return 0;
}
//row,col没越界
if(rest==0){
return 1;
}
//还没走完,row,col也没越界,上下左右移动存活的方法数累加
//得到总体生存的方法数
long live=process(N,M,row-1,col,rest-1);
live+=process(N,M,row+1,col,rest-1);
live+=process(N,M,row,col-1,rest-1);
live+=process(N,M,row,col+1,rest-1);
return live;
}
public static long gcd(long m,long n){
return n==0?m:gcd(n,m%n);
}
public static String bob2(int N,int M,int i,int j,int K){
int[][][]dp=new int[N+2][M+2][K+1];
for (int row=1;row<=N;row++){
for (int col=1;col<=M;col++){
dp[row][col][0]=1;
}
}
for (int rest=1;rest<=K;rest++){
for (int row=1;row<=N;row++){
for (int col=1;col<=M;col++) {
dp[row][col][rest]=dp[row-1][col][rest-1];
dp[row][col][rest]+=dp[row+1][col][rest-1];
dp[row][col][rest]+=dp[row][col-1][rest-1];
dp[row][col][rest]+=dp[row][col+1][rest-1];
}
}
}
long all=(long) Math.pow(4,K);
long live=dp[i+1][j+1][K];
long gcd=gcd(all,live);
return String.valueOf((live/gcd)+"/"+(all/gcd));
}
public static void main(String[] args){
int N=10;
int M=10;
int i=3;
int j=2;
int K=5;
System.out.println(bob1(N,M,i,j,K));
System.out.println(bob2(N,M,i,j,K));
}
}
运行结果:
java
945/1024
945/1024
分析:
方法一:暴力递归
-
定义递归状态
process(N,M,row,col,rest)
表示Bob当前处于 (row,col),还需要走 rest 步,在整个过程中不越界的合法走法数量。
-
递归终止条件
(1)如果row<0、row>=N、col<0或col>=M,说明已经越界,返回0。
(2)如果rest==0且仍在网格内,说明成功完成所有步数,返回1。 -
递归尝试过程
Bob每次可以向上下左右四个方向移动,因此:
process(row,col,rest)
= process(row-1,col,rest-1)
+ process(row+1,col,rest-1)
+ process(row,col-1,rest-1)
+ process(row,col+1,rest-1)
每条路径的计算结果为0或正整数,四个方向的合法方法数相加,就是当前位置剩余 rest 步的总存活方法数。
- 存活概率的计算
每一步有4种等概率选择,K步共有:
种可能的移动序列。
设 live 为不会越界的合法移动序列数,则:
例如K=5:
全部可能走法:4^5=1024
存活方法数量:945
存活概率=945/1024≈92.29%
使用 gcd(all,live) 求最大公约数,可以将最终概率化为最简分数。
- 复杂度
每一步递归最多展开4个分支,递归深度为K,因此最坏时间复杂度为 O(4^K),递归栈空间复杂度为 O(K)。由于相同的 (row,col,rest) 可能重复出现,因此存在大量重复计算。
方法二:严格表结构动态规划
- 建立三维DP表
递归过程中变化的参数只有行、列和剩余步数,因此可以建立:
int[][][] dp = new int[N+2][M+2][K+1];
其中 dp[row][col][rest] 表示从对应位置出发,继续走 rest 步仍然存活的方法数。
为了简化边界处理,原代码把实际网格坐标 (i,j) 映射为DP数组中的 (i+1,j+1),在网格外增加一圈值为0的边界。
-
初始化第0层
for (int row=1; row<=N; row++) {
for (int col=1; col<=M; col++) {
dp[row][col][0]=1;
}
}
当剩余步数为0时,只要Bob还在网格内,就有1种存活方式。因此第0层的所有有效位置均为1,网格外的边界保持0。
- 状态转移
当前状态依赖上一层的四个相邻位置:
dp[row][col][rest]
= dp[row-1][col][rest-1]
+ dp[row+1][col][rest-1]
+ dp[row][col-1][rest-1]
+ dp[row][col+1][rest-1]
如果某个相邻位置越界,对应的DP值为0,不会增加存活方法数。
- 逐层填写DP表
从 rest=1 开始,依次计算到第K层。每一层都由上一层推导得到,同一层内部的状态互不依赖,因此行列的遍历顺序不会影响结果。
以 N=10,M=10,i=3,j=2,K=5 为例,实际起点对应DP数组中的 (4,3)。
第0层:dp[4][3][0] = 1
第1层:dp[4][3][1] = 4
第2层:dp[4][3][2] = 16
第3层:dp[4][3][3] = 63
第4层:dp[4][3][4] = 245
第5层:dp[4][3][5] = 945
其中第5层的计算为:
dp[4][3][5]
= dp[3][3][4]
+ dp[5][3][4]
+ dp[4][2][4]
+ dp[4][4][4]
= 236+246+209+254
= 945
因此,最终返回:
long live = dp[i+1][j+1][K];
存活方法数为945,全部移动序列数为1024,得到:
- 复杂度
三维DP表共有约 N×M×K 个有效状态,每个状态只需要累加4个相邻位置,因此时间复杂度为 O(NMK),空间复杂度为 O(NMK)。
需要注意:原代码中的 bob2() 使用 int[][][] 存储路径数,当K较大时可能发生整数溢出,应改用 long[][][];对于更大的K,还需要考虑
两种算法对比
| 方法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
暴力递归 bob1() |
O(4^K) | O(K) |
三维动态规划 bob2() |
O(NMK) | O(NMK) |
本题与前面的象棋马跳跃问题类似,都是通过累加不同方向的合法路径数来完成状态转移。区别在于:象棋问题每次有8种移动方向,并要求到达指定目标;Bob死亡越界问题每次有4种移动方向,只要求整个移动过程中不越界,最后再用存活方法数除以全部可能移动序列数,计算存活概率。
7.零钱兑换
给一个数组arr\[\],都是正数,无重复值,一个位置的值代表一个面值的货币,比如arr3,5,10,2,要求拿任意个货币,凑够aim=1000的零钱数进行兑换,求一共有多少种方法。(leetcode322,518)
原始暴力递归代码:
java
package Class014;
public class Code_CoinsWays {
//arr里都是正数,没有重复值,每一个值代表一种货币,每一种都可以用无限张
//最终要找零钱数是aim,
//找零钱方法数返回
public static int way1(int[] arr,int aim){
return process(arr,0,aim);
}
//可以自由使用arr[index...]所有的货币
//需要搞定的钱数是rest
//返回找零的方法数
public static int process(int[]arr,int index,int rest){
//basecase
//没有方法可以选了,要求搞定的是0,则返回1;要求搞定的不是0,则返回0
if (index==arr.length){
return rest==0?1:0;
}
//arr[index]0张1张。。。不要超过rest的钱数
int ways=0;
for (int zhang=0;arr[index]*zhang<=rest;zhang++){
ways+=process(arr,index+1,rest-arr[index]*zhang);
}
return ways;
}
}
这种初始方法过于暴力,现在对它进行分析:
java
[3,5,10,1]
0,1, 2,3
aim=1000
f(0,1000)
0代表3及其之后的货币可选,1000代表需要搞定的钱数
3:0张->f(1,1000)
3:1张->f(1,997)
3:2张->f(1,994)
......
3:333张->f(1,1)
以此类推
那么如何改造呢?基本情况,index==arr.length,确定一个中间位置(2,5),分析它需要依赖哪些点
java
[3,5,1,2]
0,1,2,3
aim=10
0 1 2 3 4 5 6 7 8 9 10
0 *
1
2 ?
3 n n n n n n
4 1 0 0 0 0 0 0 0 0 0 0
由于代码中的for (int zhang=0;arrindex*zhang<=rest;zhang++){ways+=process(arr,index+1,rest-arrindex*zhang);}
我们发现,一块钱货币做选择的时候,?(2,5)位置依赖index+1行,也就是第3行,rest-arrindex*zhang列,张数是1,index=1,也就是(3,0)(3,1)(3,2)(3,3)(3,4)(3,5)这些点。
java
[3,5,1,2]
0,1,2,3
aim=10
0 1 2 3 4 5 6 7 8 9 10
0 *
1 ?
2 1 0
3
4 1 0 0 0 0 0 0 0 0 0 0
五块钱货币做选择的时候,0张的时候对应(2,8),1张的时候对应(2,3)。其他位置以此类推。
不优化枚举:
java
public static int way2(int[] arr,int aim){
if (arr==null||arr.length==0){
return 0;
}
int N=arr.length;
int[][]dp=new int[N+1][aim+1];
dp[N][0]=1;
for (int index=N-1;index>=0;index--){
for (int rest=0;rest<=aim;rest++){
int ways=0;
for (int zhang=0;arr[index]*zhang<=rest;zhang++){
ways+=dp[index+1][rest-arr[index]*zhang];
}
dp[index][rest]=ways;
}
}
return dp[0][aim];
}
不优化时,枚举行为的时间复杂度=O(N*aim)*O(aim)=O(N*aim^2),那么枚举行为有必要吗?
如图所示,我们发现对于?位置j和x位置它们的方法数为:?=a+b+c+d+...,x=b+c+d+...,,?位置j和x位置只差了一个偏移量,既然算x时已经加过了,那么算?的方法数时只需要把x的方法数叠加,即?=x+a就行了。也就是一种斜率优化,用一种枚举行为用临近方法替代(四边形不等式)。
java
0 1 2 3 4 5 6 7 8 9 10
0 *
1 x ?
2 d c b a
3
4 1 0 0 0 0 0 0 0 0 0 0
于是得到了第三种方法:
java
public static int way3(int[] arr,int aim){
if (arr==null||arr.length==0){
return 0;
}
int N=arr.length;
int[][]dp=new int[N+1][aim+1];
dp[N][0]=1;
for (int index=N-1;index>=0;index--){
for (int rest=0;rest<=aim;rest++){
//自己本行下一行的格子
dp[index][rest]=dp[index+1][rest];
//不越界的情况下
if (rest-arr[index]>=0){
//自己本行减去一个面值的格子
dp[index][rest]+=dp[index][rest-arr[index]];
}
}
}
return dp[0][aim];
}
优化后,枚举行为的时间复杂度=O(N*aim),时间复杂度明显下降。
代码:
java
package Class014;
public class Code_CoinsWays {
//arr里都是正数,没有重复值,每一个值代表一种货币,每一种都可以用无限张
//最终要找零钱数是aim,
//找零钱方法数返回
public static int way1(int[] arr,int aim){
return process(arr,0,aim);
}
//可以自由使用arr[index...]所有的货币
//需要搞定的钱数是rest
//返回找零的方法数
public static int process(int[]arr,int index,int rest){
//basecase
//没有方法可以选了,要求搞定的是0,则返回1;要求搞定的不是0,则返回0
if (index==arr.length){
return rest==0?1:0;
}
//arr[index]0张1张。。。不要超过rest的钱数
int ways=0;
for (int zhang=0;arr[index]*zhang<=rest;zhang++){
ways+=process(arr,index+1,rest-arr[index]*zhang);
}
return ways;
}
public static int way2(int[] arr,int aim){
if (arr==null||arr.length==0){
return 0;
}
int N=arr.length;
int[][]dp=new int[N+1][aim+1];
dp[N][0]=1;
for (int index=N-1;index>=0;index--){
for (int rest=0;rest<=aim;rest++){
int ways=0;
for (int zhang=0;arr[index]*zhang<=rest;zhang++){
ways+=dp[index+1][rest-arr[index]*zhang];
}
dp[index][rest]=ways;
}
}
return dp[0][aim];
}
public static int way3(int[] arr,int aim){
if (arr==null||arr.length==0){
return 0;
}
int N=arr.length;
int[][]dp=new int[N+1][aim+1];
dp[N][0]=1;
for (int index=N-1;index>=0;index--){
for (int rest=0;rest<=aim;rest++){
//自己本行下一行的格子
dp[index][rest]=dp[index+1][rest];
//不越界的情况下
if (rest-arr[index]>=0){
//自己本行减去一个面值的格子
dp[index][rest]+=dp[index][rest-arr[index]];
}
}
}
return dp[0][aim];
}
//for test
public static int[] generateRandomArray(int len,int max){
int[] arr=new int[(int) (Math.random()*len)+1];
for (int i=0;i<arr.length;i++){
arr[i]=(int) (Math.random()*max)+1;
}
return arr;
}
public static void main(String [] args){
System.out.println("hi");
int len=10;
int max=10;
int testTime=10000;
for (int i=0;i<testTime;i++){
int[] arr=generateRandomArray(len,max);
int aim=(int) (Math.random()*3*max)+max;
if (way1(arr,aim)!=way2(arr,aim)||way2(arr,aim)!=way3(arr,aim)){
System.out.println("ooops");
break;
}
}
}
}
运行结果:
java
hi
8.动态规划思路总结
尝试方法(左..右,范围),应对七成题目->记忆化搜索(列表分析表格的格子之间的关系)->严格表结构的动态规划->精细表结构的动态规划。
那么如何知晓哪一种尝试是好的?
(1)单可变参数维度(对于int整型来说,f(int)可以看作0维可变参数,即一个点,维度很低。如果函数变成f(int\[\]a,int x),这样维度就特别高,变成了1维,可能性猛增。前者的面试题目占绝对多数。)
(2)可变参数的个数(随个数上升,依次为线性数组,表格,立方体,可变参数越少,动态规划修改起来越简单)