尾递归

作者: 莫太极 | 来源:发表于2018-07-11 14:37 被阅读0次

    尾调用

    计算机科学里,尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形。这种情形下称该调用位置为尾位置。若这个函数在尾位置调用本身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归,是递归的一种特殊情形。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。

    在程序运行时,计算机会为应用程序分配一定的内存空间;应用程序则会自行分配所获得的内存空间,其中一部分被用于记录程序中正在调用的各个函数的运行情况,这就是函数的调用栈。常规的函数调用总是会在调用栈最上层添加一个新的堆栈帧(stack frame,也翻译为“栈帧”或简称为“帧”),这个过程被称作“入栈”或“压栈”(意即把新的帧压在栈顶)。当函数的调用层数非常多时,调用栈会消耗不少内存,甚至会撑爆内存空间(栈溢出[1],造成程序严重卡顿或意外崩溃。尾调用的调用栈则特别易于优化,从而可减少内存空间的使用,也能提高运行速度。[1]其中,对尾递归情形的优化效果最为明显,尤其是递归算法非常复杂的情形。[1]

    一般来说,尾调用消除是可选的,可以用,也可以不用。然而,在函数编程语言中,语言标准通常会要求编译器或运行平台实现尾调用消除。这让程序员可以用递归取代循环而不丧失性能。

    定义

    尾调用 (tail call) 指的是一个函数的最后一条语句也是一个返回调用函数的语句。在函数体末尾被返回的可以是对另一个函数的调用,也可以是对自身调用(即自身递归调用)


    尾递归

    若函数在尾位置调用自身(或是一个尾调用本身的其他函数等等),则称这种情况为尾递归。尾递归也是递归的一种特殊情形。尾递归是一种特殊的尾调用,即在尾部直接调用自身的递归函数。对尾递归的优化也是关注尾调用的主要原因。尾调用不一定是递归调用,但是尾递归特别有用,也比较容易实现。

    尾递归在普通尾调用的基础上,多出了2个特征:

    • 在尾部调用的是函数自身 (Self-called);
    • 可通过优化,使得计算仅占用常量栈空间 (Stack Space)。

    优化尾递归的分析与示例

    对函数调用在尾位置的递归或互相递归的函数,由于函数自身调用次数很多,递归层级很深,尾递归优化则使原本 O(n) 的调用栈空间只需要 O(1)。因此一些编程语言的标准要求语言实现进行尾调用消除
    Swift为例

     func sum(_ n: UInt) -> UInt {
            if n == 0 {
                return 0
            }
            return n + sum(n - 1)
        }
    

    调用sum(5)为例。相应的栈空间变化

    sum(5)
    5 + sum(4)
    5 + (4 + sum(3))
    5 + (4 + (3 + sum(2)))
    5 + (4 + (3 + (2 + sum(1))))
    5 + (4 + (3 + (2 + 1)))
    5 + (4 + (3 + 3))
    5 + (4 + 6)
    5 + 10
    15
    

    可观察,堆栈从左到右,增加到一个峰值后再计算从右到左缩小,这往往是我们不希望的,所以在C语言等语言中设计for, while, goto等特殊结构语句,使用迭代、尾递归,对普通递归进行优化,减少可能对内存的极端消耗。修改以上代码,可以成为尾递归:

        func tailSum(_ n: UInt) -> UInt {
            func sumInternal(_ n: UInt, current: UInt) -> UInt {
                if n == 0 {
                    return current
                } else {
                    return sumInternal(n - 1, current: current + n)
                }
            }
            
            return sumInternal(n, current: 0)
        }
    

    对比后者尾递归对内存的消耗

    tailSum(5, 0) 
    tailSum(4, 5) 
    tailSum(3, 9)
    tailSum(2, 12) 
    tailSum(1, 14) 
    tailSum(0, 15) 
    15
    

    则是线性的。


    调用栈

    计算机科学中存储有关正在运行的子程序的消息的。有时仅称“”,但栈中不一定仅存储子程序消息。几乎所有计算机程序都依赖于调用栈,然而高级语言一般将调用栈的细节隐藏至后台。

    调用栈最经常被用于存放子程序的返回地址。在调用任何子程序时,主程序都必须暂存子程序运行完毕后应该返回到的地址。因此,如果被调用的子程序还要调用其他的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。在递归程序中,每一层次递归都必须在调用栈上增加一条地址,因此如果程序出现无限递归(或仅仅是过多的递归层次),调用栈就会产生栈溢出


    Swift中编译器在Debug 模式下并不会对尾递归进行优化。我们可以在 scheme 设置中将 Run 的配置 改为 Release。


    阶乘的例子

        //普通递归
        func notailFactorial(_ n:Int) -> Int {
            if n == 1 {
                return 1
            }
            return n * notailFactorial(n-1)
        }
    
        //尾递归
        func factorial(_ n: Int) -> Int {
            func iter(product:Int , counter: Int = 1) -> Int {
                if product <= 1{
                   return counter
                } else {
                    return iter(product: product-1, counter: counter+product)
                }
            }
            return iter(product: n)
        }
    

    相关文章

      网友评论

          本文标题:尾递归

          本文链接:https://www.haomeiwen.com/subject/idzcpftx.html