为啥使用循环从数组的开始迭代到结束比迭代开始到结束和结束到开始更快?

Posted

技术标签:

【中文标题】为啥使用循环从数组的开始迭代到结束比迭代开始到结束和结束到开始更快?【英文标题】:Why is using a loop to iterate from start of array to end faster than iterating both start to end and end to start?为什么使用循环从数组的开始迭代到结束比迭代开始到结束和结束到开始更快? 【发布时间】:2018-04-13 10:12:08 【问题描述】:

给定一个具有.length 100 的数组,其中包含在相应索引处具有值099 的元素,其中要求找到等于n 的数组元素:51

为什么使用循环从数组的开始迭代到结束比同时迭代开始到结束和结束到开始更快?

const arr = Array.from(length: 100, (_, i) => i);
const n = 51;
const len = arr.length;

console.time("iterate from start");
for (let i = 0; i < len; i++) 
  if (arr[i] === n) break;

console.timeEnd("iterate from start");

const arr = Array.from(length: 100, (_, i) => i);
const n = 51;
const len = arr.length;

console.time("iterate from start and end");
for (let i = 0, k = len - 1; i < len && k >= 0; i++, k--) 
  if (arr[i] === n || arr[k] === n) break;

console.timeEnd("iterate from start and end");

jsperfhttps://jsperf.com/iterate-from-start-iterate-from-start-and-end/1

【问题讨论】:

不得不询问您是否在不止一个浏览器中完成了此操作:p 您确实意识到在第二个代码的每次迭代中都会发生更多事情,对吗?额外检查 &gt;=|| 和另一个 === @JaromandaX 是的。 Chromium 和 Firefox 的结果相同,从头到尾迭代最快。预期的结果是从开始到结束和结束到开始比仅仅开始到结束要快,因为应该在arr[i] === n 之前达到arr[k] === n,从9951 比从0 到更少的步骤51; 5148 分别 不过,每次迭代都会发生更多事情,也许不能在运行中轻松“优化”? @JaromandaX 第二个对象查找需要额外的时间吗?每个开始到结束和结束到开始是否应该有两个单独的循环?还是在两个不同的块中仍然是相同的代码? 老兄,我是最后一个真正询问基准测试和此类内容的人 - 如果某件事需要 101 毫秒而不是 80 毫秒,我真的不在乎:p 【参考方案1】:

答案很明显:

更多的操作需要更多的时间。

判断代码的速度时,看它会执行多少操作。只需一步一步计算它们。每条指令都将占用一个或多个 CPU 周期,并且运行时间越长。不同的指令占用不同数量的周期大多无关紧要 - 虽然数组查找可能比整数算术更昂贵,但它们基本上都需要恒定的时间,如果有太多,它会主导我们算法的成本。

在您的示例中,您可能希望单独计算几种不同类型的操作:

比较 增量/减量 数组查找 条件跳转

(我们可以更细化,例如计算变量获取和存储操作,但这些并不重要 - 反正一切都在寄存器中 - 它们的数量基本上与其他操作成线性关系)。

现在您的两个代码都迭代了大约 50 次 - 它们打破循环的元素位于数组的中间。忽略一些错误,这些是计数:

               |  forwards  | forwards and backwards
---------------+------------+------------------------
>=/===/<       |       100  |                   200
++/--          |        50  |                   100
a[b]           |        50  |                   100
&&/||/if/for   |       100  |                   200

考虑到这一点,并不意外做两次工作需要相当长的时间。

我也会回答你们 cmets 的几个问题:

第二次对象查找需要额外的时间吗?

是的,每个单独的查找都很重要。它们不能一次执行,或者优化为单个查找(可以想象,如果它们查找相同的索引)。

每个开始到结束和结束到开始应该有两个单独的循环吗?

与操作数量无关,只与它们的顺序有关。

或者,换一种说法,在数组中查找元素的最快方法是什么?

没有关于顺序的“最快”,如果您不知道元素在哪里(并且它们是均匀分布的),您必须尝试每个索引。任何订单——甚至是随机订单——都一样。但是请注意,您的代码更糟糕,因为它会在未找到元素时查看每个索引两次 - 它不会停在中间。

但仍有一些不同的方法可以对这样的循环进行微优化 - 请查看 these benchmarks。

let (仍然?)比var 慢,请参阅Why is using `let` inside a `for` loop so slow on Chrome? 和Why is let slower than var in a for loop in nodejs?。事实上,循环体范围的这种拆解(大约 50 次)确实支配了您的运行时 - 这就是为什么您的低效代码并非完全慢一倍。 与0 比较比与长度比较快一点,这使得向后循环具有优势。见Why is iterating through an array backwards faster then forwards、javascript loop performance - Why is to decrement the iterator toward 0 faster than incrementing 和Are loops really faster in reverse? 一般情况下,请参阅What's the fastest way to loop through an array in JavaScript?:它从引擎更新变为引擎更新。不要做任何奇怪的事情,编写惯用的代码,这样会得到更好的优化。

【讨论】:

我添加了您的 JSPerf 测试的另一个修订版,显示了一些通常优于正常循环的展开示例:jsperf.com/iterate-from-start-iterate-from-start-and-end/9 请注意,这是要理解的:***.com/questions/38111355/…【参考方案2】:

@Bergi 是正确的。更多的操作就是更多的时间。为什么?更多的 CPU 时钟周期。 时间实际上是执行代码所需的时钟周期的参考。 为了了解细节,您需要查看机器级代码(如汇编级代码)以找到真正的证据。每个CPU(核心?)时钟周期可以执行一条指令,那么你执行了多少条指令?

自从为嵌入式应用程序编写摩托罗拉 CPU 以来,我已经很长时间没有计算时钟周期了。如果您的代码花费的时间更长,那么它实际上会生成更大的机器代码指令集,即使循环更短或运行次数相同。

永远不要忘记,您的代码实际上是被编译成一组 CPU 将要执行的命令(内存指针、指令代码级指针、中断等)。这就是计算机的工作方式,并且在微控制器级别(如 ARM 或摩托罗拉处理器)更容易理解,但对于我们今天运行的复杂机器也是如此。

您的代码根本无法按照您编写的方式运行(听起来很疯狂吧?)。它在编译为机器级指令时运行(编写编译器并不有趣)。数学表达式和逻辑可以编译成一大堆汇编、机器级代码,这取决于编译器选择如何解释它(它是位移等,还记得二进制数学吗?)

参考: https://software.intel.com/en-us/articles/introduction-to-x64-assembly

您的问题很难回答,但正如@Bergi 所说,操作越多时间越长,但为什么呢?执行代码所需的时钟周期越多。双核、四核、线程、汇编(机器语言)很复杂。但是没有代码在你写的时候被执行。 C++、C、Pascal、JavaScript、Java,除非你用汇编语言编写(即使编译成机器代码)但它更接近实际执行代码。

精通 CS,您将开始计算时钟周期和排序时间。您可能会在机器指令集上创建自己的语言。

大多数人说谁在乎?今天的内存很便宜,CPU 的速度越来越快。

但有一些关键应用程序需要 10 毫秒,需要立即中断等。

商业、美国宇航局、核电站、国防承包商、一些机器人技术,你懂的。 . .

我投票让它继续前进。

干杯, 伍基

【讨论】:

甚至机器代码的执行方式都不像今天的 CPU 上写的那样 :-) 是的,这是真的。就像在兔子洞里追爱丽丝一样。感谢 Gigaflops 的技术之神,这让很多事情变得无关紧要。【参考方案3】:

由于您要查找的元素始终大致位于数组的中间,因此您应该期望从数组的开头和结尾向内走的版本大约是刚刚开始的版本的两倍开始。

每次变量更新都需要时间,每次比较都需要时间,而且您要做的次数是它们的两倍。由于您知道在此版本中终止循环需要少一到两次迭代,因此您应该推断它会花费大约两倍的 CPU 时间。

这个策略仍然是O(n) 时间复杂度,因为它只查看每个项目一次,当项目靠近列表中心时,情况会更糟。如果它接近尾声,这种方法将具有更好的预期运行时间。例如,尝试在两者中查找第 90 项。

【讨论】:

【参考方案4】:

选择的答案非常好。我想补充一点:试试findIndex(),比使用循环快2-3倍:

const arr = Array.from(length: 900, (_, i) => i);
const n = 51;
const len = arr.length;

console.time("iterate from start");
for (let i = 0; i < len; i++) 
  if (arr[i] === n) break;

console.timeEnd("iterate from start");

console.time("iterate using findIndex");
var i = arr.findIndex(function(v) 
  return v === n;
);
console.timeEnd("iterate using findIndex");

【讨论】:

【参考方案5】:

这里的其他答案涵盖了主要原因,但我认为一个有趣的补充可能是提到缓存。

一般来说,顺序访问数组会更有效,尤其是对于大型数组。当您的 CPU 从内存中读取数组时,它还会将附近的内存位置提取到缓存中。这意味着当您获取元素n 时,元素n+1 也可能被加载到缓存中。现在,这些天缓存相对很大,所以你的 100 int 数组可能很适合缓存。但是,在一个更大的数组上,顺序读取会比在数组的开头和结尾之间切换更快。

【讨论】:

以上是关于为啥使用循环从数组的开始迭代到结束比迭代开始到结束和结束到开始更快?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 std::variant 用开始和结束迭代器编译?

用于迭代循环和打印开始和结束标记的标准模式

flask jinja2内置变量

Jinja2-loop循环计数内置变量

STL 算法的反向迭代

迭代器