动态规划算法:硬币找零(Minimum Coin Change)
一、问题描述
给定一组硬币以及需要找零的金额,怎样才能使用最少的硬币数来凑够需要找零的金额。
例如,你现在拥有的硬币数为:2,7,3,找零金额为11。可以选择的找零方式有很多种,比如:3,3,3,2;2,2,2,2,3还可以选择2,2,7。很显然选择2,2,7是使用硬币最少的选择方式。
所以,这里我们就来讨论如何使用动态规划算法来实现这个选择,当然,这道题目还有很多其他的方法比如贪心算法,但这里我们只讨论动态规划的算法实现。
二、分析
解决动态规划算法,我们需要知道状态转移方程,这里我直接给出状态方程
设c[i][j]表示可用第0,1,2,...i 枚硬币对零钱 j 进行找零所需的最少硬币数目,其中 i 表示硬币种类,j 表示找零的金额,coins 表示存放硬币的数组
我们需要分情况来讨论
1. 当coins[i] > j 时,此时表示需要找零的零钱数小于你所持有的硬币面值,比如 j = 2 但是你当前持有的硬币面值 coins[i] = 7 比2大,所以此时不能使用 coins[i] 这个硬币来找零,所以你转而去找c[i-1][j],因为可能c[i-1] = 2,这样可以找开。就算c[i-1][j]找不开,那c[i-2][j]可能找开,而此时我们使 c[i-1][j] = c[i-2][j]。这样即使后面的硬币有找不开的,也可以把之前能找开的继承下来。
所以得到当 coins[i] > j 时的状态转移方程为:c[i][j] = c[i-1][j];
2. 当 coins[i] <= j 时,表示当前硬币可以用于找零。但此时还需要讨论,是否使用这枚硬币。
a. 当使用这枚硬币时,需要的硬币数为 c[i][ j-coins[i] ] + 1
举个例子,当 j = 5时,coins[i] = 2。使用当前硬币的情况下,所需的硬币总数为:当前硬币数1 + 凑成 (j - coins[i]) = 3 所需最少硬币个数
b. 当不使用这枚硬币时,需要的硬币数为c[i-1][j]
而我们需要的是二者最小值,即是否使用当前硬币才能使结果最优 c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1);
这里 c[i][ j-coins[i] ] + 1 而非 c[i-1][ j-coins[i] ] + 1 是考虑到每种硬币可以重复使用
所以找零问题归根到底就是使不使用当前硬币的问题。相信很多人看到这里还是没看懂,那么我接下来一步一步的分析状态转移方程为什么是这个样子的。
我们根据零钱数目w=11,和硬币种类int[] coins = {2,7,3},可以列出一个二维数组矩阵如图
横坐标和纵坐标分别表示零钱数和所拥有的的硬币面值,我们需要在空表格中填入:当取当前零钱数与硬币时,所需的硬币个数。
比如,取第二行第三列时,对应坐标为(2,2),即需要找零2元,而正好有2元的硬币,只需要一个硬币就行了,所以就可以在空格中填入1。
所以按照这种规定我们可以把这张表填满。
在填表之前我们需要填写一些边界条件
1. 当零钱数为0时,我们有2,7,3面值的硬币,这时我们不需要找零所以纵坐标值为0的项目都填上0.
2. 当硬币面值为0时,而找零数不为零,可以认为需要无数个面值为0的零钱来找零。这里我们将横坐标值为0的项目都设为Integer.MAX_VALUE - 1。
所以表就成了下图所示
接着我们来分析,当横坐标值为2时,其各个列的值分别是多少
1. 首先当找零数 j = 1时,此时的 j 小于coins[i] = 2,根据状态方程此时的 c[i][j] = c[i-1][j] = INF - 1
2. 当先当找零数 j = 2时,此时 j >= 2,根据状态方程 c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1),此时的c[i-1][j] = INF-1,c[i][ j-coins[i] ]=c[i][j-2]=c[i][0]=0,即 c[i][j] = min(INF-1, 1) = 1,所以此处填1。
3. 当 j = 3 时,此时 j >= 2,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = min(INF - 1, c[i][1] + 1) = min(INF-1, INF) = INF
4. 当 j = 4 时,j >= 2,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = min(INF - 1, c[i][2] + 1) = min(INF-1, 2) = 2
...
之后的数以同样的方式填满我们可以得到coins[i] = 2的这行表格,如图
我们再来看coins[i] = 7这行
1. j < coins[i] = 7 的数根据方程都有 c[i][j] = c[i-1][j]
2. j = 7 时,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = (INF - 1, c[i][0] + 1) = min(INF - 1, 1) = 1
3. j = 8 时,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = (INF - 1, c[i][1] + 1) = min(INF - 1,INF ) = INF - 1
4. j = 9 时,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = (INF - 1, c[i][2] + 1) = min(INF - 1, 2) = 2
...
如此得到coins[i] = 7 这行的值,如图
再来看 coins[i] = 3 这行
1. j < 3 时,c[i][j] = c[i-1][j]
2. j = 3 时,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = (INF - 1, c[i][0] + 1) = 1
3. j = 4 时,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = (2, c[i][1] + 1) = 2
4. j = 5 时,c[i][j] = min(c[i-1][j], c[i][ j-coins[i] ] + 1) = (INF - 1, c[i][2] + 1) = 2
...
所以由此可以得到完整的表格,如图
所以此二维数组的最后一个元素即为最后答案:3
通过表格还可以分析出,到底使用了哪种个硬币。
首先,最后的3显然是从c[i-1][j]继承来的,而c[i-1][j]的3是使用了当前硬币产生的,所以可以确定使用了当前行即7这个硬币。接着去找 c[i][ j - coins[i] ] = c[i][11 - 7] = c[i][4] ,此时 c[i][4] = 2 而且这个值时从c[i-1][4]继承来的,而c[i-1][4]这个值由自己产生,所以第二个硬币为2。同样接着去找c[i][ j - coins[i] ] = c[i][2] = 1这个数也是由当前行产生,所以第三个数为2,之后发现 j - coins[i] = 0,说明零钱找完,整个过程使用的硬币即为 2,2,7。
四、代码实现
我们来看代码实现,实现时并没用使用一个二维数组来按照上面分析的那样来存储每行每列产生的数据,而是采用了一个不断更新的一维数组,这是基于硬币可以重复使用的基础上
我们在填表的过程中可以发现,其实新的一行的每列都是在上一行的基础上更新得到的,
所以我们这里选择使用一个数组T[]来存放硬币个数的信息,一个数组R[]存放使用了哪些硬币的信息,这样相比于二维数组能节省不少空间
(程序是一个三哥的代码,并且他在YouTube上有关于代码的讲解,我只是将他讲解的内容用自己的话复述了一遍,有兴趣的可以看一下他的教程)
https://www.youtube.com/watch?annotation_id=annotation_2195265949&feature=iv&src_vid=Y0ZqKpToTic&v=NJuKJ8sasGk
1 public class MiniCoinChange 2 { 3 public int coinChange(int total, int[] coins) 4 { 5 int[] T = new int[total + 1]; 6 int[] R = new int[total + 1]; 7 T[0] = 0; 8 9 for(int k = 1 ;k <= total; k++) 10 { 11 T[k] = Integer.MAX_VALUE - 1; 12 R[k] = -1; 13 } 14 15 for(int j = 0; j < coins.length; j++) 16 { 17 for(int i = 1; i <= total; i++) 18 { 19 if(i >= coins[j] && T[i - coins[j]] + 1 < T[i]) 20 { 21 T[i] = T[i - coins[j]] + 1; 22 R[i] = j; 23 } 24 } 25 } 26 27 printCoinsCombination(R, coins); 28 return T[total]; 29 } 30 31 public void printCoinsCombination(int[] R, int[] coins) 32 { 33 if(R[R.length - 1] == -1) 34 { 35 System.out.println("No Solution Possible"); 36 return; 37 } 38 39 int start = R.length - 1; 40 System.out.println("Coins used to form total : "); 41 while(start != 0) 42 { 43 int j = R[start]; 44 System.out.print(coins[j] + " "); 45 start = start - coins[j]; 46 } 47 System.out.println(); 48 } 49 }
接下来,我来解释一下代码的意思
首先我们初始化两个数组 T,R
接着看代码的15到22行
首先看 coins[0] = 2 的情况,从1遍历到 total = 11。
1. 当 i < coins[0] 时,不满足 if 条件判断,不执行任何操作;
2. 当 i = 2 时,判断 T[i - coins[j]] = T[2 - 2] + 1 = 1 < T[2] = INF -1 ,所以更新T[2] = 1,R[2]=0;
3. 当 i = 3 时,T[i - coins[j]] =T[3-2] + 1 = INF > T[3] = INF - 1,if 语句不通过,所以不作任何事情,T[3] 还是 INF -1;
4. 当 i = 4 时,T[i - coins[j]] = T[4-2] + 1 = 2 < T[4] = INF - 1,所以更新T[4]=2;R[4]=0;
...
此后的操作类似,可以得到更新后的数组如图
可以对比一下,之前二维数组的分析,其实是一样的。
接下来看 coins[1] = 7 的情况,同样i从1开始遍历到11
1. 当 i < 7 时,不满足 if 语句的第一个判断条件,所以不做任何改变
2. 当 i = 7 时,T[i - coins[j]] = T[7 - 7] + 1 = 1 < T[7] = INF - 1,所以更新 T[7] = 1,R[7] = 1;
3. 当 i = 8 时,T[i - coins[j]] = T[8 - 7] + 1 = INF > T[8],不满足 if 的第二个判断条件,所以不做任何操作;
4. 当 i = 9 时,T[i - coins[j]] = T[9 - 7] + 1 = 2 < T[9] = INF - 1,所以更新 T[9] = 1,R[8] = 1;
...
如此得到此趟更新后的表如图
最后是 coins[2] = 3 的情况,继续按照前面的方法进行遍历,这里我直接给出更新后的结果
也就是最后的结果,可以看出这与前面二维数组最后一行相同的
纵观对 coins 数组的每次遍历,其实对应的就是前面二维数组的每一行结果,只不过在这里每次遍历后就对前一次的结果进行更新。
所以程序28行,返回的 T[total] = 3 即我们所求。
printCoinCombination(int[] R, int[] coins) 方法是将我们所使用的的硬币种类打印出来。
首先判断 R[i] 结果是否为 -1,如当前例,R[1] 值为 -1,是不可以找零的。抛开程序,2,7,3面值的硬币的确是不能找零1块钱的。
确定零钱数是能找开的之后,我们获取到 start = 11,同时获取到 j = R[11] = 1,打印 coins[j] = 7,表示我们用到了面值为 7 的硬币,
更新 start = 11 - 7 = 4,j = R[4] = 0,打印 coins[0] = 2,表示我们用到了面值为 2 的硬币,
更新 start = 4 - 2 = 2,j = R[2] = 0,打印 coins[0] = 2,表示我们用到了面值为 2 的硬币,
更新 start = 2 - 2 = 0,跳出循环,结束程序。
所以最后所用的硬币为:7,2,2
编写main方法来测试一下
1 public static void main(String[] args) 2 { 3 int[] coins = {2, 7, 3}; 4 int total = 11; 5 MiniCoinChange mcc = new MiniCoinChange(); 6 int val = mcc.coinChange(total, coins); 7 System.out.println("Minimum Number Of Coin used is : " + val); 8 }
结果为
posted on 2016-08-15 12:47 Traveling_Light_CC 阅读(1171) 评论(2) 编辑 收藏 举报