换零钱程序c语言,《SICP》换零钱的递归法与迭代法-程序员宅基地

技术标签: 换零钱程序c语言  

咳咳..先说一段废话..

最近开始看SICP这本书,正看到了换零钱的部分。看到里面那么多简明生动的例子,还有作者的细心讲解,真是唤起了对学习的无限激情。之前也看过王垠的一些文章,提到了诸如Lisp、scheme等语言,不过当时对此是零概念(其实现在对scheme的了解,也止于SICP换零钱这部分章节)。在了解了mit-scheme的一些基本用法后,虽然也觉得用‘括号’这种语法的确很优雅,但当我真的写完一个长长的定义之后,游荡在许许多多括号中间,找个小bug,真心觉得头疼。

换零钱

回到正题,先说一下题目:

给了50、25、10、5、1美分的硬币,将1美元(100美分)换成零钱,总共有多少种换法?

在这之前,书中也提到了一般有两种方法,线性递归和线性迭代,书中给出的解法是一个最为直观的,效率也最低的解法——线性递归。

递归法

书中的思路这样描述:

将总数为a的现金换成n种硬币的不同方式的数目等于

将现金数a换成除第一种硬币之外的所有其他硬币的不同方式数目,加上

将现金数a-d换成所有种类的硬币的不同方式数目,其中的d是第一种硬币的币值

按照这种规则,则可以总结出如下算法:

如果a就是0,应该算作是有1种换零钱的方式。

如果a小于0,应该算作是有0种换零钱的方式。

如果n是0,应该算作是有0种换零钱的方式。

n应该代表的是,第几种硬币(有1、2、3、4、5这五种币种,0就是没有可分配的币种)。

这个递归过程的代码为:

(define (first-denomination kinds-of-coin)

(cond ((= kinds-of-coin 1) 1)

((= kinds-of-coin 2) 5)

((= kinds-of-coin 3) 10)

((= kinds-of-coin 4) 25)

((= kinds-of-coin 5) 50)))

(define (cc amount kinds-of-coin)

(cond ((= amount 0) 1)

((or (< amount 0) (= kinds-of-coin 0)) 0)

(else (+ (cc amount

(- kinds-of-coin 1))

(cc (- amount

(first-denomination kinds-of-coin))

kinds-of-coin)))))

(define (count-change amount)

(cc amount 5))

所以测试了一下,结果为:

1 ]=> (count-change 100)

;Value: 292

这个递归运算是指数级的,现在看起来没什么问题,但是当总钱数比较大时,问题就明显了。

迭代法

又是一段废话...

作者说,这一解法留给读者作为一个挑战。看到'挑战'这个字眼,凭着对求知的好胜心,我的心就痒痒了,然后接下来就是自虐的过程。虽然出身于数学系,但是大学期间,基本都把青春献给dota了。

面对这个一层套一层的问题,脑袋已经不够用了,想了许多种思路,几乎都是半途发现行不通又重新思考。在想了几个小时后,终于忍不住去网上搜索答案,英文不好,只能搜搜中文的。发现有个答案,大概看了一下,说是用数组先把一部分结果算出来,然后复用。因为我的scheme水平实在有限,没接触到那些用法,所以也没细看,而且我觉得这个解法,也不符合我想要的那种答案。

只好回来继续虐自己吧,光空想,脑袋是转不过来了。突然想起,书中一般都是通过画一些推导图之类的,来寻求规律,于是我也拿起笔画起来,终于找到了一种思路。

思路

递归法是通过把每一个大问题划分成两个小问题,从而生一个树形解法,思路虽然比较好理解,但是运算时就要把树全部展开,而且子树当中存在大量重复计算,效率可想而知。而迭代法不能延续这种思路,只能通过自身的参数,来分析当前迭代的情况,并计算出来,进行下一次迭代。所以我的想法是,可不可以每次迭代通过每种币种的数量,计算出剩余的钱,然后分析出当前情况是否是一个有效的分法,然后进行下一次迭代,直至每种币种的所有数量情况遍历完毕。

