Dieses Problem ist ein bekanntes / "klassisches" Optimierungsproblem für JavaScript, das durch die Tatsache verursacht wird, dass JavaScript-Zeichenfolgen "unveränderlich" sind und das Hinzufügen durch Verketten eines einzelnen Zeichens zu einer Zeichenfolge das Erstellen einschließlich der Speicherzuweisung für und das Kopieren in JavaScript erfordert , eine ganz neue Zeichenfolge.
Leider ist die akzeptierte Antwort auf dieser Seite falsch, wobei "falsch" einen Leistungsfaktor von 3x für einfache Zeichenfolgen mit einem Zeichen und 8x-97x für kurze Zeichenfolgen bedeutet, die mehrmals wiederholt werden, bis 300x für wiederholte Sätze und unendlich falsch, wenn Nehmen Sie die Grenze der Komplexitätsverhältnisse der Algorithmen n
bis ins Unendliche. Außerdem gibt es auf dieser Seite eine andere Antwort, die fast richtig ist (basierend auf einer der vielen Generationen und Variationen der richtigen Lösung, die in den letzten 13 Jahren im Internet verbreitet wurde). Bei dieser "fast richtigen" Lösung fehlt jedoch ein wichtiger Punkt des richtigen Algorithmus, was zu einer Leistungsminderung von 50% führt.
JS-Leistungsergebnisse für die akzeptierte Antwort, die leistungsstärkste andere Antwort (basierend auf einer verschlechterten Version des ursprünglichen Algorithmus in dieser Antwort) und diese Antwort unter Verwendung meines vor 13 Jahren erstellten Algorithmus
~ Oktober 2000 Ich veröffentlichte einen Algorithmus für genau dieses Problem, der weitgehend angepasst, modifiziert, schließlich schlecht verstanden und vergessen wurde. Um dieses Problem zu beheben, veröffentlichte ich im August 2008 einen Artikel http://www.webreference.com/programming/javascript/jkm3/3.html , in dem der Algorithmus erläutert und als Beispiel für einfache JavaScript-Optimierungen für allgemeine Zwecke verwendet wurde. Inzwischen hat Web Reference meine Kontaktinformationen und sogar meinen Namen aus diesem Artikel entfernt. Und wieder einmal wurde der Algorithmus weitgehend angepasst, modifiziert, dann schlecht verstanden und weitgehend vergessen.
Ursprünglicher JavaScript-Algorithmus zur Wiederholung / Multiplikation von Zeichenfolgen von Joseph Myers, circa Y2K als Textmultiplikationsfunktion in Text.js; veröffentlicht im August 2008 in dieser Form von Web Reference:
http://www.webreference.com/programming/javascript/jkm3/3.html (Der Artikel verwendete die Funktion als Beispiel für JavaScript-Optimierungen, die die einzige für die Seltsamen ist Name "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;
}
Innerhalb von zwei Monaten nach Veröffentlichung dieses Artikels wurde dieselbe Frage an Stack Overflow gesendet und flog bis jetzt unter meinem Radar, als anscheinend der ursprüngliche Algorithmus für dieses Problem erneut vergessen wurde. Die beste auf dieser Stapelüberlaufseite verfügbare Lösung ist eine modifizierte Version meiner Lösung, die möglicherweise durch mehrere Generationen getrennt ist. Leider haben die Modifikationen die Optimalität der Lösung ruiniert. In der Tat führt die modifizierte Lösung durch Ändern der Struktur der Schleife von meinem Original einen völlig unnötigen zusätzlichen Schritt des exponentiellen Duplizierens aus (wodurch die größte in der richtigen Antwort verwendete Zeichenfolge mit sich selbst verbunden wird und diese dann verworfen wird).
Im Folgenden werden einige JavaScript-Optimierungen erläutert, die sich auf alle Antworten auf dieses Problem und zum Nutzen aller beziehen.
Technik: Vermeiden Sie Verweise auf Objekte oder Objekteigenschaften
Um zu veranschaulichen, wie diese Technik funktioniert, verwenden wir eine reale JavaScript-Funktion, mit der Zeichenfolgen beliebiger Länge erstellt werden. Und wie wir sehen werden, können weitere Optimierungen hinzugefügt werden!
Eine Funktion wie die hier verwendete besteht darin, Auffüllungen zu erstellen, um Textspalten auszurichten, Geld zu formatieren oder Blockdaten bis zur Grenze zu füllen. Eine Texterzeugungsfunktion ermöglicht auch die Eingabe variabler Länge zum Testen aller anderen Funktionen, die mit Text arbeiten. Diese Funktion ist eine der wichtigen Komponenten des JavaScript-Textverarbeitungsmoduls.
Im weiteren Verlauf werden wir zwei weitere der wichtigsten Optimierungstechniken behandeln und gleichzeitig den ursprünglichen Code zu einem optimierten Algorithmus zum Erstellen von Zeichenfolgen entwickeln. Das Endergebnis ist eine industrietaugliche Hochleistungsfunktion, die ich überall verwendet habe - das Anpassen von Artikelpreisen und -summen in JavaScript-Bestellformularen, Datenformatierung und E-Mail- / Textnachrichtenformatierung und viele andere Verwendungszwecke.
Originalcode zum Erstellen von Zeichenfolgen stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
Die Syntax ist hier klar. Wie Sie sehen, haben wir bereits lokale Funktionsvariablen verwendet, bevor wir weitere Optimierungen vornehmen.
Beachten Sie, dass es einen unschuldigen Verweis auf eine Objekteigenschaft gibt s.length
der Code enthält, der die Leistung beeinträchtigt. Noch schlimmer ist, dass die Verwendung dieser Objekteigenschaft die Einfachheit des Programms verringert, indem davon ausgegangen wird, dass der Leser die Eigenschaften von JavaScript-Zeichenfolgenobjekten kennt.
Die Verwendung dieser Objekteigenschaft zerstört die Allgemeinheit des Computerprogramms. Das Programm geht davon aus, dass x
es sich um eine Zeichenfolge der Länge eins handeln muss. Dies beschränkt die Anwendung der stringFill1()
Funktion auf alles außer der Wiederholung einzelner Zeichen. Selbst einzelne Zeichen können nicht verwendet werden, wenn sie mehrere Bytes enthalten, wie die HTML-Entität
.
Das schlimmste Problem, das durch diese unnötige Verwendung einer Objekteigenschaft verursacht wird, besteht darin, dass die Funktion eine Endlosschleife erstellt, wenn sie an einer leeren Eingabezeichenfolge getestet wird x
. Wenden Sie zur Überprüfung der Allgemeinheit ein Programm auf die kleinstmögliche Eingabemenge an. Ein Programm, das abstürzt, wenn es aufgefordert wird, den verfügbaren Speicher zu überschreiten, hat eine Entschuldigung. Ein Programm wie dieses, das abstürzt, wenn man aufgefordert wird, nichts zu produzieren, ist inakzeptabel. Manchmal ist hübscher Code giftiger Code.
Einfachheit mag ein mehrdeutiges Ziel der Computerprogrammierung sein, ist es aber im Allgemeinen nicht. Wenn einem Programm ein angemessener Grad an Allgemeinheit fehlt, kann man nicht sagen: "Das Programm ist soweit gut genug." Wie Sie sehen können, string.length
verhindert die Verwendung der Eigenschaft, dass dieses Programm in einer allgemeinen Einstellung funktioniert, und tatsächlich ist das falsche Programm bereit, einen Browser- oder Systemabsturz zu verursachen.
Gibt es eine Möglichkeit, die Leistung dieses JavaScript zu verbessern und diese beiden schwerwiegenden Probleme zu beheben?
Natürlich. Verwenden Sie einfach ganze Zahlen.
Optimierter Code zum Erstellen von Zeichenfolgen stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Timing-Code zum Vergleichen stringFill1()
undstringFill2()
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);
Der bisherige Erfolg von stringFill2()
stringFill1()
Es dauert 47,297 Mikrosekunden (Millionstelsekunden), um eine 100-Byte-Zeichenfolge zu füllen, und stringFill2()
27,68 Mikrosekunden, um dasselbe zu tun. Das ist fast eine Verdoppelung der Leistung, wenn ein Verweis auf eine Objekteigenschaft vermieden wird.
Technik: Vermeiden Sie es, langen Saiten kurze Saiten hinzuzufügen
Unser bisheriges Ergebnis sah gut aus - in der Tat sehr gut. Die verbesserte FunktionstringFill2()
ist aufgrund der Verwendung unserer ersten beiden Optimierungen viel schneller. Würden Sie es glauben, wenn ich Ihnen sagen würde, dass es verbessert werden kann, um ein Vielfaches schneller zu sein als jetzt?
Ja, wir können dieses Ziel erreichen. Im Moment müssen wir erklären, wie wir vermeiden, kurze Zeichenfolgen an lange Zeichenfolgen anzuhängen.
Das kurzfristige Verhalten scheint im Vergleich zu unserer ursprünglichen Funktion recht gut zu sein. Informatiker analysieren gerne das "asymptotische Verhalten" einer Funktion oder eines Computerprogrammalgorithmus, dh sie untersuchen ihr Langzeitverhalten, indem sie es mit größeren Eingaben testen. Manchmal wird man ohne weitere Tests nie darauf aufmerksam, wie ein Computerprogramm verbessert werden kann. Um zu sehen, was passieren wird, erstellen wir eine 200-Byte-Zeichenfolge.
Das Problem, das sich zeigt stringFill2()
Mit unserer Timing-Funktion stellen wir fest, dass die Zeit für eine 200-Byte-Zeichenfolge auf 62,54 Mikrosekunden ansteigt, verglichen mit 27,68 für eine 100-Byte-Zeichenfolge. Es scheint, dass die Zeit für doppelt so viel Arbeit verdoppelt werden sollte, aber stattdessen verdreifacht oder vervierfacht. Aus Programmiererfahrung erscheint dieses Ergebnis seltsam, da die Funktion eher etwas schneller sein sollte, da die Arbeit effizienter ausgeführt wird (200 Byte pro Funktionsaufruf statt 100 Byte pro Funktionsaufruf). Dieses Problem hat mit einer heimtückischen Eigenschaft von JavaScript-Zeichenfolgen zu tun: JavaScript-Zeichenfolgen sind "unveränderlich".
Unveränderlich bedeutet, dass Sie eine Zeichenfolge nach ihrer Erstellung nicht mehr ändern können. Durch Hinzufügen von jeweils einem Byte verbrauchen wir kein weiteres Byte mehr Aufwand. Wir erstellen tatsächlich die gesamte Zeichenfolge plus ein weiteres Byte neu.
Um einer 100-Byte-Zeichenfolge ein weiteres Byte hinzuzufügen, sind 101 Byte Arbeit erforderlich. Lassen Sie uns kurz die Berechnungskosten für die Erstellung einer N
Bytefolge analysieren . Die Kosten für das Hinzufügen des ersten Bytes betragen 1 Rechenaufwand. Die Kosten für das Hinzufügen des zweiten Bytes betragen nicht eine Einheit, sondern 2 Einheiten (Kopieren des ersten Bytes in ein neues Zeichenfolgenobjekt sowie Hinzufügen des zweiten Bytes). Das dritte Byte erfordert Kosten von 3 Einheiten usw.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)
. Das Symbol O(N^2)
wird als Big O von N im Quadrat ausgesprochen und bedeutet, dass der Rechenaufwand auf lange Sicht proportional zum Quadrat der Zeichenfolgenlänge ist. Das Erstellen von 100 Zeichen erfordert 10.000 Arbeitseinheiten, und das Erstellen von 200 Zeichen erfordert 40.000 Arbeitseinheiten.
Aus diesem Grund dauerte das Erstellen von 200 Zeichen mehr als doppelt so lange als von 100 Zeichen. Tatsächlich hätte es viermal so lange dauern sollen. Unsere Programmiererfahrung war insofern richtig, als die Arbeit für längere Saiten etwas effizienter ausgeführt wird und daher nur etwa dreimal so lange dauerte. Sobald der Overhead des Funktionsaufrufs vernachlässigbar wird, wie lange eine Zeichenfolge erstellt wird, dauert die Erstellung einer doppelt so langen Zeichenfolge viermal so lange.
(Historischer Hinweis: Diese Analyse gilt nicht unbedingt für Zeichenfolgen im Quellcode, z. B. html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
da der JavaScript-Quellcode-Compiler die Zeichenfolgen zusammenfügen kann, bevor sie zu einem JavaScript-Zeichenfolgenobjekt verarbeitet werden. Vor wenigen Jahren wurde die KJS-Implementierung von JavaScript würde beim Laden langer Quellcode-Zeichenfolgen, die durch Pluszeichen verbunden sind, einfrieren oder abstürzen. Da die Rechenzeit knapp war, war O(N^2)
es nicht schwierig, Webseiten zu erstellen, die den Konqueror-Webbrowser überlasteten, oder Safari, das den KJS-JavaScript-Engine-Kern verwendete Dieses Problem trat auf, als ich eine Markup-Sprache und einen JavaScript-Markup-Sprachparser entwickelte, und dann entdeckte ich, was das Problem verursachte, als ich mein Skript für JavaScript Includes schrieb.)
Diese schnelle Verschlechterung der Leistung ist eindeutig ein großes Problem. Wie können wir damit umgehen, da wir die Art und Weise, wie JavaScript Zeichenfolgen als unveränderliche Objekte behandelt, nicht ändern können? Die Lösung besteht darin, einen Algorithmus zu verwenden, der die Zeichenfolge so oft wie möglich neu erstellt.
Zur Verdeutlichung ist es unser Ziel, das Hinzufügen kurzer Zeichenfolgen zu langen Zeichenfolgen zu vermeiden, da zum Hinzufügen der kurzen Zeichenfolge auch die gesamte lange Zeichenfolge dupliziert werden muss.
So funktioniert der Algorithmus, um zu vermeiden, dass langen Zeichenfolgen kurze Zeichenfolgen hinzugefügt werden
Hier ist eine gute Möglichkeit, die Häufigkeit zu verringern, mit der neue Zeichenfolgenobjekte erstellt werden. Verketten Sie längere Zeichenfolgenlängen miteinander, sodass der Ausgabe mehr als ein Byte gleichzeitig hinzugefügt wird.
Um beispielsweise eine Zeichenfolge mit einer Länge zu erstellen 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 */
Dazu musste eine Zeichenfolge mit der Länge 1 erstellt, eine Zeichenfolge mit der Länge 2 erstellt, eine Zeichenfolge mit der Länge 4 erstellt, eine Zeichenfolge mit der Länge 8 erstellt und schließlich eine Zeichenfolge mit der Länge 9 erstellt werden. Wie viel Kosten haben wir gespart?
Alte Kosten C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45
.
Neue Kosten C(9) = 1 + 2 + 4 + 8 + 9 = 24
.
Beachten Sie, dass wir einer Zeichenfolge der Länge 0 eine Zeichenfolge der Länge 1 hinzufügen mussten, dann eine Zeichenfolge der Länge 1 einer Zeichenfolge der Länge 1, dann eine Zeichenfolge der Länge 2 einer Zeichenfolge der Länge 2 und dann eine Zeichenfolge der Länge 4 zu einer Zeichenfolge der Länge 4, dann eine Zeichenfolge der Länge 8 zu einer Zeichenfolge der Länge 1, um eine Zeichenfolge der Länge 9 zu erhalten. Was wir tun, kann so zusammengefasst werden, dass das Hinzufügen kurzer Zeichenfolgen zu langen Zeichenfolgen oder in anderen vermieden wird Wörter, die versuchen, Zeichenfolgen miteinander zu verketten, die gleich oder nahezu gleich lang sind.
Für die alten Rechenkosten haben wir eine Formel gefunden N(N+1)/2
. Gibt es eine Formel für die neuen Kosten? Ja, aber es ist kompliziert. Das Wichtigste ist, dass dies der Fall ist. Wenn Sie O(N)
also die Länge der Saite verdoppeln, verdoppelt sich der Arbeitsaufwand ungefähr, anstatt ihn zu vervierfachen.
Der Code, der diese neue Idee implementiert, ist fast so kompliziert wie die Formel für die Rechenkosten. Denken Sie beim Lesen daran, dass Sie >>= 1
um 1 Byte nach rechts verschieben müssen. Wenn n = 10011
es sich also um eine Binärzahl handelt, n >>= 1
ergibt sich der Wert n = 1001
.
Der andere Teil des Codes, den Sie möglicherweise nicht erkennen, ist der bitweise geschriebene Operator &
. Der Ausdruck n & 1
wertet true aus, wenn die letzte Binärziffer von n
1 ist, und false, wenn die letzte Binärziffer von n
0 ist.
Neue hocheffiziente stringFill3()
Funktion
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Für das ungeübte Auge sieht es hässlich aus, aber die Leistung ist nicht weniger als reizend.
Mal sehen, wie gut diese Funktion funktioniert. Nachdem Sie die Ergebnisse gesehen haben, werden Sie wahrscheinlich nie den Unterschied zwischen einem O(N^2)
Algorithmus und einem O(N)
Algorithmus vergessen .
stringFill1()
Die Erstellung einer 200-Byte-Zeichenfolge dauert 88,7 Mikrosekunden (Millionstelsekunden), stringFill2()
dauert 62,54 Sekunden und stringFill3()
dauert nur 4,608 Sekunden. Was hat diesen Algorithmus so viel besser gemacht? Alle Funktionen nutzten die Verwendung lokaler Funktionsvariablen, aber die Nutzung der zweiten und dritten Optimierungstechniken führte zu einer zwanzigfachen Verbesserung der Leistung von stringFill3()
.
Tiefere Analyse
Was bringt diese besondere Funktion dazu, die Konkurrenz aus dem Wasser zu jagen?
Wie ich bereits erwähnt habe, ist der Grund dafür, dass diese beiden Funktionen stringFill1()
und stringFill2()
so langsam ausgeführt werden, dass JavaScript-Zeichenfolgen unveränderlich sind. Der Speicher kann nicht neu zugewiesen werden, damit jeweils ein weiteres Byte an die von JavaScript gespeicherten Zeichenfolgendaten angehängt werden kann. Jedes Mal, wenn ein weiteres Byte am Ende der Zeichenfolge hinzugefügt wird, wird die gesamte Zeichenfolge von Anfang bis Ende neu generiert.
Um die Leistung des Skripts zu verbessern, müssen Sie daher Zeichenfolgen mit längerer Länge vorberechnen, indem Sie zwei Zeichenfolgen vorab miteinander verketten und dann die gewünschte Zeichenfolgenlänge rekursiv aufbauen.
Um beispielsweise eine 16-Buchstaben-Byte-Zeichenfolge zu erstellen, wird zunächst eine Zwei-Byte-Zeichenfolge vorberechnet. Dann würde die Zwei-Byte-Zeichenfolge wiederverwendet, um eine Vier-Byte-Zeichenfolge vorab zu berechnen. Dann würde die Vier-Byte-Zeichenfolge wiederverwendet, um eine Acht-Byte-Zeichenfolge vorab zu berechnen. Schließlich würden zwei Acht-Byte-Zeichenfolgen wiederverwendet, um die gewünschte neue Zeichenfolge mit 16 Byte zu erstellen. Insgesamt mussten vier neue Zeichenfolgen erstellt werden, eine mit der Länge 2, eine mit der Länge 4, eine mit der Länge 8 und eine mit der Länge 16. Die Gesamtkosten betragen 2 + 4 + 8 + 16 = 30.
Langfristig kann diese Effizienz berechnet werden, indem in umgekehrter Reihenfolge addiert wird und eine geometrische Reihe verwendet wird, die mit einem ersten Term a1 = N beginnt und ein gemeinsames Verhältnis von r = 1/2 aufweist. Die Summe einer geometrischen Reihe ist gegeben durch a_1 / (1-r) = 2N
.
Dies ist effizienter als das Hinzufügen eines Zeichens zum Erstellen einer neuen Zeichenfolge mit der Länge 2, das Erstellen einer neuen Zeichenfolge mit der Länge 3, 4, 5 usw. bis 16. Der vorherige Algorithmus verwendete diesen Prozess zum Hinzufügen eines einzelnen Bytes zu einem Zeitpunkt und die Gesamtkosten dafür wären n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136
.
Offensichtlich ist 136 eine viel größere Zahl als 30, und daher benötigt der vorherige Algorithmus viel, viel mehr Zeit, um eine Zeichenfolge aufzubauen.
Um die beiden Methoden zu vergleichen, können Sie sehen, wie viel schneller der rekursive Algorithmus (auch "Teilen und Erobern" genannt) auf einer Zeichenfolge mit der Länge 123.457 ist. Auf meinem FreeBSD-Computer erstellt dieser in der stringFill3()
Funktion implementierte Algorithmus die Zeichenfolge in 0,001058 Sekunden, während die ursprüngliche stringFill1()
Funktion die Zeichenfolge in 0,0808 Sekunden erstellt. Die neue Funktion ist 76-mal schneller.
Der Leistungsunterschied wächst, wenn die Länge der Zeichenfolge größer wird. In der Grenze, in der immer größere Zeichenfolgen erstellt werden, verhält sich die ursprüngliche Funktion ungefähr wie C1
(konstante) Zeiten N^2
, und die neue Funktion verhält sich wie C2
(konstante) Zeiten N
.
Von unserem Experiment können wir den Wert bestimmen C1
zu sein C1 = 0.0808 / (123457)2 = .00000000000530126997
, und den Wert C2
zu sein C2 = 0.001058 / 123457 = .00000000856978543136
. In 10 Sekunden könnte die neue Funktion eine Zeichenfolge mit 1.166.890.359 Zeichen erstellen. Um dieselbe Zeichenfolge zu erstellen, würde die alte Funktion 7.218.384 Sekunden Zeit benötigen.
Das sind fast drei Monate im Vergleich zu zehn Sekunden!
Ich antworte nur (einige Jahre zu spät), weil meine ursprüngliche Lösung für dieses Problem seit mehr als 10 Jahren im Internet schwebt und anscheinend von den wenigen, die sich daran erinnern, immer noch schlecht verstanden wird. Ich dachte, wenn ich hier einen Artikel darüber schreibe, würde ich helfen:
Leistungsoptimierungen für Hochgeschwindigkeits-JavaScript / Seite 3
Leider sind einige der anderen hier vorgestellten Lösungen immer noch einige, die drei Monate benötigen würden, um die gleiche Menge an Ausgabe zu erzeugen, die eine richtige Lösung in 10 Sekunden erzeugt.
Ich möchte mir die Zeit nehmen, einen Teil des Artikels hier als kanonische Antwort auf Stack Overflow zu reproduzieren.
Beachten Sie, dass der Algorithmus mit der besten Leistung hier eindeutig auf meinem Algorithmus basiert und wahrscheinlich von der Anpassung der 3. oder 4. Generation eines anderen geerbt wurde. Leider führten die Änderungen zu einer Verringerung der Leistung. Die Variation meiner hier vorgestellten Lösung verstand vielleicht meinen verwirrenden for (;;)
Ausdruck nicht, der wie die Endlosschleife eines in C geschriebenen Servers aussieht und einfach so konzipiert wurde, dass eine sorgfältig positionierte break-Anweisung für die Schleifensteuerung möglich ist, die kompakteste Methode Vermeiden Sie es, die Zeichenfolge eine zusätzliche unnötige Zeit exponentiell zu replizieren.