【问题标题】:Why is a recursive deep equality check faster than an iterative version in Javascript?为什么递归深度相等检查比 Javascript 中的迭代版本更快?
【发布时间】:2020-07-15 18:41:05
【问题描述】:

我注意到我发现的所有深度相等实现都使用递归,理论上迭代形式应该更快。但是,它对我来说有点慢,我不明白为什么。

假设数据是JSON.parse 的结果(即基元、普通对象和数组)。

递归:

function equals1(x, y) {
  if (x === y) return true;

  if (Array.isArray(x) && Array.isArray(y)) {
    if (x.length !== y.length) return false;

    for (let i = 0; i < x.length; i++) {
      if (!equals1(x[i], y[i])) return false;
    }
    return true;
  }

  if ((typeof x !== 'object') || (typeof y !== 'object')) return false;

  const xKeys = Object.keys(x);
  const yKeys = Object.keys(y);
  if (xKeys.length !== yKeys.length) return false;
  for (const k of xKeys) {
    if (!y.hasOwnProperty(k)) return false;

    if (!equals1(x[k], y[k])) return false;
  }

  return true;
}

迭代:

function equals2(a, b) {
  const stack = [a, b];
  let idx = 2;
  while (idx > 0) {
    const x = stack[idx - 1];
    const y = stack[idx - 2];
    idx -= 2;

    if (x === y) continue;

    if (Array.isArray(x) && Array.isArray(y)) {
      if (x.length !== y.length) return false;
  
      for (let i = 0; i < x.length; i++) {
        idx += 2;
        if (idx > stack.length) stack.push(x[i], y[i]);
        else {
          stack[idx - 1] = x[i];
          stack[idx - 2] = y[i];
        }
      }
    } else {
      if ((typeof x !== 'object') || (typeof y !== 'object')) return false;

      const xKeys = Object.keys(x);
      const yKeys = Object.keys(y);
      if (xKeys.length !== yKeys.length) return false;
      for (const k of xKeys) {
        if (!y.hasOwnProperty(k)) return false;

        idx += 2;
        if (idx > stack.length) stack.push(x[k], y[k]);
        else {
          stack[idx - 1] = x[k];
          stack[idx - 2] = y[k];
        }
      }
    }
  }

  return true;
}

我使用索引而不是传统的stack.pop 方法,因为它稍微快一些。

JSPerf:https://jsperf.com/deep-object-compare-123/1

数据来自Reddit:https://www.reddit.com/r/javascript.json

对我来说,迭代版本在 Chrome 和 Edge 上慢 20-25%,在 Firefox 上速度相同。我尝试预先分配堆栈数组并删除continue,但它并没有改变结果。据我所知,JS引擎可以优化尾递归函数,但这不是尾递归。

有什么想法吗?

【问题讨论】:

  • theoretically the iterative form should be faster .... 你是从哪里得到这个概念的? ...迭代算法总是与递归算法不同,因此哪个更快取决于算法。不涉及“理论”假设。
  • 你在几秒钟内完成了更多额外的工作。
  • 只是好奇为什么你认为迭代会更快。从代码复杂度的差异来看,迭代版本有更多的分支和内存操作。我不知道在典型的 JS 引擎中调用堆栈有多昂贵,但考虑到最终在 CPU 级别发生的基本操作,您的测试结果对我来说很有意义。
  • 我认为创建堆栈帧应该比查找数组索引更昂贵
  • 我不认为在 JS 中操作数组比在底层 C 实现中操作堆栈帧更快。

标签: javascript algorithm performance recursion


【解决方案1】:

这两种方法的主要区别在于,您的递归函数对第一个不相等的值执行正常的depth-first search,而您的迭代函数在搜索之前将数组/对象的所有子项放到stack 最后一个孩子。这会导致stack 数组变得比递归函数的调用堆栈要大得多,并且它会将整个数据结构不必要地复制到异构数组中,而不是将值保存在局部变量中。

【讨论】:

  • 在 JavaScript 中,当您创建大型数据结构时,您还可能会遇到垃圾收集方面的性能问题。
  • @btilly 不过不在这里。数据结构不是特别复杂,可以由 GC 轻松处理,因为它只是在函数结束时超出范围。
  • 我亲身经历过,只是创建了太长的平面数组。然而那是很久以前的事了——希望这些天 GC 更聪明。
  • 这是推测,还是您进行了定量测试来支持您的主张?您会看到,因为如果您选择不同的数据结构,迭代版本可能会更快,请参阅我的答案中的 jsperf 链接。
  • @mathheadinclouds 我自己没有做过定量测试,只是给出了一个定性的答案来解释 OP 所经历的差异。
【解决方案2】:

您对一个单一的数据结构进行单一测试,然后您的意思是您可以得出结论,一般而言,递归相等检查比迭代检查更快?你是认真的吗?从那一次测试中,你什么也不能得出结论。我也不能得出任何一个迭代算法获胜的测试的结论(很多)。有很多递归和迭代方法来进行深度相等测试(和其他事情),并且有很多数据结构。我做了更多个测试,我的结果非常果断:INCONCLUSIVE,见下文。

但首先,有一件事:

您的代码中有一个小错误:您没有正确检查null 的情况。如果一个参数是null,另一个是非空对象,则会抛出错误。

这可以通过添加以下行轻松解决:

if ((x===null)||(y===null)) return false;

equals1 中,将其放在if (x === y) return true; 之后,在equals2 中将其放在continue 之后。如果两个参数都是null,那么插入行之前的行会做正确的事情并且使代码无法到达插入的行,如果只有一个参数是null,那么插入的行将处理@返回 987654334@,而不是抛出错误。

我不得不承认我发现你的迭代版本很难阅读。就是看不懂,但是很想看。它是如何工作的?请您给我们解惑好吗?它是使用 LIFO(后进先出)堆栈,对应于深度优先搜索,还是基于不同的东西?我真的很想知道。

我根据您的equals1 编写了另一个迭代版本,使用 FIFO 队列(先进先出,对应于广度优先搜索) - 我发现这非常容易阅读。

我在 jsperf.com 中添加了 3 个测试用例,它们是:

redditData

linkedList1Knodes

linkedList10Knodes

所有 3 个测试都使用您在此处引用的 equals1equals2(修复了 null 错误)和我编写的 FIFO 版本。

第一个测试使用您问题中的原始 reddit 数据,第二个使用具有 1000 个节点的链表,第三个使用具有 10000 个节点的链表。

第一次测试确认您的迭代版本比递归版本慢约 20%,而我的 FIFO 版本介于两者之间,比递归版本慢约 10%。

在第二个测试中,您的迭代版本是明显的赢家,它比递归版本快得多,FIFO 排在最后(它比递归版本慢一点点)。

在第三次测试中,递归版本崩溃 - 堆栈溢出错误,您的迭代版本再次获胜(FIFO 慢了大约 30%)

抱歉,我无法向您解释为什么会这样。一个适当的解释可能必须阐明许多不同的方面,我不认为有一个“单一的大象”可以解释这一切;也许添加更多异构测试用例是有意义的,而不是仅来自 reddit 的一个示例(即使那是“真实世界”......)

这里是 FIFO 迭代版本

function equals_FIFO(x, y){
    if (x===y) return true;
    if ((x===null)||(y===null)||((typeof x)!=='object')||((typeof y)!=='object')) return false;
    var xStack = [x], yStack = [y];
    var currentIdx = 0;
    var item1, item2, kid1, kid2, keys1, keys2, i, key;
    while (currentIdx<xStack.length){
        item1 = xStack[currentIdx];
        item2 = yStack[currentIdx];
        keys1 = Object.keys(item1);
        keys2 = Object.keys(item2);
        if (keys1.length!==keys2.length) return false;
        for (i=0; i<keys1.length; i++){
            key = keys1[i];
            if (!item2.hasOwnProperty(key)) return false;
            kid1 = item1[key];
            kid2 = item2[key];
            if (kid1!==kid2){
                if ((kid1===null)||(kid2===null)||((typeof kid1)!=='object')||((typeof kid2)!=='object')) return false;
                xStack.push(kid1);
                yStack.push(kid2);
            }
        }
        currentIdx++;
    }
    return true;
}

【讨论】:

  • "我发现您的迭代版本很难阅读。我只是无法理解" - 它与您的非常相似,但使用的是实际的 LIFO 堆栈(不是FIFO queue)并在数组上进行适当的循环(not a for…in enumerations)。它确实有一个奇怪的微优化if (idx &gt; stack.length) 在不重用数组空间时使用push 而不是赋值。在链表上,深度优先与广度优先不会有任何区别,因为它只是深度,但宽度不变。您的 bfs 队列的增长速度与遍历的速度完全相同。
  • 但是,您确实有一个优化,您可以检查if (kid1!==kid2)将其放入队列中。 OP 的代码没有这个,他们把所有的值都放在栈上。
  • @Bergi 如果将相当于“我的优化”(if (kid1!==kid2)) 的代码添加到其中,OP 的代码会是什么样子?我真的很想知道通过这种优化是否可以击败递归。
  • 我知道我没有在数组上做“正确的循环”;想要简洁,所以故意跳过。我不确定数组上的“正确循环”是否值得打字。是吗?
  • 你会在第一个循环中添加if (x[i] === y[i]) continue;,在第二个循环中添加if (x[k] === y[k]) continue;
猜你喜欢
  • 2012-01-26
  • 2019-03-25
  • 2013-09-21
  • 2015-03-26
  • 2021-01-07
  • 1970-01-01
  • 1970-01-01
  • 2020-08-30
  • 2012-06-07
相关资源
最近更新 更多