Эта проблема является хорошо известной / «классической» проблемой оптимизации для JavaScript, вызванной тем, что строки JavaScript являются «неизменяемыми», а добавление путем конкатенации даже одного символа в строку требует создания, включая выделение памяти и копирование в нее. , целая новая строка.
К сожалению, принятый ответ на этой странице неправильный, где «неправильный» означает с коэффициентом производительности 3x для простых односимвольных строк и 8x-97x для коротких строк, повторяемых несколько раз, до 300x для повторяющихся предложений и бесконечно неправильным, когда принимая предел отношений сложности алгоритмов, как n
уходит в бесконечность. Кроме того, на этой странице есть еще один ответ, который является почти правильным (на основе одного из многих поколений и вариантов правильного решения, распространенного в Интернете за последние 13 лет). Однако в этом «почти правильном» решении отсутствует ключевой момент правильного алгоритма, что приводит к снижению производительности на 50%.
Результаты выполнения JS для принятого ответа, другого ответа с наибольшей эффективностью (на основе ухудшенной версии исходного алгоритма в этом ответе) и этого ответа с использованием моего алгоритма, созданного 13 лет назад
~ Октябрь 2000 г. Я опубликовал алгоритм для этой конкретной задачи, который был широко адаптирован, модифицирован, а затем, в конце концов, плохо понят и забыт. Чтобы исправить эту проблему, в августе 2008 года я опубликовал статью http://www.webreference.com/programming/javascript/jkm3/3.html, в которой объясняется алгоритм и используется его в качестве примера простой универсальной оптимизации JavaScript. К настоящему времени Web Reference удалил мою контактную информацию и даже мое имя из этой статьи. И снова, алгоритм был широко адаптирован, модифицирован, затем плохо понят и в значительной степени забыт.
Алгоритм JavaScript повторения / умножения исходных строк Джозефа Майерса, около Y2K, как функция умножения текста в Text.js; опубликовано в августе 2008 г. в этой форме посредством веб-ссылки:
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 и оставался у меня на радаре до сих пор, когда очевидно, что первоначальный алгоритм для этой проблемы был снова забыт. Лучшее решение, доступное на этой странице переполнения стека, - это модифицированная версия моего решения, возможно, разделенная на несколько поколений. К сожалению, модификации разрушили оптимальность решения. Фактически, изменяя структуру цикла по сравнению с моим оригиналом, модифицированное решение выполняет совершенно ненужный дополнительный шаг экспоненциального дублирования (таким образом, объединяя наибольшую строку, использованную в правильном ответе, с самим собой дополнительное время, а затем отбрасывая его).
Ниже следует обсуждение некоторых оптимизаций 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
должна быть строка длиной один. Это ограничивает применение stringFill1()
функции чем-либо, кроме повторения отдельных символов. Даже отдельные символы не могут использоваться, если они содержат несколько байтов, таких как объект HTML
.
Худшая проблема, вызванная этим ненужным использованием свойства объекта, заключается в том, что функция создает бесконечный цикл, если проверяется на пустой входной строке 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()
для заполнения 100-байтовой строки требуется 47,297 микросекунд (миллионные доли секунды) и stringFill2()
27,68 микросекунд для того же. Это почти удваивает производительность, избегая ссылки на свойство объекта.
Техника: избегайте добавления коротких строк в длинные
Наш предыдущий результат выглядел хорошо - на самом деле очень хорошо. Улучшенная функция stringFill2()
намного быстрее благодаря использованию наших первых двух оптимизаций. Поверите ли вы этому, если я скажу вам, что его можно улучшить во много раз быстрее, чем сейчас?
Да, мы можем достичь этой цели. Прямо сейчас нам нужно объяснить, как мы не добавляем короткие строки в длинные.
Краткосрочное поведение представляется довольно хорошим по сравнению с нашей первоначальной функцией. Специалистам по информатике нравится анализировать «асимптотическое поведение» алгоритма функции или компьютерной программы, что означает изучение его долговременного поведения путем тестирования его с большими входными данными. Иногда, не проводя дальнейшие тесты, никогда не узнаешь, как можно улучшить компьютерную программу. Чтобы увидеть, что произойдет, мы собираемся создать 200-байтовую строку.
Проблема, которая обнаруживается с stringFill2()
Используя нашу функцию синхронизации, мы находим, что время увеличивается до 62,54 микросекунд для 200-байтовой строки по сравнению с 27,68 для 100-байтовой строки. Кажется, что нужно удвоить время, чтобы выполнить вдвое больше работы, но вместо этого оно утроится или увеличится в четыре раза. Из опыта программирования этот результат кажется странным, потому что, во всяком случае, функция должна быть немного быстрее, поскольку работа выполняется более эффективно (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 из N в квадрате, и это означает, что вычислительные затраты в долгосрочной перспективе пропорциональны квадрату длины строки. Для создания 100 символов требуется 10000 единиц работы, а для создания 200 символов требуется 40000 единиц работы.
Вот почему создание 200 символов заняло более чем вдвое больше времени, чем 100 символов. На самом деле, это должно было занять в четыре раза больше времени. Наш опыт программирования был верным в том, что работа для более длинных строк выполняется немного более эффективно, и, следовательно, это заняло всего около трех раз. Как только издержки на вызов функции становятся незначительными в отношении длины создаваемой строки, на самом деле создание строки в два раза дольше.
(Историческая справка: этот анализ не обязательно применяется к строкам в исходном коде, например html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
, поскольку компилятор исходного кода JavaScript может объединять строки перед тем, как превратить их в строковый объект JavaScript. Всего несколько лет назад реализация KJS JavaScript зависал или зависал при загрузке длинных строк исходного кода, соединенных знаками плюса. Поскольку время вычислений было O(N^2)
несложным, создавать веб-страницы, которые перегружали веб-браузер Konqueror или Safari, в которых использовалось ядро движка JavaScript KJS. Сначала я столкнулся с этой проблемой, когда разрабатывал язык разметки и синтаксический анализатор языка разметки JavaScript, а затем обнаружил причину проблемы, когда написал свой сценарий для JavaScript Include.)
Очевидно, что это быстрое снижение производительности является огромной проблемой. Как мы можем справиться с этим, учитывая, что мы не можем изменить способ обработки строк в JavaScript как неизменных объектов в 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()
для создания 200-байтовой строки требуется 88,7 микросекунды (миллионные доли секунды), stringFill2()
62,54 и stringFill3()
4,608. Что сделало этот алгоритм намного лучше? Все функции воспользовались преимуществами использования локальных переменных функций, но использование второго и третьего методов оптимизации добавило в двадцать раз улучшение производительности stringFill3()
.
Более глубокий анализ
Что заставляет эту особую функцию вырывать конкуренцию из воды?
Как я уже упоминал, причина того, что обе эти функции, stringFill1()
и stringFill2()
работают так медленно, состоит в том, что строки JavaScript являются неизменяемыми. Память не может быть перераспределена, чтобы допускать добавление еще одного байта за раз к строковым данным, хранящимся в JavaScript. Каждый раз, когда в конец строки добавляется еще один байт, вся строка восстанавливается от начала до конца.
Таким образом, чтобы улучшить производительность скрипта, нужно предварительно вычислить строки большей длины, предварительно объединив две строки вместе, а затем рекурсивно выстроив желаемую длину строки.
Например, чтобы создать 16-буквенную байтовую строку, сначала будет предварительно вычислена двухбайтовая строка. Затем двухбайтовая строка будет использоваться повторно для предварительного вычисления четырехбайтовой строки. Затем четырехбайтовая строка будет повторно использована для предварительного вычисления восьмибайтовой строки. Наконец, две восьмибайтовые строки будут повторно использованы для создания желаемой новой строки из 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 лет, и, видимо, все еще плохо понимают те немногие, кто его помнит. Я думал, что, написав статью об этом здесь, я помогу:
Оптимизация производительности для высокоскоростного JavaScript / Page 3
К сожалению, некоторые другие решения, представленные здесь, все еще являются некоторыми из тех, которые потребовали бы три месяца, чтобы произвести то же самое количество продукции, которое правильное решение создает за 10 секунд.
Я хочу потратить время на то, чтобы воспроизвести часть статьи здесь в качестве канонического ответа о переполнении стека.
Обратите внимание, что наиболее эффективный алгоритм здесь явно основан на моем алгоритме и, вероятно, унаследован от чужой адаптации 3-го или 4-го поколения. К сожалению, модификации привели к снижению его производительности. Вариант моего решения, представленный здесь, возможно, не понимал моего запутанного for (;;)
выражения, которое выглядит как основной бесконечный цикл сервера, написанного на C, и которое было просто разработано, чтобы позволить аккуратно расположить оператор останова для управления циклом, самый компактный способ Избегайте экспоненциальной репликации строки еще один лишний раз.