透析遞歸應用-換零錢


題目源於《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;
        }
    }

計算換100塊錢有多少種兌換方式:

countChange(100)
343

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 + ") +";
    }

 兌換10塊錢(countChange2(10)),將得到如下結果(面值x數量):

 (10x1)         //1張10元(10元也是一種零錢)
 (5x2)          //2張5元
 (1x5) + (5x1)  //5張1元 和 1張5元
 (1x10)         //10張1元

 而使用遞歸調用的程序要得到這個結果就稍微麻煩點兒了,因為每次測試成功的時候,“手頭”並沒有像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 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM