C++两个函数可以相互递归吗_C语言(7)- 递归

(本文为原创,版权归作者所有)

递归(Recursion)

递归是一种计算方法,它的每一步计算都可以被分解为更小规模相同的计算,因此一个问题可以通过不断重复的分解来解决。一个典型的例子是计算阶乘N!的递归方法:

N! = N*(N-1)! 
(N-1)! = (N-1)*(N-2)!
……
1! = 1
0! = 1

递归和数学中的数学归纳法思想类似,数学归纳法是用小一些的问题f(N-1)来证明大一些的问题f(N)成立,而递归则是通过小一些的问题f(N-1)来求大一些的问题f(N)的解。一个可以用递归解决的问题一定是一个数学归纳问题,即一个命题如果对f(N-1)成立,那么对f(N)也成立,只有这样才能用相同的计算来表示所有的计算过程。

递归算法需要两个步骤:分解问题和合并问题的解。分解问题是将大问题分解为小问题,这种分解是可重复的,以阶乘的递归算法为例,它的原始问题f(N)可以被分解为f(N-1)*N,用同样的方式,f(N-1)可以被分解为f(N-2)*(N-1),以此类推,直到问题被分解到最小的问题f(1) ,最小问题的解一定是已知的,在这里 f(1) = 1;合并问题的解是按照与分解问题相反的顺序,将小问题的解合并为大问题的解,还是以阶乘的递归算法为例,当我们得到f(1),就可以得到f(2) = 2*f(1),以此类推,由f(N-1)的解,我们可以得出f(N)的解f(N) = f(N-1)*N。

那么我们如何用C语言来实现递归算法呢?仔细观察递归算法的两个步骤,不难发现它们可以用栈的方式来表达:分解问题就是将当前还无法求解的大问题压入栈中,暂时不求解,如此重复,直到遇到已知解的问题为止;合并问题的解则相反,从已知解的小问题开始,将上一级未知解的问题出栈,合并出这个问题的解,如此重复,直到合并出最大的问题解为止。下图就是如何用栈的方式来实现阶乘的递归算法。

6c08ab3da35a4d0d6f3ee4d7d686f316.png

递归的实现看起来是不是显得很复杂?我们需要维护一个栈结构,要分解问题,要将问题入栈,要在适当的时候将问题出栈并求解。值得庆幸的是,我们可以用递归函数来轻松的实现递归这种方式利用了函数和函数调用栈,将栈和栈管理的复杂性隐藏了起来。

递归函数被用来实现递归算法,它会在函数的实现中直接或者间接的调用它本身,从而达到不断分解问题的目的,比如下面计算阶乘的递归函数:

int 

为了实现递归算法,递归函数每一次计算都要缩小计算的规模,并且必须设置退出条件,在满足退出条件时需要返回一个计算结果而不是继续递归下去,这样可以保证在有限次递归内完成计算。

递归函数的本质是分解问题,并将分解后的问题压到调用栈里,一层一层循环往复,直到遇到最小规模的问题(即递归的退出条件)为止。调用栈保证了问题解决的顺序与问题分解的顺序相反,先从最小规模的问题开始解决,然后规模逐步扩大,直到解决了原始的问题为止。从编程的角度来讲,程序员使用递归只需要知道问题如何被分解即可,而不用理解分解后的问题是如何被解决的,因此递归比循环更加抽象,它隐藏了解决问题的实现细节。

如果算法采用非递归的方式,则要求程序员从实现的角度考虑问题,既需要把问题分解为可迭代的步骤,又需要将分解后的计算结果显式的整合在一起,所有的过程都必须是清清楚楚的,没有任何“魔法”。递归利用函数调用栈完成了一些不那么一目了然的工作,因此显得更加奇妙。

递归在计算理论和算法中具有非常重要的地位,它也是函数式编程语言的基础之一(函数式语言的递归实现原理与C语言完全不同)。从理论上讲,所有的递归算法都可以被改写为非递归的形式,例如有些递归可以用循环的方式来实现。但是在某些情况下,递归的实现简洁而自然,如果被改写则会变得复杂而不易被理解。

阶乘的递归算法也许并不能很好的说明递归的神奇之处,因为它很容易就可以被改写为基于循环的算法。下面是著名的汉诺塔(Towers of Hanoi)问题,它的递归算法显得非常优雅:

· 问题:汉诺塔问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。如果考虑把64片金盘由一根柱子上移到另一根柱子上,并且始终保持上小下大的顺序。这需要如何移动呢?

我们将问题翻译成数学语言:假设有3根柱子,分别为a、b和c,a柱上有从下往上按照大小顺序排列的N个圆盘,现在需要将N个圆盘从a柱移动到c柱上,且在任意时刻,大圆盘不能放在小圆盘上。如果一次只能移动一个圆盘,求移动圆盘的过程。

· 递归算法:算法的基本思路和数学归纳法类似。当n=1时,直接将圆盘从a移到c即可;假设已知如何移动N-1个圆盘,那么当n=N时,移动圆盘的过程可以被分解为3步:

1. 将N-1个圆盘从a移到b

2. 将第N个圆盘从a移到c

3. 将N-1个圆盘从b移到c

