您的位置:首页 > 其它

递归解决换零钱问题

2017-08-03 23:08 381 查看
以下是我们的问题:

把100元用50元,20元,10元,5元,1元去换,总共有几种换法?


形式化(formalize)

为了简化叙述,把问题记为“coc(100, [50, 20, 10, 5, 1])”。

coc: case of change或者count of change(change在英文中有零钱的意思)

为使问题更具有一般性,参数化具体值,得到:

caseOfChange(amount, cashList)

其中amount为金额,cashList为零钱列表。


递归分析

前面说到递归分两种情形,一是基本情形(base case),二是递归情形(recursive
case)。


基本情形(base case)

显然,问题难在零钱种类太多,如果只有一种零钱,问题就简单了:

比如coc(100, [50])=1,即有一种换法,也就是换成2张50的。

又如coc(50, [20])=0,即没法换,2张20不够,3张又太多。

显然,结果不是0就是1,就看能否整除。根据以上分析,得出:
caseOfChange(amount, cashList) {
// base case
if (cashList.containsOnlyOneCash()) { // 只有一种零钱时
if (amount.canBeChangedWith(cashList.getTheOnlyOneCash())) { // 能换时,即能整除
return 1;
} else {
return 0;
}
} else {
// recursive case, TODO
}
}


注:暂时请只专注于描述我们的问题,现在还不急着考虑具体的实现。


递归情形(recursive case)

因为递归的模式并不是那么明显,我们还是先回到传统的思路上考虑。

拆分问题


问题难在零钱种类太多,我们首先考虑能否拆解它。

把大问题分成几个小问题,或者说把一个复杂问题分成几个简单问题,这是我们常用的伎俩。

那么怎么分解呢?我们经常能想到的就是一分为二。

如果我们能找到一种方法,比方说,能把5种零钱拆成“1+4”,那么“1”可以在base case中解决了,更进一步,4又可拆成1+3,循环往复,问题就可解决了。


具体事例


为方便思考,我们还是用更具象的例子来分析。比如,怎么把coc(100, [50, 20, 10, 5, 1])中的50拆分出来?

在纸上写下一些具体的换法,经观察可以有两种换法:





一种如红色部分,分成“用了50的”和“没用50的”。

一种如蓝色部分,分成“完全只用50的”和“不完全用50的”(这等于说还是没把50拆分出来)。

所以,红色那种更好一点,它的另外一部分已经彻底与50无关了。

不过,与我们想像的把5拆成1+4不同,这里是把5拆成5+4。

它意味着把[50, 20, 10, 5, 1]分成了[50, 20, 10, 5, 1]和[20, 10, 5, 1]。因为前者除了50之外依然还用了其它。

考虑一下集合中的”并“操作。[50, 20, 10, 5, 1] U [20, 10, 5, 1] = [50, 20, 10, 5, 1],对吧,希望你还能记得。所以反之你确实可以把它视作某种分解。

现在可以把结果写成两部分之和,形式化的表达如下:

coc(100, [50, 20, 10, 5, 1])=cocWith50(100, [50, 20, 10, 5, 1]) + cocWithout50(100, [50, 20, 10, 5, 1])

其中:cocWith50表示“用了50的”,cocWithout50则为“没用50的”。

找到一个递归!


显然,现在已经不是递归了,coc已经变成了cocWith50和cocWithout50,星星已经不是那个星星了。

不过你要是再仔细看看上面图中红色部分的下半身:





也即是前面等式中右边第二部分的cocWithout50(100, [50, 20, 10, 5, 1])),它不就是coc(100, [20, 10, 5, 1])吗?

观察cocWithout50(100, [50, 20, 10, 5, 1]),既然方法声称不使用50,参数[50, 20, 10, 5, 1]里还带了50不就没用了吗?

所以cocWithout50(100, [50, 20, 10, 5, 1])=cocWithout50(100, [20, 10, 5, 1]),

然后,既然参数里已经不带50,方法不就没必要强调without50了吗?

所以cocWithout50(100, [20, 10, 5, 1])=coc(100, [20, 10, 5, 1])!

至此可以得到以下等式:

