Ce problème est un problème d'optimisation bien connu / "classique" pour JavaScript, dû au fait que les chaînes JavaScript sont "immuables" et l'ajout par concaténation d'un seul caractère à une chaîne nécessite la création de, y compris l'allocation de mémoire et la copie vers , une toute nouvelle chaîne.
Malheureusement, la réponse acceptée sur cette page est fausse, où "faux" signifie par un facteur de performance de 3x pour les chaînes de caractères simples et de 8x-97x pour les chaînes courtes répétées plusieurs fois, à 300x pour les phrases répétitives, et infiniment incorrect lorsque prendre la limite des rapports de complexité des algorithmes comme n
va à l'infini. En outre, il y a une autre réponse sur cette page qui est presque exacte (basée sur l'une des nombreuses générations et variations de la bonne solution qui a circulé sur Internet au cours des 13 dernières années). Cependant, cette solution "presque juste" manque un point clé de l'algorithme correct provoquant une dégradation des performances de 50%.
Résultats de performance JS pour la réponse acceptée, l'autre réponse la plus performante (basée sur une version dégradée de l'algorithme d'origine dans cette réponse), et cette réponse en utilisant mon algorithme créé il y a 13 ans
~ Octobre 2000 J'ai publié un algorithme pour ce problème exact qui a été largement adapté, modifié, puis finalement mal compris et oublié. Pour remédier à ce problème, en août 2008, j'ai publié un article http://www.webreference.com/programming/javascript/jkm3/3.html expliquant l'algorithme et l'utilisant comme exemple de simples optimisations JavaScript à usage général. À ce jour, Web Reference a effacé mes coordonnées et même mon nom dans cet article. Et encore une fois, l'algorithme a été largement adapté, modifié, puis mal compris et largement oublié.
Algorithme JavaScript original de répétition / multiplication de chaînes par Joseph Myers, vers Y2K comme fonction de multiplication de texte dans Text.js; publié en août 2008 sous cette forme par Web Reference:
http://www.webreference.com/programming/javascript/jkm3/3.html (L'article a utilisé la fonction comme exemple d'optimisations JavaScript, qui est le seul pour l'étrange nom "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;
}
Dans les deux mois suivant la publication de cet article, cette même question a été publiée sur Stack Overflow et a volé sous mon radar jusqu'à présent, alors qu'apparemment l'algorithme d'origine pour ce problème a de nouveau été oublié. La meilleure solution disponible sur cette page Stack Overflow est une version modifiée de ma solution, éventuellement séparée par plusieurs générations. Malheureusement, les modifications ont ruiné l'optimalité de la solution. En fait, en changeant la structure de la boucle par rapport à mon original, la solution modifiée effectue une étape supplémentaire complètement inutile de la duplication exponentielle (joignant ainsi la plus grande chaîne utilisée dans la bonne réponse avec elle-même un temps supplémentaire, puis la rejetant).
Ci-dessous s'ensuit une discussion sur certaines optimisations JavaScript liées à toutes les réponses à ce problème et pour le bénéfice de tous.
Technique: éviter les références aux objets ou aux propriétés des objets
Pour illustrer le fonctionnement de cette technique, nous utilisons une fonction JavaScript réelle qui crée des chaînes de la longueur nécessaire. Et comme nous le verrons, plus d'optimisations peuvent être ajoutées!
Une fonction comme celle utilisée ici consiste à créer un remplissage pour aligner des colonnes de texte, pour formater de l'argent ou pour remplir des données de bloc jusqu'à la limite. Une fonction de génération de texte permet également une entrée de longueur variable pour tester toute autre fonction qui fonctionne sur du texte. Cette fonction est l'un des composants importants du module de traitement de texte JavaScript.
Au fur et à mesure, nous couvrirons deux autres techniques d'optimisation les plus importantes tout en développant le code original en un algorithme optimisé pour créer des chaînes. Le résultat final est une fonction de haute performance de niveau industriel que j'ai utilisée partout - alignant les prix et les totaux des articles dans les formulaires de commande JavaScript, le formatage des données et le formatage des e-mails / SMS et de nombreuses autres utilisations.
Code original pour créer des chaînes stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
La syntaxe est ici est claire. Comme vous pouvez le voir, nous avons déjà utilisé des variables de fonction locales, avant de passer à d'autres optimisations.
Sachez qu'il existe une référence innocente à une propriété d'objet s.length
dans le code qui nuit à ses performances. Pire encore, l'utilisation de cette propriété d'objet réduit la simplicité du programme en faisant l'hypothèse que le lecteur connaît les propriétés des objets chaîne JavaScript.
L'utilisation de cette propriété d'objet détruit la généralité du programme informatique. Le programme suppose qu'il x
doit s'agir d'une chaîne de longueur un. Cela limite l'application de la stringFill1()
fonction à tout sauf à la répétition de caractères uniques. Même des caractères uniques ne peuvent pas être utilisés s'ils contiennent plusieurs octets comme l'entité HTML
.
Le pire problème causé par cette utilisation inutile d'une propriété d'objet est que la fonction crée une boucle infinie si elle est testée sur une chaîne d'entrée vide x
. Pour vérifier la généralité, appliquez un programme à la plus petite quantité possible d'entrée. Un programme qui se bloque lorsqu'on lui demande de dépasser la quantité de mémoire disponible a une excuse. Un programme comme celui-ci qui se bloque lorsqu'on lui demande de ne rien produire est inacceptable. Parfois, un joli code est un code toxique.
La simplicité peut être un objectif ambigu de la programmation informatique, mais ce n'est généralement pas le cas. Lorsqu'un programme n'a pas un niveau de généralité raisonnable, il n'est pas valable de dire: "Le programme est assez bon pour autant qu'il va". Comme vous pouvez le voir, l'utilisation de la string.length
propriété empêche ce programme de fonctionner dans un paramètre général et, en fait, le programme incorrect est prêt à provoquer un blocage du navigateur ou du système.
Existe-t-il un moyen d'améliorer les performances de ce JavaScript et de résoudre ces deux problèmes graves?
Bien sûr. Utilisez simplement des entiers.
Code optimisé pour créer des chaînes stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Code temporel à comparer stringFill1()
etstringFill2()
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);
Le succès jusqu'ici de stringFill2()
stringFill1()
prend 47,297 microsecondes (millionièmes de seconde) pour remplir une chaîne de 100 octets et stringFill2()
27,68 microsecondes pour faire la même chose. Cela représente presque un doublement des performances en évitant une référence à une propriété d'objet.
Technique: évitez d'ajouter des cordes courtes à des cordes longues
Notre résultat précédent semblait bon - très bon, en fait. La fonction améliorée stringFill2()
est beaucoup plus rapide grâce à l'utilisation de nos deux premières optimisations. Le croiriez-vous si je vous disais qu'il peut être amélioré pour être beaucoup plus rapide qu'il ne l'est actuellement?
Oui, nous pouvons atteindre cet objectif. À l'heure actuelle, nous devons expliquer comment nous évitons d'ajouter des chaînes courtes à des chaînes longues.
Le comportement à court terme semble assez bon par rapport à notre fonction d'origine. Les informaticiens aiment analyser le "comportement asymptotique" d'une fonction ou d'un algorithme de programme informatique, ce qui signifie étudier son comportement à long terme en le testant avec des entrées plus importantes. Parfois, sans faire d'autres tests, on ne prend jamais conscience des moyens d'améliorer un programme informatique. Pour voir ce qui va se passer, nous allons créer une chaîne de 200 octets.
Le problème qui apparaît avec stringFill2()
En utilisant notre fonction de synchronisation, nous constatons que le temps augmente à 62,54 microsecondes pour une chaîne de 200 octets, contre 27,68 pour une chaîne de 100 octets. Il semble que le temps devrait être doublé pour faire deux fois plus de travail, mais il est plutôt triplé ou quadruplé. D'après l'expérience de programmation, ce résultat semble étrange, car la fonction devrait être légèrement plus rapide car le travail est effectué plus efficacement (200 octets par appel de fonction plutôt que 100 octets par appel de fonction). Ce problème est lié à une propriété insidieuse des chaînes JavaScript: les chaînes JavaScript sont "immuables".
Immuable signifie que vous ne pouvez pas modifier une chaîne une fois qu'elle a été créée. En ajoutant un octet à la fois, nous n'utilisons pas un octet supplémentaire d'effort. Nous recréons en fait la chaîne entière plus un octet de plus.
En effet, pour ajouter un octet de plus à une chaîne de 100 octets, il faut 101 octets de travail. Analysons brièvement le coût de calcul pour créer une chaîne d' N
octets. Le coût de l'ajout du premier octet est de 1 unité d'effort de calcul. Le coût de l'ajout du deuxième octet n'est pas d'une unité mais de 2 unités (copie du premier octet dans un nouvel objet chaîne ainsi que l'ajout du deuxième octet). Le troisième octet nécessite un coût de 3 unités, etc.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)
. Le symbole O(N^2)
est prononcé Big O de N au carré, et cela signifie que le coût de calcul à long terme est proportionnel au carré de la longueur de la chaîne. Pour créer 100 caractères, il faut 10 000 unités de travail et pour créer 200 caractères, il faut 40 000 unités de travail.
C'est pourquoi il a fallu plus de deux fois plus de temps pour créer 200 caractères que 100 caractères. En fait, cela aurait dû prendre quatre fois plus de temps. Notre expérience de programmation était correcte dans la mesure où le travail est effectué un peu plus efficacement pour les chaînes plus longues, et donc cela n'a pris que trois fois plus de temps. Une fois que la surcharge de l'appel de fonction devient négligeable quant à la longueur d'une chaîne que nous créons, il faudra en fait quatre fois plus de temps pour créer une chaîne deux fois plus longtemps.
(Remarque historique: cette analyse ne s'applique pas nécessairement aux chaînes dans le code source, par exemple html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
, puisque le compilateur de code source JavaScript peut réunir les chaînes avant de les transformer en un objet chaîne JavaScript. Il y a quelques années à peine, l'implémentation KJS de JavaScript se bloquait ou se bloquait lors du chargement de longues chaînes de code source jointes par des signes plus. Comme le temps de calcul O(N^2)
n'était pas difficile de créer des pages Web qui surchargeaient le navigateur Web Konqueror ou Safari, qui utilisait le noyau du moteur JavaScript KJS. J'ai d'abord suis tombé sur ce problème lorsque je développais un langage de balisage et un analyseur de langage de balisage JavaScript, puis j'ai découvert la cause du problème lorsque j'ai écrit mon script pour les inclusions JavaScript.)
De toute évidence, cette dégradation rapide des performances est un énorme problème. Comment pouvons-nous y faire face, étant donné que nous ne pouvons pas changer la façon dont JavaScript traite les chaînes comme des objets immuables? La solution consiste à utiliser un algorithme qui recrée la chaîne le moins de fois possible.
Pour clarifier, notre objectif est d'éviter d'ajouter des chaînes courtes à des chaînes longues, car pour ajouter la chaîne courte, la chaîne longue entière doit également être dupliquée.
Fonctionnement de l'algorithme pour éviter d'ajouter des chaînes courtes à des chaînes longues
Voici un bon moyen de réduire le nombre de fois où de nouveaux objets chaîne sont créés. Concaténer des longueurs de chaîne plus longues ensemble de sorte que plusieurs octets à la fois soient ajoutés à la sortie.
Par exemple, pour créer une chaîne de longueur 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 */
Pour ce faire, il fallait créer une chaîne de longueur 1, créer une chaîne de longueur 2, créer une chaîne de longueur 4, créer une chaîne de longueur 8 et enfin, créer une chaîne de longueur 9. Combien avons-nous économisé?
Ancien coût C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45
.
Nouveau coût C(9) = 1 + 2 + 4 + 8 + 9 = 24
.
Notez que nous devions ajouter une chaîne de longueur 1 à une chaîne de longueur 0, puis une chaîne de longueur 1 à une chaîne de longueur 1, puis une chaîne de longueur 2 à une chaîne de longueur 2, puis une chaîne de longueur 4 à une chaîne de longueur 4, puis une chaîne de longueur 8 à une chaîne de longueur 1, afin d'obtenir une chaîne de longueur 9. Ce que nous faisons peut être résumé en évitant d'ajouter des chaînes courtes à des chaînes longues, ou dans d'autres mots, en essayant de concaténer des chaînes de longueur égale ou presque égale.
Pour l'ancien coût de calcul, nous avons trouvé une formule N(N+1)/2
. Existe-t-il une formule pour le nouveau coût? Oui, mais c'est compliqué. L'important est que ce soit le cas O(N)
, et donc doubler la longueur de la chaîne doublera approximativement la quantité de travail plutôt que de la quadrupler.
Le code qui implémente cette nouvelle idée est presque aussi compliqué que la formule du coût de calcul. Lorsque vous le lisez, n'oubliez pas que cela >>= 1
signifie de décaler vers la droite d'un octet. Donc, si n = 10011
est un nombre binaire, alors n >>= 1
résulte en la valeur n = 1001
.
L'autre partie du code que vous pourriez ne pas reconnaître est l'écriture au niveau du bit et de l'opérateur &
. L'expression n & 1
évalue vrai si le dernier chiffre binaire de n
est 1 et faux si le dernier chiffre binaire de n
est 0.
Nouveau très efficace stringFill3()
fonction
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Il semble laid à l'œil non averti, mais ses performances ne sont rien de moins que charmantes.
Voyons à quel point cette fonction fonctionne bien. Après avoir vu les résultats, il est probable que vous n'oublierez jamais la différence entre un O(N^2)
algorithme et un O(N)
algorithme.
stringFill1()
prend 88,7 microsecondes (millionièmes de seconde) pour créer une chaîne de 200 octets, stringFill2()
prend 62,54 et stringFill3()
ne prend que 4,608. Qu'est-ce qui a rendu cet algorithme tellement meilleur? Toutes les fonctions ont profité de l'utilisation de variables de fonction locales, mais en profitant des deuxième et troisième techniques d'optimisation, les performances ont été multipliées par vingt stringFill3()
.
Analyse plus approfondie
Qu'est-ce qui fait que cette fonction particulière fait exploser la concurrence hors de l'eau?
Comme je l'ai mentionné, la raison pour laquelle ces deux fonctions stringFill1()
et stringFill2()
s'exécutent si lentement est que les chaînes JavaScript sont immuables. La mémoire ne peut pas être réallouée pour permettre d'ajouter un octet de plus à la fois aux données de chaîne stockées par JavaScript. Chaque fois qu'un octet supplémentaire est ajouté à la fin de la chaîne, la chaîne entière est régénérée du début à la fin.
Ainsi, afin d'améliorer les performances du script, il faut précalculer des chaînes de longueur plus longue en concaténant deux chaînes ensemble à l'avance, puis en construisant récursivement la longueur de chaîne souhaitée.
Par exemple, pour créer une chaîne d'octets de 16 lettres, une chaîne de deux octets serait d'abord précalculée. Ensuite, la chaîne de deux octets serait réutilisée pour précalculer une chaîne de quatre octets. Ensuite, la chaîne de quatre octets serait réutilisée pour précalculer une chaîne de huit octets. Enfin, deux chaînes de huit octets seraient réutilisées pour créer la nouvelle chaîne souhaitée de 16 octets. Au total, quatre nouvelles chaînes ont dû être créées, une de longueur 2, une de longueur 4, une de longueur 8 et une de longueur 16. Le coût total est de 2 + 4 + 8 + 16 = 30.
À long terme, cette efficacité peut être calculée en ajoutant dans l'ordre inverse et en utilisant une série géométrique commençant par un premier terme a1 = N et ayant un rapport commun de r = 1/2. La somme d'une série géométrique est donnée par a_1 / (1-r) = 2N
.
C'est plus efficace que d'ajouter un caractère pour créer une nouvelle chaîne de longueur 2, créant une nouvelle chaîne de longueur 3, 4, 5, etc. jusqu'à 16. L'algorithme précédent utilisait ce processus d'ajout d'un seul octet à la fois , et le coût total serait n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136
.
De toute évidence, 136 est un nombre beaucoup plus grand que 30, et donc l'algorithme précédent prend beaucoup, beaucoup plus de temps pour construire une chaîne.
Pour comparer les deux méthodes, vous pouvez voir à quel point l'algorithme récursif (également appelé "diviser pour mieux régner") est plus rapide sur une chaîne de longueur 123 457. Sur mon ordinateur FreeBSD, cet algorithme, implémenté dans la stringFill3()
fonction, crée la chaîne en 0,001058 secondes, tandis que la stringFill1()
fonction d' origine crée la chaîne en 0,0808 secondes. La nouvelle fonction est 76 fois plus rapide.
La différence de performances augmente à mesure que la longueur de la chaîne augmente. Dans la limite où des chaînes de plus en plus grandes sont créées, la fonction d'origine se comporte à peu près comme des temps C1
(constants) N^2
et la nouvelle fonction se comporte comme des temps C2
(constants) N
.
À partir de notre expérience, nous pouvons déterminer la valeur de l' C1
être C1 = 0.0808 / (123457)2 = .00000000000530126997
et la valeur de l' C2
être C2 = 0.001058 / 123457 = .00000000856978543136
. En 10 secondes, la nouvelle fonction pourrait créer une chaîne contenant 1 166 890 359 caractères. Pour créer cette même chaîne, l'ancienne fonction aurait besoin de 7 218 384 secondes.
C'est près de trois mois contre dix secondes!
Je ne réponds (plusieurs années en retard) que parce que ma solution originale à ce problème flotte sur Internet depuis plus de 10 ans, et est apparemment encore mal comprise par quelques-uns qui s'en souviennent. Je pensais qu'en écrivant un article à ce sujet ici, j'aiderais:
Optimisations des performances pour JavaScript haute vitesse / Page 3
Malheureusement, certaines des autres solutions présentées ici sont encore certaines de celles qui prendraient trois mois pour produire la même quantité de sortie qu'une solution appropriée crée en 10 secondes.
Je veux prendre le temps de reproduire une partie de l'article ici comme une réponse canonique sur Stack Overflow.
Notez que l'algorithme le plus performant ici est clairement basé sur mon algorithme et a probablement été hérité de l'adaptation de 3e ou 4e génération de quelqu'un d'autre. Malheureusement, les modifications ont entraîné une réduction de ses performances. La variation de ma solution présentée ici n'a peut-être pas compris mon for (;;)
expression déroutante qui ressemble à la boucle infinie principale d'un serveur écrit en C, et qui a été simplement conçue pour permettre une instruction de rupture soigneusement positionnée pour le contrôle de la boucle, le moyen le plus compact de éviter de répliquer de façon exponentielle la chaîne une fois de plus inutile.