这个问题是 JavaScript 的一个众所周知的/“经典”优化问题,原因是 JavaScript 字符串是“不可变的”,并且通过将单个字符连接到字符串来添加,包括为并复制到一个全新的字符串。
不幸的是,此页面上接受的答案是错误的,其中“错误”意味着简单的单字符字符串的性能因子为 3 倍,短字符串重复多次的性能因子为 8x-97x,重复句子的性能因子为 300x,并且当将算法的复杂度比率限制为n 时,无限错误。此外,此页面上还有另一个答案几乎是正确的(基于过去 13 年在互联网上流传的正确解决方案的许多代和变体之一)。然而,这种“几乎正确”的解决方案错过了正确算法的关键点,导致性能下降 50%。
JS Performance Results for the accepted answer, the top-performing other answer (based on a degraded version of the original algorithm in this answer), and this answer using my algorithm created 13 years ago
~ 2000 年 10 月,我针对这个确切的问题发布了一个算法,该算法被广泛地改编、修改,但最终却很少被理解和遗忘。为了解决这个问题,我在 2008 年 8 月发表了一篇文章http://www.webreference.com/programming/javascript/jkm3/3.html,解释了算法并将其用作简单的通用 JavaScript 优化示例。到目前为止,Web Reference 已经从这篇文章中删除了我的联系信息,甚至我的名字。再一次,该算法已被广泛采用、修改,然后却鲜为人知,并在很大程度上被遗忘了。
原始字符串重复/乘法JavaScript算法由
Joseph Myers,大约 Y2K,作为 Text.js 中的文本乘法函数;
2008 年 8 月由 Web Reference 以这种形式发布:
http://www.webreference.com/programming/javascript/jkm3/3.html(该
文章使用该函数作为 JavaScript 优化的示例,
这是奇怪名称“stringFill3”的唯一名称。)
/*
* Usage: stringFill3("abc", 2) == "abcabc"
*/
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
在那篇文章发表后的两个月内,同样的问题被发布到 Stack Overflow 并一直在我的雷达下飞行,直到现在,显然这个问题的原始算法再次被遗忘了。此 Stack Overflow 页面上可用的最佳解决方案是我的解决方案的修改版本,可能由几代人分开。不幸的是,这些修改破坏了解决方案的最优性。事实上,通过改变我原来的循环结构,修改后的解决方案执行了一个完全不需要的额外的指数复制步骤(因此将正确答案中使用的最大字符串与自身连接额外的时间,然后丢弃它)。
下面将讨论一些与此问题的所有答案相关的 JavaScript 优化,并造福于所有人。
技术:避免引用对象或对象属性
为了说明这种技术的工作原理,我们使用了一个真实的 JavaScript 函数,它可以创建所需长度的字符串。正如我们将看到的,可以添加更多优化!
这里使用的功能是创建填充以对齐文本列、格式化货币或将块数据填充到边界。文本生成函数还允许可变长度输入,以测试对文本进行操作的任何其他函数。该函数是JavaScript文本处理模块的重要组成部分之一。
随着我们的继续,我们将介绍另外两种最重要的优化技术,同时将原始代码开发为用于创建字符串的优化算法。最终结果是我在任何地方都使用过的工业级高性能函数——在 JavaScript 订单表单、数据格式和电子邮件/文本消息格式以及许多其他用途中对齐项目价格和总计。
创建字符串的原始代码stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
这里的语法很清楚。如您所见,在进行更多优化之前,我们已经使用了局部函数变量。
请注意,代码中有一个对对象属性s.length 的无辜引用会损害其性能。更糟糕的是,使用这个对象属性会假设读者知道 JavaScript 字符串对象的属性,从而降低了程序的简单性。
使用这个对象属性破坏了计算机程序的通用性。该程序假定x 必须是长度为1 的字符串。这将stringFill1() 函数的应用限制为除了单个字符的重复之外的任何内容。如果单个字符包含多个字节,例如 HTML 实体 &nbsp;,则即使单个字符也无法使用。
这种不必要地使用对象属性导致的最严重问题是,如果在空输入字符串x 上进行测试,该函数会创建一个无限循环。要检查一般性,请将程序应用于尽可能少的输入。当被要求超过可用内存量时崩溃的程序有一个借口。像这样的程序在被要求不产生任何内容时崩溃是不可接受的。有时漂亮的代码是有毒的代码。
简单性可能是计算机编程的一个模棱两可的目标,但通常不是。当一个程序缺乏任何合理的通用性水平时,说“程序就其发展而言已经足够好”是无效的。如您所见,使用string.length 属性会阻止此程序在一般设置下运行,实际上,不正确的程序已准备好导致浏览器或系统崩溃。
有没有办法提高这个 JavaScript 的性能,同时解决这两个严重的问题?
当然。只需使用整数。
优化了创建字符串的代码stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
比较stringFill1()和stringFill2()的时序代码
function testFill(functionToBeTested, outputSize) {
var i = 0, t0 = new Date();
do {
functionToBeTested('x', outputSize);
t = new Date() - t0;
i++;
} while (t < 2000);
return t/i/1000;
}
seconds1 = testFill(stringFill1, 100);
seconds2 = testFill(stringFill2, 100);
stringFill2()迄今为止的成功
stringFill1() 需要 47.297 微秒(百万分之一秒)来填充 100 字节的字符串,stringFill2() 需要 27.68 微秒来完成相同的操作。通过避免引用对象属性,这几乎使性能翻了一番。
技巧:避免在长字符串中添加短字符串
我们之前的结果看起来不错——实际上非常好。由于使用了我们的前两个优化,改进的函数stringFill2() 更快。如果我告诉你它可以改进到比现在快很多倍,你会相信吗?
是的,我们可以实现这个目标。现在我们需要解释如何避免将短字符串附加到长字符串。
与我们的原始函数相比,短期行为似乎相当不错。计算机科学家喜欢分析函数或计算机程序算法的“渐近行为”,这意味着通过使用更大的输入对其进行测试来研究其长期行为。有时不做进一步的测试,人们永远不会意识到可以改进计算机程序的方法。为了看看会发生什么,我们将创建一个 200 字节的字符串。
stringFill2() 出现的问题
使用我们的计时函数,我们发现 200 字节字符串的时间增加到 62.54 微秒,而 100 字节字符串的时间为 27.68 微秒。做两倍工作的时间似乎应该加倍,但实际上是三倍或四倍。从编程经验来看,这个结果似乎很奇怪,因为如果有的话,函数应该稍微快一些,因为工作效率更高(每个函数调用 200 字节而不是每个函数调用 100 字节)。这个问题与 JavaScript 字符串的一个隐蔽属性有关:JavaScript 字符串是“不可变的”。
不可变意味着字符串一旦创建就不能更改。通过一次添加一个字节,我们不会再消耗一个字节。我们实际上是在重新创建整个字符串加上一个字节。
实际上,要向 100 字节的字符串添加一个字节,需要 101 个字节的工作量。让我们简要分析一下创建N 字节字符串的计算成本。添加第一个字节的成本是 1 个计算工作单位。添加第二个字节的成本不是一个单位而是 2 个单位(将第一个字节复制到新的字符串对象以及添加第二个字节)。第三个字节需要 3 个单位的成本,以此类推。
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)。符号O(N^2)发音为Big O of N squared,这意味着长期的计算成本与字符串长度的平方成正比。创建 100 个字符需要 10,000 个工作单位,创建 200 个字符需要 40,000 个工作单位。
这就是为什么创建 200 个字符所用的时间是创建 100 个字符所用时间的两倍多。事实上,它应该花费四倍的时间。我们的编程经验是正确的,因为对于较长的字符串,工作效率稍高一些,因此只用了大约三倍的时间。一旦函数调用的开销对于我们创建的字符串的长度变得可以忽略不计,实际上创建两倍长的字符串需要四倍的时间。
(历史记录:这种分析不一定适用于源代码中的字符串,例如html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n',因为JavaScript源代码编译器可以在将字符串组合成JavaScript字符串对象之前将它们连接在一起。仅仅几年以前,JavaScript 的 KJS 实现在加载加号连接的长串源代码时会死机或崩溃。由于计算时间为O(N^2),因此制作超载 Konqueror Web 浏览器或 Safari 的网页并不难,这使用的是KJS JavaScript引擎核心。我在开发标记语言和JavaScript标记语言解析器时第一次遇到这个问题,然后我在编写JavaScript Includes脚本时发现了导致问题的原因。)
显然,这种性能的快速下降是一个大问题。鉴于我们无法改变 JavaScript 将字符串作为不可变对象处理的方式,我们该如何处理呢?解决方案是使用尽可能少地重新创建字符串的算法。
澄清一下,我们的目标是避免将短字符串添加到长字符串中,因为要添加短字符串,还必须复制整个长字符串。
算法如何避免将短字符串添加到长字符串中
这是减少创建新字符串对象次数的好方法。将较长的字符串连接在一起,以便一次将一个以上的字节添加到输出中。
例如,制作一个长度为N = 9的字符串:
x = 'x';
s = '';
s += x; /* Now s = 'x' */
x += x; /* Now x = 'xx' */
x += x; /* Now x = 'xxxx' */
x += x; /* Now x = 'xxxxxxxx' */
s += x; /* Now s = 'xxxxxxxxx' as desired */
这样做需要创建一个长度为 1 的字符串,创建一个长度为 2 的字符串,创建一个长度为 4 的字符串,创建一个长度为 8 的字符串,最后创建一个长度为 9 的字符串。我们节省了多少成本?
旧成本C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45.
新费用C(9) = 1 + 2 + 4 + 8 + 9 = 24。
请注意,我们必须将长度为 1 的字符串添加到长度为 0 的字符串中,然后将长度为 1 的字符串添加到长度为 1 的字符串中,然后将长度为 2 的字符串添加到长度为 2 的字符串中,然后再添加一个字符串将长度为 4 的字符串转换为长度为 4 的字符串,然后将长度为 8 的字符串转换为长度为 1 的字符串,从而获得长度为 9 的字符串。我们所做的可以概括为避免在长字符串中添加短字符串,或者换句话说,尝试将长度相等或几乎相等的字符串连接在一起。
对于旧的计算成本,我们找到了一个公式N(N+1)/2。新成本有公式吗?是的,但它很复杂。重要的是它是O(N),因此将字符串长度加倍大约会使工作量增加一倍而不是四倍。
实现这一新想法的代码几乎与计算成本的公式一样复杂。阅读时请记住,>>= 1 表示右移 1 个字节。所以如果n = 10011 是一个二进制数,那么n >>= 1 的结果就是n = 1001。
您可能不认识的代码的另一部分是按位和运算符,写作&。如果n 的最后一个二进制数字为1,则表达式n & 1 计算结果为真,如果n 的最后一个二进制数字为0,则计算结果为假。
新的高效stringFill3()函数
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
在未经训练的人眼中看起来很丑,但它的表现却不亚于可爱。
让我们看看这个函数的性能如何。看到结果后,您可能永远不会忘记O(N^2) 算法和O(N) 算法之间的区别。
stringFill1() 需要 88.7 微秒(百万分之一秒)来创建一个 200 字节的字符串,stringFill2() 需要 62.54,stringFill3() 只需 4.608。是什么让这个算法变得更好?所有函数都利用了局部函数变量,但利用第二和第三个优化技术,stringFill3() 的性能提高了 20 倍。
深入分析
是什么让这个特殊的功能让竞争对手脱颖而出?
正如我所提到的,stringFill1() 和 stringFill2() 这两个函数运行如此缓慢的原因是 JavaScript 字符串是不可变的。无法重新分配内存以允许一次多一个字节附加到 JavaScript 存储的字符串数据。每在字符串末尾增加一个字节,就会从头到尾重新生成整个字符串。
因此,为了提高脚本的性能,必须通过提前将两个字符串连接在一起来预先计算更长的字符串,然后递归地建立所需的字符串长度。
例如,要创建一个 16 个字母的字节字符串,首先要预先计算一个两个字节的字符串。然后将重用两个字节的字符串来预先计算一个四字节的字符串。然后将重新使用四字节字符串来预先计算八字节字符串。最后,将重用两个 8 字节字符串来创建所需的 16 字节新字符串。总共需要创建四个新字符串,一个长度为 2,一个长度为 4,一个长度为 8,一个长度为 16。总成本为 2 + 4 + 8 + 16 = 30。
从长远来看,这个效率可以通过以相反的顺序相加并使用以第一项 a1 = N 开始并且具有共同比率 r = 1/2 的几何级数来计算。几何级数的总和由a_1 / (1-r) = 2N 给出。
这比添加一个字符来创建一个长度为 2 的新字符串、创建一个长度为 3、4、5 等直到 16 的新字符串更有效。之前的算法使用添加单个字节的过程一次,总成本为n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136。
显然,136 是一个比 30 大得多的数字,因此前面的算法需要很多很多的时间来构建一个字符串。
要比较这两种方法,您可以看到递归算法(也称为“分治法”)在长度为 123,457 的字符串上的速度要快得多。在我的 FreeBSD 计算机上,这个算法在 stringFill3() 函数中实现,在 0.001058 秒内创建字符串,而原始 stringFill1() 函数在 0.0808 秒内创建字符串。新功能速度提高了 76 倍。
随着字符串的长度变大,性能上的差异也越来越大。在创建越来越大的字符串的限制下,原始函数的行为大致类似于C1(常量)乘以N^2,而新函数的行为类似于C2(常量)乘以N。
通过我们的实验,我们可以确定C1 的值为C1 = 0.0808 / (123457)2 = .00000000000530126997,C2 的值为C2 = 0.001058 / 123457 = .00000000856978543136。在 10 秒内,新函数可以创建一个包含 1,166,890,359 个字符的字符串。为了创建相同的字符串,旧函数需要 7,218,384 秒的时间。
与十秒相比,这几乎是三个月!
我只是回答(晚了几年),因为我对这个问题的最初解决方案已经在互联网上流传了 10 多年,而且显然仍然为少数记得它的人所理解。我认为通过在这里写一篇关于它的文章我会有所帮助:
Performance Optimizations for High Speed JavaScript / Page 3
不幸的是,这里介绍的其他一些解决方案仍然需要三个月才能产生与适当解决方案在 10 秒内产生的相同数量的输出。
我想花时间在此处复制部分文章作为 Stack Overflow 上的规范答案。
请注意,这里表现最好的算法显然是基于我的算法,并且可能是从其他人的第 3 代或第 4 代改编继承而来的。不幸的是,这些修改导致其性能降低。我在这里提出的解决方案的变体可能不理解我令人困惑的for (;;) 表达式,它看起来像用 C 编写的服务器的主无限循环,它的设计目的只是为了允许仔细定位的 break 语句进行循环控制,最一种紧凑的方法来避免以指数方式复制字符串一个额外的不必要的时间。