coc(100, [50, 20, 10, 5, 1])=cocWith50(100, [50, 20, 10, 5, 1]) + coc(100, [20, 10, 5, 1])

子问题中的一个已经向着原问题递归了,而且第二个参数cashList在减小,这意味着递归有收敛的可能!

临门一脚


现在激动地畅想一下,如果我们能把等式右边第一部分的cocWith50也能向coc靠拢,那不就大功告成了吗?

怎么往coc这条大腿上靠呢?我们不妨从结果出发,所谓向它靠拢,也即是要寻求一种等价关系,使得:

cocWith50(100, [50, 20, 10, 5, 1])=coc(?, [?..?])

观察一下,等式左边有三个元素:

1. cocWith50,

2. 100,

3. [50, 20, 10, 5, 1]

从结果出发,cocWith50要变成coc,这个是我们的目标。

但这样就会丢掉一个特性,那就是“with50”(它强调了每种换法一定要用到50),coc方法显然是没这种强调的;

所以其它两个元素的变化必须体现这个特性或者说至少做出某种呼应。

那么,我们还有两个元素可以变。而且从要让递归收敛的角度考虑,它们应该是一种递减的变化。

现在看第二个元素,100能怎么变?或者说100能变吗?毕竟你是要拿100去换呀!不太明朗,我们先看看第三个。

第三个元素,cashList,能怎么变呢?既然要递减,我们无非希望能去掉list中的一些,让我们看看:

1. cocWith50点名了要50,不能少,

2. 但也没说其它的如20,10,5,1之类的就不要呀,所以也不能少!(从图中也能直观看出,这些零钱都可能会用到)

另外,也不清楚如何去呼应“with50”这一丢失的特性。看来我们只好把目光再次投向第二个元素“100”了:

毕竟,你能依靠的也就这哥俩了,100会是我们的救命稻草吗?

让我们大胆猜测一下,让100变成“100-50”,也即是认定:

cocWith50(100, [50, 20, 10, 5, 1])=coc(100-50, [50, 20, 10, 5, 1]) =coc(50, [50, 20, 10, 5, 1])

注:最后的50是由100-50得到的,恰好也是50,但与红色标出的50是不同的。

我们当然不是胡乱猜测:

理由之一:cocWith50变到coc,去掉了“with50”,我们也在100里减去50,正好呼应了这一变化。

理由之二:这使得参数在减小,符合递归要求参数递减的要求。

当然,我们想要的其实是cashList的递减,amount的减小不能让我们递归到base case上。

但从另一角度看,如果amount能不断减小,将导致cashList中的一些零钱比amount还大,这将间接导致cashList的递减,也即递归将会收敛。

比如100递减到了20,那么coc(20, [50, 20, 10, 5, 1])=coc(20, [20, 10, 5, 1]),因为50比20还大,所以去掉也没影响了。

自然,我们还需要寻求更加明显的理由来支持我们的猜测。紧扣“with50”这一特性,从图片观察:





显然,既然每种不同换法都带了50,那么去掉它之后,不同的换法彼此间还是保持不同。所以:





那么,100-50的意义在哪,现在就很明确了,因为每种换法都带了50,所以减去它不影响等价性。所以:

cocWith50(100, [50, 20, 10, 5, 1])=coc(100-50, [50, 20, 10, 5, 1])=coc(50, [50, 20, 10, 5, 1]) ,猜测被证实!

综合之有:

coc(100, [50, 20, 10, 5, 1])=coc(100-50, [50, 20, 10, 5, 1]) + coc(100, [20, 10, 5, 1])

递归模式已经被归纳出来。参数化,抽象化后:

coc(A, [C1, C2…Cn])=coc(A-C1, [C1, C2…Cn]) + coc(A, [C2, C3…Cn]);

即:
caseOfChange(amount, cashList) {
// base case
if (cashList.containsOnlyOneCash()) { // 只有一种零钱时
if (amount.canBeChangedWith(cashList.getTheOnlyOneCash())) { // 能换时,即能整除
return 1;
} else {
return 0;
}
} else {
// recursive case
return caseOfChange(amount.subtractBy(cashList.getFirstCash()), cashList)
+ caseOfChange(amount, cashList.newCashListWithoutFirstCash());
}
}


其中:
amount.subtractBy(cashList.getFirstCash()),正如方法名字所暗示那样,表示“金额减去第一个零钱”;
cashList.newCashListWithoutFirstCash()表示“去掉第一个零钱后的新零钱列表”。

至此,主体的结构已经基本OK了,不过,还遗留一个问题,递归情形中的第一部分还无法收敛,还需要增加一些额外判断,即当金额比零钱列表中的第一个还小时,就减小零钱列表的元素,这样就能向着base case归约了。

或者换种思路,让金额一直递减,直到它为0甚至为负时我们再作出决定。我们来看看这意味着什么。


手动演算


根据递归模式,我们也可以手动地演算一下。以简单的“把10元换成5元与1元”为例,有coc(10, [5, 1])=3。

具体为5+5,5+1+1+1+1+1,1+1+1+1+1+1+1+1+1+1三种。

公式推演如下:

coc(10, [5, 1])

=coc(10-5, [5, 1]) + coc(10, [1])

=coc(5, [5, 1]) + 1

=coc(5-5, [5, 1]) + coc(5, [1]) + 1

=coc(0, [5, 1]) + 1 + 1

那么现在问题来了,coc(0, [5, 1])=?

显然,根据最终结果为3,所以coc(0, [5, 1])=1,也即用0元去换,有一种换法。

这可能会让人觉得有些不好理解,现实中没人去拿0元去换。

如果观察上述的推算过程,还出现了coc(5, [5, 1]),即5元又拿5元与1元去换,显然,现实中也不会发生这样的换法。

其实前面也出现了coc(50, [50, 20, 10, 5, 1],50元又用了50元去换。

但我们知道,之所以出现这种情况,是等价替换造成的,coc(5, [5, 1])是经过一次约减后的中间结果,而coc(0, [5, 1])则是经过两次约减后得来的,它实际就是原来的“5+5”这种换法。

金额为0的情况


可以用另一种角度来看待这个问题,以coc(10, [5, 1])=3为例,这里实质是什么呢?

10=2*5+0*1,相当于5+5,也即10元能换成2张5元加0张1元,只不过我们通常省略后者。

10=1*5+5*1,相当于5+1+1+1+1+1,也即10元能换成1张5元加5张1元。

10=0*5+10*1,相当于1+1+1+1+1+1+1+1+1+1,也即10元能换成0张5元加10张1元,只不过我们通常省略前者。

所以=3实质就是有三种系数的组合,(2, 0),(1, 5),(0, 10)。

我们不考虑系数为负的情况,否则就有无数种可能了。如10=3*5+(-5)*1=4*5+(-10)*1=…

那么0=0*5+0*1,相当于有一种且唯一一种系数组合(0, 0),也即0元能换成0张5元加0张1元。

不考虑系数为负的情况,那么就不可能有其它组合了,因为两个零钱都是正数。

实际上,coc(0, [50, 20, 10, 5, 1])也是1种。

系数组合为(0, 0, 0, 0, 0)。不管有多少零钱种类,只要用0元去换,都有一种且唯一一种换法。

显然,只要能整除,反复约减之下就能得到0.

同理:coc(5, [5, 1])有两种系数组合(1, 0), (0, 5)。


金额为负数的情况


反之,如果不能整除,反复约减之下就会得到负数,如coc(50, [20, …]),那么,三次约减之后就会出现形如“coc(-10, [20, …]”,那么,这种情况就认为有0种换法,即不可换。


最终结果

综上,增加两种base case的判断,程序就能收敛了,最终形式化的结果如下:

caseOfChange(amount, cashList) {
// base case
if (amount.isNegative()) { // 负数
return 0;
}
if (amount.isZero()) { // 0元
return 1;
}
if (cashList.containsOnlyOneCash()) { // 只有一种零钱时
if (amount.canBeChangedWith(cashList.getTheOnlyOneCash())) { // 能换时,即能整除
return 1;
} else {
return 0;
}
}

// recursive case
return caseOfChange(amount.subtractBy(cashList.getFirstCash()), cashList)
+ caseOfChange(amount, cashList.newCashListWithoutFirstCash());
}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: