尾调用优化

1,632 阅读4分钟

前言

今天刷leetcode时看到爬楼梯这道题可以使用递归调用进行解题。

/*
 假设你正在爬楼梯。需要 `n` 阶你才能到达楼顶。

 每次你可以爬 `1` 或 `2` 个台阶。你有多少种不同的方法可以爬到楼顶呢?
*/
/*
 思路:最后一步有两种选择选择走 1 或 2 个台阶,因此 n 阶楼梯的爬法就是 n - 1 与 n - 2 阶 楼梯的爬法只和,可以递归求解
*/
function climbStairs(n: number): number {
    if (n === 1) return 1;
    if (n === 2) return 2;
    return climbStairs(n - 1) + climbStairs(n - 2);
};

但递归求解会出现超时和调用堆栈溢出的问题,可以考虑使用尾递归进行优化,因此总结一波。

尾调用

尾调用(Tail Call)作为函数式编程的一个重要概念,其概念很简单,即某个函数的最后一步操作总是返回另一个函数的调用结果。

    function inner () {}
    function outer () {
        return inner();
    }

尾调用仅是指函数的最后一步操作,因此函数调用不一定出现在函数尾部。

    function outer (x) {
        if (x > 0)return inner1();
        return inner2();
    }

尾调用的核心是指函数执行的是最后一步操作,本质是函数调用另一个函数时其本身是否被释放掉。我们知道函数在调用时内存会形成一个调用记录,也就是函数调用帧,保存着函数的执行位置与变量信息等内容。函数被执行时调用帧被入栈,执行完成之后会出栈。而尾调用的特殊之处在于其最后一步是调用另一个函数,也就意味着其本身的调用记录信息已不再需要,可以被内部函数的调用记录取代。这就是"尾调用优化"(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。"尾调用优化"的意义也在于此。

    function inner1 () {
        console.log('tail call');
    }
    function inner2 () {}
    function outer () {
        return inner2();
    }
    outer();
    
    /*
     * 执行js,把main主程序推入调用栈中并执行,发现需要调用outer
     * 将 outer 入栈,执行 outer 发现需要调用 inner2
     * 将 inner2 入栈,执行 inner2 发现需要调用 inner1
     * 将 inner1 入栈,执行 inner1 发现需要调用 console.log
     * 将 console.log 入栈,执行 console.log 并打印结果
     * 出栈 console.log
     * 出栈 inner1
     * 出栈 inner2
     * 出栈 outer
     * 出栈 main js程序执行完毕
    */

尾递归

递归是指函数执行时调用自身,而尾递归是指尾调用自身,即是函数最后一步执行的是自身。尾递归之所以作为概念且被讨论,是因为我们都知道递归是是十分占用内存的,因此在运行时需要同时保存成百上千个调用记录,且特别容易造成“栈溢出”错误(stack overflow)。而尾递归由于仅需保存最后一步的调用记录,无需存储之前的调用记录,从而不会造成栈溢出问题,避免内存大量占用。

function climbStairs(n: number): number {
    if (n === 1) return 1;
    if (n === 2) return 2;
    return climbStairs(n - 1) + climbStairs(n - 2);
};

如上是使用递归求解爬楼梯问题,由于需要保留每一步的调用记录,当 n 较大时极其容易造成溢出,且执行时间很容易超限。

如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。

function climbStairsByTailCall(n: number, ac1 = 1 , ac2 = 1): number {
    if (n <= 1) return ac2;
    return climbStairsByTailCall(n - 1, ac2, ac1 + ac2);
}

function climbStairs(n: number): number {
    return climbStairsByTailCall(n);
};

image.png 由此可见,“尾调用优化”对递归操作具有重要意义,因此一些函数式编程语言也将其写入了语言规格。其中 ECMAScript(6及以上) 也明确规定“尾调用优化”的实现。这就是意味着,ECMAScript(6及以上) 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。但目前只有 Safari 浏览器支持尾调用优化,Chrome 和 Firefox 暂都不支持。