钱币找零问题 - 动态规划

先从一个题目引出动态规划。

有数组 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;
}

暴力搜索之所以暴力是因为存在大量的重复计算,比如:

  1. 如果已经使用了 0 张 5 元和 1 张 10 元的情况下,后续将继续求:process1(arr, 2, 990)
  2. 如果已经使用了 2 张 5 元和 0 张 10 元的情况下,后续同样会继续求:process1(arr, 2, 990)

类似这样的情况在暴力递归的过程中大量的发生,所以暴力递归的时间复杂度就非常的高。

所以就有了下面的这种记忆搜索的方法:

记忆搜索方法

重复计算之所以会大量发生实际上是因为每一个递归过程的结果并没有记录下来,所以下次还要重复去求,所以可以事先准备好一个哈希表,每计算完一个递归结果后,都讲结果放入 map 中,下次进行递归过程之前,先在这个 map 中查询这个递归过程是否已经计算过了,如果已经计算过了,则直接取值,如果不存在,才进行递归计算。

因为本题的递归过程可以由两个变量来表示,所以 map 是一张二维表,map[i][j] 代表 p(arr, i, j) 的返回结果。

  1. 每计算完一个 p(index, aim), 都将结果放入 map 中,index 和 aim 组成共同的 key,返回结果为 value。
  2. 要进入一个递归过程 p(index, aim),先以 index 和 aim 注册的 key 在 map 中查询是否已经存在的 value,如果存在,则直接取值,如果不存在,才进行递归计算。

代码如下:

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张图片

解法如下:
1、对于组成矩阵第一列的值表示组成钱数为 0 的方法数,那么很明显就是 1 种(不适用任何的货币),所以 dp 第一列的值统一设置为 1。

钱币找零问题 - 动态规划_第2张图片

2、对于 dp 矩阵第一行的值,表示只使用 arr[0] 这一种货币的情况下组成的方法数,只有 arr[0] 的整数倍的位置才能被 arr[0] 组成,其他钱数统统不行,所以就将相应位置设置为 1,其他位置设置为 0。

钱币找零问题 - 动态规划_第3张图片

3、第一行第一列以外的 dp[i][j] 的值是以下情况的累加:

  • 如果完全不使用 arr[i] 货币,只使用 arr[0…i-1] 货币时,方法数为 dp[i-1][j]
  • 如果只使用一张 arr[i] 货币,剩下的钱用 arr[0…i-1] 货币组成时,方法数为 dp[i-1][j-1*arr[i]]
  • 如果用两张 arr[i] 货币,剩下的钱用 arr[0…i-1] 货币组成时,方法数为 dp[i-1][j-2*arr[i]]
  • 如果用三张 arr[i] 货币,剩下的钱用 arr[0…i-1] 货币组成时,方法数为 dp[i-1][j-3*arr[i]]

钱币找零问题 - 动态规划_第4张图片

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];
}

记忆搜索方法与动态规划方法的联系

  1. 记忆化搜索方法就是某种形态的动态规划方法
  2. 记忆化搜索的方法不关心到达某一个递归过程的路径,只是单纯地对计算过的递归过程进行记录,避免重复的递归计算。
  3. 动态规划的方法则是规定好每一个递归过程的计算顺序,依次进行计算,后面的计算过程严格依赖前面的计算过程。
  4. 两者都是空间换时间的方法,也都有枚举的过程,区别就在于动态规划规定计算顺序,而记忆搜索不用规定。

到底什么是动态规划方法

  1. 其本质是利用申请的空间来记录每一个暴力搜索的计算过程,下次要用结果的时候直接使用,而不再进行重复的递归过程。

  2. 动态规划规定每一种递归状态的计算顺序,依次进行计算。

动态规划方法和记忆搜索方法本质上是相同的,但是动态规划方法把状态的计算顺序给规定了,从而让状态的进一步化简成为可能。

通过刚才对问题的分析,我们知道 (i,j) 位置的 dp 值需要上面很多位置的值的累加:
dp[i][j] = dp[i-1][j] + dp[i-1][j - penny[i]] + dp[i-1][j - 2 * penny[i]] +…

钱币找零问题 - 动态规划_第5张图片

可以发现红框圈出来的部分中的黑色方块的值就是 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];
}

面试中遇到的暴力递归题目可以优化成动态规划的方法的大体过程:

  1. 实现暴力递归方法。
  2. 在暴力递归方法的函数中看看哪些参数可以代表递归过程,找到那些参数,把这些参数整体当做 key,把这个递归过程的计算结果当做 value 存入 map 中,每个递归过程计算完成后,都把结果放入 map 中,下次再碰到同样的状态要进行计算的时候,就可以直接从 map 中取出来用了。
  3. 有了记忆化搜索方法,要想得到动态规划的方法,下一步就要去整理各个状态之间的依赖关系,简单的可以直接得到的状态先计算(如果是一个二维的,一般就是第一行和第一列了),依赖简单状态的计算结果的复杂的状态后计算。
  4. 然后再看看每个状态能不能再简化,得到更简单的状态方程,可以就相当于得到了更好的动态规划方法。

现在就可以解释一下为什么很多人会觉得动态规划方法很难了,比如动态规划的经典问题:求最长递增子序列问题,0-1 背包问题,硬币找零问题… 其实是因为很多人对这种经典动态规划问题最开始的暴力搜索问题不了解,在课本接触到动态规划问题时,教学者又省掉了最初的暴力搜索过程,而是把优化后的动态规划方法的整套方法论直接进行讲述,所以不了解整套优化过程的人当然会觉得动态规划方法理解起来非常的困难。

所以动态规划方法其实就是那些我们不了解的先贤们在当初他们面对暴力搜索的时候发现了有一些常规的优化方法,并且加以总结所形成的用空间换时间的一整套方法的集合。

看到这里,在把课本上那一套关于动态规划方法的原理搬出来理解理解:

动态规划方法的关键点:

  1. 最优化原理,也就是最优子结构性质。这指的是一个最优化的策略具有这样的性质:不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略,简单来说就是一个最优化策略的子策略总是最优的,如果一个问题满足最优化原理,就称其具有最优子结构性质。
  2. 无后效性,指的是某状态下决策的收益,至于状态和决策相关,与到达该状态的方式无关。
  3. 子问题的重叠性,动态规划将原来具有指数级时间复杂度的暴力搜索算法改进成了具有多项式时间复杂度的算法。其中的关键在于解决冗余,这是动态规划算法的根本目的。

关注我的微信公众号(曲健磊的个人随笔),观看更多精彩内容:
钱币找零问题 - 动态规划_第6张图片

你可能感兴趣的:(【算法相关】)