怎么判断,什么是一个有效的分法呢?就是每种硬币,它的币值乘以它的数量,最后相加在一起,正好等于总钱数时,即剩余钱数是0时,就是一种有效的分法。

设有如下对应关系:

币种

币值

数量

1

50

c1

2

25

c2

3

10

c3

4

5

c4

5

1

c5

总钱数为amount;

那么,当amount = coin1*c1+coin2*c2+coin3*c3+coin4*c4+coin5*c5;时,

就是一种有效的分法。

有几个问题需要考虑:

按照怎么样顺序去遍历各币种的数量,直至当剩余钱数符合一种特殊情况(小于0或者等于0)。

当剩余钱数等于或者小于0的时候,怎样安排下一次顺序。

怎么判断结束情况。

分钱这种事,大概一想也知道,如果你有100块,分别可分成2张50块的,4张25块的,10张10块的,20张5块的,100张1块的。币值越大,可分的数量就越少,如果给定有一张是50块的,那令外50又可分成,2张25块的,5张10块的等等...

按这个规律,我们又发现,全分成50的,有2张;50、25混着分,有3张;全分成25的,有4张。这样就是说,币种平均值越小,可分的张数就越多。

那我们就先从平均值最大的币种开始遍历(没试过从平均值最小的遍历),迭代就是只要剩余钱数大于0,就让数量增加,每当遇到特殊情况,也就是剩余钱数小于0或者等于0时,就开始遍历平均值第二大的,这样可分的张数就越来越多。直到只剩下平均值最小的币种了,那么就结束了。

方法

先从币值最大的币种(第一种币种)开始遍历,每次数量加1,计算出剩余钱数,直到剩余钱数等于0或者小于0时,就将此次的最大币种的数量减1,并将币值第二大的币种(第二种币种)数量加1;接下来每次第一种的数量保持不变,将第二种的数量加1,再次直到剩余钱数等于或者小于0,然后将第二种数量减1,第三种数量加1,此时重复之前的步骤,直至到最后一个币种。可以发现,我们需要有个标识记录,当前是哪种硬币的数量应该加1。总结后,遍历情况如下图:

3e7477ab72de

图1.jpg

思考:

到了图中最后一步时,游标已经走到了最后一个币种,但此时遍历还远没有结束。接下来就是需要找到比现在的币种平均值要小的搭配了。一开始,我直接把第一个币种的数量减1,并且后面的币种数量清0,进而开始迭代,结果发现,漏掉了许多情况。漏掉的情况就是当第一个币种数量不变(即上图中数量为1)时,除第一种币种之外,剩余其他币种的随意搭配。所以,只能从后往前,开始减少币种的数量,这样就不会漏掉情况了。

接下来继续,目前游标已经是第五种硬币了,所以要将前面离的最近的且数量不为0的币种数量减1,将下一币种数量加1(将游标移动到此币种),再将这之后的币种硬币数清零,然后继续用之前的方法迭代。

接着图1继续迭代,如下图:

3e7477ab72de

图2.jpg

最后一个问题,怎么判断结束呢?

由于是一步一步减少币值较大的币种的数量,所以最后会看见这样一幅画面:

3e7477ab72de

图3.jpg

很明显,当前四种硬币的数量为0时,第5种硬币数量为100,这就是最后一种有效的分法,可以结束了。

代码

方法已经有了,接下来就是还原成代码吧。由于对scheme不熟,如果直接用scheme,估计我就崩溃了。为了快速验证我的想法,我首先用C语言实现了一下(我用的是Xcode),代码如下:

// 获取对应币种的币值

int get_coin(int index){

switch (index) {

case 1:{

return 50;

break;

}

case 2:{

return 25;

break;

}

case 3:{

return 10;

break;

}

case 4:{

return 5;

break;

}

case 5:{

return 1;

break;

}

default:

break;

}

return 0;

}

// 为了计算执行了多少次迭代

static long long cn2 = 0;

// 剩余钱数 有效分法 游标

