下面以一道题目为例,演示如何将暴力递归优化成动态规划。
题目描述:给定n个位置:1~n,有一个机器人,当前处于cur
位置,它想要去end
位置,但是必须要走k
步,问有多少种行走方法?
暴力递归
解题思路:分解尝试,机器人在当前位置上会有向左走和向右走两种选择,但如果在最左端和最右端就会只有一种选择,每走一步就把剩余步数rest减一,当rest为0时,如果机器人在end上,说明形成了一种可行的方法。
public class RobotWalk {
public static int walkWays(int n, int e, int s, int k) {
return f(n, e, k, s);
}
/**
* 暴力递归尝试
* @param n 一共 1 ~ n 这么多位置——固定参数
* @param end 最终的目标是end——固定参数
* @param rest 还剩rest步要走
* @param cur 当前在cur位置
* @return 可以到达目的地的方法数
*/
public static int f(int n, int end, int rest, int cur) {
// rest == 0已经不能再走了,就看当前位置是否已经来到了end
if (rest == 0) {
return cur == end ? 1 : 0;
}
// rest == 1,只能往右走
if (cur == 1) {
return f(n, end, rest - 1, cur + 1);
}
// rest == n,只能往左走
if (cur == n) {
return f(n, end, rest - 1, cur - 1);
}
// 当前处于中间位置,左右都能走
return f(n, end, rest - 1, cur - 1)
+ f(n, end, rest - 1, cur + 1);
}
}
记忆化搜索优化
上述代码中的递归函数 f 的形参中,n
和 end
是固定参数,只有 rest
和 cur
是可变参数,也就是说,每一种 rest
和 cur
的组合,都记录了从当前位置 cur
到 end
的方法数。
但是不难发现,所有组合的依赖关系其实是一棵二叉树:
树的高度为k,所以时间复杂度为
O
(
2
k
)
O(2^k)
O(2k),很多f(rest, cur)
会被重复计算,导致总的时间复杂度很高。
由于每个 f(rest, cur)
都没有计算的先后次序要求,所以我们可以用一种数据结构将计算结果存储起来,避免大量的重复计算;因为有两个变量,所以可以利用二维数组来存储(变量更多的话,就用多维数组)。
public static int walkWays2(int n, int end, int start, int k) {
// dp[rest][cur]: 从cur出发,走rest步,有多少种方法可以到达end
int[][] dp = new int[k + 1][n + 1];
for (int[] ints : dp) {
Arrays.fill(ints, -1);
}
return f2(n, end, k, start, dp);
}
/**
* 记忆化搜索优化
*
* @param n 一共 1 ~ n 这么多位置——固定参数
* @param end 最终的目标是end——固定参数
* @param rest 还剩rest步要走
* @param cur 当前在cur位置
* @return 可以到达目的地的方法数
*/
public static int f2(int n, int end, int rest, int cur, int[][] dp) {
if (dp[rest][cur] != -1) {
// 已经有记录了,直接返回
return dp[rest][cur];
}
// rest == 0已经不能再走了,就看当前位置是否已经来到了end
if (rest == 0) {
dp[rest][cur] = cur == end ? 1 : 0;
} else if (cur == 1) {
// rest == 1,只能往右走
dp[rest][cur] = f2(n, end, rest - 1, cur + 1, dp);
} else if (cur == n) {
// rest == n,只能往左走
dp[rest][cur] = f2(n, end, rest - 1, cur - 1, dp);
} else {
// 当前处于中间位置,左右都能走
dp[rest][cur] = f2(n, end, rest - 1, cur - 1, dp)
+ f2(n, end, rest - 1, cur + 1, dp);
}
return dp[rest][cur];
}
由于有了记忆表的存在,整个算法的时间复杂度降低为 O ( N ∗ K ) O(N*K) O(N∗K)
严格表结构动态规划
上述的记忆化搜索,其实就是一个不断查表、填表的过程,我们可以试着分析一下整张表是怎样填的(以 n = 5、start = 2、end = 4、k = 4 为例):
-
表的行代表当前剩余的步数
rest
(0 ~ k),列代表当前所处的位置cur
(1~n) -
rest = 0
这一行的结果是已知的,也就是只有cur = end
时,才是1,其余都是0。 -
cur
来到左边界时,只能向右走,所以[rest, cur] = [rest - 1, cur + 1]
,也就是只依赖右上角的值
-
cur
来到右边界时,只能向左走,所以[rest, cur] = [rest - 1, cur - 1]
,也就是只依赖左上角的值
-
cur
来到中间位置时,左右都能走,所以[rest, cur] = [rest - 1, cur - 1] + [rest - 1, cur + 1]
,也就是依赖左上角和右上角的值。
-
通过上述分析,我们可以发现整个递归过程完全可以转化成填表的过程,这其实就是严格表结构的动态规划,它和记忆化搜索是有区别的:
- 记忆化搜索不去整理表格位置间的依赖关系,本质就是一个纯缓存
- 严格表结构的动态规划需要纠结表格位置间的依赖顺序。
- 而这个依赖顺序,其实就是我们数据结构课上学的动态转移方程(突然就好理解多了😭)
/**
* 严格表结构的动态规划
*/
public static int dpWays(int n, int end, int start, int k) {
// dp[...][0]废了不用
int[][] dp = new int[k + 1][n + 1];
dp[0][end] = 1;
for (int rest = 1; rest <= k; rest++) {
for (int cur = 1; cur <= n; cur++) {
if (cur == 1) {
dp[rest][1] = dp[rest - 1][2];
} else if (cur == k) {
dp[rest][k] = dp[rest - 1][k - 1];
} else {
dp[rest][cur] = dp[rest - 1][cur - 1] + dp[rest - 1][cur + 1];
}
}
}
return dp[k][start];
}
时间复杂度也是 O ( N ∗ K ) O(N*K) O(N∗K)
实战:
-
LeetCode原题:2400. 恰好移动 k 步到达某一位置的方法数目 - 力扣(LeetCode)
-
难度:Medium
-
题意和上面的题是一样的,注意会有不可到达的情况出现,以及,别忘了取模。
题目练习:最少硬币
题目描述:给你一包含n个硬币的集合int arr[n]
,其中的每一个数代表的是对应硬币的面值,可能会有重复的硬币,请问组成(恰好等于)总金额 aim
所需的最少硬币数是多少?
解题思路:
首先,先找出一种可尝试的方法,即暴力递归法,如何尝试?
- 从左到右的思路,对于每一枚硬币,都有选与不选两种选择
- 初始时,我需要从0号硬币开始,求得凑齐
aim
块钱最少需要多少硬币,即此时rest = aim
,index = 0
- 做出选择:
- 如果选
index
号硬币,则之后我需要从inedx + 1
号硬币开始,求得凑齐rest - arr[index]
块钱最少需要多少硬币 - 如果不选
index
号硬币,则之后我需要从inedx + 1
号硬币开始,求得凑齐rest
块钱最少需要多少硬币
- 如果选
- Base Case的考虑:
rest < 0
时,说明已经选择的硬币总金额超过了目标金额aim
,不满足条件,返回-1
,表示没有办法达到条件了(说明之前的决策是错误的)rest == 0
时,说明已经选择的硬币总金额恰好组成目标金额aim
,不再需要硬币了,返回0
rest > 0
时:- 如果
index == arr.length
,说明已经没有硬币可选了,但是已经选择的硬币总金额还没达标,返回-1
- 否则
index < arr.length
,说明rest > 0
并且还有硬币可选,做出选择
- 如果
/**
* 暴力递归
* @param arr 所有的硬币
* @param index 从哪个硬币开始选
* @param rest 需要凑齐的金额
* @return 所需的最小硬币数
*/
public static int process1(int[] arr, int index, int rest) {
if (rest < 0) {
// 超过目标金额了
return -1;
} else if (rest == 0) {
return 0;
} else {
if (index == arr.length) {
// 没有硬币了
return -1;
} else {
int select = process1(arr, index + 1, rest - arr[index]);
int notSelect = process1(arr, index + 1, rest);
if (select == -1 && notSelect == -1) {
// 选和不选都行不通,说明之前的决策有问题,返回-1
return -1;
} else {
// 返回可行的解
if (select == -1) {
return notSelect;
}
if (notSelect == -1) {
return select + 1;
}
// 返回所需硬币数较少的解
return Math.min(select + 1, notSelect);
}
}
}
}
进一步优化:记忆化搜索版本
public static int minCoins2(int[] arr, int aim) {
int[][] dp = new int[arr.length + 1][aim + 1];
for (int[] i : dp) {
Arrays.fill(i, -2);
}
return process2(arr, 0, aim, dp);
}
/**
* 记忆化搜索
* @param arr 所有的硬币
* @param index 从哪个硬币开始选
* @param rest 需要凑齐的金额
* @param dp dp[index][rest]记录process2(arr, index, rset)的值
* @return 所需的最少硬币数
*/
public static int process2(int[] arr, int index, int rest, int[][] dp) {
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) {
// 没有硬币了
dp[index][rest] = -1;
} else {
int select = process2(arr, index + 1, rest - arr[index], dp);
int notSelect = process2(arr, index + 1, rest, dp);
if (select == -1 && notSelect == -1) {
// 选和不选都行不通,说明之前的决策有问题,返回-1
dp[index][rest] = -1;
} else {
// 返回可行的解
if (select == -1) {
dp[index][rest] = notSelect;
} else if (notSelect == -1) {
dp[index][rest] = select + 1;
} else {
// 返回所需硬币数较少的解
dp[index][rest] = Math.min(select + 1, notSelect);
}
}
}
}
return dp[index][rest];
}
严格表结构的动态规划
分析上述dp表(dp[index][rest]
表示从index号硬币开始选,凑够rest块钱需要的最少硬币数)的依赖关系
- 首先
rest < 0
(在表外),表示超过的目标金额,此时默认为 -1 rest = 0
,表示恰好凑齐目标金额,不再需要硬币了,将该列设为0rest > 0
,但index = arr.length
说明已经没有硬币可取,但还未凑够钱,说明决策有问题,将该行设为-1rest > 0
,且index < arr.length
,就需要决策当前硬币的选与不选两种情况
public static int minCoins3(int[] arr, int aim) {
int arrLength = arr.length;
int[][] dp = new int[arrLength + 1][aim + 1];
// rest = 0 的部分都为0
for (int i = 0; i <= arrLength; i++) {
dp[i][0] = 0;
}
// index = arrLength, rest > 0的部分都为-1
for (int i = 1; i <= aim; i++) {
dp[arrLength][i] = -1;
}
for (int cur = arrLength - 1; cur >= 0; cur--) {
for (int res = 1; res <= aim; res++) {
// 选当前硬币cur
int select = -1;
if (res - arr[cur] >= 0) {
select = dp[cur][res - arr[cur]];
}
// 不选当前硬币cur
int notSelect = dp[cur + 1][res];
if (select == -1 && notSelect == -1) {
dp[cur][res] = -1;
} else if (select == -1) {
dp[cur][res] = notSelect;
} else if (notSelect == -1) {
dp[cur][res] = select + 1;
} else {
dp[cur][res] = Math.min(select + 1, notSelect);
}
}
}
return dp[0][aim];
}