【问题标题】:Performance of Recursive Programming [closed]递归编程的性能[关闭]
【发布时间】:2019-12-11 01:02:57
【问题描述】:

如果数组列表太大,下面的递归代码会导致堆栈溢出。如何解决这个问题并仍然保留递归模式?

var list = readHugeList();
var nextListItem = function() {
  var item = list.pop();
  if (item) {
    // process the list item...
    nextListItem();
  }
};

【问题讨论】:

  • 一个递归模式,如果递归的次数足够多,会导致堆栈溢出。如果需要递归模式,那么溢出的可能性也会存在
  • 这听起来像是一个面试问题...
  • 这暗示readHugeList() 将整个负载“某物”读入内存...但是如果pop() 被定制为从流中读取呢?
  • 查找“递归蹦床”可能会给你一些指导。

标签: javascript arrays performance recursion


【解决方案1】:

使用事件队列

在这种情况下,您可以轻松地将递归卸载到事件队列中。

var list = readHugeList();
var nextListItem = function() {
  var item = list.pop();
  if (item) {
    console.log("processing", item);
    
    // schedule the processing of the next list item
    setTimeout(nextListItem);
  }
};

nextListItem();


function readHugeList() {
  return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; //example
}

这是您可以做的最简单的事情 - setTimeout 将安排 nextListItem 再次调用,但首先会清除当前堆栈帧。这样,您就不会导致堆栈溢出,并且可以处理任意大的输入。

注意:这不会通过递归导致堆栈溢出,但是,它会缓慢。问题是the minimum timeout length is 4ms,所以如果每次调用不到4ms就可以完成,那么整个操作会花费更长的时间。例如,如果每个操作需要 4ms,那么通过setTimeout 延迟将需要 4 倍的时间。即使删除了最小超时(例如in Node.jsusing setImmediate in Node.js),它也会比其他替代方案更快但仍然慢 - 这仍然会等待整个事件循环完成。虽然比强制的最少 4 毫秒等待要快,但它仍然是一个额外的开销。

但是,此方法确实允许同时发生其他事情,因此即使它较慢,它也可能是防止阻塞的合适实现。这样做的一种用途是创建增量更新。否则它可能会允许其他不相关的任务发生,因此它们不会被延迟。所以,它是一种有用的技术,但可能不是最好的——如果你有足够大的数据集导致堆栈溢出,那么你肯定会遇到时间问题。

使用蹦床

另一种方法是使用a trampoline。这基本上是tail call optimisation 的手动实现。这是一个示例实现:

function trampoline(fn) {
  while (typeof fn === "function") {
    fn = fn();
  }

  return fn;
};

这是一个简单的基础实现——它不考虑参数,但更好的实现会考虑。蹦床有库实现,不用自己写。

无论如何,蹦床还需要更改递归函数 - 而不是返回 result,它必须返回需要评估的 thunk。所以,这是它的工作原理:

var list = readHugeList();
var nextListItem = function() {
  var item = list.pop();
  if (item) {
    console.log("processing", item);
    
    // a thunk for the next list item
    return () => nextListItem();
  }
};

trampoline(nextListItem);


function trampoline(fn) {
  while (typeof fn === "function") {
    fn = fn();
  }

  return fn;
};

function readHugeList() {
  return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; //example
}

(注意:return () => nextListItem() 也可以像 return nextListItem 一样简单,但我想在这里明确)

蹦床将继续调用该函数,直到它停止返回更多要评估的 thunk。并且每个 thunk 本身都将包含对该函数的递归调用。因此,在通过 fn() 评估 thunk 时,您现在可以 间接 调用函数,而不是 直接 调用它自己。而且由于这是在循环中完成的,它仍然可以防止堆栈溢出具有非常深的嵌套递归。

第三个选项使用事件队列并作弊

好吧,这并不是真正的作弊,但你可以给自己一个优势。如前所述,将递归委托给事件队列将解决堆栈溢出问题,但会引入速度问题,因为它对每个延迟操作施加了最小延迟。只是为了演示这将如何影响性能,下面是一个示例:

var list = readHugeList();
var nextListItem = function() {
  var item = list.pop();
  if (item) {
    console.log("processing", item);
    
    // schedule the processing of the next list item
    setTimeout(nextListItem);
  } else {
    console.log("operation took (in ms):", performance.now() - startTime)
  }
};

var startTime = performance.now();
nextListItem();

function readHugeList() {
  //generate an array of 100 sequential numbers
  return Array.from({length: 100}, (_, i) => i);
}

递归地浏览一百个项目的列表大约需要一秒钟(至少在我的机器上)。无论花费多少时间,它都太长了——一百个项目并不多,甚至不会首先导致调用堆栈溢出。我不会尝试使用可能导致溢出的数字,因为它会工作,但等待它完成会非常长。

但是,您仍然可以通过 microtasks 来利用事件循环。一个非常快速的总结是事件循环处理两种类型的任务

  • 宏任务(例如 setTimeout 设置的内容)- 它们具有 4 毫秒的最小延迟,并且还允许其他事情发生,例如浏览器动画。
  • 微任务 - 它们具有更高的优先级,没有任何最小延迟,并且会尽快执行。

如果您卸载到 micro 任务队列而不是宏任务队列,那么您将获得更快的处理速度。为此,您可以在浏览器中使用 Promise - 已解决的 Promise 会将所需的任何进一步处理(使用 .then 添加的回调)添加到微任务队列。

这是它的外观和行为方式:

var list = readHugeList();
var nextListItem = function() {
  var item = list.pop();
  if (item) {
    console.log("processing", item);
    
    // schedule the processing of the next list item
    Promise.resolve()//resolve immediately
      .then(nextListItem); //adds a microtask
  } else {
    console.log("operation took (in ms):", performance.now() - startTime)
  }
};

var startTime = performance.now();
nextListItem();

function readHugeList() {
  //generate an array of 10 000 sequential numbers
  return Array.from({length: 10000}, (_, i) => i);
}

这大约需要二分半(在我的机器上)。这与setTimeout 相当,只是微任务版本处理两个数量级 更多的项目。那是 10 000,而只有 100。

【讨论】:

  • 对建议使用事件队列的大粗体文本投了反对票。请参阅 this Q&A 以进一步说明为什么 setTimeout 是一个糟糕的选择
  • @user633183 但这显然是the intended answer。我以为我认出了这种模式,所以我提到了事件队列,因为在这些情况下有预期的答案。事实证明,这实际上是同一个问题。不确定原始来源是什么。
【解决方案2】:

或者简单地重写 ex。像这样:

var list = (function readHugeList() { return [3, 1, 4, 1, 5, 9, 2] })();
var nextListItem = function() {
  do {
    var item = list.pop();
    if (item) {
      // process the list item...
      console.log("Processing item:", item);
      continue;
    }
  } while (item);
};

nextListItem();

【讨论】:

  • “并且仍然保留递归模式”是 OP 问题的一部分。
【解决方案3】:

您可以将递归调用移到函数的末尾以获取 tail call optimization

这样替换了函数调用的返回值,不增加栈。

var list = readHugeList();
var nextListItem = function() {
        var item = list.pop();
        if (!item) return;
        // process the list item...
        return nextListItem();
    };

【讨论】:

  • 值得注意的是,环境必须支持 TCO。我认为 Node.js(嗯,V8)最近(去年?)放弃了他们的 TCO 实现。目前我不确定 TCO 支持的状态
  • 对,我认为,这更像是一个操作的理论问题。在现实生活中,没有人会为了获取数组中的一项而进行递归。生成器/迭代器也可以。
  • 谢谢大家。非常感谢。 VLAZ 和 Nina Scholz
猜你喜欢
  • 2020-05-19
  • 2013-04-29
  • 1970-01-01
  • 2016-04-03
  • 1970-01-01
  • 2011-08-17
  • 2011-02-09
  • 2021-10-30
  • 1970-01-01
相关资源
最近更新 更多