浅谈递归
递归无疑是一种威力强大的解决问题的方法,这从那个著名的“汉诺塔”问题就可以看出来。看上去无从下手的问题,需要我们从问题的整体来考虑,而不是把注意力放在“部分”的具体实现上。在解决汉诺塔问题时,我们只是找出了递归的策略,而把具体的操作让计算机去完成。然后,我们惊讶地发现,原来这个问题可以用一种如此简单美妙的方法来解决。
说到递归,让我们再来看另外一种常用来解决重复问题的办法——迭代。迭代无疑也是一种强大的方法。利用迭代,我们可以不断求精,求得一个超越方程足够精度的解,也可以让结果一步步趋于最优。于是在编程语言中,我们用if,while,for来进行条件控制和不断循环。
递归与之不同,在递归的世界,循环似乎是不需要的。我们把一个大的问题分解成小问题,而小问题的形式和大问题在本质上没有什么不同。求5!和求4!在问题本质上没什么不同,而通过关系式n!=(n-1)!*n,我们把问题简化了,直到问题的最简形式:1!=1。“相同形式”是递归方法的重点,小问题和大问题必须要有相同的形式。
或者,我们可以用下面的模板来说明递归:
Recursion(n) { if(问题足够简单) { 直接解决这个简单的问题 } else { 把现在的问题分解为更简单的问题,他和原问题有相同的形式 用递归的方法解决现在的子问题 把子问题的解组合起来得到原来问题的解 } }
在这其中,关键是我们需要确定一个递归的分解方式,比如上面的n!=(n-1)!*n。而这,通常并不很容易想到。
让我们先从数学角度去看,因为递归在数学问题中很常见,而且数学问题可以很容易的写出表达式。有的时候,这个表达式天然就是递归的形式,有的时候,就需要我们化简变形。
意大利人斐波那契养了一群兔子,这群兔子给我们留下了斐波那契问题。这个问题如此著名,所以不在这里赘述。下面的公式曾经在小学找规律填数字的问题里面让脑子不开窍的我想了半天:
还好,我们现在可以用递归的方法来解决它:
int Fabonacci(int n) //求斐波那契数列的第n项 { if (n<=2) //最简单的形式,直接可以给出结果 { return 1; } else //数学的递推公式已经给了我们分解问题的方法 { return Fabonacci(n-1)+Fabonacci(n-2); } }
而这种问题应该是最简单的递归问题,我们从数学式子里面直接得出了分解方法,从而确定了如何编程。这时候,初始条件的确定就显得很重要,因为如果初始条件给的不充分或是不对,将可能导致递归无法收敛到最简问题而导致程序崩溃。
我们来看一个组合数的问题:杨辉三角可以说是古代中国人的巨大成就之一。从杨辉三角里面我们可以得出下面的组合数规律:
分解方法既然已经有了,让我们来找一下最简形式应该写成什么样,其实,我们只要先拿一个按照上面的分解方法算一下就可以看出来大概:
(1)k=0,C(n,k)=1;
(2)k=n,C(n,k)=1;
(3)k>n,C(n,k)=0;
(4)k=1,C(n,k)=n
下面就是根据上面的结论写出的计算函数:
int CombineNum(int n,int k) { if (k==0) { return 1; } else if(k==1) { return n; } else if (n==k) { return 1; } else if (n<k) { return 0; } else { return CombineNum(n-1,k-1)+CombineNum(n-1,k); } }
我们要有对递归跳跃的信任。无论是在写一个递归函数还是在试图理解一个递归函数时候,都必须达到忽视单个递归调用细节的地步,只要选择了正确的分解,确认了相应的简单情景,并且正确实现了子问题的组合,那递归调用就能够自己运行,我们不必过多考虑它的细节,这是繁琐的,也是没有必要的。当然,用这样方法写出的程序也很有可能是错的,但这个"错误是在递归的实现里面,而不是递归机制的本身。如果程序出现了问题,我们应该在一个简单的递归层次上去寻找Bug,分析递归的其它层次不会有什么用。如果简单情景起作用并且递归分解师正确的,那么子调用就会自己正常的工作。如果没有,那就要检查递归函数本身了。"——《C程序设计的抽象思维》
下面我们用这个方法解决字符串反向的问题:
char * Reverse(char *str)把字符串str反向。我们可以用下面的代码来测试:
int main() { //测试用例 char str[20]; while (scanf("%s",str)!=EOF) { printf("%s\n",Reverse(str)); } return 0; }
我们把原来的问题进行分解:
(1)要把str反向,可以先把str[1]~str[n-2]反向,也就是说可以先把str的开头和结尾字符去掉,把剩下的部分反向
(2)然后我们要把str[0]和str[n-1]交换位置,这样就完成了str的反向操作。
(3)我们 考虑递归在何处收敛,也就是那个最简情景:
显然,当str的长度是1或者2的时候,反向是很简单的,只需要不动或者把str[0]和str[1]交换就行了。
然而,我们会发现我们的策略将会依赖于str的长度。而Reverse函数的参数列表里并没有str的长度,每次函数调用都用strlen函数求一次字符串长是一种方法,但是在频繁调用库函数的时候未免会造成时间的浪费。这种情况下,我们可以把Reverse函数作为所谓的“包装函数”,也是就说,Reverse并不直接执行递归操作,而是在里面再调用一个递归函数,在这个函数里,字符串长作为参数。不过,我还是愿意采用下面的方法,把字符串的起始和结束都作为参数,这样没有别的目的,只是个人觉得较为清楚:
char* Reverse(char str[]) { DiguiReverse(str,0,strlen(str)-1); return str; }下面我们看DiguiReverse函数的实现。根据上面的分解,我们已经能够写出DiguiReveerse的大概过程:
void DiguiReverse(char str[],int start,int end) { if(start>=end) //字符串长度为1 //什么都不做 else if(end-start==1) //字符串长度为2 //Exchange(str[start],str[end]) else { DiguiReverse(str,start+1,end-1); //Exchange(str[start],str[end]) } }
根据上面的伪码,可以很容易写出C代码:
/* 交换两个字符的位置 */ void ChangeTwoChar(char *p1,char *p2) { char tem; tem=*p1; *p1=*p2; *p2=tem; } /* 递归解决字符串反转问题 */ void DiguiReverse(char str[],int start,int end) { if (start>=end) { return; } else if(end-start==1) { ChangeTwoChar(&str[start],&str[end]); } else { DiguiReverse(str,start+1,end-1); ChangeTwoChar(&str[start],&str[end]); } }
#include <stdio.h> #include <string.h> /* 交换两个字符的位置 */ void ChangeTwoChar(char *p1,char *p2) { char tem; tem=*p1; *p1=*p2; *p2=tem; } /* 递归解决字符串反转问题 */ void DiguiReverse(char str[],int start,int end) { if (start>=end) { return; } else if(end-start==1) { ChangeTwoChar(&str[start],&str[end]); } else { DiguiReverse(str,start+1,end-1); ChangeTwoChar(&str[start],&str[end]); } } /* 包装函数 */ char* Reverse(char str[]) { DiguiReverse(str,0,strlen(str)-1); return str; } int main() { //测试用例 char str[20]; while (scanf("%s",str)!=EOF) { printf("%s\n",Reverse(str)); } return 0; }
如此这样,就解决了字符串的反转问题。