static int c_c(int leftAmount, int count, int cur, int c1, int c2, int c3, int c4, int c5){

cn2++;

// 当剩余钱数等于或者小于0时

if (leftAmount == 0 || leftAmount < 0){

// 1.当游标走到4时,其实是从前往后遍历的时候

// 2.当游标在5时,但是第4种硬币的数量大于0,属于从后往前遍历

// 这两种情况处理是一样的,之后的判断与处理跟此处类似

// 这里还有一个稍微特殊一点的地方:

// 当属于第2种情况时,由于第5种硬币已经是最后一种硬币,所以直接加1,且之后并没有需要数量清零的币种

if ((cur==5 && c4>0) ||

cur==4){

return c_c(leftAmount+get_coin(4)-get_coin(5),

(leftAmount<0)?count:count + 1,// 如果剩余钱数等于0,有效次数加1,反之不变

5,

c1,

c2,

c3,

c4-1,

c5+1);

}

else if ((cur==5 && c4==0 && c3>0) ||

cur==3){

return c_c(leftAmount+get_coin(3)-get_coin(4)+get_coin(5)*c5,

(leftAmount<0)?count:count + 1,

4,

c1,

c2,

c3-1,

c4+1,

0);

}

else if ((cur==5 && c4==0 && c3==0 && c2>0) ||

cur==2){

return c_c(leftAmount+get_coin(2)-get_coin(3)+get_coin(4)*c4+get_coin(5)*c5,

(leftAmount<0)?count:count + 1,

3,

c1,

c2-1,

c3+1,

0,

0);

}

else if ((cur==5 && c4==0 && c3==0 && c2==0 && c1>0) ||

cur==1){

return c_c(leftAmount+get_coin(1)-get_coin(2)+get_coin(3)*c3+get_coin(4)*c4+get_coin(5)*c5,

(leftAmount<0)?count:count + 1,

2,

c1-1,

c2+1,

0,

0,

0);

}

// 结束情况: (cur==5 && c4==0 && c3==0 && c2==0 && c1==0)

else{

// 如果剩余钱数等于0,有效次数加1,并返回

return (leftAmount<0)?count:count+1;

}

}

// 剩余钱数大于0

else{

// 继续将游标处的币种数量加1

return c_c(leftAmount-get_coin(cur),

count,

cur,

(cur==1)?c1+1:c1,

(cur==2)?c2+1:c2,

(cur==3)?c3+1:c3,

(cur==4)?c4+1:c4,

(cur==5)?c5+1:c5);

}

}

int getCountChange(int amount){

cn2 = 0;

// 将第一种搭配当参数传入

int count = c_c(amount-get_coin(1),0,1,1,0,0,0,0);

printf("执行了%lld次",cn2);

return count;

}

然后进行测试

printf("count: %d",getCountChange(100));

控制台打印:

->] 执行了1554次

->] count: 292

其实我也把递归法实现了,有对比才好说话,递归法 (getCountChange2) 的测试如下:

printf("count: %d",getCountChange2(100));

控制台打印:

->] 执行了15499次

->] count: 292

看来差距还不是非常的大(10倍),那增加到500试试...

printf("迭代法count: %d",getCountChange3(500));

printf("递归法count: %d",getCountChange2(500));

控制台打印:

->] 迭代法执行了346730次

->] 迭代法count: 59576

->] 递归法执行了12822611次

->] 递归法count: 59576

恩,看来差距还是有的。

细心的朋友可能发现了,这个迭代法用的是getCountChange3,这个并不是bug。其实当我用getCountChange方法时,总钱数到200多的时候,Xcode就阻止继续运行了,此时是迭代了3万次左右,这个原因我不太懂,可能是会检查函数的最大循环调用次数。所以我写了个getCountChange3方法,方法里面用一个循环代替了函数递归,其他方式都不变,以此模拟函数迭代法。

看来没什么问题了,可以用scheme测试了。代码如下:

(define (get-coin index)

(cond ((= index 1) 50)

((= index 2) 25)

((= index 3) 10)

((= index 4) 5)

((= index 5) 1)))

(define (c-c leftAmount count cursor c1 c2 c3 c4 c5)

(cond ((or (= leftAmount 0) (< leftAmount 0)) ; 当剩余的钱小于或等于0的时候

(cond ((or (= cursor 4)

(and (= cursor 5) (> c4 0))) ;如果cursor=4,或者 cursor=5且c4>0

(c-c (- (+ leftAmount

(get-coin 4))

(get-coin 5)) ;leftAmount将第4种硬币钱数加回来一个,且c4减1,紧接着加上第5种硬币钱数,且c5加1

(if (< leftAmount 0)

count

(+ count 1)) ;如果leftAmount=0,说明正好分完一次,所以count加1;反之,count不变

5 ;将游标指向第五种硬币

c1

c2

c3

(- c4 1) ;c4减1

(+ c5 1))) ;c5加1,不清零是因为c5是最后一种硬币,无需从零开始计算

((or (= cursor 3)

(and (= cursor 5) (= c4 0) (> c3 0)))

(c-c (- (+ leftAmount

(get-coin 3)

(* c5 (get-coin 5)));此时c5需要清零,所以把第五种硬币的钱数都加回来

(get-coin 4))

(if (< leftAmount 0)

count

(+ count 1))

4

c1

c2

(- c3 1)

(+ c4 1)

0))

((or (= cursor 2)

(and (= cursor 5) (= c4 0) (= c3 0) (> c2 0)))

(c-c (- (+ leftAmount

(get-coin 2)

(* c4 (get-coin 4))

(* c5 (get-coin 5)))

(get-coin 3))

(if (< leftAmount 0)

count

(+ count 1))

3

c1

(- c2 1)

(+ c3 1)

0 ;清零原理同上

0))

((or (= cursor 1)

(and (= cursor 5) (= c4 0) (= c3 0) (= c2 0) (> c1 0)))

(c-c (- (+ leftAmount

(get-coin 1)

(* c3 (get-coin 3))

(* c4 (get-coin 4))

(* c5 (get-coin 5)))

(get-coin 2))

(if (< leftAmount 0)

count

(+ count 1))

2

(- c1 1)

(+ c2 1)

0

0

0))

(else (if (< leftAmount 0); 此时c4=0,c3=0,c2=0,c1=0,所以全部遍历完毕,结束。

count

(+ count 1)))))

(else (c-c (- leftAmount

(get-coin cursor));此时如果leftAmount>0,继续将游标指向的硬币数量加1

count

cursor

(if (= cursor 1)

(+ c1 1)

c1)

(if (= cursor 2)

(+ c2 1)

c2)

(if (= cursor 3)

(+ c3 1)

c3)

(if (= cursor 4)

(+ c4 1)

c4)

(if (= cursor 5)

(+ c5 1)

c5)))))

(define (count-change2 amount)

(c-c (- amount

(get-coin 1))

0

1

1

0

0

0

0))

进行测试

1]=> (count-change2 100)

;Value: 292

为了与递归法对比,我分别执行了钱数为1000的情况,迭代法也是过了20秒左右才出结果(801451种),但是递归法至今还没有算出来(也许是我等的时间太短了?)...

优化

仔细研究研究这个算法,还是有优化的余地的,比如当游标处的币种数量减1时,下一币种数量不是加1,而是直接加上当前剩余钱数(因为剩余钱数有可能小于0)与此游标处币种的币值与需要清零的所有币值的和,然后再除以游标下一币种的币值最后得到的商,这样可以减少迭代次数。而且按照这个思路,第一次传入参数的时候,也直接把第一种硬币的数量设置为总钱数与第一种币值的商,也减少了一些迭代次数(总钱数越大,减少的也越多,不过这个第一次的计算在计算机面前可以忽略的,只是为了追求完美)。

这个方法我试了,对于1000这个总钱数,从递归法的333082021次调用,到上述迭代法的4732860次调用,最后到这个优化迭代法的801871调用(总换法是801451,个人觉得对于这种遍历的算法,真是优化到了极致),每一次都是一个数量级的提升。具体优化后的代码就不贴了,看了太多代码也会烦吧。

总结

虽然最后代码看起来很长,但思路还算清晰,由于自认为挑战成功吧,还是有点小高兴的,也算是给了自己一个比较满意的答案。

学习的过程比较漫长,情绪也是跌宕起伏,但我相信结果是美的,继续往下学吧...

文章在Github上同步更新

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_29169505/article/details/117051262

智能推荐

VITIS2021编译petalinux启动文件启动linaro系统_vitis petalinux-程序员宅基地

文章浏览阅读281次。• 对于内核,请选择 “linux-kernel () --->”,然后选择 “(X)ext-local-src”。对于 U-Boot,请选择“u-boot () --->”,然后选择“(X)ext-local-src”。选择“External u-boot local source settings --->”。如果想在Petalinux编译完成后保留Kernel和Uboot源码,则需要在project-spec/meta-user/conf/petalinuxbsp.conf里,添加如下内容,_vitis petalinux

Oracle 10046 SQL TRACE-程序员宅基地

文章浏览阅读165次。为什么我们要使用10046 trace? 10046 trace帮助我们解析 一条/多条SQL、PL/SQL语句的运行状态 ,这些状态包括 :Parse/Fetch/Execute三个阶段中遇到的等待事件、消耗的物理和逻辑读、CPU时间、执行计划等等。即10046 为我们揭示了 一条/多条SQL 的运行情况, 对于 以点入手的 SQL调优是很好的辅助工具,特别是在 10g之前没有A..._oracle 10046 trace 指标

servlet3.1规范: 第4章 Servlet上下文(ServletContext)_不允许从未在web.xml,web-fragment.xml文件中定义或未用@weblistener-程序员宅基地

文章浏览阅读2.5k次。转载: Servlet规范Servlet上下文4.1 ServletContext接口介绍ServletContext(Servlet上下文)接口定义了servlet运行在的Web应用的视图。容器供应商负责提供Servlet容器的ServletContext接口的实现。Servlet可以使用ServletContext对象记录事件,获取URL引用的资源,存取当前上下文的其他Servlet可以访问的属_不允许从未在web.xml,web-fragment.xml文件中定义或未用@weblistener注释的servle

使用Taro 实现的小程序商城的购物车功能模块_react+trao小程序商城-程序员宅基地

文章浏览阅读1.4k次,点赞2次,收藏10次。Taro是一套遵循React语法规范的多端开发解决方案。现如今市面上端的形态多种多样,Web、React-Native、微信小程序等各种端大行其道,当业务要求同时在不同的端都要求有所表现的时候,针对不同的端去编写多套代码的成本显然非常高,这时候只编写一套代码就能够适配到多端的能力就显得极为需要。使用Taro,我们可以只书写一套代码,再通过Taro的编译工具,将源代码分别编译..._react+trao小程序商城

数据库操作语言的DDL,DML,DCL,DQL分别是什么_数据库ddl dml dcl dql称为什么-程序员宅基地

文章浏览阅读433次,点赞10次,收藏12次。除了 GRANT 和 REVOKE 之外,其他的创建、更新或者删除用户的操作也会导致事务隐式提交。2、DML (Data Manipulation Language)是用来操作数据库中的数据的语言,如插入、更新、删除数据等,在DML中支持事务的提交和回滚操作。3、DDL (Data Definition Language)是用来定义数据库结构的语言,如创建表、修改表结构等。4、DCL (Data Control Language)是用来控制数据库访问权限的语言,如赋予、收回用户权限等。_数据库ddl dml dcl dql称为什么

WebService apache cxf wsdl 生成客户端代码_cxf生成客户端代码 utf-8-程序员宅基地

文章浏览阅读4.1k次。使用WebService时,我们使用WSDL文档来生成客户端代码,调用服务器端接口。具体操作步骤:1.下载apache cxf2.解压进入apache-cxf-3.0.10\bin\目录3.使用wsdl2java wsdl2java -encoding utf-8 -p com.jeiao.boss -impl http://127.0.0.1:9999/boss/_cxf生成客户端代码 utf-8

