作为小白,我看到递归程序只是能看懂,但是自己写不出来,我知道要有一个临界条件(这个并不难找),但我不知道怎么演进,这让我十分头疼,因此找到了一篇个人认为写的不错的文章如下,根据我对递归的理解和疑问对原文做了一些标注,欢迎各位大佬,写下自己对递归的理解,本小白感激不尽。
如何写一个递归程序
总是听到大大们说递归递归的,自己写程序的时候却用不到递归。其中的原因,一个是害怕写递归,另一个就是不知道什么时候用递归。这篇文章就浅析一下,希望看完之后不再害怕递归,这就是本文最大的目的。
递归到底有什么意义?
在说怎么写递归之前必须要说一下它的意义,其实这就是为什么大多数人在看了许多递归的例子后还是不明所以的原因。可以肯定的是,递归是个十分强大的工具,有许多算法如果不用递归可能非常难写。很多地方介绍递归会用阶乘或者斐波那契数列作例子,这完全是在误导初学者。尽管用递归实现阶乘或者斐波那契数列是可以的,但是这是没有意义的。
先掉一下书袋,递归的定义是这样的:程序调用自身的编程技巧称为递归(
recursion)。在函数调用的过程中是有一个叫函数调用栈的东西存在的。调用一个函数,首先要把原函数的局部变量等压入栈中,这是为了保护现场,保证调用函数完成后能够顺利返回继续运行下去。当调用函数返回时,又要将这些局部变量等从栈中弹出。在普通的函数调用中,一般调用深度最多不过十几层,但是来到了递归的世界情况就不一样了。先看一段随便从网上就能找到的阶乘程序:
double fab(int n)
{
if(n == 0 || n == 1){
return 1;
}else{
return n*fab(n-1);
}
}
如果n = 100,很显然这段程序需要递归地调用自身100次。这样调用深度至少就到了100。栈的大小是有限的,当n变的更大时,有朝一日总会使得栈溢出,从而程序崩溃。除此之外,每次函数调用的开销会导致程序变慢。所以说这段程序十分不好。那什么是好的递归,先给出一个结论,接着看下去自然会明白。结论是如果递归能够将问题的规模缩小,那就是好的递归。
怎样才算是规模缩小了呢。举个例子,比如要在一个有序数组中查找一个数,最简单直观的算法就是从头到尾遍历一遍数组,这样一定可以找到那个数。如果数组的大小是N,那么我们最坏情况下需要比较N次,所以这个算法的复杂度记为O(N)。
简单的分析一下二分法为什么会快。可以发现二分法在每次比较之后都帮我们排除了一半的错误答案,接下去的一次只需要搜索剩下的一半,这就是说问题的规模缩小了一半。而在直观的算法中,每次比较后最多排除了一个错误的答案,问题的规模几乎没有缩小(仅仅减少了1)。这样的递归就稍微像样点了。
而阶乘的递归,每次递归后问题并没有本质上的减小(仅仅减小1),这和简单的循环没有区别,但循环没有函数调用的开销,也不会导致栈溢出。所以结论是如果仅仅用递归来达到循环的效果,那还是改用循环吧。
总结一下,递归的意义就在于将问题的规模缩小,并且缩小后问题并没有发生变化(二分法中,缩小后依然是从数组中寻找某一个数),这样就可以继续调用自身来完成接下来的任务。我们不用写很长的程序,就能得到一个十分优雅快速的实现。
怎么写递归程序?
终于进入正题了。很多初学者都对递归心存畏惧,其实递归是符合人思考方式的。写递归程序是有套路的,总的来说递归程序有几条法则的。
用二分查找作为例子,先给出函数原型:
int binary_search(int* array, int start, int end, int num_wanted)
返回值是元素在数组中的位置,如果查找失败返回-1。
1. 终止条件
其实在实际中,这是十分容易确定的。例如在二分查找中,终止条件就是找到了我们想要的数或者搜索完了整个数组(查找失败)。
if(end < start){
return -1;
}else if(num_wanted == array[middle]){
return middle;
}
2. 不断演进,推出演进的公式
可以先找解决问题的第一步,然后再找第二步时,发现第二步和第一步相同,只不过规模变小了,则此时可以考虑用递归。
如下二分查找中,就是继续查找剩下的一半数组。
if(num_wanted > array[middle]){
index = binary_search(array, middle+1, end, num_wanted);
}else{
index = binary_search(array, start, middle-1, num_wanted);
}
3. 用人的思考方式设计
这条法则我认为是非常重要的,它不会出现在编码中,但却是理解递归的一条捷径。它的意思是说,在一般的编程实践中,我们通常需要用大脑模拟电脑执行每一条语句,从而确定编码的正确性,然而在递归编码中这是不需要的!
递归编码的过程中,只需要知道前两条法则就够了。之后我们就会看到这条法则的如何工作的了。
4. 有限次的调用
现在我们可以写出我们完整的二分法的程序了:
int binary_search(int* array, int start, int end, int num_wanted)
{
int middle = (end - start)/2 + start; // 1
if(end < start){
return -1;
}else if(num_wanted == array[middle]){
return middle;
}
int index;
if(num_wanted > array[middle]){
index = binary_search(array, middle+1, end, num_wanted); // 2
}else{
index = binary_search(array, start, middle-1, num_wanted); // 3
}
return index; // 4
}
编写的时候只要认为2或者3一定会正确运行,并且立刻返回,不要考虑2和3内部是如何运行的,因为这就是你现在在编写的。
4是一个比较关键的步骤,经常容易被忘记。在这里只需要将找到的index返回就可以了。
不适合递归的方法:
我们可以看一下斐波那契数列的递归实现:
long int fib(int n)
{
if(n <= 1){
return 1;
}else{
return fib(n-1) + fib(n-2); // 1
}
}
乍看之下,这段程序很精练,它也是一段正确的递归程序,有基准条件、不断推进。但是如果仔细分析一下它的复杂度可以发现,如果我们取n=N,那么每次fib调用会增加额外的2次fib调用(在1处),即fib的运行时间T(N) = T(N-1) + T(N-2),可以得到其复杂度是O(2^N),
几乎是可见的复杂度最大的程序了(其中详细的计算各位有兴趣可以google一下,这里就不展开了。所以如果在一个递归程序中重复多次地调用自身,又不缩小问题的规模,通常不是个好主意。
PS. 大家可以比较一下二分法与斐波那契数列的递归实现的区别,尽管二分法也出现了2次调用自身,但是每次运行只有其中一个会被真正执行。
到此其实你已经可以写出任何一个完整的递归程序了,虽然上面的例子比较简单,但是方法总是这样的。不过我们可以对递归程序再进一步分析。二分查找的递归算法中我们注意到在递归调用之后仅仅是返回了其返回值,这样的递归称作尾递归。尽管在编写的时候不必考虑递归的调用顺序,但真正运行的时候,递归的函数调用过程可以分为递和归两部分。在递归调用之前的部分称作递,调用之后的部分称作归。而尾递归在归的过程中实际上不做任何事情,对于这种情况可以很方便的将这个递归程序转化为非递归程序(好处就是不会导致栈的溢出)。
举例:
-
辗转相除法
辗转相除法基于如下原理:两个整数的最大公约数等于其中较小的数和两数的相除余数的最大公约数。例如,252和105的最大公约数是21;252= 2 *105 + 42 ,同除以21,可知左右都是整数,所以21也是42的约数。
int gcd(int x, int y);
void main()
{
int m, n;
cout<< “输入两个数字:”;
cin >> m >> n;
cout<< “最大公约数:”;
cout<< gcd(m, n) << endl;
}
int gcd(int a, int b)
{
int i;
if (b == 0)
i= a;
else
i= gcd(b, a%b);
return i;
} -
十进制转八进制
#include “iostream”
using namespace std;
int func(int x)
{
int res;
if(x / 8 == 0)
{
printf("%d\n", x % 8);
return x % 8;
}
res= x % 8;
printf("%d\n",res);
return res + 10* func(x / 8);
}
int main()
{
int n;
cin >> n;
printf("%d\n",func(n));
system(“pause”);
return 0;
}