汉诺塔问题的递归函数如下:

void 

汉诺塔递归算法的核心依然是分解问题:将n个圆盘的问题分解为两个n-1个圆盘的问题。我们知道这个递归函数一定可以得出问题的解,但是程序员可以不用理解解的产生过程,这就是递归算法的魅力所在。如果读者感兴趣,可以尝试采用非递归算法来解决汉诺塔问题,你会发现你需要自己来维护一个栈结构,这将是一个艰苦的探索。

并不是所有问题都适合采用递归算法。递归函数最大的缺点是对调用栈的空间使用,按照C语言的函数调用惯例,每一次函数调用都会生成一个新的栈帧,如果递归的深度过大,就会用完有限的栈空间,产生栈溢出(stack overflow)。栈溢出是一个非常严重的错误,会导致程序或者系统崩溃。所以在命令式语言的编程实践中,工程师们都尽量避免使用递归函数,算法要尽可能的采用非递归的方式来实现。

是否有办法既能使用递归函数,又能避免栈溢出的风险呢?尾递归(Tail Recursion)是递归的一种特殊形式,它没有一般递归的栈溢出问题,这又是如何做到的呢?在讨论尾递归之前,我们先来看一下尾调用和对尾调用的优化。

尾调用(Tail Call)

尾调用是指在函数的最后一步所进行的函数调用。

int 

在上面的代码中,foo的最后一步是调用函数bar,这就被称为尾调用。下面两种情况不属于尾调用,因为foo在调用bar之后,还有别的操作:

int 

一般来讲,函数调用是需要在调用栈上产生新的栈帧的,然而如果我们仔细观察尾调用的情形,会发现在这种情况下,我们只需要复用当前函数的栈帧就可以完成任务,函数调用可以被优化为跳转指令,并且可以省略一个返回指令,这样既节省了调用栈的空间,又节省了程序运行的时间。我们来看看下面的例子:

int 

在函数f1中对f2的调用即为尾调用。我们可以看出,f1在对f2的调用之后再没有任何工作,因此f1的栈帧中大部分内容都是不被需要的了。更进一步,当f2返回到f1之后,紧接着的下一步就是返回到main函数,如果可以直接从f2返回到main,那么f1中的返回指令也可以被省略了。

实际上,在对f2进行尾调用时,编译器可以合理的修改f1的栈帧,将f2需要的参数放入到f1的栈帧里,并保持当前栈帧中的返回地址,那么f2就可以复用f1的栈帧了。这时,函数调用可以被更改为直接跳转到f2的入口,这只需要一个无条件跳转指令即可,无需生成新的栈帧;当尾调用f2返回时,由于栈帧中的返回地址没有改变,f2就可以直接返回到main函数,好像f1根本就不存在一样。实际上,在f1进行尾调用的那个时刻,f1就已经被f2“取代”了。

将尾调用从函数调用改为跳转指令被称为尾调用优化(Tail Call Optimization/Elminination)。尾调用优化由编译器来完成,它可以减少对栈空间的使用,并能提高函数调用的效率。对于C语言来说,尾调用优化是可选的,因为在大多数情形,尾调用优化对程序运行效率的提升有限。

尾递归(Tail Recursion)

尾递归是一种特殊的递归形式。使用尾调用的方式来调用自身,被称为尾递归。

递归通常效率较低,存在栈溢出的风险。但对于尾递归来说,由于尾调用优化的存在,只需要一个栈帧就可以了,因此不会发生栈溢出。如果一个递归函数可以被改写为尾递归,那么它的效率就可以与循环相当了,比如上文中计算阶乘的递归函数factorial就可以被优化为:

int 

修改成尾递归的函数tailFactorial需要两个参数,n和result,其中的result是计算的中间结果。然而我们仔细观察可以看出,经过优化的尾递归的每一步迭代都会顺序执行,并将中间结果result传给下一步,它实际上就是将基于循环的算法改写为尾递归的形式,n代表循环变量,result为计算结果,它的本质依然是循环。尾递归和循环是等价的,这也是为什么函数式编程语言可以没有循环语句的原因了!

// 基于循环的factorial

需要强调的是,大多数递归算法都不能被改写为简单的基于循环的算法,即不能被改写为尾递归。通常,递归算法的空间复杂度为O(n),而循环的空间复杂度为O(1)。如果一个递归算法能被改写为循环,只能说明算法本身还有很大的提升空间,这不是通过编译器的优化就可以完成的工作。这里所谓的简单是指算法的空间复杂度为O(1)。

那么是不是说尾递归就没有什么好处了呢?对于具有循环语句的命令式语言来说,尾递归确实没有太大的意义;然而对于纯粹的函数式语言来说(没有循环语句),递归是实现循环的方式

更进一步,如果考虑用代码的方式来实现栈的操作,任何递归算法都可以用循环来实现。这样做的结果无非是将递归在调用栈上的操作转移到程序员自己维护的栈上来,算法的本质并没有改变,其空间复杂度依然为O(n)。但是这样做也是有好处的,它可以节省调用栈上的内存空间,用堆来实现栈的功能(通常堆的空间远远大于栈的空间),从而可以避免栈溢出的发生。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值