递规与递推.docx
- 文档编号:23610289
- 上传时间:2023-05-19
- 格式:DOCX
- 页数:24
- 大小:68.63KB
递规与递推.docx
《递规与递推.docx》由会员分享,可在线阅读,更多相关《递规与递推.docx(24页珍藏版)》请在冰豆网上搜索。
递规与递推
递归与递推
第一部分 递归
想必大家都听过这样一个故事,从前有座山,山上有座庙,庙里有个老和尚,老和尚他说从前有座山,山上有座庙,庙里有个老和尚,老和尚他说从前有座山,山上有座庙,庙里有个老和尚,老和尚他说从前有座山,山上有座庙,……。
这个故事没完没了的重复着,直到讲故事的人烦了、累了才会停下来。
这个故事就是一个典型的递归的例子,故事中直接调用了故事本身,从而使得这个故事永无止境的扩展下去。
一、递归的概念
若在一个函数、过程或者数据结构定义的内部,直接(或间接)出现定义本身的应用,则称它们是递归的,或者是递归定义的。
递归是一种强有力的数学工具,它可使问题的描述和求解变得简洁和清晰。
在数学中的很多概念都是用递归的方式定义或描述的,比如阶乘n!
=n*(n-1)!
、斐波那契数列f(n)=f(n-1)+f(n-2)等。
对于一个递归定义而言,除了要定义递归的方式(即如何递归)外,还必须定义递归的终止条件(即如何停止递归),否则递归将永无止境的进行下去,也就没有了实际意义。
比如在老和尚讲故事中,递归的终止条件就是“讲故事的人烦了、累了”;在阶乘的递归定义中,递归的终止条件就是“n=0”;在斐波那契数列的递归定义中,递归的终止条件就是“n=0或n=1”。
因此,阶乘的完整定义为
;斐波那契数列的完整定义为
。
递归是设计和描述算法的一种有力的工具,它在复杂算法的描述中被经常采用。
在pascal语言中,直接递归程序的写法与普通的程序调用没有什么区别,但间接递归的时候,由于正常的定义无法满足“先定义后使用”的原则,因此,在定义的时候需要使用特殊的格式“向前引用”。
procedureb;forward;
procedurea;
begin
b;
end;
procedureb;
begin
a;
end;
递归算法常常比非递归算法更易设计,尤其是当问题本身或所涉及的数据结构是递归定义的时候,使用递归算法特别合适。
那么下面我们就先来看一下递归算法到底适合解决哪些问题。
二、递归算法一般用于解决哪些问题
一般来说,能够用递归解决的问题应该满足以下三个条件:
1.需要解决的问题可以化为一个或多个子问题来求解,而这些子问题的求解方法与原来的问题完全相同,只是在数量规模上不同;
2.递归调用的次数必须是有限的;
3.必须有结束递归的条件(边界条件)来终止递归。
具体而言,主要有以下几种类型的问题:
1.数据的定义形式是按递归定义的。
如求阶乘、斐波那契数列等。
这类问题用递归编写的程序经常可以在程序中出现与递归定义相似的语句。
比如求阶乘的递归代码如下:
functionjc(n:
integer):
longint;
begin
if(n=0)then
jc:
=1
else
jc:
=n*jc(n-1);
end;
其中jc:
=n*jc(n-1)这条语句就与递归定义中的n!
=n*(n-1)!
非常类似。
2.问题的解法按递归算法实现。
如回溯算法。
回溯算法的思想一般都是递归的,在具体书写代码的时候,可以写出递归或非递归两种形式。
这部分内容我们在下面讲回溯的时候再详细讨论。
3.数据结构的形式是按递归定义的。
如树的遍历。
我们知道树的定义是递归的,因此我们在写树的遍历算法时,也采用了递归算法,比如二叉树中序遍历的伪代码如下:
proceduresearch(t:
node)
begin
if(t<>nil)then
begin
search(t.left);
visit(t);
search(t.right);
end;
end;
我们可以发现,用递归算法编写的代码十分的简洁、可读性好。
那么,下面我们就来学习如何用递归算法分析问题、解决问题。
但在之前,我们有必要先来了解一下,递归算法在计算机中是如何被执行的,有哪些特别的地方。
三、递归算法在计算机中的执行过程
首先,我们来看一段求n!
的递归程序的执行过程。
(PPT)
从这段演示中我们可以看出,递归程序在执行时分为递进和回归两个阶段。
在这两个阶段中,系统会分别完成如下的操作:
1.在递归调用之前,系统需完成三件事:
⑴为被调用过程的局部变量分配存储区;
⑵将所有的实参、返回地址等信息传递给被调用过程保存;
⑶将控制转移到被调过程的入口。
2.从被调用过程返回调用过程之前,系统也应完成三件工作:
⑴保存被调过程的计算结果;
⑵释放被调过程的数据区;
⑶依照被调过程保存的返回地址将控制转移到调用过程。
在计算机中,是通过使用系统栈来完成上述操作的。
一般来说,系统栈的元素类型会包括值参、局部变量和返回地址。
在每次执行递归调用之前,系统自动把本程序所使用到的值参和局部变量的当前值以及调用后的返回地址压栈(保存现场);当每次递归调用结束之后,系统又自动把栈顶元素出栈,覆盖掉相应的值参和局部变量,使他们恢复到递归调用之前的值,然后程序无条件的转向由返回地址所指定的位置继续执行。
我们来看一下在递归执行过程中,系统栈以及局部变量、值参等的变化过程。
我们还是以刚才的程序为例。
(PPT)
由于在递归的执行过程中,值参也是会被保存到系统栈的,因此,值参可以被用来描述当前递归的状态,这一点希望同学们在学习的过程中好好体会。
四、应用举例
(一)汉诺塔问题
【问题描述】
如图所示,我们有三根柱子(A、B、C)和若干大小各异的圆盘,一开始的时候,所有圆盘都在A柱上,且按照从小到大的顺序排列整齐(小的在上,大的在下),现在请你把所有的圆盘从A柱搬到C柱上去。
在搬动的时候,一次只能将某柱最顶端的一个圆盘移动到另一柱的最顶端,且在移动的过程中,永远不允许出现大圆盘在小圆盘之上的情况。
【输入数据】
输入文件仅一行,包含一个自然数n(1≤n≤10),即圆盘的个数。
【输出数据】
输出文件包含若干行,每行都是一个形如“X->Y”的字符串,表示一次移动操作,即将X柱顶端的盘子移动到Y柱顶端。
所有移动操作按先后顺序依次排列。
【样例】
输入:
3
输出:
A->C
A->B
C->B
A->C
B->A
B->C
A->C
【分析】
这是一道经典的递归算法的例题,但光从题目的描述来看,好像并不能找到递归形式定义。
既然这样,我们使用最笨的办法,先来模拟一些小数据,看看能不能发现一些规律。
首先,我们考虑一个盘子的情况。
当只有一个盘子的时候,非常简单,我们只要直接把A柱上的那个盘子直接移动到C柱就可以了。
也就是只有一步操作:
A->C。
再加一个盘子,这时候就有了两个盘子了,我们很快应该就能发现,两个盘子移动也非常简单,只要先把A柱顶端的盘子移动到B柱,然后再把A柱上剩下的那个盘子移动到C柱,最后把B柱上的盘子移动到C柱即可。
即总共需要三步操作:
A->B,A->C,B->C。
再加一个盘子,变成三个盘子。
这时候我们会发现,如果要快速的找出移动步骤就不是那么容易了。
那么,该怎么办呢?
我们是否可以通过移动一个、两个盘子的情况来找出移动三个盘子的方法呢?
这时候我们采用这样的思考方式,把三个盘子中的上面两个盘子看成是一个整体,这样的话,就又回到移动两个盘子的情况了,于是,我们只需要三步移动就可以了。
但这时候我们又发现,第一步和第三步实际上移动的不是一个盘子,而是两个,那么怎样来移动这两个盘子呢?
很简单啊,我们刚才已经讲过了两个盘子如何从A->C,而我们现在第一步是需要将两个盘子从A->B,第三步是需要将两个盘子从B->C,那移动的思路应该完全是一样的,只要修改相应的字母即可,即第一步可以被拆分为A->C,A->B,C->B,第三步可以被拆分为B->A,B->C,A->C。
因此我们可以得到完整的移动步骤:
A->C,A->B,C->B,A->C,B->A,B->C,A->C。
再加一个盘子,变成四个盘子。
这时,我们思考的方法还是一样,把四个盘子分成上三下一两部分,然后按两个盘子的方法移动,然后再考虑其中第一步和第三步如何移动三个盘子。
而移动三个盘子的方法我们上面已经讨论过了,因此我们也就很容易得到四个盘子的移动步骤。
依此类推,如果要移动n个盘子,那么我们就需要把这n个盘子分成两个部分,前n-1盘子和第n个盘子,然后按移动两个盘子的方法进行移动。
然后对第一步和第三步进行细化,而细化的过程与移动n个盘子的思路是完全一致的,只是移动的起始柱、目标柱不同。
因此,我们可以得到汉诺塔问题的递归描述:
当只移动一个盘子的时候,直接从起始柱移动到目标柱,否则,执行如下操作;
1.将前n-1盘子以相同的方法从起始柱移动到临时柱;
2.将第n个盘子直接移动到目标柱;
3.将前n-1个盘子以相同的方法从临时柱移动到目标柱;
那么如何来实现这个算法呢?
从上面的分析我们可以看到,对于每一次移动来说,我们都必须知道我们要移动几个盘子以及从起始柱和目标柱。
因此,这三个数据应该做为每一次移动的值参传递给递归过程。
于是,我们可以形成下面的代码。
var
n:
integer;
procedurehanoi(n:
integer;a,b,c:
char);//n要移动的盘子数目,a:
起始柱,c:
目标柱,b:
临时柱
begin
if(n=1)then
writeln(a,'->',c)//直接将起始柱顶端的盘子移动到目标柱
else
begin
hanoi(n-1,a,c,b);//递归移动前n-1个盘子从起始柱移动到临时柱
writeln(a,'->',c);//直接将最后一个盘子从起始柱移动到目标柱
hanoi(n-1,b,a,c);//递归移动前n-1个盘子从临时柱移动到目标柱
end;
end;
begin
read(n);
hanoi(n,'A','B','C');//将n个盘子从A柱移动到C柱
end.
(二)斐波那契数列
【问题描述】
有一种兔子,出生后一个月就可以长大,然后再过一个月一对长大的兔子就可以生育一对小兔子且以后每个月都能生育一对。
现在,我们有一对刚出生的这种兔子,那么,n个月过后,我们会有多少对兔子呢?
假设所有的兔子都不会死亡。
【输入数据】
输入文件仅一行,包含一个自然数n。
【输出数据】
输出文件仅一行,包含一个自然数,即n个月后兔子的对数。
【样例】
输入
5
输出
5
【分析】
同样,从问题描述中我们还是不能发现其中的递归定义,所以,我们还是来模拟一下兔子数目的变化情况(PPT)。
从上面的分析可以看到,第n个月后兔子的数目有两部分构成:
1、上月的所有兔子;2、上月的老兔子所生的小兔子。
因此,若我们记第n个月的兔子总数为F(n)、老兔子数为G(n)的话,那么F(n)=F(n-1)+G(n-1)。
那么G(n)怎么得到呢?
通过分析上图,我们可以发现,第n个月的老兔子数等于第n-1个月的兔子总数,即G(n)=F(n-1)。
因此,F(n)=F(n-1)+G(n-1)=F(n-1)+F(n-2)。
这样,我们就找到了兔子问题的递归定义。
那么递归结束条件呢?
还是从图上我们可以看到,第一个月后和第二个月后都是只有一只兔子,因此递归的结束条件就是n=1或n=2。
我们就可以写出如下的递归代码。
var
n:
integer;
functionF(k:
integer):
longint;
begin
if(k=1)or(k=2)then
F:
=1
else
F:
=F(k-1)+F(k-2)
end;
begin
read(n);
writeln(F(n));
end.
(三)N皇后问题
【问题描述】
在一个n×n的棋盘上放置n个国际象棋中的皇后,要求所有的皇后直接都不形成攻击。
请你给出所有可能的排布方案总数。
【输入数据】
输入文件仅一行,包含一个自然数n(1≤n≤10),即棋盘大小(同时也是皇后的个数)。
【输出数据】
输出文件仅一行,包含一个整数,即所有可行方案的总数。
【样例】
输入
4
输出
2
【分析】
对于n皇后问题,并没有现成的公式可以套用,最直接的方法就是枚举所有的皇后放置方法,然后从中找出所有可行的方案。
但在n×n的棋盘上放n个棋子的所有放置方法有
种,而这个数字是非常庞大的,n=3时为504,n=4时为16380,n=8时为178********7760。
要枚举这样庞大的数据在时间上肯定时无法实现的,因此必须首先想方法将枚举的数量级降下来。
这时我们可以利用皇后攻击的特性,由于皇后攻击范围呈“米”字型,即所有皇后都不能同行、同列和同对角线。
因此,如果我们考虑所有皇后都不同行、不同列的话,那么总的放置方法就只有n!
种,n=8时为40320。
这样的数量级进行枚举就可行多了。
我们只要能枚举出这n!
种排法,然后找出其中所有皇后都不共对角线的排法即可。
那么如何进行枚举呢?
既然所有的皇后都不能同行或同列,那么不妨我们先人为规定第k个皇后在第k行,这样的话我们只要给出每个皇后所处的列号就可以描述皇后的位置了。
比如四皇后的时候,两种可行的排布就可以被表示为2、4、1、3和3、1、4、2。
不难发现,这n!
种排布正好就是1~n的全排列。
于是,我们只需要能求1~n的全排列即可。
要求全排列,我们可以采用这样的思路,假设当前生成全排列中第k个数,如果k>n,则说明生成了一个全排列,否则执行下面的操作:
1.从1~n依次尝试,如果此数字前面已经出现,则取下一个数字,直到找到第一个没有出现的数字;
2.把这个数字标记为已出现,然后以同样的方法生成全排列中的下一个数字;
3.把这个数字标记为未出现。
因此我们可以得到此算法的递归伪代码:
proceduresearch(k:
integer);
begin
ifk>nthen
得到一组全排列
else
fori:
=1tondo
if(i未出现过)then
begin
标记i已出现;
记录第k个数是i;
search(k+1);
标记i未出现;
end
end;
要记录i是否出现,可以使用集合或者一维数组(哈希表)来实现。
于是我们可以得到下面的代码(用一维数组实现):
begin
ifk>nthen
得到一组全排列
else
fori:
=1tondo
if(hash[i]=0)then//hash[i]=0代表i未被使用,hash[i]=1代码i已被使用
begin
hash[i]:
=1;//标记i已被使用
result[k]:
=i;//记录下第k个数字是i
search(k+1);//递归搜索下一个数组
hash[i]:
=0;//标记i未被使用
end
end;
当得到一个全排列后,我们只要检查一下是否满足所有的皇后都不共对角线即可。
我们知道,判断是否共对角线只要判断一下两个位置行、列差的绝对值是否相等即可。
由于具有对称性,因此为了提高检查的效率,我们假设i>j。
我们可以得到如下的检测过程:
functioncheck:
boolean;
var
i,j:
integer;
begin
check:
=true;
fori:
=2tondo
forj:
=1toi-1do
if(i-j)=abs(result[i]-result[j])then
begin
check:
=false;
exit;
end;
end;
于是我们可以得到求n皇后问题的完整的代码。
var
n,count:
integer;
hash,result:
array[1..10]ofinteger;
functioncheck:
boolean;
var
i,j:
integer;
begin
check:
=true;
fori:
=2tondo
forj:
=1toi-1do
if(i-j)=abs(result[i]-result[j])then
begin
check:
=false;
exit;
end;
end;
proceduresearch(k:
integer);
var
i:
integer;
begin
ifk>nthen
begin
ifcheck()theninc(count);
end
else
fori:
=1tondo
if(hash[i]=0)then
begin
hash[i]:
=1;
result[k]:
=i;
search(k+1);
hash[i]:
=0;
end
end;
begin
read(n);
fillchar(hash,sizeof(hash),0);
count:
=0;
search
(1);
writeln(count);
end.
【思考】
我们现在是先枚举出所有的n!
中情况,然后逐一检测是否符合要求,当n较大的时候还是会耗费较多的时间,那么是否可以枚举更少的情况呢?
这个问题请同学们先思考一下。
五、递归算法的优缺点
通过上面几个例子,我们可以看出,对于递归算法来说,其优点主要有:
1、能用有限的语句来定义对象的无限集合;
2、简化某些复杂问题的处理过程;
3、代码的可读性较好。
但随着数据规模的增大,递归算法也体现出其缺点:
1、系统开销大,程序的运行效率降低
我们在前面已经分析过递归执行的过程,我们知道在进入递归调用和离开递归调用的时候,系统会自动完成一系列的操作,而这些操作都会占用一定的时间和空间,从而降低系统运行效率。
2、递归的深度受到系统栈空间的限制
在早期的DOS系统中,用户程序可以使用的内存空间只有640KB,而其中程序代码、常量、变量和系统堆栈各64KB,这样,这就限制了递归的深度。
虽然现在FP下我们可以不再受64KB的限制,但也不是说我们就可以无限制的递归下去,默认情况下,FP编译的时候设定的系统栈大小为128KB。
为了充分发挥递归算法的优势,同时尽量减少其缺点带来的影响,在实际编写程序的过程中,我们经常会采用用递归的思路分析问题,用非递归的方法来编写代码。
那么如何将递归代码转为非递归代码呢?
六、递归算法转非递归算法
递归算法转为非递归算法一般有三种常用的手段:
1.递归转化为递推:
当递归算法所涉及的数据定义形式是递归的情况下,通常可以将递归算法转化为递推算法,用递归的边界条件做为递推的边界条件。
比如求阶乘、斐波那契数列等。
阶乘算法的递推代码如下:
s:
=1;
fori:
=1tondo
s:
=s*i;
writeln(s);
斐波那契数列的递推代码如下:
a:
=1;b:
=1;
fori:
=3tondo
begin
c:
=a+b;
a:
=b;
b:
=c;
end;
writeln(c);
2.递归转化为回溯:
对于可以用回溯算法解决的问题,也可以用非递归的回溯来实现。
如n皇后问题。
具体讲解参考回溯部分。
3.手工模拟递归:
对于一般的递归算法,可以通过自己建立一个堆栈,然后模拟递归执行的过程,手工的将必要的参数入栈、出栈。
设P是一个递归算法,假定P中共有m个值参和局部变量,共有t处递归调用P的语句,则把P改写成一个非递归算法的一般规则为:
1.定义一个栈S,用来保存每次递归调用前值参和局部变量的当前值以及调用后的返回地址。
即S应该含有m+1个域,且S的深度必须足够大,使得递归过程中不会发生栈溢出。
2.定义t+2个语句标号,其中用一个标号标在原算法中的第一条语句上,用另一个标号标在作返回处理的第一条语句上,其余t个标号标在t处递归调用的返回地址,分别标在相应的语句上。
3.把每一个递归调用语句改写成如下形式:
(1)把值参和局部变量的当前值以及调用后的返回地址压入栈;
(2)把值参所对应的实在参数表达式的值赋给值参变量;
(3)无条件转向原算法的第一条语句;
4.在算法结束前增加返回处理,当栈非空时做:
(1)出栈;
(2)把原栈顶中前m个域的值分别赋给各对应的值参和局部变量;
(3)无条件转向由本次返回地址所指定的位置;
5.增设一个同S栈的成分类型(元素)相同的变量,作为进出栈的缓冲变量,对于递归函数,还需要再增设一个保存函数值中间结果的临时变量,用这个变量替换函数体中的所有函数名,待函数结束之前,在把这个变量的值赋给函数名返回。
6.在原算法的第一条语句之前,增加一条把栈置空的语句。
7.对于递归函数而言,若某条赋值语句中包含两处或多处递归调用(假设为n处),则应首先把它拆成n条赋值语句,使得每条赋值语句只包含一处递归调用,同时对增加的n-1条赋值语句,要增设n-1个局部变量,然后按以上六条规则转换成非递归函数。
七、小结与思考
递归算法简单直观,是整个计算机算法和程序设计领域一个非常重要的方面,必须熟练掌握和应用它。
递归算法在计算机中的执行过程比较复杂,需要用系统栈进行频繁的进出栈操作和转移操作。
递归转化为非递归后,可以解决一些空间上不够的问题,但程序太复杂。
所以,并不是一切递归问题都要设计成非递归算法。
实际上,很多稍微复杂一点的问题(比如:
汉诺塔问题、二叉树的遍历、图的遍历、快速排序等),不仅很难写出它们的非递归过程,而且即使写出来也非常累赘和难懂。
在这种情况下,编写出递归算法是最佳选择。
第二部分递推
一、递推的概念
从上面递规的分析过程中,我们可以看到,在某些问题中,相邻两项或多项数字(或状态)之间存在某种关系,可以通过前一项或多项按照某一规律推出其后一项数字(或状态),或者是通过后一项或多项按照某一规律推出其前一项数字(或状态)。
我们可将这种规律归纳成如下递推关系式:
或者
。
已知初始值
,通过递推关系式
求出最终结果
的递推方式称为顺推法;已知最终结果
,通过递推关系式
求出初始值
的递推方式称为倒推法。
由于在递推过程中,一般都是依照递推公式用循环语句来计算最终结果,整个过程中,不需要像递规那样进出栈,因此,递推程序的效率是比较高的。
在实际比赛的过程中,很多问题如果能找到它的递推公式就可以以很少的代码和很高的效率将它实现。
二、递推代码的编写
对于递推程序而言,其代码编写应该是非常方便的,一般至少要有两个部分,第一部分是通过赋值设置递推的初始状态;第二部分是递推过程的实现,一般都是通过循环实现。
比如我们上面讲到的斐波那契数列,它的初始状态是F1=1,F2=1;递推公式是
,因此,我们就可以很方便的得到如下的递推代码:
Varf1,f2,f3:
integer;
f1:
=1;f2:
=1;//初始状态
fori:
=3tondo//循环递推
begin
f3:
=f1+f2;//递推
f1:
=f2;//更新状态
f2:
=f3;
end.
对于可以用递推实现的程序而言,如何得到其递推公式更为重要。
三、应用举例
(一)平面分割
【问题描述】
同一平面内有n(n≤500)条直线,已知其中p(p≥2)条直线相交于同一点,则
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 递规与递推.docx
![提示](https://static.bdocx.com/images/bang_tan.gif)