1. 程式人生 > >透析遞歸應用-換零錢

透析遞歸應用-換零錢

什麽 format con 成了 數據分布 equals integer equal 方便

題目源於《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; //該種嘗試失敗了(零錢加起來比整錢多了),沒有可換的零錢種類了

似乎可以把這棵樹稱為測試樹,每個葉子節點代表了測試結果,歸結起來就知道成功了多少次。神奇的是遞歸巧妙地完成了遍歷並進行測試。

知道了這種遞歸其實是在做遍歷測試,那我們可以用一種簡單而粗暴的測試:

    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)得到如下結果:

 (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沒有進行"記錄”?原因是,仔細看看遞歸樹就會發現,僅當樹往右邊走一步的時候才是真正地開啟了一次測試之旅。往左的分支表示減少一種面值的錢幣,並沒開始進行這種測試。

(完)

原創作品,轉載時請標註出處地址:http://www.cnblogs.com/huqiaoblog/p/7606664.html

透析遞歸應用-換零錢