随便推点

50个很棒的免费工具和资源,总有一款适合您!_picsum.photo-程序员宅基地

文章浏览阅读775次,点赞2次,收藏3次。免费的东西总是令人兴奋的。但是,如果它是免费且很棒的呢?让我们开始吧!1. tablericons免费和开源的图标设计与关注细节,使您的设计脱颖而出。Link: tablericons.com2. removebg删除背景,适合做证件照,比自己Ps效果好多了。Link: remove.bg3. devresource为开发人员提供的协作资源列表,以精心策划的类别呈现。Link: devresourc.es4. Blender免费和开放的3D创作软件。Link: blender.or_picsum.photo

「WebPack」WebPack 模块化问题_为什么webpack的版本和项目中不同-程序员宅基地

文章浏览阅读204次。如何在浏览器端实现模块化文章目录如何在浏览器端实现模块化课程简介浏览器端的模块化根本原因解决办法常见的构建工具webpack的安装和使用webpack简介webpack的安装使用模块化兼容性同模块化标准不同模块化标准最佳实践课程简介本门课需要的前置知识:ES6、模块化、包管理器、git本门课的讲解特点:合适的深度:webpack使用层面很简单,但原理层面非常复杂合适的广度:webpa..._为什么webpack的版本和项目中不同

KCF目标追踪_kcf跟踪初始化帧需要手动点选吗-程序员宅基地

文章浏览阅读2.4k次,点赞3次,收藏38次。KCF创新点:使用核相关滤波器训练一个判别式分类器,使用轮转矩阵生成负样本去训练分类器。在KCF中,作者将目标跟踪问题的求解转换为一个分类问题(前景目标和背景)。这个分类问题的求解应用了岭回归方法,所得到的分类器中包含了矩阵的逆运算,其运算量复杂,严重影响了实时性。KCF在分类器的计算中引入了循环矩阵,巧妙地规避了矩阵的逆运算,大大减少了分类器的运算量。高斯核函数引入可以将非线性问题转换为高维空..._kcf跟踪初始化帧需要手动点选吗

Python中的AI库有哪些?_python aigc相关库-程序员宅基地

文章浏览阅读413次。这只是一小部分Python中可用的AI库。根据具体需求,还有其他许多库可供选择。Python中有许多用于人工智能开发的流行库。_python aigc相关库

悬赏任务源码|悬赏任务app源码PHP可二开_任务悬赏源码-程序员宅基地

文章浏览阅读1k次,点赞17次,收藏17次。随着专有的光传输网络变得过时,悬赏任务源码经过优化,可以通过混合光纤同轴电缆(HFC)和开放的、无处不在的全数字以太网连接来促进现有视频和数据服务的传输。悬赏任务源码的前景非常广阔。悬赏任务源码能够为这个市场提供一个完整的解决方案,促进悬赏任务平台的发展和壮大。悬赏任务源码提供了一个完整的平台架构,包括用户管理、任务发布、任务接受、资金交易等功能,为创业者提供了一个快速、方便的搭建悬赏任务平台的方式。悬赏任务源码可以提供灵活的收益模式设置,帮助平台运营者实现盈利,进一步提升悬赏任务平台的吸引力和竞争力。_任务悬赏源码

Ubuntu 12.04上享用新版本Linux的功能_docker 3.13.0-105-generic-程序员宅基地

文章浏览阅读820次。Ubuntu 12.04上享用新版本Linux的功能我司有一批Ubuntu 12.04的服务器暂时没有升级计划,但是像编译Android N代码等需求要求Linux的版本更新。 如何在不升级Ubuntu 12.04的情况下实现升级Linux版本的需求呢?我们有两大利器可以使用:docker和虚拟机。Docker大法Docker安装升级内核Docker需要64位的Linux支持,幸好,这条是满足的。_docker 3.13.0-105-generic