透析递归应用-换零钱

题目源于《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 

posted @ 2017-09-28 14:54  at0x7c00  阅读(1558)  评论(2编辑  收藏  举报
CSDN - ITeye - GitHub  |  HuQiao's Blog  |  业余经营:IT快报