深入思考递归引发的栈溢出

439 阅读3分钟

「这是我参与2022首次更文挑战的第13天,活动详情查看:2022首次更文挑战

前言

前段时间进行低代码平台开发的过程中,碰到了一个相对棘手的问题:在页面编辑的过程,进行某个节点的复制的过程中,如果节点树深度太大的话,会出现栈溢出的报错。研究了一下发现,是因为其中有一段代码进行节点树的深度复制(递归拷贝)导致的。

原始复制

首先,先看一下之前的深度复制方法。由于之前没有进行全面的考虑,直接采用了深度优先的递归调用方式。

    function copyNode() {
      ...
    }

    function deepCopyNode (originNode) {
      const rootNode = copyNode(originNode);

      if (Array.isArray(originNode?.childern)) {
        rootNode.childern = originNode.childern.map(node => deepCopyNode(node));
      }

      return rootNode;
    }

栈溢出的原因

栈溢出是由于C语言系列没有内置检查机制来确保复制到缓冲区的数据不得大于缓冲区的大小,因此当这个数据足够大的时候,将会溢出缓冲区的范围

进行递归拷贝的时候,不断的创建执行上下文并压入调用栈,由于存在引用关系,所以栈内存得不到释放。当栈内存达到阈值的时候,这时就会报错栈溢出:超过了最大栈调用大小(Maximum call stack size exceeded)

解决方案

1. 广度优先递归

对之前的递归方式进行优化,从根节点开始遍历,然后遍历深度为1的所有节点,再处理深度为2的所有节点,知道所有的节点都遍历完成。其实,广度优先递归采用的队列的方式:先进先出,及时将调用栈进行释放,从而解决栈溢出问题。

优化之后的代码,删除了部分敏感代码,只留下了大体实现逻辑:

    function copyNode() {
      ...
    }

    function deepCopyNode(origin) {
      const rootNode = copyNode(origin);

      const uncopiedNodeList = origin.children.map((leftNode) => ({
        parent: rootNode.children,
        child: leftNode,
      }));

      while (uncopiedNodeList.length) {
        const { parent, child } = uncopiedNodeList.shift();
        const node = copyNode(child);

        parent.push(node);

        if (!child.children?.length) {
          continue;
        }

        child.children.forEach((leftNode) => {
          uncopiedNodeList.push({
            parent: node.children,
            child: leftNode,
          });
        });
      }
    }

2. 尾递归调用

尾调用是ES6中提出的一种解决递归耗费内存的新方案。

尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

官方提供的尾递归调用的实例:

    function factorial(n, total) {
      if (n === 1) return total;
      return factorial(n - 1, n * total);
    }

结合官方示例,我大体实现了尾递归拷贝节点:

    function copyNode() {
      ...
    }

    function deepCopyNode (originNode) {
      if (!Array.isArray(originNode)) {
        originNode = [originNode];
      }

      originNode.forEach(node => copyNode(node))

      if (Array.isArray(originNode?.childern)) {
        return deepCopyNode(originNode.childern);
      }
    }

3. 设置深度阈值

其实,在解决这种问题的时候,还有一种很简单的方式:那就是设置深度阈值。一旦递归的深度超过了深度阈值,那么就抛弃掉这部分节点。当然,这种方案不适用于这里的业务场景,所以我就不做具体代码展示了。

结语

我这里采用的是第一种方式来解决的栈溢出问题,所以第二种尾递归调用的实现略显瑕疵,没有实现之前实际业务中的具体需求,仅供参考。

栈溢出的问题看似不是很显眼,但是一旦出现对于用户体验来说及其不友好,所以再使用递归调用的时候一定要注意。