Masalah ini adalah masalah optimisasi JavaScript yang terkenal / "klasik", disebabkan oleh fakta bahwa string JavaScript "tidak dapat diubah" dan penambahan dengan penggabungan bahkan satu karakter ke string memerlukan kreasi, termasuk alokasi memori untuk dan penyalinan ke , seluruh string baru.
Sayangnya, jawaban yang diterima pada halaman ini salah, di mana "salah" berarti oleh faktor kinerja 3x untuk string satu karakter sederhana, dan 8x-97x untuk string pendek yang diulang lebih sering, hingga 300x untuk kalimat berulang, dan jauh salah ketika mengambil batas rasio kompleksitas algoritma n
hingga tak terhingga. Juga, ada jawaban lain di halaman ini yang hampir benar (berdasarkan salah satu dari banyak generasi dan variasi solusi yang tepat yang beredar di seluruh Internet dalam 13 tahun terakhir). Namun, solusi "hampir tepat" ini melewatkan titik kunci dari algoritma yang benar yang menyebabkan penurunan kinerja 50%.
Hasil Kinerja JS untuk jawaban yang diterima, jawaban lain dengan performa terbaik (berdasarkan versi terdegradasi dari algoritma asli dalam jawaban ini), dan jawaban ini menggunakan algoritme saya yang dibuat 13 tahun yang lalu
~ Oktober 2000 Saya menerbitkan algoritma untuk masalah yang tepat ini yang secara luas diadaptasi, dimodifikasi, kemudian akhirnya kurang dipahami dan dilupakan. Untuk mengatasi masalah ini, pada bulan Agustus 2008 saya menerbitkan sebuah artikel http://www.webreference.com/programming/javascript/jkm3/3.html menjelaskan algoritma dan menggunakannya sebagai contoh sederhana pengoptimalan tujuan umum JavaScript. Sekarang, Referensi Web telah menggosok informasi kontak saya dan bahkan nama saya dari artikel ini. Dan sekali lagi, algoritme telah banyak diadaptasi, dimodifikasi, kemudian kurang dipahami dan sebagian besar dilupakan.
Algoritme pengulangan / perkalian string asli oleh Joseph Myers, sekitar Y2K sebagai fungsi pengganda teks dalam Text.js; diterbitkan Agustus 2008 dalam bentuk ini dengan Referensi Web:
http://www.webreference.com/programming/javascript/jkm3/3.html (Artikel ini menggunakan fungsi sebagai contoh optimasi JavaScript, yang merupakan satu-satunya untuk yang aneh nama "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;
}
Dalam waktu dua bulan setelah publikasi artikel itu, pertanyaan yang sama ini diposting ke Stack Overflow dan terbang di bawah radar saya sampai sekarang, ketika tampaknya algoritma asli untuk masalah ini sekali lagi dilupakan. Solusi terbaik yang tersedia di halaman Stack Overflow ini adalah versi modifikasi dari solusi saya, mungkin dipisahkan oleh beberapa generasi. Sayangnya, modifikasi merusak optimalitas solusi. Bahkan, dengan mengubah struktur loop dari yang asli, solusi yang dimodifikasi melakukan langkah ekstra yang sama sekali tidak diperlukan dari duplikasi eksponensial (sehingga bergabung dengan string terbesar yang digunakan dalam jawaban yang tepat dengan dirinya sendiri waktu tambahan dan kemudian membuangnya).
Di bawah ini terjadi diskusi tentang beberapa optimasi JavaScript yang terkait dengan semua jawaban untuk masalah ini dan untuk kepentingan semua.
Teknik: Hindari referensi ke objek atau properti objek
Untuk menggambarkan bagaimana teknik ini bekerja, kami menggunakan fungsi JavaScript kehidupan nyata yang menciptakan string dengan panjang berapa pun yang dibutuhkan. Dan seperti yang akan kita lihat, lebih banyak optimasi dapat ditambahkan!
Fungsi seperti yang digunakan di sini adalah membuat padding untuk meluruskan kolom teks, untuk memformat uang, atau untuk mengisi data blok hingga batas. Fungsi pembuatan teks juga memungkinkan input panjang variabel untuk menguji fungsi lain yang beroperasi pada teks. Fungsi ini adalah salah satu komponen penting dari modul pemrosesan teks JavaScript.
Saat kami melanjutkan, kami akan membahas dua teknik optimasi yang paling penting sambil mengembangkan kode asli menjadi algoritma yang dioptimalkan untuk membuat string. Hasil akhirnya adalah kekuatan industri, fungsi kinerja tinggi yang saya gunakan di mana-mana - menyelaraskan harga barang dan total dalam formulir pemesanan JavaScript, pemformatan data, dan pemformatan pesan email / teks dan banyak kegunaan lainnya.
Kode asli untuk membuat string stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
Sintaksnya di sini jelas. Seperti yang Anda lihat, kami telah menggunakan variabel fungsi lokal, sebelum melanjutkan ke optimasi lainnya.
Perlu diketahui bahwa ada satu referensi tidak bersalah ke properti objek s.length
dalam kode yang merusak kinerjanya. Lebih buruk lagi, penggunaan properti objek ini mengurangi kesederhanaan program dengan membuat asumsi bahwa pembaca tahu tentang properti objek string JavaScript.
Penggunaan properti objek ini merusak keumuman program komputer. Program mengasumsikan bahwa x
harus berupa string yang panjang. Ini membatasi penerapan stringFill1()
fungsi hanya untuk pengulangan karakter tunggal. Bahkan karakter tunggal tidak dapat digunakan jika mengandung banyak byte seperti entitas HTML
.
Masalah terburuk yang disebabkan oleh penggunaan properti objek yang tidak perlu ini adalah bahwa fungsi tersebut menciptakan loop tak terbatas jika diuji pada string input kosong x
. Untuk memeriksa sifat umum, terapkan suatu program dengan jumlah input sekecil mungkin. Suatu program yang mogok ketika diminta untuk melebihi jumlah memori yang tersedia memiliki alasan. Program seperti ini yang macet saat diminta tidak menghasilkan apa-apa tidak dapat diterima. Terkadang kode cantik adalah kode beracun.
Kesederhanaan mungkin merupakan tujuan ganda dari pemrograman komputer, tetapi umumnya tidak. Ketika suatu program tidak memiliki tingkat umum yang masuk akal, itu tidak sah untuk mengatakan, "Program ini cukup baik sejauh itu berjalan." Seperti yang Anda lihat, menggunakan string.length
properti mencegah program ini bekerja dalam pengaturan umum, dan pada kenyataannya, program yang salah siap menyebabkan peramban atau kerusakan sistem.
Apakah ada cara untuk meningkatkan kinerja JavaScript ini serta merawat dua masalah serius ini?
Tentu saja. Cukup gunakan bilangan bulat.
Kode yang dioptimalkan untuk membuat string stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Kode waktu untuk membandingkan stringFill1()
danstringFill2()
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);
Keberhasilan sejauh ini stringFill2()
stringFill1()
membutuhkan 47.297 mikrodetik (sepersejuta detik) untuk mengisi string 100-byte, dan stringFill2()
membutuhkan 27,68 mikrodetik untuk melakukan hal yang sama. Itu hampir dua kali lipat dalam kinerja dengan menghindari referensi ke properti objek.
Teknik: Hindari menambahkan string pendek ke string panjang
Hasil kami sebelumnya terlihat bagus - sangat bagus, sebenarnya. Fungsi yang ditingkatkan stringFill2()
jauh lebih cepat karena penggunaan dua optimasi pertama kami. Percayakah Anda jika saya memberi tahu Anda bahwa itu dapat ditingkatkan menjadi jauh lebih cepat daripada sekarang?
Ya, kita dapat mencapai tujuan itu. Saat ini kita perlu menjelaskan bagaimana kita menghindari menambahkan string pendek ke string panjang.
Perilaku jangka pendek tampaknya cukup baik, dibandingkan dengan fungsi asli kami. Ilmuwan komputer suka menganalisis "perilaku asimptotik" dari suatu fungsi atau algoritma program komputer, yang berarti mempelajari perilaku jangka panjangnya dengan mengujinya dengan input yang lebih besar. Terkadang tanpa melakukan tes lebih lanjut, seseorang tidak pernah menyadari cara-cara program komputer dapat ditingkatkan. Untuk melihat apa yang akan terjadi, kita akan membuat string 200-byte.
Masalah yang muncul dengan stringFill2()
Menggunakan fungsi timing kami, kami menemukan bahwa waktu meningkat menjadi 62,54 mikrodetik untuk string 200-byte, dibandingkan dengan 27,68 untuk string 100-byte. Sepertinya waktunya harus dua kali lipat untuk melakukan pekerjaan dua kali lebih banyak, tetapi justru berlipat tiga atau empat kali lipat. Dari pengalaman pemrograman, hasil ini tampak aneh, karena jika ada, fungsi harus sedikit lebih cepat karena pekerjaan dilakukan lebih efisien (200 byte per panggilan fungsi daripada 100 byte per panggilan fungsi). Masalah ini berkaitan dengan properti berbahaya dari string JavaScript: String JavaScript "tidak dapat diubah."
Abadi berarti Anda tidak dapat mengubah string setelah itu dibuat. Dengan menambahkan satu byte pada satu waktu, kami tidak menggunakan upaya satu byte lagi. Kami benar-benar menciptakan kembali seluruh string ditambah satu byte lagi.
Efeknya, untuk menambahkan satu byte lagi ke string 100-byte, dibutuhkan kerja senilai 101 byte. Mari kita secara singkat menganalisis biaya komputasi untuk membuat serangkaian N
byte. Biaya penambahan byte pertama adalah 1 unit upaya komputasi. Biaya menambahkan byte kedua bukan satu unit tetapi 2 unit (menyalin byte pertama ke objek string baru serta menambahkan byte kedua). Bita ketiga membutuhkan biaya 3 unit, dll.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)
. Simbol O(N^2)
diucapkan Big O dari N kuadrat, dan itu berarti bahwa biaya komputasi dalam jangka panjang sebanding dengan kuadrat dari panjang string. Untuk membuat 100 karakter dibutuhkan 10.000 unit kerja, dan untuk membuat 200 karakter dibutuhkan 40.000 unit kerja.
Inilah sebabnya mengapa dibutuhkan lebih dari dua kali lebih lama untuk membuat 200 karakter dari 100 karakter. Padahal, seharusnya memakan waktu empat kali lebih lama. Pengalaman pemrograman kami benar karena pekerjaan dilakukan sedikit lebih efisien untuk string yang lebih lama, dan karenanya hanya butuh sekitar tiga kali lebih lama. Setelah overhead panggilan fungsi diabaikan untuk berapa lama string yang kita buat, sebenarnya akan membutuhkan waktu empat kali lebih banyak untuk membuat string dua kali lebih lama.
(Catatan historis: Analisis ini tidak selalu berlaku untuk string dalam kode sumber, seperti html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
, karena kompiler kode sumber JavaScript dapat menggabungkan string sebelum menjadikannya menjadi objek string JavaScript. Hanya beberapa tahun yang lalu, implementasi KJS dari JavaScript akan membeku atau mogok saat memuat string panjang kode sumber yang bergabung dengan tanda plus. Karena waktu komputasi O(N^2)
itu tidak sulit untuk membuat halaman Web yang membebani browser Web Konqueror atau Safari, yang menggunakan inti mesin KJS JavaScript. Saya pertama kali menemukan masalah ini ketika saya sedang mengembangkan bahasa markup dan parser bahasa markup JavaScript, dan kemudian saya menemukan apa yang menyebabkan masalah ketika saya menulis skrip saya untuk Termasuk JavaScript.)
Jelas penurunan kinerja yang cepat ini adalah masalah besar. Bagaimana kita bisa menghadapinya, mengingat kita tidak bisa mengubah cara JavaScript menangani string sebagai objek yang tidak dapat diubah? Solusinya adalah dengan menggunakan algoritma yang menciptakan string sesering mungkin.
Untuk memperjelas, tujuan kami adalah untuk menghindari menambahkan string pendek ke string panjang, karena untuk menambahkan string pendek, seluruh string panjang juga harus diduplikasi.
Bagaimana algoritma bekerja untuk menghindari penambahan string pendek ke string panjang
Berikut adalah cara yang baik untuk mengurangi berapa kali objek string baru dibuat. Menggabungkan panjang string yang lebih panjang sehingga lebih dari satu byte pada suatu waktu ditambahkan ke output.
Misalnya, untuk membuat string panjang 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 */
Melakukan ini diperlukan membuat string dengan panjang 1, membuat string dengan panjang 2, membuat string dengan panjang 4, membuat string dengan panjang 8, dan akhirnya, membuat string dengan panjang 9. Berapa biaya yang telah kita hemat?
Biaya lama C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45
.
Biaya baru C(9) = 1 + 2 + 4 + 8 + 9 = 24
.
Perhatikan bahwa kita harus menambahkan string dengan panjang 1 ke string dengan panjang 0, kemudian string dengan panjang 1 ke string dengan panjang 1, kemudian string dengan panjang 2 ke string dengan panjang 2, kemudian string dengan panjang 4 ke string dengan panjang 4, lalu string dengan panjang 8 ke string dengan panjang 1, untuk mendapatkan string dengan panjang 9. Apa yang kita lakukan dapat diringkas sebagai menghindari menambahkan string pendek ke string panjang, atau di lain kata-kata, mencoba menyatukan string yang panjangnya sama atau hampir sama.
Untuk biaya komputasi lama kami menemukan rumus N(N+1)/2
. Apakah ada formula untuk biaya baru? Ya, tapi rumit. Yang penting adalah itu O(N)
, dan menggandakan panjang tali akan kira-kira dua kali lipat jumlah pekerjaan daripada empat kali lipatnya.
Kode yang mengimplementasikan ide baru ini hampir sama rumitnya dengan rumus untuk biaya komputasi. Ketika Anda membacanya, ingat itu >>= 1
artinya bergeser ke kanan sebesar 1 byte. Jadi jika n = 10011
angka biner, maka n >>= 1
menghasilkan nilai n = 1001
.
Bagian lain dari kode yang mungkin tidak Anda kenali adalah bitwise dan operator, ditulis &
. Ekspresi n & 1
bernilai true jika digit biner terakhir n
adalah 1, dan false jika digit biner terakhir n
adalah 0.
stringFill3()
Fungsi baru yang sangat efisien
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Itu terlihat jelek bagi mata yang tidak terlatih, tetapi kinerjanya tidak kalah indah.
Mari kita lihat seberapa baik fungsi ini bekerja. Setelah melihat hasilnya, kemungkinan Anda tidak akan pernah melupakan perbedaan antara suatu O(N^2)
algoritma dan O(N)
algoritma.
stringFill1()
membutuhkan 88,7 mikrodetik (sepersejuta detik) untuk membuat string 200-byte, stringFill2()
membutuhkan 62,54, dan stringFill3()
hanya membutuhkan 4,608. Apa yang membuat algoritma ini jauh lebih baik? Semua fungsi mengambil keuntungan dari menggunakan variabel fungsi lokal, tetapi mengambil keuntungan dari teknik optimasi kedua dan ketiga menambahkan peningkatan dua kali lipat untuk kinerja stringFill3()
.
Analisis yang lebih dalam
Apa yang membuat fungsi khusus ini membuat kompetisi keluar dari air?
Seperti yang saya sebutkan, alasan kedua fungsi ini, stringFill1()
dan stringFill2()
, berjalan sangat lambat adalah bahwa string JavaScript tidak dapat diubah. Memori tidak dapat dialokasikan kembali untuk memungkinkan satu byte lagi pada satu waktu ditambahkan ke data string yang disimpan oleh JavaScript. Setiap kali satu byte lagi ditambahkan ke ujung string, seluruh string dibuat ulang dari awal hingga akhir.
Dengan demikian, untuk meningkatkan kinerja skrip, kita harus melakukan pra-string string yang lebih panjang dengan menggabungkan dua string bersama sebelumnya, dan kemudian secara rekursif membangun panjang string yang diinginkan.
Misalnya, untuk membuat string byte 16 huruf, pertama string dua byte akan dikomputasi. Kemudian string dua byte akan digunakan kembali untuk precompute string empat byte. Kemudian string empat byte akan digunakan kembali untuk precompute string delapan byte. Akhirnya, dua string delapan byte akan digunakan kembali untuk membuat string baru yang diinginkan sebesar 16 byte. Secara keseluruhan empat string baru harus dibuat, satu panjang 2, satu panjang 4, satu panjang 8 dan satu panjang 16. Total biaya adalah 2 + 4 + 8 + 16 = 30.
Dalam jangka panjang efisiensi ini dapat dihitung dengan menambahkan urutan terbalik dan menggunakan deret geometri yang dimulai dengan suku pertama a1 = N dan memiliki rasio umum r = 1/2. Jumlah deret geometri diberikan oleh a_1 / (1-r) = 2N
.
Ini lebih efisien daripada menambahkan satu karakter untuk membuat string baru dengan panjang 2, membuat string baru dengan panjang 3, 4, 5, dan seterusnya, sampai 16. Algoritma sebelumnya menggunakan proses penambahan byte tunggal pada suatu waktu , dan total biaya itu akan menjadi n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136
.
Jelas, 136 adalah angka yang jauh lebih besar dari 30, dan algoritma sebelumnya membutuhkan lebih banyak waktu untuk membangun string.
Untuk membandingkan dua metode, Anda dapat melihat seberapa cepat algoritma rekursif (juga disebut "divide and conquer") ada pada string dengan panjang 123.457. Pada komputer FreeBSD saya, algoritma ini, diimplementasikan dalam stringFill3()
fungsi, membuat string dalam 0,001058 detik, sedangkan stringFill1()
fungsi asli membuat string dalam 0,0808 detik. Fungsi baru 76 kali lebih cepat.
Perbedaan kinerja tumbuh seiring panjangnya string menjadi lebih besar. Dalam batas sebagai string yang lebih besar dan lebih besar dibuat, fungsi asli berperilaku kira-kira seperti waktu C1
(konstan) N^2
, dan fungsi baru berperilaku seperti waktu C2
(konstan) N
.
Dari percobaan kami, kami dapat menentukan nilai C1
menjadi C1 = 0.0808 / (123457)2 = .00000000000530126997
, dan nilai C2
menjadi C2 = 0.001058 / 123457 = .00000000856978543136
. Dalam 10 detik, fungsi baru dapat membuat string berisi 1.166.890.359 karakter. Untuk membuat string yang sama ini, fungsi lama akan membutuhkan waktu 7.218.384 detik.
Ini hampir tiga bulan dibandingkan dengan sepuluh detik!
Saya hanya menjawab (beberapa tahun terlambat) karena solusi asli saya untuk masalah ini telah beredar di Internet selama lebih dari 10 tahun, dan tampaknya masih kurang dipahami oleh beberapa orang yang mengingatnya. Saya berpikir bahwa dengan menulis artikel tentang itu di sini saya akan membantu:
Optimalisasi Kinerja untuk JavaScript Kecepatan Tinggi / Halaman 3
Sayangnya, beberapa solusi lain yang disajikan di sini masih beberapa yang membutuhkan waktu tiga bulan untuk menghasilkan jumlah output yang sama dengan yang dihasilkan oleh solusi yang tepat dalam 10 detik.
Saya ingin meluangkan waktu untuk mereproduksi bagian dari artikel di sini sebagai jawaban kanonik pada Stack Overflow.
Perhatikan bahwa algoritma berkinerja terbaik di sini jelas berdasarkan pada algoritme saya dan mungkin diturunkan dari adaptasi generasi ke-3 atau ke-4 orang lain. Sayangnya, modifikasi mengakibatkan pengurangan kinerjanya. Variasi solusi saya yang disajikan di sini mungkin tidak mengerti for (;;)
ekspresi membingungkan saya yang terlihat seperti loop tak terbatas utama dari server yang ditulis dalam C, dan yang hanya dirancang untuk memungkinkan pernyataan break diposisikan dengan hati-hati untuk kontrol loop, cara paling ringkas untuk hindari mereplikasi string secara eksponensial satu waktu ekstra yang tidak perlu.