使用动态规划算法解释硬币找零问题

原文链接

硬币找零问题被公认为动态规划算法的典型应用,其解法包含了动态规划的计算机思想。维基百科上对于动态规划是这样定义的:

“是一种数学优化方法,也是计算机编程方法...它是指通过将一个复杂问题分解为许多简单的子问题的简化过程”

换句话说,动态规划是一种将问题简化为许多更小的问题的编程方法。例如,直接问你3*89等于几,你可能没办法回答得像问2*2的结果一样快。但是如果你已经知道了3*88=264,那瞬间就可以推断出3*89=264+3=267。这就是个简单的关于动态规划的例子,从中可以发现动态规划算法如何高效解决复杂问题的一点端倪。

有了上面的例子,我们再来看看硬币找零问题。硬币找零问题有很多变种,以下只列举其中之一。现有一些不同面值的硬币(例如1分、5分和10分的),数量不限,计算用这些硬币凑足金额数字N,存在多少种不同的组合方式。

问题简单版本

先来个简单的例子。
例一:假设硬币面额有1分、5分和10分三种,合计金额N=8分,可以有多少种组合方式?

输入:N=8
      Coins: 1, 5, 10
输出: 2

过程解释:
    组合1:
        1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 8 分
    组合2:
        1 + 1 + 1 + 5 = 8 分

 

你要做的就是想出所有合计金额等于8分的组合方式。八个1分等于8分,三个1分加一个5分等于8分。因此使用1分、5分和10分组成8分一共有两种方式。

 

稍微提高一点难度。
例二:假设硬币面额有1分、5分和10分三种,合计金额N=10分,可以有多少种组合方式?

输入:N=19
      Coins: 1, 5, 10
输出: 4

过程解释:
    组合1:
        1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 10 分
    组合2:
        1 + 1 + 1 + 1 + 1 + 5 = 10 分
    组合3:
        5 + 5 = 10 分
    组合4:
        10 = 10 分

 

通过以上两个例子,可以很容易理解硬币找零的问题本身和找到N较小时的答案。

但是怎样找到N值很大时的答案呢?当然是写代码解决。这个计算N值较大时有多少种硬币组合方式的程序怎么写呢?这是就需要动态规划算法。动态规划算法的精髓是将问题的每个部分都分割成更小的部分。就像本文开头解决算术问题的过程一样,如果已知4*35=140但不知道4*36等于几,可以直接在4*35的结果上加4,得到4*36=144

增加难度

程序计算组成N金额的硬币组合方式有多少种的过程与此类似,我们先来看个例子:

N = 12
数组下标:[0, 1, 2]
硬币金额:[1, 5, 10]

 

硬币面值有1分、5分和10分三种,N的值是12.也就是说我们需要找出有多少种达到12分的组合方式。

 

N值较小时的结果较容易计算,那么在计算N=12时,考虑其中的动态过程,我们需要理清如何加上之前N值较小时的结果,这样也可以避免重复计算已知结果的N值。
对于硬币,我们需要遍历所有的硬币,如果硬币面额大于N值,那就不能用来凑足N值。

动态规划解法之一

硬币找零问题的解法之一是从面值零开始计算用硬币组成面值的组合方式,直到面值N。
即:

组合方式数量(下文简称ways)

[0, 0, 0, ..., 第N个] 由上文,此处N=12

在这样一个长度为N+1(从N=0开始)的数组中,元素的下标就表示不同组合方式合计的金额,元素本身的值则代表组成金额的组合方式有多少种。如果一个硬币的面额大于元素下标的值,那么这枚硬币就不能被用来凑足下标对应的金额。为便于理解,请参照以下示例。

数值继承自上文例子:

N = 12
硬币数组(coins)下标:[0, 1, 2]
硬币金额:[1, 5, 10]

组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,预定义状态):
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  0,  0,  0]

在开始迭代之前,我们需要定义ways数组的初始状态,将下标0位置的元素的值设为1,因为只有一种情况可以凑足0分钱:用0个硬币。

然后,我们可以遍历所有的硬币,并比较硬币面值和ways数组的元素,以此决定每个硬币在凑足下标对应的金额时可以被使用多少次。

例如,首先设置ways[0]=1,然后比较第一枚硬币,1分

N = 12
硬币数组(coins)下标:[0, 1, 2]
硬币金额:[1, 5, 10]

组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,预定义状态):
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0,  0,  0,  0]

比较coins[0]和ways数组中的每个元素,如果coins[0]的值小于等于ways数组元素的下标,那么将ways[j]的值设值为ways[j-coins[0]]+ways[j]。这样把每一部分都分解成更小的部分,这个过程会在下文继续。比较过coins[0]和ways数组中的所有元素后,ways数组的值将被更新成:


