遞歸解決換零錢問題--問題分析

如下是咱們的問題:數組

把100元用50元,20元,10元,5元,1元去換,總共有幾種換法?spa

形式化(formalize)

 

爲了簡化敘述,把問題記爲「coc(100, [50, 20, 10, 5, 1])」。.net

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

爲使問題更具備通常性,參數化具體值,獲得:orm

caseOfChange(amount, cashList)blog

其中amount爲金額,cashList爲零錢列表。遞歸

遞歸分析

前面說到遞歸分兩種情形,一是基本情形(base case),二是遞歸情形(recursive case)圖片

基本情形(base case)

顯然,問題難在零錢種類太多,若是隻有一種零錢,問題就簡單了:get

好比coc(100, [50])=1,即有一種換法,也就是換成2張50的。it

又如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拆分出來?

在紙上寫下一些具體的換法,經觀察能夠有兩種換法:

image

一種如紅色部分,分紅「用了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,星星已經不是那個星星了。

不過你要是再仔細看看上面圖中紅色部分的下半身:

image

也便是前面等式中右邊第二部分的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」這一特性,從圖片觀察:

image

顯然,既然每種不一樣換法都帶了50,那麼去掉它以後,不一樣的換法彼此間仍是保持不一樣。因此:

image

那麼,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]) ,猜想被證明!

因爲100-50剛好爲50,兩個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());
    }
}

 

其中:

  1. amount.subtractBy(cashList.getFirstCash()),正如方法名字所暗示那樣,表示「金額減去第一個零錢」;

  2. 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());
}

 

因爲篇幅太長,將在後面的篇章中給出具體的程序實現並作一些回顧與總結。

下一篇見遞歸解決換零錢問題--代碼實現

相關文章
相關標籤/搜索