美文网首页
你可能不知道的递归

你可能不知道的递归

作者: BlindingDark | 来源:发表于2019-03-04 19:21 被阅读0次

    递归 尾递归 CPS trampoline memoize 缓存


    本文使用 JavaScript 进行描述。
    本文简要介绍了几种常见的递归用法。文中出现的代码仅供示意,不代表可以直接用于生产环境。

    作者水平有(很)限(菜),疏漏之处敬请谅解。


    尾递归

    先来提一下尾递归,顾名思义就是递归调用的位置在函数的最后。

    如果你对递归和尾递归等相关概念还不太熟悉,可以看看张大胖学递归归这篇文章。

    这里需要解释的是,尾递归本身只是一种书写的方法,就是字面意思上的把递归调用的位置放到了函数最后,仅此而已,并不能提升递归的效率。
    真正起到魔法作用,其实是尾递归调用优化。宣称自己是函数式语言的都支持这种优化。你也可以用“一个语言是否支持尾递归优化”来判断这种语言是否是函数式语言。;)
    JavaScript 主流实现仍然缺少对尾递归调用优化的支持,所以本文的某些例子仍然有可能导致溢出。
    不过,在不引起歧义的前提下,也可以用尾递归来指代尾递归调用优化。

    蹦蹦床

    刚才说某些语言并不支持尾递归调用优化,难道除了干等着语言的实现者来实现,就没有办法了么?
    有!蹦蹦床就是其中一种比较好玩的东西。看名字就比较好玩,它的运行原理也很像一张蹦床。为什么说它是个蹦床呢?先看代码:

    function trampoline(maybeFuction) {
      while (maybeFuction && maybeFuction instanceof Function) {
        // 如果参数是个函数,那么执行这个函数,拿到执行结果,作为下次循环的判断条件
        maybeFuction = maybeFuction();
      }
    
      // 如果不是函数,则就会跳出循环,返回最终结果
      return maybeFuction;
    }
    

    那么怎么使用呢?首先,我们需要对原有的尾递归函数做一点非常微小的改动。比如这是一个尾递归形式的斐波那契数列:

    // 修改前
    function fibonacci(n, a = 0, b = 1) {
      if (n === 0) {
        return a;
      } else {
        return fibonacci(n - 1, b, a + b);
      }
    }
    

    只需要把原来递归的动作包裹在函数中即可:

    // 修改后
    function fibonacci(n, a = 0, b = 1) {
      if (n === 0) {
        return a; // 蹦床会直接返回这个值
      } else {
        // 把要执行的动作放在函数里,这样蹦床就会弹起这个动作
        return () => fibonacci(n - 1, b, a + b);
      }
    }
    

    然后套上蹦床来执行,就不会发生栈溢出了。

    trampoline(fibonacci(10000));
    

    我们大致分析一下执行流程。如果返回值是函数,则证明接下来的动作其实并没有执行完毕,那就弹起来执行这个函数,如果再执行结果还是函数,那就再弹起来再执行,直到不是函数,则证明整个调用过程结束,可以返回结果了。
    所以这张蹦床是只会弹起函数,如果不是函数,啪叽,摔地上了。

    这种作法消灭函数调用栈的原理是,修改原来的递归函数,把递归的动作包裹在函数中直接返回,这样自然不会占用函数调用栈,最后其实真正触发执行的是交给了蹦床,而蹦床里用的是循环语句,也不会占用额外空间。

    除了上面说的用法,蹦床还能用来改造相互递归调用,原理是一致的。感兴趣的同学可以自己查阅一下,在此就不赘述了。

    Continuation-passing style

    CPS (Continuation-passing style) 指的是一种编程风格,这种风格会把函数将要执行的下一步动作包裹在函数中,在需要递归的时候,把动作传入递归参数中,在下次需要的时候再执行。
    是不是和上面的蹦床挺像?其实是有差距的,蹦床是一个“第三方”帮助函数,而 CPS 一种递归写法,两者甚至还能结合使用。
    我们这里举一个二叉树遍历的栗子来进行说明。

    先看看一般的递归写法,想要遍历二叉树,首先要有一颗二叉树,大概长这样。

    /************
    
          1
         / \
        2   5
       / \
      3   4
    
    *************/
    
    tree = {
      value: 1,
      left: {
        value: 2,
        left: {
          value: 3
        },
        right: {
          value: 4
        }
      },
      right: {
        value: 5
      }
    }
    

    先来写一下前序遍历。我们回忆一下前序遍历的定义:首先访问根节点,然后访问左子树,最后访问右子树。对于左子树和右子树,再次用前序遍历的方式进行访问,直到没有节点可以访问为止。
    这是个典型的递归定义,所以我们把根节点作为函数参数,进行递归处理

    function preorder(nowNode) {
      if (nowNode != null) {
        console.log(nowNode.value); // 前序遍历,所以先打印根节点
        preorder(nowNode.left); // 然后进行左子树遍历
        preorder(nowNode.right); // 右子树遍历
      }
    }
    
    preorder(tree);
    

    虽然这种写法很简单很清晰,但是,为了按要求实现遍历,必须要等到左子树遍历执行完才能执行右子树的遍历。也就意味着这种写法在递归发生时需要保存调用堆栈信息,没有办法享受到尾递归优化。
    这里就需要 CPS 登场了。
    首先,我们要想办法把接下来的动作能随参数携带,这样在递归的时候就可以直接返回,也就是所谓的尾递归。
    但是,遍历动作没办法以简单的数值形式进行传递,所以我们把它包裹在函数中,也就是说,我们把接下来要做的动作放进函数里,传递给下一次递归。
    所以,我们需要一个参数,就取名叫 continuation 吧

    function preorder(nowNode, continuation) {
      if (nowNode != null) {
        console.log(nowNode.value); // 前序遍历,先打印根节点
        // 注意下面这行代码,依然先遍历左子树,所以 nowNode.left 作为递归的第一个参数
        // 遍历右子树的动作属于“接下来要做的事情”,所以包装在函数中,作为下次递归的 continuation 参数。
        // 而当前这次递归的 continuation 就变成了“接下来之后”“再接下来”做的事情,所以就是下次递归 continuation 参数中的 continuation 参数。
        // 比较绕,理解一下
        return preorder(nowNode.left, () => preorder(nowNode.right, continuation));
      } else {
        // 当前节点为 null 也就是说到底了,此时就要开始做“接下来要做的事情”了。
        return continuation();
      }
    }
    

    那么 continuation 的初始值是什么呢?第一次其实没有“接下来要做的事情”,所以初始值就是啥都不做的空函数。所以这样调用即可:

    preorder(tree, () => {})
    

    最后我们配合蹦床函数,使得在不支持尾递归优化的环境下也不会溢出,只需要非常简单的在递归的位置套上一层函数。

    function preorder(nowNode, continuation) {
      if (nowNode != null) {
        console.log(nowNode.value);
        // 递归的位置包一层函数,让蹦床工作起来
        return () => preorder(nowNode.left, () => preorder(nowNode.right, continuation));
      } else {
        return continuation();
      }
    }
    
    trampoline(preorder(tree, () => {}));
    

    可见,这种作法并不是完全消灭了递归传递的链条,而是把它从栈移动到了函数里,而函数所在的内存区域并没有像栈那样受限,所以本来在栈中会溢出的就不会溢出了。
    最后再给出中序遍历和后续遍历的代码,以供对比参考。注意三种遍历方式的打印语句分别处于哪一层 continuation。

    // 中序
    function inorder(nowNode, continuation) {
      if (nowNode == null) {
        return continuation();
      } else {
        return inorder(
          nowNode.left,
          () => {
            console.log(nowNode.value);
            return inorder(nowNode.right, continuation);
          }
        );
      }
    }
    
    // 后序
    function postorder(nowNode, continuation) {
      if (nowNode == null) {
        return continuation();
      } else {
        return postorder(
          nowNode.left,
          () => postorder(
            nowNode.right,
            () => {
              console.log(nowNode.value);
              return continuation();
            }
          )
        );
      }
    }
    

    这里留个问题,聪明的你能不能试着把 fibonacci 改写成 CPS 风格的代码呢?

    CPS 好是好,就是太费脑子。那么有没有可能自动把一个递归函数转换成 CPS 呢?这样不就太爽啦?写出简单的递归代码,然后自动生成 CPS 代码,然后自动套上蹦床!小白啥也不做也能写出高效的递归了!

    你想的真美好,现实是…还真有!甚至有些语言都内置了通用的自动转换的函数!真的是小白福音!
    鉴于篇幅的原因(其实因为我不会),对于通用的 CPS 转换的原理感兴趣的同学可以阅读文末给出的参考文章(需要 lambda 演算相关的知识才能看懂)。

    在递归中使用缓存

    递归存在的另一个问题就是会重复计算相同的函数调用。(其实非递归形式也存在这种现象。)
    一种通常的作法就是将函数结果缓存起来,下次遇到相同的调用时,直接返回上次计算的结果。

    比如这是一个经典的斐波那契数列的递归写法:

    function fibonacci(n) {
      if (n == 0 || n == 1) {
        return n;
      } else {
        return fibonacci(n - 1) + fibonacci(n - 2);
      }
    }
    

    我们可以像这样手动的给它加上缓存:

    let cache = {};
    
    function fibonacci(n) {
      if (n in cache) { // 命中缓存
        value = cache[n];
      } else {
        if (n == 0 || n == 1) {
          value = n;
        } else {
          value = fibonacci(n - 1) + fibonacci(n - 2);
        }
    
        cache[n] = value; // 存入缓存
      }
    }
    

    有了缓存,就可以免去多次重复计算的开销。但是这种必须修改原函数才能实现缓存的方式显然不够通用。
    我们可以写一个高阶函数,使用函数的参数列表作为缓存的 key,利用闭包来存放缓存,最后生成一个带缓存的函数。

    function memoize(func) {
      let cache = {};
    
      return function() {
        // arguments 是函数的参数信息的封装
        // [...arguments] 可以把它转成数组,作为缓存的 key
        // 如果参数不在缓存中,就使用 apply 调用原函数得到结果,并缓存
        if (arguments in cache) {
          return cache[[...arguments]];
        } else {
          return cache[[...arguments]] = func.apply(this, arguments);
        }
      };
    }
    

    这样就可以非侵入地定义任意带缓存的函数了:

    // 原始的未经修改的 fibonacci
    function fibonacci(n) {
      if (n == 0 || n == 1) {
        return n;
      } else {
        return fibonacci(n - 1) + fibonacci(n - 2);
      }
    }
    
    // 生成出带缓存的版本
    let memoized_fibonacci = memoize(fibonacci);
    
    memoized_fibonacci(10);
    

    这种作法所依赖的前提条件是,递归的函数必须是 引用透明 的。
    引用透明的意思也就是说,函数的返回值只与参数有关。如果 f 函数具有引用透明的性质,那么你今天调用 f(1) 的结果是 1 ,那么明天、明年调用它,在任何代码段中调用它,它的结果始终是 1
    比如如果一个函数读取了数据库中的值,那么可能过一会儿调用的返回值就不同了,那它就不是一个引用透明的函数。
    显然,引用透明的函数可以被安全缓存。

    总结

    递归中有太多有趣知识,但是工作中却很少使用,尤其是对于广大使用非函数式编程的程序员。这里抛砖引玉,希望能让各种程序员都能体会到递归的美妙!
    本人也是小白级别,如果阅读过程中发现了什么错误疏漏、晦涩语句,欢迎在下面留言交流。


    参考文章

    性能优化:memoization
    尾递归与Continuation
    JavaScript调用栈、尾递归和手动优化
    CPS变换与CPS变换编译
    On Recursion, Continuations and Trampolines


    相关文章

      网友评论

          本文标题:你可能不知道的递归

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