组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,与coins[0]比较后):
    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1,  1,  1,  1]

 

继续比较第二枚硬币,面值5分的。

N = 12
硬币数组(coins)下标:[0, 1, 2]
硬币金额:[1, 5, 10]

组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,与coins[0]比较后):
    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1,  1,  1,  1]

比较coins[1]和ways数组中的每个元素,如果coins[1]的值小于等于ways数组元素的下标,那么将ways[j]的值设值为ways[j-coins[1]]+ways[j]。这样把每一部分都分解成更小的部分,这个过程会在下文继续。比较过coins[1]和ways数组中的所有元素后,ways数组的值将被更新成:


组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,与coins[1]比较后):
    [1, 1, 1, 1, 1, 2, 2, 2, 2, 2,  3,  3,  3]

这个过程是计算第二到第n个硬币在组成各下标值的金额时分别被使用了多少次。为什么要比较所有的硬币?是为了动态地更新前一次比较后的结果,减少重复计算(与每个金额分别计算组合方式数目相比)。例如此时ways中下标10对应的元素值是3,它是怎么来的?在计算时,`j-coins[1]`就是10-5,即5,这就是除了当前硬币面额5以外,我们需要想办法凑到金额10所缺少的部分。而此时ways中下标5的元素值是2,也就是说此时凑足5的方式有2种。所以2种凑足5的方式加上当前凑足10的方式(计算之前是1),就是更新后的全部凑足10的组合方式的数量(即3)

 

接着比较第三枚硬币,面值10分的。

N = 12
硬币数组(coins)下标:[0, 1, 2]
硬币金额:[1, 5, 10]

组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,与coins[1]比较后):
    [1, 1, 1, 1, 1, 2, 2, 2, 2, 2,  3,  3,  3]

比较coins[2]和ways数组中的每个元素,如果coins[2]的值(10)小于等于ways数组元素的下标,那么将ways[j]的值设值为ways[j-coins[2]]+ways[j]。这样把每一部分都分解成更小的部分,这个过程会在下文继续。比较过coins[2]和ways数组中的所有元素后,ways数组的值将被更新成:


组合方式数量数组(ways)的下标:
    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
组合方式数量数组(ways,与coins[2]比较后):
    [1, 1, 1, 1, 1, 2, 2, 2, 2, 2,  4,  4,  4]

可知N=12时的答案是ways[12],即4

代码实现

原文有Java、Python和C#代码的实现,仅摘录Python版本

def get_num_of_ways(N, coins):
    ways = [0] * (N + 1)
    
    ways[0] = 1
    
    for c in coins:
        for i in range(N+1):
            if c <= i:
                ways[i] += ways[i-c]
    
    return ways[-1]

 

运行结果:

In [3]: get_num_of_ways(10, [1, 5, 10])                                                             
Out[3]: 4

In [4]: get_num_of_ways(20, [1, 5, 10])                                                             
Out[4]: 9

In [5]: get_num_of_ways(100, [1, 5, 10])                                                            
Out[5]: 121

In [6]: get_num_of_ways(1000, [1, 5, 10])                                                           
Out[6]: 10201

 

思考

另外一篇关于动态规划的文章(ref)中提到,动态规划有自顶向下自底向上两种解法,上文中的解法应该属于自底向上的,从最基础的状态开始,逐步推断到要求的结果。

引用:动态规划中列出状态转移方程的步骤
  1. 确定base case,最基础、无法被拆分的状态是什么,此时问题的解是什么
  2. 确定状态,也就是原问题子问题中会变化的变量。由于硬币数量不限,面额是预先设定的,只有目标金额会不断向base case靠近,所以唯一的状态就是目标金额N
  3. 确定选择,也就是导致状态发生变化的行为。目标金额为什么变化呢?因为你在选硬币,没选择一枚硬币,就相当于减少了要凑足的目标金额,所以硬币的面值,就是选择
  4. 明确dp函数(自顶向下解法中)/数组(自底向上解法中)的定义。在上文中,数组coins的定义是凑足下标i金额的组合方式有coins[i]种。也可以换种自定义的dp函数,dp(n) 凑足金额n所需要的硬币最少有几个,用来计算凑足金额N所需要的硬币最少可以是几个

根据所要求的问题不同,base case、状态转移方程的状态、选择行为以及dp函数/数组的定义都会发生改变。

posted @ 2020-11-23 17:18  harelion  阅读(1341)  评论(0编辑  收藏  举报