透析遞迴應用-換零錢,透析遞迴換零錢
題目源於《SICP》,這裡做一下調整,如下:
給了面值為50元、20元、10元、5元、1元的五種零錢若干,思考把面值100元人民幣換成零錢一共有多少種方式?
SICP給出的遞迴演算法思想如下:
將總數為a的現金換成n種不同面值的不同方式的數目等於:
- 將現金a換成除了第一種面值之外的所有其他面值的不同方式數目,加上
- 將現金a-d換成所有種類的面值的不同方式數目,其中d是第一種面值的錢幣
下面有解釋到,遞迴的思想是要將問題歸約到對更少現金數或更多種類面值錢幣的同一個問題。有如下的約定:
- 如果a==0,應該算作是有1種換零錢的方式
- 如果a<0,應該算作是有0中換零錢的方式
- 如果n=0,應該算作是有0種換零錢的方式
大家先不要糾結於為何要有這種約定,只需要記住這個約定就好了,先看看Lisp代碼的實現:
(define (count-change amount) (cc amount 5))(define (cc amount kinds-of-coins) (cond ((= amount 0) 1) ((or (< amount 0) (= kinds-of-coins 0)) 0) (else ( + (cc amount (- kinds-of-coins 1)) (cc (- amount (first-denomination kinds-of-coins) kinds-of-coins)) ) ))(define (first-denomination kinds-of-coins) (cond ((= kinds-of-coins 1) 1) ((= kinds-of-coins 2) 5) ((= kinds-of-coins 3) 10) ((= kinds-of-coins 4) 20) ((= kinds-of-coins 5) 50) ))
如果對Lisp有點兒暈,可以看看等價的Java實現:
//換零錢 public static int countChange(int mount){ return cc(mount,5); } /** * @param mount 整錢數量 * @param coinKinds 零錢類型數量 */ private static int cc(int mount, int coinKinds) { if(mount == 0 ) return 1; if(mount<=0 || coinKinds == 0) return 0; return cc(mount,coinKinds - 1) + cc(mount - denomination(coinKinds),coinKinds); } private static int denomination(int coinKind){ switch(coinKind){ case 1:return 1; case 2:return 5; case 3: return 10; case 4: return 20; default: return 50; } }
SICP大讚遞迴是如何的強大,能將問題簡化,初看上面的遞迴覺得確實如此,但要真正徹底理解上面的代碼好像還沒那麼容易,更別說要自己空手寫出上面的代碼。
我在看到代碼之後,就是不明白為什麼會出現下面的代碼:
if(mount == 0 ) return 1; if(mount<=0 || coinKinds == 0) return 0;
因為程式是遞迴的,程式其他地方沒出現過return 1,所以可以大概的知道,方法最終得到的換零錢方式數目肯定是這些個1相加得到。
那為什麼是mount等於0的時候返回1呢? 需要找個例子,來真正看看程式遞迴樹才知道其中的原因。
為了把問題簡化,假設我手頭有一張100元的,另外只有兩種零錢,分別是50的和20的。這樣一來結果好像很明顯了,因為換零錢的方式就兩種:兩個50的或者5個20的。
其實可以更簡化,比如就只有一種50的零錢,但那樣展示的遞迴樹對協助我們理解程式不是很明顯。
看看下面的遞迴樹:
樹節點中左邊數字表示amount,右邊表示零錢種類。
每一個完整的右斜線代表了全部換成某種面值的嘗試;
這些右斜線的左分支代表了換了N個某種面值之後再嘗試換其他面值的嘗試;
看明白了這個遞迴樹之後,就知道了下面判斷條件的意義了:
if(mount == 0 ) return 1;//整數面值的錢剛好被換完了 if(mount<=0 || coinKinds == 0) return 0; //mount<=0:該種嘗試失敗了(零錢加起來比整錢多了);coinKinds == 0:沒有可換的零錢種類了
似乎可以把這棵樹稱為測試樹,每個葉子節點代表了測試結果,歸結起來就知道成功了多少次。神奇的是遞迴巧妙地完成了遍曆並進行測試。
知道了這種遞迴其實是在做遍曆測試,那我們可以用一種簡單而粗暴的測試:
private static int countChange2(int mount){ int count = 0; int d1 = denomination(1); int d2 = denomination(2); int d3 = denomination(3); int d4 = denomination(4); int d5 = denomination(5); for(int i=0;i*d1<=mount;i++){ for(int j=0;j*d2<=mount;j++){ for(int k=0;k*d3<=mount;k++){ for(int l=0;l*d4<=mount;l++){ for(int m=0;m*d5<=mount;m++){ int test = i * d1 + j * d2 + k * d3 + l * d4 + m * d5; if(test==mount){ count++; } } } } } } return count; }
如果要畫出上述演算法的運行軌跡,恐怕跟遞迴樹是一樣的。並且效能上跟上述遞迴代碼也是一樣的。
思考另外一個問題,如果要列印出所有換零錢的方式呢?(而不是方式的總數)
對於上述for迴圈的遍曆,很容易就能得到:
if(test==mount){ String str = format(d1,i); str += format(d2,j); str += format(d3,k); str += format(d4,l); str += format(d5,m); str = str.substring(0,str.length() - 1); System.out.println(str); count++; }
format方法如下:
private static String format(int d,int count){ if(count==0){ return ""; } return " ("+d + "x" + count + ") +"; }
計算countChange2(10)得到如下結果(面值x數量):
(10x1) (5x2) (1x5) + (5x1) (1x10)
而使用遞迴調用的程式要得到這個結果就稍微麻煩點兒了,因為每次測試成功的時候,“手頭”並沒有像for迴圈這樣方便的資料。這些資料分布在了遞迴調用鏈上。要想拿到這些資料,就需要新增一個參數,將調用過程“記錄”在這個參數中。
/** * @param mount 整錢數量 * @param coinKinds 零錢類型數量 */ private static int cc(int mount, int coinKinds,String str) { if(mount == 0 ) { format2(str); return 1; } if(mount<=0 || coinKinds == 0) return 0; return cc(mount,coinKinds - 1,str) + cc(mount - denomination(coinKinds),coinKinds,str += "," + coinKinds); }
這裡用了一個字串來記錄兌換過程中都詳細地兌換了哪些面值的錢幣,兌換記錄用“,”分隔。
下面時分析兌換記錄,形成我們需要的結果:
private static void format2(String str) { String[] ds = str.split(","); int[] dCount = new int[6]; for(String dStr :ds){ if(dStr==null || dStr.equals("")) continue; dCount[Integer.parseInt(dStr)]++; } String res = ""; for(int i = 1;i<dCount.length;i++){ if(dCount[i]==0) continue; res += " (" + denomination(i) +"x"+dCount[i] + ") +" ; } if(res.length()>0) res = res.substring(0,res.length() - 1); System.out.println(res); }
仔細觀察:
cc(mount,coinKinds - 1,str) + cc(mount - denomination(coinKinds),coinKinds,str += "," + coinKinds)
會現為什麼左樹上面的str沒有進行"記錄”?原因是,仔細看看遞迴樹就會發現,僅當樹往右邊走一步的時候才是真正地開啟了一次測試之旅。往左的分支表示減少一種面值的錢幣,並沒開始進行這種測試。
總結
不能把遞迴僅僅理解為“在方法中調用自己”,它更是一種解決問題的強有力的武器。SICP中提到,遞迴分為樹形遞迴和線性遞迴,普通的線性遞迴可以很方便的轉換成for迴圈。樹形遞迴雖然在效能上有時可能有些問題,但它可以簡化問題,將複雜的問題歸約為更小的容易解決的問題。要真正理解樹形遞迴,就非得深入到演算法的每一步,去跟一下。不畫出樹形圖,真不知道埋藏的這棵樹這麼明顯,這麼有意思。
(完)
原創作品,轉載時請標註出處地址:http://www.cnblogs.com/huqiaoblog/p/7606664.html