递归算法.docx
- 文档编号:30157071
- 上传时间:2023-08-05
- 格式:DOCX
- 页数:22
- 大小:26.65KB
递归算法.docx
《递归算法.docx》由会员分享,可在线阅读,更多相关《递归算法.docx(22页珍藏版)》请在冰豆网上搜索。
递归算法
前言
说白了递归就象我们讲的那个故事:
山上有座庙,庙里有个老和尚,老和尚在讲故事,它讲的故事是:
山上有座庙,庙里有个老和尚,老和尚在讲故事,它讲的故事是:
……也就是直接或间接地调用了其自身。
就象上面的故事那样,故事中包含了故事本身。
因为对自身进行调用,所以需对程序段进行包装,也就出现了函数。
函数的利用是对数学上函数定义的推广,函数的正确运用有利于简化程序,也能使某些问题得到迅速实现。
对于代码中功能性较强的、重复执行的或经常要用到的部分,将其功能加以集成,通过一个名称和相应的参数来完成,这就是函数或子程序,使用时只需对其名字进行简单调用就能来完成特定功能。
例如我们把上面的讲故事的过程包装成一个函数,就会得到:
voidStory()
{
puts("从前有座山,山里有座庙,庙里有个老和尚,老和尚在讲故事,它讲的故事是:
");
getchar();//按任意键听下一个故事的内容
Story();//老和尚讲的故事,实际上就是上面那个故事
}
函数的功能是输出这个故事的内容,等用户按任意键后,重复的输出这段内容。
我们发现由于每个故事都是相同的,所以出现导致死循环的迂回逻辑,故事将不停的讲下去。
出现死循环的程序是一个不健全的程序,我们希望程序在满足某种条件以后能够停下来,正如我们听了几遍相同的故事后会大叫:
“够了!
”。
于是我们可以得到下面的程序:
#include
constintMAX=3;
voidStory(intn);//讲故事
intmain(void)
{
Story(0);
getchar();
return0;
}
voidStory(intn)
{
if(n { puts("从前有座山,山里有座庙,庙里有个老和尚,老和尚对小和尚说了一个故事: "); getchar(); Story(n+1); } else { printf("都讲%d遍了! 你烦不烦哪? \n",n); return; } } 上面的Story函数设计了一个参数n,用来表示函数被重复的次数,当重复次数达到人们忍受的极限(MAX次)时,便停下来。 基本递归 数学归纳法表明,如果我们知道某个论点对最小的情形成立,并且可以证明一个情形暗示着另一个情形,那么我们就知道该论点对所有情形都成立。 数学有时是按递归方式定义的。 例1: 假设S(n)是前n个整数的和,那么S (1)=1,并且我们可以将S(n)写成S(n)=S(n-1)+n。 根据递归公式,我们可以得到对应的递归函数: intS(intn)//求前n个整数的和 { if(n==1) return1; else returnS(n-1)+n; } 函数由递归公式得到,应该是好理解的,要想求出S(n),得先求出S(n-1),递归终止的条件(递归出口)是(n==1)。 再举一个典型的例子: 斐波那契(Fibonacci)数列。 例2: 斐波那契数列为: 1、1、2、3、5、8、13、21、…,即fib (1)=1;fib (2)=1;fib(n)=fib(n-1)+fib(n-2)(当n>2时)。 我们曾经用迭代法解决了这个问题,实际上数列公式本身是一个递归公式,如果用递归算法来解将更自然。 根据递归公式,很容易递归函数: intFib(intn) { if(n<1)//预防错误 return0; if(n==1||n==2)//递归出口 return1; returnFib(n-1)+Fib(n-2); } 注意: 尽管Fib似乎是在调用自身,但实际上它在调用自身的一个副本。 该副本是具有不同参数的另一个函数。 任何时候只有一个副本是活动的,其余的都将被挂起。 递归实现要求进行某种簿记工作来跟踪挂起的递归调用,如果递归调用链非常长,计算机就会耗尽内存。 实际在上例中,当n=40时,程序将变得缓慢,当n再大一些,内存就不够了。 不用说,这种特例不能说明递归调用是最佳的调用,因为问题如此简单,不用递归也能解决。 大多数恰当使用递归的地方不会耗尽计算机内存,只是比非递归耗费稍多时间。 但是,递归一般可以得到更紧凑的代码。 在实际编程中,有许多定义或者问题本身就具有递归性质。 所以我们顺其自然就想到用递归来解决,这样不仅代码少,而且结构清晰。 但是问题是我们应该怎样设计递归呢? 这确实一个问题。 由于许多问题并不是很明显的表现出递归的关系,所有很大一部分需要我们进行推导,从而得出递归关系,有了递归关系,编写代码就相对的比较简单了。 首先,我们了解递归算法的特点,所谓的递归,就是把一个不能或不好直接求解的“大问题”转化成一个或几个与原问题相似的“小问题”来解决,再把这些“小问题”进一步分解成更小的“小问题”来解决,如此分解,直至每个“小问题”都可以直接解决(此时分解到递归出口)。 在逐步求解“小问题”后,再返回得到“大问题”的解。 因此,递归的执行过程由分解和求值两部分构成。 首先是逐步把“大问题”分解成形式相同但规模减小的“小问题”,直至分解到递归出口。 一旦遇到递归出口,分解过程结束,开始求值过程,所以分解过程是“量变”过程,即原来的“”大问题”在慢慢变小,但尚未解决。 遇到递归出口后,便发生了“质变”,即原递归问题转换成直接问题。 由于递归只需要少量的步骤就可描述解题过程中所需要的多次重复计算,所以大大的减少了代码量。 递归算法设计的关键在于,找出递归方程和递归终止条件(又叫边界条件或递归出口)。 递归关系就是使问题向边界条件转化的过程,所以递归关系必须能使问题越来越简单,规模越小。 一定要知道,没有设定边界的递归是只能‘递’不能‘归’的,即死循环。 因此,递归算法设计,通常有以下3个步骤: 1.分析问题,得出递归关系。 2.设置边界条件,控制递归。 3.设计函数,确定参数。 我们来看一个简单的应用。 例3: 楼梯有n阶台阶,上楼可以一步上1阶,也可以一步上2阶,编一程序计算共有多少种不同的走法。 例如,当n=3时,共有3种走法,即1+1+1,1+2,2+1。 算法分析: 设n阶台阶的走法数为f(n),显然有: 1n=1 f(n)={2n=2 f(n-1)+f(n-2)n>2 得到相应的函数如下: intF(intn) { if(n==1||n==2) returnn; returnF(n-1)+F(n-2); } 例4: 整数划分的问题: 对于一个整数n的划分,就是把n表示成一系列的正整数的和的表达式,注意划分与次序无关. 例如,6可以可以划分为: 6; 5+1; 4+2,4+1+1; 3+3,3+2+1,3+1+1+1; 2+2+2,2+2+1+1,2+1+1+1+1; 1+1+1+1+1+1 现在问题是,给一个n求他的所有划分. 这个问题初看,很难找出大规模问题与小规模问题之间的关系,我们注意了,对于上面的第一行,所有加数不超过6,第2行,所有加数不超过5,.....第6行所有加数不超过1.因此,我们可以定义一个q(n,m)的函数,表示n所有加数不超过m的划分数目.所以n的划分总数目可以表示为q(n,n).那我们怎样才能把找出q(n,n)的递归关系呢? 很显然,我们可以立即得到以下关系, q(n,n)=q(n,n-1)+1; 所以问题规模变小,但是我们很不能根据这个关系转化为更小的问题,所以我们主要考虑这种情况: q(n,m)(其中m 我们尝试的把q(n,m)变为q(n,m-1);我们惊奇的发现,只要把q(n,m-1)加上包含加数m的项就等于q(n,n),即q(n,m)=q(n,m-1)+包含m加数的表达式数。 例如m=4,我们可以把q(n,4)=q(n,3)+2(包含4加数的表达使有两个: 4+2,4+1+1)。 而我们发现,包含4的表达可以转化为q(n-4,4)(想想? ),所以的递归关系式就出来了: q(n,m)=q(n,m-1)+q(n-m,m); 接下来就是找边界条件了,我们知道当n=1时,q(n,n)=1;当m=1时,q(n,m) =1;有了边界条件,我们递归基本上完成了.得到递归公式: 1,n=1,m=1; q(n,m)={q(n,n),n 1+q(n,n-1),n=m; q(n,m-1)+q(n-m,m),n>m>1. 编写代码如下: #include intF(intn,intm); intmain() { inti; for(i=1;i<21;i++) printf("%d-%d\n",i,F(i,i)); getchar(); return0; } intF(intn,intm) { if(n==1||m==1)//递归出口 return1; if(n==m) returnF(n,n-1)+1; elseif(n returnF(n,n); else returnF(n,m-1)+F(n-m,m); } 例5: Hanoi塔问题: 设A,B,C是3个塔座。 开始时,在塔座A上有一叠共n个圆盘,这些圆盘自上而下,由小到大地叠在一起。 各圆盘从小到大编号为1,2,…,n,现要求将塔座A上的这一叠圆盘移到塔座C上,并仍按同样顺序叠置。 在移动圆盘时应遵守以下移动规则: 1.每次只能移动1个圆盘; 2.任何时刻都不允许将较大的圆盘压在较小的圆盘之上; 3.在满足移动规则1和2的前提下,可将圆盘移至A,B,C中任一塔座上. 算法分析: 这是一个典型的适合用递归算法来解决的问题。 试想要把n个盘子从柱A移到柱C上,则必须先把上面n-1个盘子从柱A全部移到柱B,然后把第n个盘子由柱A移到柱C,再把柱B上的n-1个盘子全部移到柱C。 这样就把移动n个盘子的问题变成了移动n-1个盘子的问题,如此不断减小递归的规模,直到递归出口。 递归的边界条件是,当n==1时,直接把它由柱A移到柱C。 #include voidHanoi(intn,chara,charb,charc);//移动汉诺塔的主要函数 intmain(void) { intn=4; Hanoi(n,'A','B','C'); getchar(); return0; } voidHanoi(intn,chara,charb,charc)//汉诺塔,把柱A所有的盘子移到柱C { if(n==1)//如果只有一个盘子,直接由柱A移到柱C printf("Movediskfrom%cto%c\n",a,c); else { Hanoi(n-1,a,c,b);//先把上面n-1个盘子从柱A全部移到柱B printf("Movediskfrom%cto%c\n",a,c);//再把第n个盘子由柱A移到柱C Hanoi(n-1,b,a,c);//再把柱B上的n-1个盘子全部移到柱C } } 从上述的例子中我们可以发现,递归算法具有以下三个基本规则: 基本情形: 至少有一种无需递归即可获得解决的情形,也即前面说的边界条件。 进展: 任意递归调用必须向基本情形迈进,即前面所说的使得问题规模变小。 正确性假设: 总是假设递归调用是有效的。 递归调用的有效性是可以用数学归纳法证明的,所以当我们在设计递归函数时,不必设法跟踪可能很长的递归调用途径(比如Hanoi塔问题)。 这种任务可能很麻烦,易于使设计和验证变得更加困难。 所以我们一旦决定使用递归算法,则必须假设递归调用是有效的。 递归和非递归的转换 在第一讲《算法设计之枚举法》中我们有一道练习题: 例6: 构造一个3*3的魔方: 把数字1-9添入如图的表格中 276 951 438 要求每横,竖,斜列之和均相等(如图是一种情况)。 输出所有可能的魔方。 当时我们是使用枚举法解的,通过剪枝等优化后得到一个8重嵌套循环,而且每个循环的结构都是一样的,既繁琐,又复杂。 既然如此,那么我们是否可以用一个递归函数来实现呢? 答案是肯定的。 程序如下: #include #defineMAX9 intIsElement(inta[],intlen,intx); voidF(inta[],intlen); intmain() { inta[MAX]={0}; inti; for(a[0]=1;a[0]<=MAX;a[0]++) { F(a,0); } getchar(); return0; } voidF(inta[],intlen)//以递归代替多重嵌套循环 { inti; if(len { len++; for(a[len]=1;a[len]<=9;a[len]++) { F(a,len); } } elseif(len==MAX-2) { a[8]=45-a[0]-a[1]-a[2]-a[3]-a[4]-a[5]-a[6]-a[7]; if((a[0]+a[1]+a[2])==(a[3]+a[4]+a[5])&&(a[0]+a[1]+a[2])==(a[6]+a[7]+a[8]) &&(a[0]+a[3]+a[6])==(a[1]+a[4]+a[7])&&(a[0]+a[3]+a[6])==(a[2]+a[5]+a[8]) &&(a[0]+a[1]+a[2])==(a[0]+a[4]+a[8])&&(a[0]+a[1]+a[2])==(a[2]+a[4]+a[6])) { for(i=0;i<9;i++) { printf("%5d",a[i]); if((i+1)%3==0) printf("\n"); } printf("\n\n"); } } } intIsElement(inta[],intlen,intx) { inti; for(i=0;i { if(a[i]==x) return0; } return1; } 从本例中我们可以发现,用递归代替多重嵌套循环不仅使程序结构清晰,可读性强,且容易用数学规纳法证明算法的正确性,因此设计算法与调试程序都很方便。 实际上,递归是软件设计中的一种重要的方法和技术.递归函数是通过调用自身来完成与自身要求相同的子问题的求解,编译系统能自动实现调用过程中信息的保存与恢复.在问题的求解方法具有递归特征时,采用递归技术就比不用递归技术简捷得多,且具有较高的开发效率,所设计的程序具有更好的可读性和可维护性.然而,在实际应用中,由于程序设计语言对递归的支持性和程序运行时间方面的原因,在有些情况下要求写出问题的非递归函数.由于许多问题求解程序中的递归函数比非递归函数要容易设计,因此,常常先设计出递归函数,然后将其转换为等价的非递归函数. 要向实现递归和非递归函数的相互转换,我们先要了解递归调用的内部实现原理。 在调用一个函数的过程中出现直接或间接的调用该函数本身,称为函数的递归调用.与每次调用相关的一个重要概念是递归函数运行的“层次”.设调用该递归函数的主函数为第0层,则从主函数调用递归函数为进入第1层;从第i层递归调用本函数为进入“下一层”,即第i+1层.反之,退出第i层递归应返回至“上一层”,即第i-1层.因此,编译系统需设立一个“工作栈”来进行调用函数和被调函数之间的链接和信息交换.设工作栈开始时为空,则递归调用内部实现描述如下: 1)调用时需执行的操作 a.将返回地址、调用层(第i层)中的形参和局部变量的值压人工作栈中(第0层调用不考虑); b.为被调层(第i+1层)准备数据: 计算实在参数的值,并赋予对应的形参; C.转入被调函数执行. 2)函数返回时需执行的操作 a.如果函数有返回值,将返回值保存到一临时变量中; b.从栈顶取出返回地址及各变量、形参的值,并退栈,即恢复调用层(第i-1层)的局部变量和形参; c.按返回地址返回到调用层(第i-1层); d.返回后自动执行如下操作: 如函数有返回值,则从临时变量中取出返回值赋予调用层(第i-1层)相应的局部变量或代人表达式中. 了解了内部实现原理,现在我们来看由递归到非递归的转换规则。 既然编译系统内部是利用“工作栈”这种数据结构来实现递归函数的,因此,递归函数用非递归函数实现时也必然要用“栈”来保存相应的形参和局部变量.通过仔细研究编译系统内部实现递归函数的工作原理,得到转换规则如下: 1)设置一个工作栈,用S表示,并在开始时将其初始化为空. 2)在递归函数的人口处设置一个标号(如设为L0). 3)对递归函数中的每一个递归调用,用以下几个等价操作来替换: a.保留现场: 开辟栈顶存储空间,用于保存调用层中的形参、局部变量的值和返回地址; b.准备数据: 为被调层准备数据,即计算实参的值,并赋予对应的形参; C.转入(递归函数)执行,即gotoL0; d.在返回处设一个标号Li(i一1,2,3,⋯),并根据需要设置以下语句: 如果递归函数有返回值,则增设一个变量保存回传变量返回的值. 4)在返回语句前判断栈空否,如果栈不空,则依次增加如下操作: a.回传数据: 若函数有返回值,增设一个变量,将返回值保存到该变量(称回传变量)中; b.恢复现场: 从栈顶取出返回地址(不妨保存到Lx中)及各变量、形参的值,并退栈; C.返回: 按返回地址返回(即执行gotoX). 5)对其中的非递归调用和返回语句照抄. 6)如果递归程序中只有一处递归调用,则在转换时,返回地址不必入栈. 7)在模拟尾递归调用时,不必执行入栈操作. 注尾递归即递归调用处于递归函数的最后位置,其后面没有其他操作. 用上述规则可将任意的递归函数转换为等价的非递归函数.不过,转换得到的函数的结构一般比较差,因而需要重新调整. 转换规则看上去很复杂,其实我们可以理解得简单些,求解递归问题有两种方式,一种是直接求值,不需要回溯的;另一种是不能直接求值,需要回溯的。 这两种方式在转换成非递归问题时采用的方法也不相同。 前者使用一些中间变量保存中间结果,称之为直接转换法(即转换成迭代算法);后者需要回溯,所以要用栈保存中间结果,称为间接转换法。 下面分别讨论这两种方法。 直接转换法 仍然以斐波那契(Fibonacci)数列为例,前面我们介绍了斐波那契(Fibonacci)数列的递归算法,发现该算法虽然代码短小精悍,可读性强,但是由于做了大量的重复运算,使得效率极为低下,所以应该转换成非递归算法。 斐波那契(Fibonacci)数列的非递归算法即迭代法,我们在第2讲《算法设计之迭代法》中已经做了详细解释,这里不再重复。 我们看一个其他的例子: 例7: 逆序打印数字。 例如考虑如何打印数字1369。 我们首先需要打印9,然后打印6,再打印3,最后打印1。 很明显我们可以把问题看成是先打印1369的个位数字,然后打印136的个位数字,然后打印13的个位数字,最后打印1的个位数字。 一个很典型的递归问题。 #include voidPrintInt(intn); intmain() { intn=138400; PrintInt(n); getchar(); return0; } voidPrintInt(intn) { printf("%d",n%10); if(n>=10) PrintInt(n/10); } 同时我们可以发现,递归函数中使用了尾递归(仅在方法的末尾实行一次递归调用,这样的递归叫尾递归)。 尾递归很容易被循环所替换,下面是使用循环的写法。 voidPrintInt(intn) { while(n>0) { printf("%d",n%10); n/=10; } } 这个例子实在是太简单,下面我们看一个稍微复杂点的例子。 例8: 逆序排列数组。 例如原数组为a[]={1,2,3,4,5},经过逆序排列后变成a[]={5,4,3,2,1}。 经典的逆序排列算法是使用循环。 voidReverse(inta[],intlen) { intl,r; inttemp; for(l=0,r=len-1;l { temp=a[l]; a[l]=a[r]; a[r]=temp; } } 实际上我们可以看到逆置数组的操作是一个从两端到中间,规模逐渐减小的过程,每次交换元素a[i]和a[n-i],动作完全是一样的,所以也可以使用递归算法。 voidReverse(inta[],intlen) { Rev(a,0,len-1); } voidRev(inta[],intleft,intright) { inttemp; if(left { temp=a[left]; a[left]=a[right]; a[right]=temp; Rev(a,left+1,right-1); } } 这里的递归函数是Rev,为了保证同循环算法的接口保持一致,我们把函数Reverse作为Rev的驱动函数,这在比较复杂的递归算法中是很常见的。 间接转换法 前面讲了一个逆序打印数字的例子,现在来看一个与它类似的例子: 例9: 以任意基数打印数字。 例如考虑如何打印数字1369。 我们首先需要打印1,然后打印3,再打印6,最后打印9。 问题是获得首位麻烦,给定数字n,我们需要循环确定n的首位。 与之相反的是末位,利用n%10就可以得到它。 用递归实现这个例程是非常简单的: #include voidPrintInt(intn,intbase); intmain() { intn=100; intbase=2; PrintInt(n,base); getchar(); return0; } voidPrintInt(intn,intbase)//n表示被输出的十进制数,base表示被输出的数的基数,即进制, { if(n>=base)//这里要求base<=10 PrintInt(n/base,base);
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 递归 算法