写下这个题目,心里还是有点儿发虚的,自己作为一个算法新手,在这个地方大谈递归算法实在是有点儿不知道天高地厚的感觉。
先说这篇文章的性质以及适合人群,这篇文章是个人学习算法过程中的一个总结,没有太多高深的东西,但也尽量能够做到生动具体并对递归的精髓有所触及。如果你是算法大神,那恐怕会让你失望,如果你也是刚刚接触递归算法,倒是可以读一读,我保证是不会有坏处的。
背景介绍:递归是一种程序设计方法,更恰当的说是一技巧。既然是技巧就会有一些问题,那就是当用得合适时,会有事半功倍的效果,但大多数情况下这种技巧是不适合的。递归程序相比于一般的迭代程序会有更多的程序开销,所以在使用递归前要衡量好简化代码和增加开销之间的关系。
从定义来看,只要是函数自己调用了自己,那就是应用了递归。而从我这些天的学习来看,这种递归调用大致分为三类:第一类是简单递归,用于处理一些数学公式中的递归定义。常见的例子有求n的阶乘、斐波那契数列等;第二类是代循环递归,也就是递归的使用是来代替循环的。我们要知道,所有的循环设计都是可以用递归的方法来实现的;第三种是“假如”递归(为什么起这个名字,看完就明白了),这种递归是最难的递归使用,也是递归算法的精髓所在。一会儿我们分别举例说明这三类递归程序,在此之前先来看一下递归的设计思路和设计方法:
设计思路:
递归算法的设计思路就是要解决一个规模为n的问题,先看规模为n-1(或者n-k或者n/2,总之是比原问题规模小)的问题是否和原问题有同样的性质,如果性质相同,那这个问题应该可以用递归算法解决(规模为1的问题几乎总是可以解决的)。这么说可能还是有点儿抽象,不要着急,知识都是这样,直接告诉你结论会让你感觉这都是废话。
设计方法:
从设计思路来看,我们在设计程序时要找出两件东西:
(1)基础步:也就是问题的出口,这里假设a1是问题P(1)的解,我们前面提到了,无论多复杂的问题,当其规模为1时也几乎总是可以轻松解决的。
(2)归纳步:也就是如何实现递归过程,是设计的关键。我们假设已经知道了问题P(k)的解为ak,那就要找到P(k+1)的解ak+1=p(ak)。这里注意大P代表问题,而小p代表对问题解的一种处理过程,也就是说,知道了问题P(k)的解ak,那就通过p来处理一下ak,就可以得到问题P(k+1)的解ak+1了。当然这个p就是我们要找的对应关系。
好了,下面我们用例子的形式来分别对三种递归方法做一些介绍。先说明一下,这里的例子都是从郑宗汉老师编写的《算法设计与分析(第二版)》中找到的,在这里感谢郑老师和他的书,个人认为写的很不错,大家有机会一定要读一读。
(1)简单递归
这种递归是属于那种“一眼就可以看出来的递归”,因为问题的数学描述公式就是递归定义的,这里我们还是用那个最简单的例子来说明。
例1:计算阶乘函数n!
什么是阶乘就不啰嗦了,这个例子应该是递归算法的老基友了,其实其循环算法很容易得到,并不需要动用递归这种核武器。当然,刚开始拿一个简单的开刀也是完全可以理解的。
怎么入手呢?先不要急着敲代码,不是让你来做题的,要养成算法分析的习惯。
基础步:当n=0,那n!=1,即f(0)=1
归纳步:当n!=0,那由阶乘的定义知n!=n*(n-1)!,即f(n)=n*f(n-1)
(注意上面这两行字是要写在纸上的,不要太相信你的大脑了!这是算法设计成败的关键!它们是如此重要,以至于我都不得不为它们起个名字了,就叫“两行字”吧)
有了两行字,那就可以着手写代码了:
int factorial(int n)
{
//功能:求数n的阶乘
//输入:非负整数n
//输出:n的阶乘的值
//说明:以参数n-1递归调用自身直至n为0
if(n==0) return 1;//基础步
else
{//归纳步
return n*factorial(n-1);
}
}
注意:写递归程序,一般先写基础步,再写归纳步。
时间复杂度:取乘法作为算法的基本步骤,则f(0)=0,f(n)=f(n-1)+1。则时间复杂度为O(n),怎么来的就不多说了,可以查看别的资料。
这里的编码问题没有什么好说的,代码几乎完全是两行字的变形,甚至连变形都算不上。
(2)代循环递归
这种递归方法是最受人诟病的,因为如果一个问题可以用迭代的方法来解决。那原则上是不允许使用递归的。当然事无绝对,一些问题虽然可以用迭代来解决,但解决起来是比较费力的,比如生成类算法(常见的例子有生成n个数的全排列),这种算法如果不用递归栈进行变量暂存那就要自己设计容器来存储生成的中间结果,实在让人头疼。还有一种问题是问题本身逻辑比较复杂,需要多种递归策略的结合使用(常见的例子有汉诺塔问题),这种时候代循环递归就派上用场了。
说了那么多,代循环递归有什么特点呢?其实很简单,一句话就可以概况,那就是递归语句在归纳步的最后。从算法上来看,递归语句在归纳步的最后,说明我们对当前步的处理放在递归前面,也就是说我们解决问题的思路是这样的:先处理眼前这一部分,然后调用递归处理剩下的部分,这显然是迭代可以完成的功能。下面还是举个例子吧。
例子2:基于递归的插入排序
什么是插入排序也不多说了,其迭代版本相信大家都见到过或者自己完成过,现在来看一下其递归实现。
这个例子和上面的求阶乘不同,求阶乘有明确的数学算式,程序代码是把数学公式挪一个地方就完成了。而这一题里没有明确的数学算式,你也不可能自己写出对应的数学算式。这时候要用自己的语言来写两行字。
基础步:n=1时,就一个元素,不用排序。
归纳步:n!=1时,将当前元素与前面元素一一比较,将其插入合适位置(插入排序的基本思想).然后n加1递归调用自身。
下面我们可以写出其递归代码了。
void insert_sort_rec(Type A[],int m,int n)
{
//输入:数组首地址A,数组中下标0-m的元素已有序,数组最大下标n
//输出:递增有序的数组A
//功能:将第m+1个元素进行插入排序,然后以参数A,m+1,n调用自身,直到m==n成立
if(m==n)
{
return;//基础步
}
else
{ //归纳步
Type temp=A[m+1];
size_t index = m;
while((index>=0)&&(A[index]>temp))
{
A[index+1] = A[index];
index--;
}
A[index+1] = temp;
insert_sort_rec(A,m+1,n);
}
}
(这里说明一下,代码实现和两行字直接有一个小小的转化,即代码中的m是归纳步中的n,而程序中的n用来表示元素总个数(下标)了。实际上二者是完全对应的。)
时间复杂度:取移动元素为基本操作,可得f(0)=0,f(n)=f(n-1)+(n-1),则时间复杂度为O(n^2),这里不再多说。
再用这个例子来重申一下这种代循环递归的策略:我们的解决方案是,从数组头开始(即m=0,这一点就跟迭代方法吻合了),先调用递归排第二个,再调用递归排第三个……直到m=n,看出来了吧,其算法实质就是迭代,就是循环。
(3)“假如”递归
前面已经说了,这种递归是比较难掌握的,它之所以难学难用,和人的思维过程有直接关系。人类的正常思维都是递推式的,即由条件A得到结果B,再从结果B推导出结果C……也就是顺着事情或逻辑的发展进行的。而这种递归算法正好与此相反,是“反着来的”。要想知道A,假如已经得到B该怎么做呢?要想明白B,假如已经搞到C该怎么做呢?……无根性和脱节性(自己创的词,能理解就好)是很多人学习这种递归算法时的感受:总感觉没把握,这样就可以了吗?你说假如就能假如吗?,,,这些感受都是正常的,因为你的大脑就不是为处理这种问题设计的,而计算机的大脑是可以的,计算机通过栈可以很容易的实现这种过程。(栈这种数据结构和递归算法有着天然的联系,学习栈的工作过程对理解递归算法有至关重要的帮助)
为了对比,我们这里还用上面的例子
例子3:基于递归的插入排序
我们现在用“假如”的思想来重写两行字。
基础步:n=0时,没有元素不用排序,直接返回
归纳步:n!=0时。假如(假如二字值千金,所以精髓都在这上面)前n-1个元素已经排序,那应该将第n个元素与前面元素一一对比,将其插入合适位置。
下面我们来写程序
void insert_sort_rec(int A[],int n)
{
//功能:对数组进行插入排序,时期递增有序
//输入,数组首地址A,数组元素个数n
//输出:递增有序的数组A
//说明:以参数A,n-1递归调用自身直至n==1成立
int k;
Type a;
n = n - 1;
if(n>0)
{//以下为归纳步,基础步为不进行操作直接返回
insert_sort_rec(A,n);
a = A[n];
k = n - 1;
while((k>=0)&&(A[k]>a))
{
A[k+1] = A[k];
k = k - 1;
}
A[k+1] = a;
}
}
取移动元素为基本操作,可得f(0)=0,f(n)=f(n-1)+(n-1),则时间复杂度为O(n^2),这里不再多说。
这里要着重比较一下前后两种算法,后面这种算法的“假如”是怎么实现的呢?就是通过先调用小参数的自己来实现的,然后在实现的基础上来做下一步操作,体现在代码上就是递归调用在归纳步的前面。(这里很多人会感觉别扭,写了就实现了吗?我做后面的操作时前面已经排好序了吗?没错,写了就实现了,你写后面的操作时前面已经排好序了)
好了,三种策略都介绍完了,都是用了很简单的例子,在实际应用中,问题会比这些复杂很多,有些是这三种的结合,即先递归调用自己干一些事,然后处理当前步,再调用自己干一些事,再处理当前步(如汉诺塔问题)……这时候你也要学会递归的理解问题,哪些东西是代循环的,哪些东西是假如的,他们之间有时候还是相对的……
你可能感觉更迷糊了,前面稍微提到了一点,在没有足够感性经验作为铺垫之前,理解理性的知识是很难的,甚至可以说是不可能的,你的感觉就都是废话。要改善这种现状,那方法只有一个,就是大量积累感性经验,说白了就是多做题,多分析各种各样的递归算法。另外本人的能力有限,所以只能写成这样了。要想真正了解递归算法,一定是要找一本算法书,熟悉理论,然后大量分析例子的,这个过程谁也替不了你。
最后,还得以一段理性结论作为结语。这段话原文出自严蔚敏老师的《数据结构(C语言版)》对递归算总结得很到位,与君共勉吧:递归设计的实质是,当一个复杂的问题可以分解成若干子问题来处理时,其中某些子问题与原问题有相同的特征属性,则可以利用和原问题相同的分析处理方法;反之,这些子问题解决了,原问题也就迎刃而解了。由于递归函数的设计用的是归纳思维的方法,则在设计递归函数时,应注意:(1)首先应书写函数的首部和规格说明,严格定义函数的功能和接口(递归调用的界面),对求精函数中所得的和原问题性质相同的子问题,只要接口一致,便可进行递归调用;(2)对函数中的每一个递归调用都看成只是一个简单的操作,只要接口一致,必能实现规格说明中定义的功能,切忌想得太深太远。