Questo problema è un noto / classico "problema di ottimizzazione" per JavaScript, causato dal fatto che le stringhe JavaScript sono "immutabili" e l'aggiunta mediante concatenazione anche di un singolo carattere in una stringa richiede la creazione, inclusa l'allocazione di memoria per e la copia in , un'intera nuova stringa.
Sfortunatamente, la risposta accettata in questa pagina è sbagliata, dove "errato" significa un fattore di prestazione di 3x per stringhe semplici di un carattere e 8x-97x per stringhe brevi ripetute più volte, a 300x per frasi ripetute e infinitamente sbagliato quando prendendo il limite dei rapporti di complessità degli algoritmi come n
va all'infinito. Inoltre, c'è un'altra risposta in questa pagina che è quasi giusta (basata su una delle tante generazioni e varianti della soluzione corretta che circola su Internet negli ultimi 13 anni). Tuttavia, questa soluzione "quasi giusta" manca un punto chiave dell'algoritmo corretto causando un degrado delle prestazioni del 50%.
Risultati delle prestazioni JS per la risposta accettata, l'altra risposta con le migliori prestazioni (basata su una versione degradata dell'algoritmo originale in questa risposta) e questa risposta utilizzando il mio algoritmo creato 13 anni fa
~ Ottobre 2000 ho pubblicato un algoritmo per questo esatto problema che è stato ampiamente adattato, modificato, quindi alla fine poco compreso e dimenticato. Per porre rimedio a questo problema, nell'agosto 2008 ho pubblicato un articolo http://www.webreference.com/programming/javascript/jkm3/3.html che spiega l'algoritmo e lo utilizza come esempio di semplici ottimizzazioni JavaScript per scopi generici. Ormai Web Reference ha cancellato le mie informazioni di contatto e persino il mio nome da questo articolo. E ancora una volta, l'algoritmo è stato ampiamente adattato, modificato, quindi poco compreso e ampiamente dimenticato.
Algoritmo JavaScript di ripetizione / moltiplicazione di stringhe originale di Joseph Myers, circa Y2K come funzione di moltiplicazione del testo all'interno di Text.js; pubblicato agosto 2008 in questo modulo da Web Reference:
http://www.webreference.com/programming/javascript/jkm3/3.html (L'articolo utilizzava la funzione come esempio di ottimizzazioni JavaScript, che è l'unica per gli strani nome "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;
}
Entro due mesi dalla pubblicazione di quell'articolo, questa stessa domanda fu posta su Stack Overflow e finì sotto il mio radar fino ad ora, quando apparentemente l'algoritmo originale per questo problema è stato nuovamente dimenticato. La migliore soluzione disponibile in questa pagina Stack Overflow è una versione modificata della mia soluzione, eventualmente separata da diverse generazioni. Sfortunatamente, le modifiche hanno rovinato l'ottimalità della soluzione. In effetti, cambiando la struttura del loop dal mio originale, la soluzione modificata esegue un ulteriore passaggio completamente non necessario di duplicazione esponenziale (unendo così la stringa più grande utilizzata nella risposta corretta con se stessa un tempo extra e quindi scartandola).
Di seguito segue una discussione di alcune ottimizzazioni JavaScript relative a tutte le risposte a questo problema e a beneficio di tutti.
Tecnica: evitare riferimenti a oggetti o proprietà degli oggetti
Per illustrare come funziona questa tecnica, utilizziamo una funzione JavaScript nella vita reale che crea stringhe di qualsiasi lunghezza sia necessaria. E come vedremo, è possibile aggiungere ulteriori ottimizzazioni!
Una funzione come quella qui usata è quella di creare un riempimento per allineare colonne di testo, per formattare denaro o per riempire i dati dei blocchi fino al limite. Una funzione di generazione del testo consente inoltre di inserire una lunghezza variabile per testare qualsiasi altra funzione che opera sul testo. Questa funzione è uno dei componenti importanti del modulo di elaborazione del testo JavaScript.
Man mano che procediamo, tratteremo altre due delle più importanti tecniche di ottimizzazione mentre svilupperemo il codice originale in un algoritmo ottimizzato per la creazione di stringhe. Il risultato finale è una funzione industriale e ad alte prestazioni che ho usato ovunque: allineare prezzi e totali degli articoli nei moduli d'ordine JavaScript, formattazione dei dati e formattazione di e-mail / messaggi di testo e molti altri usi.
Codice originale per la creazione di stringhe stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
La sintassi è qui è chiara. Come puoi vedere, abbiamo già utilizzato variabili di funzione locali, prima di passare a ulteriori ottimizzazioni.
Tenere presente che s.length
nel codice è presente un riferimento innocente a una proprietà dell'oggetto che ne danneggia le prestazioni. Ancora peggio, l'uso di questa proprietà dell'oggetto riduce la semplicità del programma assumendo che il lettore sia a conoscenza delle proprietà degli oggetti stringa JavaScript.
L'uso di questa proprietà dell'oggetto distrugge la generalità del programma per computer. Il programma presuppone che x
deve essere una stringa di lunghezza uno. Ciò limita l'applicazione della stringFill1()
funzione a tutto tranne che alla ripetizione di singoli caratteri. Anche i singoli caratteri non possono essere utilizzati se contengono più byte come l'entità HTML
.
Il problema peggiore causato da questo uso non necessario di una proprietà dell'oggetto è che la funzione crea un ciclo infinito se testata su una stringa di input vuota x
. Per verificare la generalità, applicare un programma alla minima quantità possibile di input. Un programma che si arresta in modo anomalo quando viene chiesto di superare la quantità di memoria disponibile ha una scusa. Un programma come questo che si arresta in modo anomalo quando viene chiesto di produrre nulla è inaccettabile. A volte il codice grazioso è un codice velenoso.
La semplicità può essere un obiettivo ambiguo della programmazione per computer, ma generalmente non lo è. Quando un programma manca di un ragionevole livello di generalità, non è valido dire "Il programma è abbastanza buono per quanto va". Come puoi vedere, l'uso della string.length
proprietà impedisce a questo programma di funzionare in un'impostazione generale e, di fatto, il programma errato è pronto a causare un arresto del sistema o del browser.
C'è un modo per migliorare le prestazioni di questo JavaScript e prendersi cura di questi due gravi problemi?
Ovviamente. Usa solo numeri interi.
Codice ottimizzato per la creazione di stringhe stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Codice temporale per confrontare stringFill1()
estringFill2()
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);
Il successo finora stringFill2()
stringFill1()
impiega 47.297 microsecondi (milionesimi di secondo) per riempire una stringa di 100 byte e stringFill2()
impiega 27,68 microsecondi per fare la stessa cosa. Questo è quasi un raddoppio delle prestazioni evitando un riferimento a una proprietà dell'oggetto.
Tecnica: evitare di aggiungere stringhe corte a stringhe lunghe
Il nostro risultato precedente sembrava buono - molto buono, in effetti. La funzione migliorata stringFill2()
è molto più veloce grazie all'uso delle nostre prime due ottimizzazioni. Ci crederesti se ti dicessi che può essere migliorato per essere molte volte più veloce di adesso?
Sì, possiamo raggiungere questo obiettivo. In questo momento dobbiamo spiegare come evitare di aggiungere stringhe brevi a stringhe lunghe.
Il comportamento a breve termine sembra essere abbastanza buono, rispetto alla nostra funzione originale. Agli informatici piace analizzare il "comportamento asintotico" di una funzione o un algoritmo di programma per computer, il che significa studiare il suo comportamento a lungo termine testandolo con input più ampi. A volte senza fare ulteriori test, non si diventa mai consapevoli dei modi per migliorare un programma per computer. Per vedere cosa accadrà, creeremo una stringa di 200 byte.
Il problema che si presenta con stringFill2()
Usando la nostra funzione di temporizzazione, scopriamo che il tempo aumenta a 62,54 microsecondi per una stringa di 200 byte, rispetto a 27,68 per una stringa di 100 byte. Sembra che il tempo dovrebbe essere raddoppiato per fare il doppio del lavoro, ma invece è triplicato o quadruplicato. Dall'esperienza di programmazione, questo risultato sembra strano, perché semmai la funzione dovrebbe essere leggermente più veloce poiché il lavoro viene svolto in modo più efficiente (200 byte per chiamata di funzione anziché 100 byte per chiamata di funzione). Questo problema ha a che fare con una proprietà insidiosa delle stringhe JavaScript: le stringhe JavaScript sono "immutabili".
Immutabile significa che non è possibile modificare una stringa una volta creata. Aggiungendo un byte alla volta, non stiamo consumando un altro byte di sforzo. Stiamo ricreando l'intera stringa più un altro byte.
In effetti, per aggiungere un altro byte a una stringa di 100 byte, sono necessari 101 byte di lavoro. Analizziamo brevemente il costo computazionale per la creazione di una stringa di N
byte. Il costo per aggiungere il primo byte è 1 unità di sforzo computazionale. Il costo per aggiungere il secondo byte non è una unità ma 2 unità (copia del primo byte in un nuovo oggetto stringa e aggiunta del secondo byte). Il terzo byte richiede un costo di 3 unità, ecc.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)
. Il simbolo O(N^2)
è pronunciato Grande O di N al quadrato e significa che il costo computazionale a lungo termine è proporzionale al quadrato della lunghezza della stringa. Per creare 100 caratteri sono necessarie 10.000 unità di lavoro e per creare 200 caratteri sono necessarie 40.000 unità di lavoro.
Questo è il motivo per cui ci sono voluti più del doppio del tempo per creare 200 caratteri rispetto a 100 caratteri. In effetti, avrebbe dovuto impiegare quattro volte di più. La nostra esperienza di programmazione è stata corretta in quanto il lavoro viene svolto in modo leggermente più efficiente per stringhe più lunghe, e quindi ci è voluto solo circa tre volte di più. Quando l'overhead della chiamata di funzione diventa trascurabile su quanto tempo stiamo creando una stringa, in realtà ci vorrà quattro volte più tempo per creare una stringa il doppio.
(Nota storica: questa analisi non si applica necessariamente alle stringhe nel codice sorgente, ad esempio html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
poiché il compilatore del codice sorgente JavaScript può unire le stringhe prima di trasformarle in un oggetto stringa JavaScript. Solo pochi anni fa, l'implementazione KJS di JavaScript si bloccherebbe o si arrestasse in modo anomalo durante il caricamento di lunghe stringhe di codice sorgente unite da segni più. Dal momento del calcolo O(N^2)
non era difficile creare pagine Web che sovraccaricassero il browser Konqueror o Safari, che utilizzava il core del motore JavaScript KJS. mi sono imbattuto in questo problema quando stavo sviluppando un linguaggio di markup e un parser per il linguaggio di markup JavaScript, e poi ho scoperto cosa stava causando il problema quando ho scritto il mio script per JavaScript Include.)
Chiaramente questo rapido degrado delle prestazioni è un grosso problema. Come possiamo gestirlo, dato che non possiamo cambiare il modo di JavaScript di gestire le stringhe come oggetti immutabili? La soluzione è utilizzare un algoritmo che ricrea la stringa il meno volte possibile.
Per chiarire, il nostro obiettivo è evitare l'aggiunta di stringhe corte a stringhe lunghe, poiché per aggiungere la stringa corta, è necessario duplicare anche l'intera stringa lunga.
Come funziona l'algoritmo per evitare di aggiungere stringhe brevi a stringhe lunghe
Ecco un buon modo per ridurre il numero di volte in cui vengono creati nuovi oggetti stringa. Concatena lunghezze di stringa più lunghe insieme in modo che più di un byte alla volta venga aggiunto all'output.
Ad esempio, per creare una stringa di lunghezza 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 */
Per fare ciò è necessario creare una stringa di lunghezza 1, creare una stringa di lunghezza 2, creare una stringa di lunghezza 4, creare una stringa di lunghezza 8 e infine creare una stringa di lunghezza 9. Quanto abbiamo risparmiato?
Vecchio costo C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45
.
Nuovo costo C(9) = 1 + 2 + 4 + 8 + 9 = 24
.
Nota che abbiamo dovuto aggiungere una stringa di lunghezza 1 a una stringa di lunghezza 0, quindi una stringa di lunghezza 1 a una stringa di lunghezza 1, quindi una stringa di lunghezza 2 a una stringa di lunghezza 2, quindi una stringa di lunghezza 4 a una stringa di lunghezza 4, quindi a una stringa di lunghezza 8 a una stringa di lunghezza 1, al fine di ottenere una stringa di lunghezza 9. Ciò che stiamo facendo può essere riassunto evitando di aggiungere stringhe brevi a stringhe lunghe, o in altri parole, cercando di concatenare stringhe insieme di lunghezza uguale o quasi uguale.
Per il vecchio costo computazionale abbiamo trovato una formula N(N+1)/2
. Esiste una formula per il nuovo costo? Sì, ma è complicato. L'importante è che lo sia O(N)
, e quindi raddoppiare la lunghezza della stringa raddoppierà approssimativamente la quantità di lavoro anziché quadruplicarla.
Il codice che implementa questa nuova idea è quasi complicato quanto la formula del costo computazionale. Quando lo leggi, ricorda che >>= 1
significa spostare a destra di 1 byte. Quindi, se n = 10011
è un numero binario, allora si n >>= 1
ottiene il valore n = 1001
.
L'altra parte del codice che potresti non riconoscere è l'operatore bit per bit, scritto &
. L'espressione n & 1
valuta vero se l'ultima cifra binaria di n
è 1 e falsa se l'ultima cifra binaria di n
è 0.
Nuova stringFill3()
funzione altamente efficiente
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Sembra brutto a un occhio non allenato, ma le sue prestazioni non sono altro che adorabili.
Vediamo quanto bene svolge questa funzione. Dopo aver visto i risultati, è probabile che non dimenticherai mai la differenza tra un O(N^2)
algoritmo e un O(N)
algoritmo.
stringFill1()
impiega 88,7 microsecondi (milionesimi di secondo) per creare una stringa di 200 byte, stringFill2()
richiede 62,54 e stringFill3()
richiede solo 4,608. Cosa ha reso questo algoritmo molto meglio? Tutte le funzioni hanno sfruttato l'utilizzo delle variabili di funzione locali, ma sfruttando la seconda e la terza tecnica di ottimizzazione ha aggiunto un miglioramento di venti volte alle prestazioni di stringFill3()
.
Analisi più approfondite
Cosa rende questa particolare funzione spazzare via la concorrenza dall'acqua?
Come ho già detto, il motivo per cui entrambe queste funzioni stringFill1()
e stringFill2()
, eseguite così lentamente, è che le stringhe JavaScript sono immutabili. Non è possibile riallocare la memoria per consentire di aggiungere un altro byte alla volta ai dati della stringa memorizzati da JavaScript. Ogni volta che viene aggiunto un altro byte alla fine della stringa, l'intera stringa viene rigenerata dall'inizio alla fine.
Pertanto, al fine di migliorare le prestazioni dello script, è necessario pre-calcolare stringhe di lunghezza maggiore concatenando due stringhe insieme in anticipo e quindi costruendo ricorsivamente la lunghezza della stringa desiderata.
Ad esempio, per creare una stringa di byte di 16 lettere, per prima cosa viene precompilata una stringa di due byte. Quindi la stringa di due byte verrebbe riutilizzata per precomputare una stringa di quattro byte. Quindi la stringa di quattro byte verrebbe riutilizzata per precompilare una stringa di otto byte. Infine, due stringhe di otto byte verrebbero riutilizzate per creare la nuova stringa desiderata di 16 byte. Complessivamente sono state create quattro nuove stringhe, una di lunghezza 2, una di lunghezza 4, una di lunghezza 8 e una di lunghezza 16. Il costo totale è 2 + 4 + 8 + 16 = 30.
A lungo termine questa efficienza può essere calcolata aggiungendo in ordine inverso e utilizzando una serie geometrica che inizia con un primo termine a1 = N e con un rapporto comune di r = 1/2. La somma di una serie geometrica è data da a_1 / (1-r) = 2N
.
Questo è più efficiente dell'aggiunta di un carattere per creare una nuova stringa di lunghezza 2, creando una nuova stringa di lunghezza 3, 4, 5 e così via, fino al 16. L'algoritmo precedente utilizzava quel processo di aggiunta di un singolo byte alla volta e il costo totale sarebbe n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136
.
Ovviamente, 136 è un numero molto maggiore di 30, e quindi l'algoritmo precedente impiega molto, molto più tempo per costruire una stringa.
Per confrontare i due metodi puoi vedere quanto più velocemente l'algoritmo ricorsivo (chiamato anche "divide and conquer") è su una stringa di lunghezza 123.457. Sul mio computer FreeBSD questo algoritmo, implementato nella stringFill3()
funzione, crea la stringa in 0,001058 secondi, mentre la stringFill1()
funzione originale crea la stringa in 0,0808 secondi. La nuova funzione è 76 volte più veloce.
La differenza di prestazioni aumenta con l'aumentare della lunghezza della stringa. Nel limite in cui vengono create stringhe sempre più grandi, la funzione originale si comporta approssimativamente come tempi C1
(costanti) N^2
e la nuova funzione si comporta come tempi C2
(costanti) N
.
Dal nostro esperimento possiamo determinare il valore di C1
essere C1 = 0.0808 / (123457)2 = .00000000000530126997
e il valore di C2
essere C2 = 0.001058 / 123457 = .00000000856978543136
. In 10 secondi, la nuova funzione potrebbe creare una stringa contenente 1.166.890.359 caratteri. Per creare questa stessa stringa, la vecchia funzione avrebbe bisogno di 7.218.384 secondi di tempo.
Sono quasi tre mesi rispetto a dieci secondi!
Sto solo rispondendo (con diversi anni di ritardo) perché la mia soluzione originale a questo problema sta fluttuando su Internet da più di 10 anni, e apparentemente è ancora poco compresa da pochi che se ne ricordano. Ho pensato che scrivendo un articolo a riguardo qui avrei aiutato:
Ottimizzazioni delle prestazioni per JavaScript / Pagina 3 ad alta velocità
Sfortunatamente, alcune delle altre soluzioni presentate qui sono ancora alcune di quelle che richiederebbero tre mesi per produrre la stessa quantità di output che una soluzione adeguata crea in 10 secondi.
Voglio prendere il tempo per riprodurre parte dell'articolo qui come una risposta canonica su Stack Overflow.
Si noti che l'algoritmo con le migliori prestazioni qui è chiaramente basato sul mio algoritmo ed è stato probabilmente ereditato dall'adattamento di qualcun altro di terza o quarta generazione. Sfortunatamente, le modifiche hanno portato a ridurne le prestazioni. La variazione della mia soluzione presentata qui forse non ha compreso la mia for (;;)
espressione confusa che assomiglia al principale ciclo infinito di un server scritto in C e che è stato semplicemente progettato per consentire un'istruzione break posizionata con cura per il controllo del loop, il modo più compatto per evitare di replicare in modo esponenziale la stringa un ulteriore tempo inutile.