先从一个题目引出动态规划。
有数组 penny,penny 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,给定一个整数 N 表示货币总数,再给定一个整数 aim (小于等于 1000 )代表要找的钱数,求换钱有多少种方法。
给定数组penny及它的大小(小于等于 50 ),同时给定一个整数aim,请返回有多少种方法可以凑成aim。
测试样例:
[1,2,4],3,3
返回:2
本题非常经典,经典之处在于本题可以体现:暴力搜索方法,记忆搜索方法,动态规划方法之间的关系,并可以在动态规划的基础上进行再一次的优化从而能够帮助大家了解什么是动态规划。
假设:penny = {5, 10, 25, 1}, aim = 1000。
暴力搜索的过程如下:
1、用 0 张 5 元的货币,让 [10, 25, 1] 组成剩下的 1000,最终方法数记为 res1
2、用 1 张 5 元的货币,让 [10, 25, 1] 组成剩下的 995,最终方法数记为 res2
3、用 2 张 5 元的货币,让 [10, 25, 1] 组成剩下的 990,最终方法数记为 res3
…
201、用 200 张 5 元的货币,让 [10, 25, 1] 组成剩下的 0,最终方法数记为 res201
所以最终总的方法数为 res1 + res2 + res3 +…+ res201。
据此定义递归函数:int p1(arr, index, aim),它的含义是用 arr[index…N - 1]这些面值的钱组成 aim,返回总的方法数。
代码如下:
public int coins1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
return process1(arr, 0, aim);
}
public int process1(int[] arr, int index, int aim) {
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
for (int i = 0; i * arr[index] <= aim; i++) {
res += process1(arr, index + 1, aim - i * arr[index]);
}
}
return res;
}
暴力搜索之所以暴力是因为存在大量的重复计算,比如:
类似这样的情况在暴力递归的过程中大量的发生,所以暴力递归的时间复杂度就非常的高。
所以就有了下面的这种记忆搜索的方法:
重复计算之所以会大量发生实际上是因为每一个递归过程的结果并没有记录下来,所以下次还要重复去求,所以可以事先准备好一个哈希表,每计算完一个递归结果后,都讲结果放入 map 中,下次进行递归过程之前,先在这个 map 中查询这个递归过程是否已经计算过了,如果已经计算过了,则直接取值,如果不存在,才进行递归计算。
因为本题的递归过程可以由两个变量来表示,所以 map 是一张二维表,map[i][j] 代表 p(arr, i, j) 的返回结果。
代码如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[][] map = new int[penny.length + 1][aim + 1];
return process1(penny, 0, aim, map);
}
public int process1(int[] arr, int index, int aim, int[][] map) {
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
for (int i = 0; i * arr[index] <= aim; i++) {
int mapValue = 0;
if (mapValue != 0) {
res += mapValue == -1 ? 0 : mapValue;
} else {
res += process1(arr, index + 1, aim - i * arr[index], map);
}
}
}
map[index][aim] = res == 0 ? -1 : res;
return res;
}
如果数组长度为 N,生成行数为 N,列数为 aim + 1(因为组成的钱数可能为 0,所以要多加一列) 的矩阵 dp。dp[i][j]的含义是:在使用 arr[0…i] 货币的情况下,组成钱数 j 有多少种方法。
解法如下:
1、对于组成矩阵第一列的值表示组成钱数为 0 的方法数,那么很明显就是 1 种(不适用任何的货币),所以 dp 第一列的值统一设置为 1。
2、对于 dp 矩阵第一行的值,表示只使用 arr[0] 这一种货币的情况下组成的方法数,只有 arr[0] 的整数倍的位置才能被 arr[0] 组成,其他钱数统统不行,所以就将相应位置设置为 1,其他位置设置为 0。
3、第一行第一列以外的 dp[i][j] 的值是以下情况的累加:
dp[i][j] 的求法:从左到右依次求出 dp 矩阵中每一行的值,然后再计算下一行的值,最终最右下角的值,也就是 dp[N-1][aim] 的值就是最终的结果,返回即可。
注:在求每一个位置的值时都要枚举这个位置上一排左边所有的值,时间复杂度为 O(aim)。dp 中一共有 N * aim 个位置,所以总体的时间复杂度为 O( N * aim * aim )。
代码如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[][] dp = new int[n][aim + 1];
// 初始化第一行的值
for (int i = 0; i < aim + 1; i++) {
if (i % penny[0] == 0) {
dp[0][i] = 1;
}
}
// 初始化第一列的值
for (int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < aim + 1; j++) {
for (int k = 0; j - k * penny[i] >= 0; k++) {
dp[i][j] += dp[i - 1][j - k * penny[i]];
}
}
}
return dp[n - 1][aim];
}
其本质是利用申请的空间来记录每一个暴力搜索的计算过程,下次要用结果的时候直接使用,而不再进行重复的递归过程。
动态规划规定每一种递归状态的计算顺序,依次进行计算。
动态规划方法和记忆搜索方法本质上是相同的,但是动态规划方法把状态的计算顺序给规定了,从而让状态的进一步化简成为可能。
通过刚才对问题的分析,我们知道 (i,j) 位置的 dp 值需要上面很多位置的值的累加:
dp[i][j] = dp[i-1][j] + dp[i-1][j - penny[i]] + dp[i-1][j - 2 * penny[i]] +…
可以发现红框圈出来的部分中的黑色方块的值就是 dp[i][j - penny[i]] 的值,所以dp[i][j] 的值就可以化简为:dp[i][j] = dp[i - 1][j] + dp[i][j - penny[i]],时间复杂度就从 O(n * aim * aim) 降低到了 O(n * aim),从而进一步得到了优化,代码如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[][] dp = new int[n][aim + 1];
// 初始化第一行的值
for (int i = 0; i < aim + 1; i++) {
if (i % penny[0] == 0) {
dp[0][i] = 1;
}
}
// 初始化第一列的值
for (int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < aim + 1; j++) {
if (j - penny[i] >= 0) {
dp[i][j] += dp[i][j - penny[i]];
}
dp[i][j] += dp[i - 1][j];
}
}
return dp[n - 1][aim];
}
到这里其实我们发现只用一个一维的数组就可以记录已有的状态,空间上更优的代码如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[] dp = new int[aim + 1];
// 初始化第一行的值
for (int i = 0; i < aim + 1; i++) {
if (i % penny[0] == 0) {
dp[i] = 1;
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < aim + 1; j++) {
if (j - penny[i] >= 0) {
dp[j] += dp[j - penny[i]];
}
}
}
return dp[aim];
}
面试中遇到的暴力递归题目可以优化成动态规划的方法的大体过程:
现在就可以解释一下为什么很多人会觉得动态规划方法很难了,比如动态规划的经典问题:求最长递增子序列问题,0-1 背包问题,硬币找零问题… 其实是因为很多人对这种经典动态规划问题最开始的暴力搜索问题不了解,在课本接触到动态规划问题时,教学者又省掉了最初的暴力搜索过程,而是把优化后的动态规划方法的整套方法论直接进行讲述,所以不了解整套优化过程的人当然会觉得动态规划方法理解起来非常的困难。
所以动态规划方法其实就是那些我们不了解的先贤们在当初他们面对暴力搜索的时候发现了有一些常规的优化方法,并且加以总结所形成的用空间换时间的一整套方法的集合。
看到这里,在把课本上那一套关于动态规划方法的原理搬出来理解理解:
动态规划方法的关键点: