Este problema es un problema de optimización bien conocido / "clásico" para JavaScript, causado por el hecho de que las cadenas de JavaScript son "inmutables" y la adición por concatenación de incluso un solo carácter a una cadena requiere la creación de, incluida la asignación de memoria y la copia a , una cadena completamente nueva.
Desafortunadamente, la respuesta aceptada en esta página es incorrecta, donde "incorrecto" significa un factor de rendimiento de 3x para cadenas simples de un carácter, y 8x-97x para cadenas cortas repetidas más veces, a 300x para oraciones repetidas, e infinitamente incorrecto cuando tomando el límite de las razones de complejidad de los algoritmos n
hasta el infinito. Además, hay otra respuesta en esta página que es casi correcta (basada en una de las muchas generaciones y variaciones de la solución correcta que ha circulado por Internet en los últimos 13 años). Sin embargo, esta solución "casi correcta" pierde un punto clave del algoritmo correcto y causa una degradación del rendimiento del 50%.
JS Performance Results para la respuesta aceptada, la otra respuesta de mejor desempeño (basada en una versión degradada del algoritmo original en esta respuesta), y esta respuesta usando mi algoritmo creado hace 13 años
~ Octubre de 2000 Publiqué un algoritmo para este problema exacto que fue ampliamente adaptado, modificado, y finalmente mal entendido y olvidado. Para solucionar este problema, en agosto de 2008 publiqué un artículo en http://www.webreference.com/programming/javascript/jkm3/3.html que explica el algoritmo y lo utiliza como un ejemplo de optimizaciones simples de JavaScript de uso general. Por ahora, Web Reference ha borrado mi información de contacto e incluso mi nombre de este artículo. Y una vez más, el algoritmo ha sido ampliamente adaptado, modificado, luego mal entendido y en gran parte olvidado.
Algoritmo JavaScript original de repetición / multiplicación de cadenas por Joseph Myers, alrededor de Y2K como una función de multiplicación de texto dentro de Text.js; publicado en agosto de 2008 en este formulario por Web Reference:
http://www.webreference.com/programming/javascript/jkm3/3.html (El artículo utilizó la función como ejemplo de optimizaciones de JavaScript, que es la única para los extraños nombre "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;
}
Dos meses después de la publicación de ese artículo, esta misma pregunta fue publicada en Stack Overflow y pasó desapercibida hasta ahora, cuando aparentemente el algoritmo original para este problema ha sido olvidado nuevamente. La mejor solución disponible en esta página de desbordamiento de pila es una versión modificada de mi solución, posiblemente separada por varias generaciones. Desafortunadamente, las modificaciones arruinaron la optimización de la solución. De hecho, al cambiar la estructura del bucle de mi original, la solución modificada realiza un paso adicional completamente innecesario de duplicación exponencial (uniendo así la cadena más grande utilizada en la respuesta adecuada consigo mismo un tiempo extra y luego descartándola).
A continuación se presenta una discusión sobre algunas optimizaciones de JavaScript relacionadas con todas las respuestas a este problema y en beneficio de todos.
Técnica: evitar referencias a objetos o propiedades de objetos
Para ilustrar cómo funciona esta técnica, utilizamos una función de JavaScript de la vida real que crea cadenas de cualquier longitud necesaria. Y como veremos, ¡se pueden agregar más optimizaciones!
Una función como la que se usa aquí es crear relleno para alinear columnas de texto, formatear dinero o rellenar datos de bloque hasta el límite. Una función de generación de texto también permite la entrada de longitud variable para probar cualquier otra función que opera en texto. Esta función es uno de los componentes importantes del módulo de procesamiento de texto JavaScript.
A medida que avanzamos, cubriremos dos más de las técnicas de optimización más importantes mientras desarrollamos el código original en un algoritmo optimizado para crear cadenas. El resultado final es una función industrial de alto rendimiento que he usado en todas partes, alineando los precios de los artículos y los totales en los formularios de pedido de JavaScript, el formato de datos y el formato de correo electrónico / mensaje de texto y muchos otros usos.
Código original para crear cadenas stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
La sintaxis está aquí es clara. Como puede ver, ya hemos usado variables de función local, antes de continuar con más optimizaciones.
Tenga en cuenta que hay una referencia inocente a una propiedad de objeto s.length
en el código que perjudica su rendimiento. Peor aún, el uso de esta propiedad de objeto reduce la simplicidad del programa al suponer que el lector conoce las propiedades de los objetos de cadena de JavaScript.
El uso de esta propiedad de objeto destruye la generalidad del programa de computadora. El programa supone que x
debe ser una cadena de longitud uno. Esto limita la aplicación de la stringFill1()
función a cualquier cosa, excepto la repetición de caracteres individuales. Incluso los caracteres individuales no se pueden usar si contienen múltiples bytes como la entidad HTML
.
El peor problema causado por este uso innecesario de una propiedad de objeto es que la función crea un bucle infinito si se prueba en una cadena de entrada vacía x
. Para verificar la generalidad, aplique un programa a la menor cantidad de entrada posible. Un programa que falla cuando se le pide que exceda la cantidad de memoria disponible tiene una excusa. Un programa como este que se bloquea cuando se le pide que no produzca nada es inaceptable. A veces, el código bonito es un código venenoso.
La simplicidad puede ser un objetivo ambiguo de la programación de computadoras, pero generalmente no lo es. Cuando un programa carece de un nivel razonable de generalidad, no es válido decir: "El programa es lo suficientemente bueno hasta donde llega". Como puede ver, el uso de la string.length
propiedad evita que este programa funcione en una configuración general y, de hecho, el programa incorrecto está listo para provocar un bloqueo del navegador o del sistema.
¿Hay alguna manera de mejorar el rendimiento de este JavaScript y de solucionar estos dos problemas serios?
Por supuesto. Solo usa números enteros.
Código optimizado para crear cadenas stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Código de tiempo para comparar stringFill1()
ystringFill2()
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);
El éxito hasta ahora de stringFill2()
stringFill1()
toma 47.297 microsegundos (millonésimas de segundo) para llenar una cadena de 100 bytes, y stringFill2()
toma 27.68 microsegundos para hacer lo mismo. Eso es casi una duplicación en el rendimiento al evitar una referencia a una propiedad de objeto.
Técnica: evite agregar cadenas cortas a cadenas largas
Nuestro resultado anterior se veía bien, muy bueno, de hecho. La función mejorada stringFill2()
es mucho más rápida debido al uso de nuestras dos primeras optimizaciones. ¿Lo creerías si te dijera que se puede mejorar para que sea mucho más rápido de lo que es ahora?
Sí, podemos lograr ese objetivo. En este momento necesitamos explicar cómo evitamos agregar cadenas cortas a cadenas largas.
El comportamiento a corto plazo parece ser bastante bueno, en comparación con nuestra función original. A los científicos informáticos les gusta analizar el "comportamiento asintótico" de una función o algoritmo de programa informático, lo que significa estudiar su comportamiento a largo plazo probándolo con entradas más grandes. A veces, sin realizar más pruebas, uno nunca se da cuenta de las formas en que un programa de computadora podría mejorarse. Para ver qué sucederá, crearemos una cadena de 200 bytes.
El problema que aparece con stringFill2()
Usando nuestra función de temporización, encontramos que el tiempo aumenta a 62.54 microsegundos para una cadena de 200 bytes, en comparación con 27.68 para una cadena de 100 bytes. Parece que el tiempo debería duplicarse para hacer el doble de trabajo, pero en su lugar se triplicó o cuadruplicó. Según la experiencia de programación, este resultado parece extraño, porque en todo caso, la función debería ser un poco más rápida ya que el trabajo se realiza de manera más eficiente (200 bytes por llamada a la función en lugar de 100 bytes por llamada a la función). Este problema tiene que ver con una propiedad insidiosa de las cadenas de JavaScript: las cadenas de JavaScript son "inmutables".
Inmutable significa que no puede cambiar una cadena una vez que se ha creado. Al agregar un byte a la vez, no estamos usando un byte más de esfuerzo. En realidad, estamos recreando la cadena completa más un byte más.
En efecto, para agregar un byte más a una cadena de 100 bytes, se requieren 101 bytes de trabajo. Analicemos brevemente el costo computacional para crear una cadena de N
bytes. El costo de agregar el primer byte es 1 unidad de esfuerzo computacional. El costo de agregar el segundo byte no es una unidad sino 2 unidades (copiar el primer byte a un nuevo objeto de cadena y agregar el segundo byte). El tercer byte requiere un costo de 3 unidades, etc.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)
. El símbolo O(N^2)
se pronuncia Big O de N al cuadrado, y significa que el costo computacional a largo plazo es proporcional al cuadrado de la longitud de la cadena. Crear 100 caracteres requiere 10,000 unidades de trabajo, y crear 200 caracteres requiere 40,000 unidades de trabajo.
Es por eso que tomó más del doble de tiempo crear 200 caracteres que 100 caracteres. De hecho, debería haber tardado cuatro veces más. Nuestra experiencia en programación fue correcta en el sentido de que el trabajo se realiza de manera un poco más eficiente para cadenas más largas y, por lo tanto, solo tardó aproximadamente tres veces más. Una vez que la sobrecarga de la llamada a la función se vuelve insignificante en cuanto a la longitud de una cadena que estamos creando, en realidad tomará cuatro veces más tiempo crear una cadena dos veces más larga.
(Nota histórica: este análisis no se aplica necesariamente a las cadenas en el código fuente, por ejemplo html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
, dado que el compilador del código fuente de JavaScript puede unir las cadenas antes de convertirlas en un objeto de cadena de JavaScript. Hace solo unos años, la implementación de KJS de JavaScript se congelará o bloqueará al cargar largas cadenas de código fuente unidas por signos más. Desde el momento de la computación O(N^2)
no fue difícil crear páginas web que sobrecargaran el navegador web Konqueror o Safari, que usaban el núcleo del motor KJS JavaScript. Me encontré con este problema cuando estaba desarrollando un lenguaje de marcado y un analizador de lenguaje de marcado JavaScript, y luego descubrí qué estaba causando el problema cuando escribí mi script para JavaScript Incluye.)
Claramente, esta rápida degradación del rendimiento es un gran problema. ¿Cómo podemos lidiar con eso, dado que no podemos cambiar la forma en que JavaScript maneja las cadenas como objetos inmutables? La solución es usar un algoritmo que recrea la cadena lo menos posible.
Para aclarar, nuestro objetivo es evitar agregar cadenas cortas a cadenas largas, ya que para agregar la cadena corta, también se debe duplicar toda la cadena larga.
Cómo funciona el algoritmo para evitar agregar cadenas cortas a cadenas largas
Aquí hay una buena manera de reducir la cantidad de veces que se crean nuevos objetos de cadena. Concatene longitudes de cadena más largas juntas de modo que se agregue más de un byte a la vez a la salida.
Por ejemplo, para hacer una cadena de longitud 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 */
Hacer esto requirió crear una cadena de longitud 1, crear una cadena de longitud 2, crear una cadena de longitud 4, crear una cadena de longitud 8 y, finalmente, crear una cadena de longitud 9. ¿Cuánto hemos ahorrado?
Viejo costo C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45
.
Nuevo costo C(9) = 1 + 2 + 4 + 8 + 9 = 24
.
Tenga en cuenta que tuvimos que agregar una cadena de longitud 1 a una cadena de longitud 0, luego una cadena de longitud 1 a una cadena de longitud 1, luego una cadena de longitud 2 a una cadena de longitud 2, luego una cadena de longitud 4 a una cadena de longitud 4, luego una cadena de longitud 8 a una cadena de longitud 1, para obtener una cadena de longitud 9. Lo que estamos haciendo puede resumirse como evitar agregar cadenas cortas a cadenas largas, o en otro palabras, tratando de concatenar cadenas que son de igual o casi igual longitud.
Para el antiguo costo computacional encontramos una fórmula N(N+1)/2
. ¿Existe una fórmula para el nuevo costo? Sí, pero es complicado. Lo importante es que lo es O(N)
, por lo que duplicar la longitud de la cadena duplicará aproximadamente la cantidad de trabajo en lugar de cuadruplicarlo.
El código que implementa esta nueva idea es casi tan complicado como la fórmula del costo computacional. Cuando lo lea, recuerde que >>= 1
significa desplazarse a la derecha en 1 byte. Entonces, si n = 10011
es un número binario, entonces n >>= 1
da como resultado el valor n = 1001
.
La otra parte del código que tal vez no reconozca es el bit a bit y el operador, escrito &
. La expresión n & 1
evalúa verdadero si el último dígito binario de n
es 1, y falso si el último dígito binario de n
es 0.
Nueva altamente eficientes en stringFill3()
función de
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Parece feo a simple vista, pero su rendimiento es nada menos que encantador.
Veamos qué tan bien funciona esta función. Después de ver los resultados, es probable que nunca olvide la diferencia entre un O(N^2)
algoritmo y un O(N)
algoritmo.
stringFill1()
toma 88.7 microsegundos (millonésimas de segundo) para crear una cadena de 200 bytes, stringFill2()
toma 62.54 y stringFill3()
toma solo 4.608. ¿Qué hizo que este algoritmo fuera mucho mejor? Todas las funciones aprovecharon el uso de variables de función local, pero aprovechar las técnicas de optimización segunda y tercera agregó una mejora de veinte veces al rendimiento de stringFill3()
.
Análisis más profundo
¿Qué hace que esta función particular expulse a la competencia del agua?
Como he mencionado, la razón por la que ambas funciones stringFill1()
y su stringFill2()
ejecución son tan lentas es que las cadenas de JavaScript son inmutables. La memoria no se puede reasignar para permitir que se agregue un byte más a la vez a los datos de cadena almacenados por JavaScript. Cada vez que se agrega un byte más al final de la cadena, la cadena completa se regenera de principio a fin.
Por lo tanto, para mejorar el rendimiento del script, uno debe calcular previamente cadenas de mayor longitud concatenando dos cadenas juntas antes de tiempo, y luego acumulando recursivamente la longitud de cadena deseada.
Por ejemplo, para crear una cadena de bytes de 16 letras, primero se calculará previamente una cadena de dos bytes. Luego, la cadena de dos bytes se reutilizaría para calcular previamente una cadena de cuatro bytes. Luego, la cadena de cuatro bytes se reutilizaría para calcular previamente una cadena de ocho bytes. Finalmente, dos cadenas de ocho bytes se reutilizarían para crear la nueva cadena deseada de 16 bytes. En total, se tuvieron que crear cuatro cadenas nuevas, una de longitud 2, una de longitud 4, una de longitud 8 y una de longitud 16. El costo total es 2 + 4 + 8 + 16 = 30.
A la larga, esta eficiencia se puede calcular sumando en orden inverso y utilizando una serie geométrica que comience con un primer término a1 = N y que tenga una relación común de r = 1/2. La suma de una serie geométrica viene dada por a_1 / (1-r) = 2N
.
Esto es más eficiente que agregar un carácter para crear una nueva cadena de longitud 2, crear una nueva cadena de longitud 3, 4, 5, y así sucesivamente, hasta 16. El algoritmo anterior utilizó ese proceso de agregar un solo byte a la vez , y el costo total sería n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136
.
Obviamente, 136 es un número mucho mayor que 30, por lo que el algoritmo anterior toma mucho, mucho más tiempo para construir una cadena.
Para comparar los dos métodos, puede ver qué tan rápido es el algoritmo recursivo (también llamado "divide y vencerás") en una cadena de longitud 123,457. En mi computadora FreeBSD, este algoritmo, implementado en la stringFill3()
función, crea la cadena en 0.001058 segundos, mientras que la stringFill1()
función original crea la cadena en 0.0808 segundos. La nueva función es 76 veces más rápida.
La diferencia en el rendimiento aumenta a medida que la longitud de la cadena se hace más grande. En el límite a medida que se crean cadenas cada vez más grandes, la función original se comporta aproximadamente como C1
(constantes) veces N^2
, y la nueva función se comporta como C2
(constantes) veces N
.
De nuestro experimento podemos determinar el valor de C1
ser C1 = 0.0808 / (123457)2 = .00000000000530126997
y el valor de C2
ser C2 = 0.001058 / 123457 = .00000000856978543136
. En 10 segundos, la nueva función podría crear una cadena que contiene 1,166,890,359 caracteres. Para crear esta misma cadena, la función anterior necesitaría 7.218.384 segundos de tiempo.
¡Esto es casi tres meses en comparación con diez segundos!
Solo respondo (varios años tarde) porque mi solución original a este problema ha estado flotando en Internet durante más de 10 años, y aparentemente los pocos que la recuerdan todavía no la entienden. Pensé que al escribir un artículo al respecto aquí ayudaría:
Optimizaciones de rendimiento para JavaScript de alta velocidad / Página 3
Desafortunadamente, algunas de las otras soluciones presentadas aquí todavía son algunas de las que tomarían tres meses para producir la misma cantidad de salida que una solución adecuada crea en 10 segundos.
Quiero tomarme el tiempo de reproducir parte del artículo aquí como respuesta canónica en Stack Overflow.
Tenga en cuenta que el algoritmo de mejor rendimiento aquí se basa claramente en mi algoritmo y probablemente fue heredado de la adaptación de tercera o cuarta generación de otra persona. Desafortunadamente, las modificaciones resultaron en la reducción de su rendimiento. La variación de mi solución presentada aquí tal vez no entendió mi for (;;)
expresión confusa que se parece al bucle infinito principal de un servidor escrito en C, y que simplemente fue diseñada para permitir una declaración de interrupción cuidadosamente posicionada para el control del bucle, la forma más compacta de evite replicar exponencialmente la cadena un tiempo innecesario adicional.