今天看《C和指针》这本书,看到递归这一块,书中讲解递归时候似乎批判了一些书中讲解递归时使用了一些误导的例子,而这些例子不就是我当年接触递归时候看过的例子吗?当时得意洋洋地以为自己掌握了递归,现在看来就是一个笑话?(是不是我的境界提高了?)。这几个例子就是计算阶乘和斐波那契数列。

作者说这是非常不幸的,现在看来也是如此!使用递归求解阶乘毫无优越之处,使用递归求解斐波那契数列,效率低到恐怖。从某种角度说,将这两个例子作为学习递归的敲门砖是不幸的。

C通过运行时堆栈支持递归函数的实现。递归函数就是直接或间接调用自己的函数。

书中举了一个例子就是:

把一个整数从二进制形式转换为可打印的字符形式。例如,给出一个值4267,我们需要依次产生字符,‘4’,‘2’,‘6’,‘7’。

这是一个十分经典的例子了,我曾记得当年学习微机原理等课程时也有这种例子,而解决这种问题的思想也很经典。下面会慢慢道来,讲解这个解题思路之前,我想让大家提前感性地认识下递归的思想。认真看完之后,也许不太明白,但别不耐烦,继续看下去,我相信你对递归的认识一定会有一个质的提高。我在没有看完这些形象地解释之前,对解题的思想是无比疑惑的,特别是对堆栈中变量的创建与销毁无比迷惑。


第一个形象的说法来自于:什么是递归?

用查字典的过程来形象地说明递归的思想(竟然不能复制,要一个字一个字的打了):

我们使用的字典,本身就是递归。为了解释一个词,需要使用更多的词。

当你查一个词,发现这个词的解释中某个词仍然不懂,于是你开始查这第二个词,可惜,第二个词中仍然有不懂的词,于是查第三个词,这样查下去,直到有一个词的解释是你完全能看懂的,那么递归走到了尽头,然后你开始后退,逐个明白之前查过的每一个词,最终你明白了最开始查的那个词的意思。

是不是很刺激,当我见到这种解释的时候觉得很形象,别急,再看下一个类似的说法:

一个小朋友坐在第10排,他的作业本被小组长扔到了第1排,小朋友要拿回他的作业本,可以怎么办?他可以拍拍第9排小朋友,说:“帮我拿第1排的本子”,而第9排的小朋友可以拍拍第8排小朋友,说:“帮我拿第1排的本子”...如此下去,消息终于传到了第1排小朋友那里,于是他把本子递给第2排,第2排又递给第3排...终于,本子到手啦!
这就是递归,拍拍小朋友的背可以类比函数调用,而小朋友们都记得要传消息、送本子,是因为他们有记忆力,这可以类比栈。
更严谨一些,递归蕴含的思想其实是数学归纳法:为了求解问题p(n),首先解决基础情形p(1),然后假定p(n-1)已经解决,在此基础上若p(n)得解,那所有问题均得解。这也启发我们:使用递归,切忌纠结中间步骤,因为这样做的代价是手动推理中间的若干步骤,而这些脏活,应该是计算机给我们干的!
作者:lishichengyan
链接:https://www.zhihu.com/question/20507130/answer/317659122
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


好了,上面的两个形象解释看完了,脑子里大概有个印象了,递归是一个有进有退的过程,这很重要。特别是这个退的过程,很容易被忽略。

下面把上面提出的一个例子拿过来:

把一个整数从二进制形式转换为可打印的字符形式。例如,给出一个值4267,我们需要依次产生字符,‘4’,‘2’,‘6’,‘7’。

我们的思路是把这个值反复除以10,并打印各个余数。例如,4267除以10的余数是7,然后取商,4267/10 = 426。然后用这个值重复上述步骤。

这里需要说明的是,我们不能直接打印这个余数(例如 7),我们需要打印的是机器字符集中表示数字‘7’的值。在ASCII码中,字符‘7’的值为55,所以我们需要在余数上加上48来获得正确的字符。但是,使用字符常量而不是整型常量可以提高程序的可移植性。

考虑下面的关系:

‘0’ + 0 = ‘0’;

‘0’ + 1 = ‘1’;

‘0’ + 2 = ‘2’;

etc.

从这些关系中,我们很容易看出在余数上加上‘0’就可以产生对应字符的代码。

别高兴的太早,我们仔细想想可知,这种处理方法的唯一问题是它产生的数字次序正好相反,它们是逆向打印的。也就是打印的数分别是‘7’,‘6’,‘2’,‘4’。

这时我们使用递归来修正这个问题:

先给出程序:

//接受一个整型值(无符号),把它转换为字符并打印它。

#include <stdio.h>

void binary_to_ascii(unsigned int value)
{
	unsigned int quotient;
	
	quotient = value / 10;
	if(quotient != 0)
		binary_to_ascii(quotient);
	
	putchar(value % 10 + '0');
	
} 

为了理解这个递归的程序还真不容易,下面为了理解递归的工作原理,我们追踪递归调用的执行过程。下面就进行这项工作。

追踪一个递归函数执行过程的关键是理解函数中所声明的变量是如何存储的。当函数被调用时,它的变量的空间是创建于运行时堆栈上的。以前调用的函数的变量仍保留在堆栈上,但它们被新函数的变量所掩盖,因此是不能被访问的。

当递归函数调用自身时,情况也是如此。每进行一次新的调用,都将创建一批变量,它们将掩盖递归函数前一次调用所创建的变量。当我们追踪一个递归函数的执行过程时,必须把分属不同次调用的变量区分开来,以避免混淆。

