换零钱问题
/ / 点击 / 阅读耗时 12 分钟换零钱问题
在《SICP》第一章 1.22节,刚介绍完递归和迭代,给了一道相当有难度的例题:统计换零钱的方法
给定半美元、25美分、10美分、5美分、1美分 5种硬币,将 1 美元换成硬币,有多少种硬币组合?
给定任意数量的现金 和 任意组合的硬币种类,计算换零钱所有方式的种数。
解题思路
书上给出的解题思想为递归分治: 将总数为 a 的现金换成 n 种硬币组合的数目等于两部分之和:
- 第一部分为现金
a
换成除第一种硬币外的所有其他硬币种类的数目 - 第二部分为现金数
a-d
换成所有种类硬币的不同方式数目,其中d
为第一种硬币的币值。
怎么来理解呢,这里是将换零钱的方法分为两组,第一组是都没有使用第一种硬币,第二组是都用了第一咱硬币。显然,换成零钱的全部方式就等于完全不用第一种硬币的方式的数目,加上用了第一种硬币的方式的数目。可以看出,分成两组后,这两个问题新问题相对于原问题的范围缩小了,第一组现金没有变,但可选的硬币种类少了一种;第二组硬币种类没有变,但是现金减少了第一种硬币的币值。
这样,就可以将给定金额的换零钱方式问题,通过递归化简为对 更少现金 和 更少种类硬币的同一问题,而求解这样的递归子问题,我们只需确定好问题的边界在哪里,就可以完成运算。边界问题如下:
- 当现金数
a
为 0 时,应该算作是有 1 种换零钱的方法 - 当现金数
a
小于 0 时,应该算作是有 0 种换零钱的方法 - 当换零钱可选的硬币种类为 0 时,应该算作是有 0 种换零钱的方法
分析
光看这些解释还不够直观,我们来用 10 美分来演算一下。
10 美分能够用到的硬币种类只有[10, 5, 1]
三种。我们用 10$[10, 5, 1]
这种记法来标记问题。先按币值为10的硬币来分类,将问题化为:
- 第一组,不使用 10 美分的硬币来表示,只用5美分和1美分来表示 即
10$[5, 1]
- 第二组,使用了 10 美分的硬币,剩下的金额 0 使用全部的硬币种类表示,即
0$[10, 5, 1]
根据上面的规则,当现金数为0 时,应该算作是有 1 种换零钱的方法,所以第二组的值为 1。我们只用求解第一组的 10$[5, 1]
, 然后加上第二组的 1 就是问题 10$[10, 5, 1]
的结果。继续化简问题 10$[5, 1]
,按币值为 5 的硬币来分解:
- 1-1. 第一组,不使用 5 美分的硬币,只用 1 美分的币值来表示 10 美分,即
10$[1]
- 1-2. 第二组,使用了一个 5 美分,剩下的 5 美分仍用
[5, 1]
两种来表示,即5$[5, 1]
其中第一组的结果为 1 ,继续求解第二组问题 5$[5, 1]
,仍按 5 美分来分类,可以分为:
- 1-2-1. 第一组,不使用 5 美分的硬币,只用 1 美分的币值来表示 5 美分,即
5$[1]
- 1-2-2. 第二组,使用一个 5 美分的硬币,剩下的金额 0 使用全部的硬币种类表示
0$[5, 1]
综上所述,10 美分金额的硬币换的结果为:
1 | 10$[10, 5, 1] = 10$[5, 1] + 0$[10, 5, 1] |
按边界规则,可以直观看出,当金额为 0 或 可选币种只有 1 种时,组合数都为 1 。
所以 10$[10, 5, 1]
的最终结果为 4 。这其中的思想就是将一个复杂的问题归约为简单的子问题求解。
scheme代码
书上 1.22节给出的示例代码如下:
1 | #lang planet neil/sicp |
由于第一章刚刚介绍完 Lisp 的判断语句,没有学习数据结构,需要用判断语句来实现硬币种类,所以上面的示例相对麻烦一些。
在学习完第二章的数据结构链表后,可以用表来表示硬币的种类和币值,这样可以轻易更换兑换币种。将上面的程序再重写一下。
1 | #lang planet neil/sicp |
上面的两种方案都是用递归来化简问题,其中用 car
、 cdr
、 null?
来实现对列表的操作。
Python代码
1 | # -- coding = utf-8 -- |
上面代码使用递归方法实现,逻辑非常清楚直观。缺点也比较明显,一是Python 有递归深度限制,当数据金额特别大时可能报错。二是空间复用度高。
凡是递归能解决的问题都可以用迭代解决,具体到这个题目,可以用动态规划的思路来解决。 下面是网上大神的动态规划代码,特别优雅,只有6行。
1 | # -- coding = utf-8 -- |
总结
通过对换零钱问题的分析和求解,让我们更深入的理分治算法的思想,就是将复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。