Problem ten jest dobrze znanym / „klasycznym” problemem optymalizacji dla JavaScript, spowodowanym faktem, że ciągi JavaScript są „niezmienne”, a dodanie przez połączenie nawet jednego znaku z ciągu wymaga utworzenia, w tym alokacji pamięci i skopiowania do , cały nowy ciąg.
Niestety, przyjęta odpowiedź na tej stronie jest błędna, gdzie „zła” oznacza współczynnik wydajności 3x dla prostych ciągów jednoznakowych i 8x-97x dla krótkich ciągów powtarzanych więcej razy, do 300x dla powtarzania zdań i nieskończenie zły, gdy przyjmowanie granicy współczynników złożoności algorytmów ndo nieskończoności. Jest też inna odpowiedź na tej stronie, która jest prawie słuszna (oparta na jednej z wielu generacji i odmian prawidłowego rozwiązania krążących w Internecie w ciągu ostatnich 13 lat). Jednak w tym „prawie właściwym” rozwiązaniu brakuje kluczowego punktu prawidłowego algorytmu, powodując obniżenie wydajności o 50%.
Wyniki wydajności JS dla zaakceptowanej odpowiedzi, drugiej najbardziej wydajnej odpowiedzi (na podstawie zdegradowanej wersji oryginalnego algorytmu w tej odpowiedzi) i tej odpowiedzi przy użyciu mojego algorytmu utworzonego 13 lat temu
~ Październik 2000 Opublikowałem algorytm tego dokładnie problemu, który został szeroko dostosowany, zmodyfikowany, a następnie ostatecznie źle zrozumiany i zapomniany. Aby temu zaradzić, w sierpniu 2008 roku opublikowałem artykuł http://www.webreference.com/programming/javascript/jkm3/3.html wyjaśniający algorytm i wykorzystujący go jako przykład prostych ogólnych optymalizacji JavaScript. Do tej pory Web Reference wyczyściło moje dane kontaktowe, a nawet moje nazwisko z tego artykułu. I jeszcze raz algorytm został szeroko dostosowany, zmodyfikowany, a następnie źle zrozumiany i w dużej mierze zapomniany.
Oryginalny algorytm JavaScript powtarzania / mnożenia napisany przez Josepha Myersa, około Y2K jako funkcja mnożenia tekstu w Text.js; opublikowany w sierpniu 2008 roku w tej formie przez Web Reference:
http://www.webreference.com/programming/javascript/jkm3/3.html (W artykule wykorzystano tę funkcję jako przykład optymalizacji JavaScript, która jest jedyną dziwną nazwa „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;
}
W ciągu dwóch miesięcy po opublikowaniu tego artykułu to samo pytanie zostało wysłane do Stack Overflow i leciało pod moim radarem aż do chwili, kiedy najwyraźniej pierwotny algorytm tego problemu został ponownie zapomniany. Najlepszym rozwiązaniem dostępnym na tej stronie przepełnienia stosu jest zmodyfikowana wersja mojego rozwiązania, prawdopodobnie rozdzielona przez kilka pokoleń. Niestety modyfikacje zepsuły optymalność rozwiązania. W rzeczywistości, zmieniając strukturę pętli z mojego oryginału, zmodyfikowane rozwiązanie wykonuje całkowicie niepotrzebny dodatkowy krok wykładniczego powielania (łącząc w ten sposób największy ciąg użyty we właściwej odpowiedzi ze sobą dodatkowy czas, a następnie odrzucając go).
Poniżej omówiono niektóre optymalizacje JavaScript związane z wszystkimi odpowiedziami na ten problem i dla wszystkich.
Technika: Unikaj odwołań do obiektów lub właściwości obiektów
Aby zilustrować działanie tej techniki, używamy rzeczywistej funkcji JavaScript, która tworzy ciągi o dowolnej potrzebnej długości. I jak zobaczymy, można dodać więcej optymalizacji!
Funkcją taką jak tutaj użyta jest tworzenie wypełnienia w celu wyrównania kolumn tekstu, formatowania pieniędzy lub wypełniania danych bloku do granicy. Funkcja generowania tekstu umożliwia także wprowadzanie zmiennych długości w celu testowania dowolnej innej funkcji, która działa na tekście. Ta funkcja jest jednym z ważnych elementów modułu przetwarzania tekstu JavaScript.
W miarę postępów będziemy omawiać jeszcze dwie najważniejsze techniki optymalizacji, jednocześnie rozwijając oryginalny kod w zoptymalizowany algorytm do tworzenia łańcuchów. Ostateczny wynik to przemysłowa, wysokowydajna funkcja, z której korzystałem wszędzie - wyrównanie cen produktów i sum w formularzach zamówień JavaScript, formatowanie danych oraz formatowanie wiadomości e-mail / SMS i wiele innych zastosowań.
Oryginalny kod do tworzenia ciągów stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
Składnia jest tutaj jasna. Jak widać, użyliśmy już lokalnych zmiennych funkcji, zanim przejdziemy do dalszych optymalizacji.
Pamiętaj, że s.lengthw kodzie jest jedno niewinne odniesienie do właściwości obiektu, które ma negatywny wpływ na jego wydajność. Co gorsza, użycie tej właściwości obiektu zmniejsza prostotę programu, przyjmując założenie, że czytelnik wie o właściwościach ciągów znaków JavaScript.
Użycie tej właściwości obiektu niszczy ogólność programu komputerowego. Program zakłada, że xmusi to być ciąg długości jeden. Ogranicza to zastosowanie stringFill1()funkcji do czegokolwiek poza powtarzaniem pojedynczych znaków. Nawet pojedyncze znaki nie mogą być użyte, jeśli zawierają wiele bajtów, takich jak encja HTML .
Najgorszym problemem spowodowanym tym niepotrzebnym użyciem właściwości obiektu jest to, że funkcja tworzy nieskończoną pętlę, jeśli jest testowana na pustym ciągu wejściowym x. Aby sprawdzić ogólność, zastosuj program do jak najmniejszej ilości danych wejściowych. Program, który ulega awarii, gdy zostanie wyświetlony monit o przekroczenie ilości dostępnej pamięci, ma usprawiedliwienie. Program taki jak ten, który ulega awarii, gdy zostanie poproszony o nic nie produkować, jest niedopuszczalny. Czasami ładny kod jest trującym kodem.
Prostota może być niejednoznacznym celem programowania komputerowego, ale generalnie tak nie jest. Gdy programowi brakuje jakiegokolwiek rozsądnego poziomu ogólności, nie można powiedzieć: „Program jest wystarczająco dobry, o ile to możliwe”. Jak widać, użycie tej string.lengthwłaściwości uniemożliwia temu programowi działanie w ustawieniach ogólnych, aw rzeczywistości niepoprawny program jest gotowy do spowodowania awarii przeglądarki lub systemu.
Czy istnieje sposób na poprawę wydajności tego JavaScript oraz rozwiązanie tych dwóch poważnych problemów?
Oczywiście. Po prostu użyj liczb całkowitych.
Zoptymalizowany kod do tworzenia ciągów stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Kod czasowy do porównania stringFill1()istringFill2()
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);
Dotychczasowy sukces stringFill2()
stringFill1()zajmuje 47.297 mikrosekund (milionowych części sekundy), aby wypełnić łańcuch o długości 100 bajtów, i stringFill2()zajmuje 27,68 mikrosekund, aby zrobić to samo. To prawie dwukrotne zwiększenie wydajności dzięki uniknięciu odwołania do właściwości obiektu.
Technika: Unikaj dodawania krótkich łańcuchów do długich łańcuchów
Nasz poprzedni wynik wyglądał dobrze - w rzeczywistości bardzo dobry. Udoskonalona funkcja stringFill2()jest znacznie szybsza dzięki zastosowaniu naszych pierwszych dwóch optymalizacji. Czy uwierzyłbyś, gdybym ci powiedział, że można go poprawić wiele razy szybciej niż teraz?
Tak, możemy osiągnąć ten cel. W tej chwili musimy wyjaśnić, w jaki sposób unikamy dodawania krótkich łańcuchów do długich łańcuchów.
Krótkoterminowe zachowanie wydaje się być całkiem dobre w porównaniu z naszą pierwotną funkcją. Informatycy lubią analizować „asymptotyczne zachowanie” algorytmu funkcji lub programu komputerowego, co oznacza badanie jego długoterminowego zachowania poprzez testowanie go przy użyciu większych danych wejściowych. Czasami bez wykonywania dalszych testów nigdy nie zdajemy sobie sprawy, w jaki sposób można poprawić program komputerowy. Aby zobaczyć, co się stanie, utworzymy ciąg 200-bajtowy.
Problem, który się pojawia stringFill2()
Korzystając z naszej funkcji pomiaru czasu, okazuje się, że czas wzrasta do 62,54 mikrosekundy dla ciągu 200-bajtowego, w porównaniu do 27,68 dla ciągu 100-bajtowego. Wydaje się, że czas należy podwoić, aby wykonać dwukrotnie więcej pracy, ale zamiast tego jest on trzykrotnie lub czterokrotnie. Z doświadczenia w programowaniu wynik ten wydaje się dziwny, ponieważ jeśli już, funkcja powinna być nieco szybsza, ponieważ praca jest wykonywana bardziej wydajnie (200 bajtów na wywołanie funkcji zamiast 100 bajtów na wywołanie funkcji). Ten problem dotyczy podstępnej właściwości ciągów JavaScript: ciągi JavaScript są „niezmienne”.
Niezmienny oznacza, że nie można zmienić łańcucha po jego utworzeniu. Dodając jeden bajt na raz, nie zużywamy jeszcze jednego bajtu wysiłku. W rzeczywistości odtwarzamy cały ciąg plus jeszcze jeden bajt.
W efekcie, aby dodać jeszcze jeden bajt do ciągu 100-bajtowego, potrzeba 101 bajtów pracy. Przeanalizujmy krótko koszt obliczeniowy tworzenia ciągu Nbajtów. Koszt dodania pierwszego bajtu to 1 jednostka wysiłku obliczeniowego. Koszt dodania drugiego bajtu to nie jedna jednostka, ale 2 jednostki (skopiowanie pierwszego bajtu do nowego obiektu ciągu, a także dodanie drugiego bajtu). Trzeci bajt wymaga kosztu 3 jednostek itp.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2). Symbol O(N^2)jest wymawiany jako Big O z N do kwadratu, a to oznacza, że koszt obliczeniowy w długim okresie jest proporcjonalny do kwadratu długości łańcucha. Stworzenie 100 znaków wymaga 10 000 jednostek pracy, a stworzenie 200 znaków wymaga 40 000 jednostek pracy.
Dlatego utworzenie ponad 200 znaków zajęło ponad dwa razy więcej czasu. W rzeczywistości powinno to zająć cztery razy dłużej. Nasze doświadczenie w programowaniu było prawidłowe, ponieważ praca jest wykonywana nieco bardziej wydajnie dla dłuższych ciągów, a zatem zajęło to tylko około trzy razy dłużej. Gdy narzut wywołania funkcji stanie się nieistotny, jeśli chodzi o długość tworzonego ciągu, utworzenie tego ciągu będzie czterokrotnie więcej czasu.
(Uwaga historyczna: Ta analiza niekoniecznie dotyczy łańcuchów w kodzie źródłowym, na przykład html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n', ponieważ kompilator kodu źródłowego JavaScript może łączyć łańcuchy razem przed przekształceniem ich w obiekt łańcucha JavaScript. Kilka lat temu implementacja KJS JavaScript zawiesza się lub ulega awarii podczas ładowania długich ciągów kodu źródłowego połączonych znakami plus. Ponieważ czas obliczeniowy O(N^2)nie był trudny, tworzenie stron internetowych, które przeciążały przeglądarkę Konqueror lub Safari, które korzystały z rdzenia silnika KJS JavaScript. natknąłem się na ten problem, gdy opracowywałem język znaczników i parser języka znaczników JavaScript, a następnie odkryłem, co było przyczyną problemu, gdy napisałem skrypt dla JavaScript Includes).
Oczywiście ta szybka degradacja wydajności jest ogromnym problemem. Jak sobie z tym poradzić, skoro nie możemy zmienić sposobu obsługi napisów przez JavaScript jako obiektów niezmiennych? Rozwiązaniem jest użycie algorytmu, który odtworzy ciąg tak mało, jak to możliwe.
Aby to wyjaśnić, naszym celem jest unikanie dodawania krótkich łańcuchów do długich łańcuchów, ponieważ aby dodać krótki łańcuch, cały długi łańcuch również musi zostać zduplikowany.
Jak działa algorytm, aby uniknąć dodawania krótkich łańcuchów do długich łańcuchów
Oto dobry sposób na zmniejszenie liczby razy, gdy tworzone są nowe obiekty łańcuchowe. Połącz dłuższe ciągi razem, aby do wyjścia dodawano więcej niż jeden bajt na raz.
Na przykład, aby utworzyć ciąg długości 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 */
Wykonanie tego wymagało utworzenia ciągu o długości 1, utworzenia ciągu o długości 2, utworzenia ciągu o długości 4, utworzenia ciągu o długości 8 i wreszcie utworzenia ciągu o długości 9. Ile zaoszczędziliśmy na kosztach?
Stary koszt C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45.
Nowy koszt C(9) = 1 + 2 + 4 + 8 + 9 = 24.
Zauważ, że musieliśmy dodać ciąg o długości 1 do ciągu o długości 0, następnie ciąg o długości 1 do ciągu o długości 1, a następnie ciąg o długości 2 do ciągu o długości 2, a następnie ciąg o długości 4 na ciąg o długości 4, następnie ciąg o długości 8 na ciąg o długości 1, w celu uzyskania ciągu o długości 9. To, co robimy, można streścić, unikając dodawania krótkich ciągów do długich ciągów lub w inny sposób słowa, próbując połączyć ze sobą ciągi o równej lub prawie równej długości.
Dla starego kosztu obliczeniowego znaleźliśmy wzór N(N+1)/2. Czy istnieje wzór na nowy koszt? Tak, ale to skomplikowane. Ważną rzeczą jest to, że tak jest O(N), dlatego podwojenie długości struny w przybliżeniu podwoi ilość pracy, zamiast ją czterokrotnie.
Kod implementujący ten nowy pomysł jest prawie tak skomplikowany, jak formuła kosztu obliczeniowego. Kiedy ją czytasz, pamiętaj, że >>= 1oznacza to przesunięcie w prawo o 1 bajt. Więc jeśli n = 10011jest liczbą binarną, to n >>= 1daje wartość n = 1001.
Inną częścią kodu, której możesz nie rozpoznać, jest zapis bitowy i operator &. Wyrażenie n & 1ocenia true, jeśli ostatnia cyfra binarna nto 1, i false, jeśli ostatnia cyfra binarna nwynosi 0.
Nowa wysoce wydajna stringFill3()funkcja
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Dla niewprawnego oka wygląda to brzydko, ale jego wydajność jest niczym innym, jak uroczym.
Zobaczmy, jak dobrze działa ta funkcja. Po zobaczeniu wyników prawdopodobnie nigdy nie zapomnisz różnicy między O(N^2)algorytmem a O(N)algorytmem.
stringFill1()zajmuje 88,7 mikrosekund (milionowych części sekundy), aby utworzyć ciąg 200-bajtowy, stringFill2()zajmuje 62,54 i stringFill3()zajmuje tylko 4,608. Co sprawiło, że ten algorytm był o wiele lepszy? Wszystkie funkcje wykorzystały lokalne zmienne funkcyjne, ale skorzystanie z drugiej i trzeciej techniki optymalizacji dodało dwudziestokrotną poprawę wydajności stringFill3().
Głębsza analiza
Co sprawia, że ta konkretna funkcja wysadza konkurencję z wody?
Jak już wspomniano, dlatego, że obie te funkcje, stringFill1()i stringFill2()uruchomić tak powoli, że ciągi są niezmienne JavaScript. Pamięci nie można ponownie przydzielić, aby umożliwić dołączenie jednego bajtu naraz do danych ciągów przechowywanych przez JavaScript. Za każdym razem, gdy na końcu łańcucha dodawany jest jeszcze jeden bajt, cały łańcuch jest generowany od początku do końca.
Tak więc, aby poprawić wydajność skryptu, należy wstępnie obliczyć łańcuchy o większej długości, łącząc dwa łańcuchy przed czasem, a następnie rekurencyjnie budując pożądaną długość łańcucha.
Na przykład, aby utworzyć 16-literowy ciąg bajtów, najpierw należy wstępnie obliczyć ciąg dwóch bajtów. Następnie dwubajtowy ciąg znaków zostałby ponownie wykorzystany do wstępnego obliczenia czterobajtowego ciągu. Następnie czterobajtowy ciąg znaków zostanie ponownie wykorzystany do wstępnego obliczenia ośmiobajtowego ciągu. Wreszcie dwa ośmiobajtowe ciągi znaków zostaną ponownie wykorzystane do utworzenia pożądanego nowego ciągu 16 bajtów. W sumie musiały zostać utworzone cztery nowe ciągi, jeden o długości 2, jeden o długości 4, jeden o długości 8 i jeden o długości 16. Całkowity koszt to 2 + 4 + 8 + 16 = 30.
Na dłuższą metę wydajność tę można obliczyć, dodając w odwrotnej kolejności i używając szeregu geometrycznego rozpoczynającego się od pierwszego członu a1 = N i mającego wspólny stosunek r = 1/2. Suma szeregu geometrycznego jest podana przez a_1 / (1-r) = 2N.
Jest to bardziej wydajne niż dodawanie jednego znaku w celu utworzenia nowego ciągu o długości 2, tworzenia nowego ciągu o długości 3, 4, 5 itd. Do 16. Poprzedni algorytm używał tego procesu dodawania pojedynczego bajtu naraz , a całkowity koszt wyniósłby n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136.
Oczywiście 136 jest znacznie większą liczbą niż 30, więc poprzedni algorytm zajmuje dużo, dużo więcej czasu, aby zbudować ciąg.
Aby porównać te dwie metody, możesz zobaczyć, o ile szybciej algorytm rekurencyjny (zwany także „dziel i rządź”) ma ciąg długości 123.457. Na moim komputerze FreeBSD ten algorytm, zaimplementowany w stringFill3()funkcji, tworzy ciąg w 0,001058 sekund, podczas gdy oryginalna stringFill1()funkcja tworzy ciąg w 0,0808 sekund. Nowa funkcja jest 76 razy szybsza.
Różnica w wydajności rośnie wraz ze wzrostem długości łańcucha. W granicy, gdy tworzone są coraz większe ciągi, oryginalna funkcja zachowuje się mniej więcej tak jak C1(stałe) czasy N^2, a nowa funkcja zachowuje się jak C2(stała) razy N.
Na podstawie naszego eksperymentu możemy określić wartość C1bytu C1 = 0.0808 / (123457)2 = .00000000000530126997i wartość C2bytu C2 = 0.001058 / 123457 = .00000000856978543136. W ciągu 10 sekund nowa funkcja może utworzyć ciąg zawierający 1168 890 359 znaków. Aby utworzyć ten sam ciąg, stara funkcja potrzebowałaby 7 218 384 sekund.
To prawie trzy miesiące w porównaniu do dziesięciu sekund!
Odpowiadam tylko (kilka lat z opóźnieniem), ponieważ moje oryginalne rozwiązanie tego problemu krąży w Internecie od ponad 10 lat i najwyraźniej wciąż jest słabo rozumiane przez niewielu, którzy go pamiętają. Pomyślałem, że pisząc tutaj o tym artykuł, pomogę:
Optymalizacje wydajności dla szybkiego JavaScript / strona 3
Niestety, niektóre z innych przedstawionych tutaj rozwiązań to nadal niektóre z tych, które zajęłyby trzy miesiące, aby uzyskać taką samą moc wyjściową, jaką tworzy właściwe rozwiązanie w 10 sekund.
Chcę poświęcić trochę czasu na odtworzenie tutaj tego artykułu jako kanonicznej odpowiedzi na temat przepełnienia stosu.
Zauważ, że najskuteczniejszy algorytm tutaj jest wyraźnie oparty na moim algorytmie i prawdopodobnie został odziedziczony z adaptacji trzeciej lub czwartej generacji innej osoby. Niestety modyfikacje spowodowały zmniejszenie jego wydajności. Przedstawiona tutaj odmiana mojego rozwiązania może nie rozumieć mojego mylącego for (;;)wyrażenia, które wygląda jak główna nieskończona pętla serwera napisanego w C i które zostało po prostu zaprojektowane, aby umożliwić dokładnie ustawione polecenie break do sterowania pętlą, najbardziej kompaktowy sposób unikaj wykładniczego powtarzania ciągu o jeden dodatkowy niepotrzebny czas.