上面的程序中的函数有两个变量:参数value和局部变量quotient。下面的一些图显示了堆栈的状态,当前可以访问的变量位于栈顶。所有其他调用的变量用灰色表示,表示它们不能被当前正在执行的函数访问。

假定我们以4267这个值调用递归函数。当函数刚开始执行时,堆栈的内容如下:

【 C 】深入了解递归_堆栈

注:上图中的下方“其他函数调用使用的变量”用灰色表示。

执行除法运算后,也就是quotient = 4267/10 = 426.

堆栈的内容如下:

【 C 】深入了解递归_迭代_02

接着,if语句判断出quotient的值非零,所以对该函数执行递归调用。当这个函数第二次被调用之初,堆栈的内容如下(也就是把quotient的值传递给了value):

【 C 】深入了解递归_递归_03

堆栈上创建了一批新的变量,隐藏了前面的那批变量,除非当前这次递归调用返回,否则它们是不能被访问的。再次执行除法运算之后,堆栈的内容如下:

【 C 】深入了解递归_递归函数_04

【 C 】深入了解递归_斐波那契数列_05

【 C 】深入了解递归_斐波那契数列_06

到了这一步,就到达一个关键阶段了,这个时候quotient的内容为0了,函数不再调用自己了。

此时打印value对10取余的值,也即是4,转变为字符后打印出来,得到字符‘4’。

【 C 】深入了解递归_递归函数_07

下一步十分的关键,这个时候还要进行什么样的骚操作呢?

函数开始了返回过程,并开始销毁堆栈上的变量值。

销毁当前的变量后,之前的变量变成了当前变量,如下图:

【 C 】深入了解递归_斐波那契数列_08

此时value为42,取余后为2,加上‘0’得到字符‘2’并打印。

打印之后继续返回,它的变量也销毁,栈顶变量再次更新,如下图:

【 C 】深入了解递归_递归_09

此时,value为426,取余得6,加上‘0’得到字符‘6’,打印。

打印之后继续返回,当前变量销毁,栈顶变量更新,如下图:

【 C 】深入了解递归_递归_10

这就返回到了最后一层了,此时value为4267,取余得到7,加‘0’得到字符‘7’,打印。

然后,这个递归函数就彻底返回到其他函数调用它的地点。

如果你把打印出来的字符一个接一个排在一起,出现在打印机或屏幕上,你将看到正确的值。

分析完毕。


前面还说了,使用递归来实现阶乘和斐波那契数列是很蠢的做法(大概是这个意思!),下面分析为何如此呢?

用递归来计算阶乘的函数如下:

//用递归方法计算n的阶乘。

long factorial(int n)
{
	if(n < 0)
		return 1;
	else
		return n * factorial(n - 1);
	
}

这个程序能够产生正确的结果,但它并不是递归的良好用法。为什么呢?

递归函数调用将涉及到一些运行时开销——参数必须压到堆栈中,为局部变量分配内存空间(所有递归都是如此,并非特指这个例子),寄存器的值必须保存等。

当递归函数的每次调用返回时,上述这些操作必须还原,恢复成原来的样子。也就是为了求factorial(n)必须求factorial(n - 1),为了求factorial(n-1)必须求factorial(n-2),如此下去,求到最后的factorial(0),递归不再调用自己,返回一个正确的阶乘值。这并没有结束,递归调用完了之后还有一个返回的过程,变量占用的堆栈内存要一个又一个的销毁,直到恢复到最开始的n值在堆栈中存储着。该递归函数彻底的返回到调用它的函数的位置。

所以,基于这些开销,对于这个程序而言,它并没有简化问题的解决方案。

下面提出一种改进方案:

采用循环迭代计算阶乘。该循环方案并没有降低代码的可读性,同递归一样简洁。

代码如下:

//用迭代法计算n的阶乘。

long factorial( int n )
{
	int result = 1;
	
	while(n > 1)
	{
		result *= n;
		n -= 1;
	}
	
	return result;
	
}

下面的一个问题是,什么时候使用递归更合适呢?

许多问题都是以递归的形式进行解释的,这只是因为它比递归形式更清晰。但是,这些问题的迭代往往比递归实现效率更高,虽然代码的可读性可能稍差一些。但是当一个问题相当复杂,难以用迭代方法实现时,此时递归实现的简洁性便可以补偿它所带来的运行时的开销。

下面再举一个更为极端的例子:

斐波那契数就是一个数列,数列中每个数的值都是前面两个数的和,用公式描述如下:

【 C 】深入了解递归_递归函数_11

这个描述形式很容易让人采用递归来解决问题,既然如此,我们就顺水推舟,先用递归来实现这个数列:

//用递归方法计算第n个斐波那契数列的值

long fibonacci( int n )
{
	if(n < 2)
		return 1;
	else
		return fibonacci(n-1) + fibonacci(n-2);
	
}

确实简洁!!!

但它的问题如下:

【 C 】深入了解递归_斐波那契数列_12

//迭代实现

long fibonacci( int n )
{
	long result;
	long previous_result;
	long next_older_result;
	
	result = 1;
	previous_result = 1;
	
	while(n > 2)
	{
		n -= 1;
		next_older_result = previous_result;
		previous_result = result;
		result = previous_result + next_older_result;
	}
	
	return result;

}

诸位兄台可以验算一下,这个迭代方式也可以算出第n个斐波那契数列值。

【 C 】深入了解递归_斐波那契数列_13

上面那个顺序的变量的代表的数列数的前后顺序是:next_older_result、previous_result、result。

一开始赋值为:

result = 1;
previous_result = 1;

如果n = 1或者2时,返回result为1,如果n大于2就进入循环,循环体内的语句不必多说了, 如果看不懂,你也不可能看到现在的。


总结不易,如需转载,请注明原地址。