Vấn đề này là một vấn đề tối ưu hóa nổi tiếng / "cổ điển" đối với JavaScript, do thực tế là các chuỗi JavaScript là "bất biến" và thêm vào đó bằng cách ghép một ký tự duy nhất vào một chuỗi yêu cầu tạo, bao gồm cấp phát bộ nhớ cho và sao chép vào , một chuỗi hoàn toàn mới.
Thật không may, câu trả lời được chấp nhận trên trang này là sai, trong đó "sai" có nghĩa là hệ số hiệu suất 3x cho các chuỗi một ký tự đơn giản và 8x-97x cho các chuỗi ngắn lặp lại nhiều lần hơn, đến 300 lần cho các câu lặp lại và vô cùng sai khi lấy giới hạn của các tỷ lệ phức tạp của các thuật toán n
đi đến vô cùng. Ngoài ra, có một câu trả lời khác trên trang này gần như đúng (dựa trên một trong nhiều thế hệ và biến thể của giải pháp chính xác lưu hành trên Internet trong 13 năm qua). Tuy nhiên, giải pháp "gần như đúng" này bỏ lỡ một điểm chính của thuật toán chính xác gây ra sự suy giảm hiệu suất 50%.
Kết quả hiệu suất JS cho câu trả lời được chấp nhận, câu trả lời khác có hiệu suất cao nhất (dựa trên phiên bản xuống cấp của thuật toán gốc trong câu trả lời này) và câu trả lời này sử dụng thuật toán của tôi được tạo ra cách đây 13 năm
~ Tháng 10 năm 2000 tôi đã xuất bản một thuật toán cho vấn đề chính xác này đã được điều chỉnh, sửa đổi rộng rãi, sau đó cuối cùng được hiểu và bị lãng quên. Để khắc phục vấn đề này, vào tháng 8 năm 2008, tôi đã xuất bản một bài viết http://www.webreference.com/programming/javascript/jkm3/3.html giải thích thuật toán và sử dụng nó như một ví dụ đơn giản về tối ưu hóa JavaScript cho mục đích chung. Đến bây giờ, Web Reference đã xem xét thông tin liên lạc của tôi và thậm chí tên của tôi từ bài viết này. Và một lần nữa, thuật toán đã được điều chỉnh, sửa đổi rộng rãi, sau đó được hiểu kém và bị lãng quên.
Thuật toán JavaScript lặp lại / nhân chuỗi gốc của Joseph Myers, tuần hoàn Y2K như một hàm nhân văn bản trong Text.js; được xuất bản vào tháng 8 năm 2008 dưới dạng này bởi Web Reference:
http://www.webreference.com/programming/javascript/jkm3/3.html (Bài viết đã sử dụng chức năng này như một ví dụ về tối ưu hóa JavaScript, chỉ dành cho người lạ tên "chuỗiFill3.")
/*
* 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;
}
Trong vòng hai tháng sau khi xuất bản bài báo đó, câu hỏi tương tự đã được đăng lên Stack Overflow và bay theo radar của tôi cho đến bây giờ, khi rõ ràng thuật toán ban đầu cho vấn đề này một lần nữa bị lãng quên. Giải pháp tốt nhất có sẵn trên trang Stack Overflow này là phiên bản sửa đổi của giải pháp của tôi, có thể được phân tách bằng nhiều thế hệ. Thật không may, các sửa đổi đã phá hỏng sự tối ưu của giải pháp. Trong thực tế, bằng cách thay đổi cấu trúc của vòng lặp từ bản gốc của tôi, giải pháp đã sửa đổi thực hiện một bước bổ sung hoàn toàn không cần thiết của sao chép hàm mũ (do đó nối chuỗi lớn nhất được sử dụng trong câu trả lời thích hợp với chính nó thêm thời gian và sau đó loại bỏ nó).
Dưới đây đảm bảo một cuộc thảo luận về một số tối ưu hóa JavaScript liên quan đến tất cả các câu trả lời cho vấn đề này và vì lợi ích của tất cả.
Kỹ thuật: Tránh tham chiếu đến các đối tượng hoặc thuộc tính đối tượng
Để minh họa cách thức hoạt động của kỹ thuật này, chúng tôi sử dụng hàm JavaScript ngoài đời thực, tạo ra các chuỗi có độ dài bất kỳ là cần thiết. Và như chúng ta sẽ thấy, có thể thêm nhiều tối ưu hóa!
Một chức năng giống như chức năng được sử dụng ở đây là tạo phần đệm để căn chỉnh các cột văn bản, để định dạng tiền hoặc để điền dữ liệu khối lên đến ranh giới. Hàm tạo văn bản cũng cho phép nhập độ dài thay đổi để kiểm tra bất kỳ chức năng nào khác hoạt động trên văn bản. Hàm này là một trong những thành phần quan trọng của mô đun xử lý văn bản JavaScript.
Khi chúng tôi tiến hành, chúng tôi sẽ đề cập đến hai kỹ thuật tối ưu hóa quan trọng nhất trong khi phát triển mã gốc thành một thuật toán tối ưu hóa để tạo chuỗi. Kết quả cuối cùng là một chức năng hiệu suất cao, có độ bền công nghiệp mà tôi đã sử dụng ở mọi nơi - căn chỉnh giá vật phẩm và tổng số trong các mẫu đơn đặt hàng JavaScript, định dạng dữ liệu và định dạng email / tin nhắn văn bản và nhiều cách sử dụng khác.
Mã gốc để tạo chuỗi stringFill1()
function stringFill1(x, n) {
var s = '';
while (s.length < n) s += x;
return s;
}
/* Example of output: stringFill1('x', 3) == 'xxx' */
Cú pháp ở đây là rõ ràng. Như bạn có thể thấy, chúng ta đã sử dụng các biến chức năng cục bộ rồi, trước khi tiếp tục tối ưu hóa nhiều hơn.
Xin lưu ý rằng có một tham chiếu vô tội đến một s.length
thuộc tính đối tượng trong mã làm tổn thương hiệu năng của nó. Thậm chí tệ hơn, việc sử dụng thuộc tính đối tượng này làm giảm tính đơn giản của chương trình bằng cách đưa ra giả định rằng người đọc biết về các thuộc tính của các đối tượng chuỗi JavaScript.
Việc sử dụng thuộc tính đối tượng này sẽ phá hủy tính tổng quát của chương trình máy tính. Chương trình giả định rằng x
phải là một chuỗi có độ dài một. Điều này giới hạn việc áp dụng stringFill1()
hàm cho bất cứ điều gì ngoại trừ việc lặp lại các ký tự đơn. Ngay cả các ký tự đơn cũng không thể được sử dụng nếu chúng chứa nhiều byte như thực thể HTML
.
Vấn đề tồi tệ nhất gây ra bởi việc sử dụng thuộc tính đối tượng không cần thiết này là hàm tạo ra một vòng lặp vô hạn nếu được kiểm tra trên một chuỗi đầu vào trống x
. Để kiểm tra tổng quát, áp dụng một chương trình với lượng đầu vào nhỏ nhất có thể. Một chương trình gặp sự cố khi được yêu cầu vượt quá dung lượng bộ nhớ khả dụng có một lý do. Một chương trình như thế này bị sập khi được yêu cầu sản xuất không có gì là không thể chấp nhận được. Đôi khi mã đẹp là mã độc.
Đơn giản có thể là một mục tiêu mơ hồ của lập trình máy tính, nhưng nói chung là không. Khi một chương trình thiếu bất kỳ mức độ tổng quát hợp lý nào, sẽ không hợp lệ để nói, "Chương trình này đủ tốt cho đến khi nó đi." Như bạn có thể thấy, việc sử dụng thuộc string.length
tính sẽ ngăn chương trình này hoạt động trong cài đặt chung và trên thực tế, chương trình không chính xác đã sẵn sàng gây ra sự cố trình duyệt hoặc hệ thống.
Có cách nào để cải thiện hiệu suất của JavaScript này cũng như chăm sóc hai vấn đề nghiêm trọng này không?
Tất nhiên. Chỉ cần sử dụng số nguyên.
Mã được tối ưu hóa để tạo chuỗi stringFill2()
function stringFill2(x, n) {
var s = '';
while (n-- > 0) s += x;
return s;
}
Mã thời gian để so sánh stringFill1()
vàstringFill2()
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);
Thành công cho đến nay stringFill2()
stringFill1()
mất 47.297 micro giây (một phần triệu giây) để điền vào chuỗi 100 byte và stringFill2()
mất 27,68 micro giây để làm điều tương tự. Điều đó gần như tăng gấp đôi hiệu suất bằng cách tránh tham chiếu đến một thuộc tính đối tượng.
Kỹ thuật: Tránh thêm chuỗi ngắn vào chuỗi dài
Kết quả trước đây của chúng tôi có vẻ tốt - thực sự rất tốt. Chức năng được cải thiện stringFill2()
nhanh hơn nhiều do sử dụng hai tối ưu hóa đầu tiên của chúng tôi. Bạn có tin không nếu tôi nói với bạn rằng nó có thể được cải thiện nhanh hơn nhiều lần so với bây giờ?
Vâng, chúng ta có thể hoàn thành mục tiêu đó. Ngay bây giờ chúng ta cần giải thích làm thế nào chúng ta tránh nối các chuỗi ngắn vào chuỗi dài.
Hành vi ngắn hạn có vẻ khá tốt, so với chức năng ban đầu của chúng tôi. Các nhà khoa học máy tính muốn phân tích "hành vi tiệm cận" của một thuật toán chức năng hoặc chương trình máy tính, có nghĩa là nghiên cứu hành vi dài hạn của nó bằng cách kiểm tra nó với các đầu vào lớn hơn. Đôi khi không thực hiện các bài kiểm tra tiếp theo, người ta không bao giờ nhận thức được các cách mà một chương trình máy tính có thể được cải thiện. Để xem điều gì sẽ xảy ra, chúng ta sẽ tạo một chuỗi 200 byte.
Vấn đề xuất hiện với stringFill2()
Sử dụng chức năng định thời của chúng tôi, chúng tôi thấy rằng thời gian tăng lên 62,54 micro giây cho chuỗi 200 byte, so với 27,68 cho chuỗi 100 byte. Có vẻ như thời gian nên tăng gấp đôi để thực hiện gấp đôi công việc, nhưng thay vào đó, nó tăng gấp ba hoặc gấp bốn lần. Từ kinh nghiệm lập trình, kết quả này có vẻ lạ, bởi vì nếu có bất cứ điều gì, chức năng sẽ nhanh hơn một chút vì công việc được thực hiện hiệu quả hơn (200 byte cho mỗi lệnh gọi hàm thay vì 100 byte cho mỗi lệnh gọi hàm). Vấn đề này liên quan đến một thuộc tính xảo quyệt của các chuỗi JavaScript: Các chuỗi JavaScript là "không thay đổi".
Bất biến có nghĩa là bạn không thể thay đổi một chuỗi khi nó được tạo. Bằng cách thêm vào một byte mỗi lần, chúng tôi không sử dụng thêm một byte nỗ lực. Chúng tôi thực sự đang tạo lại toàn bộ chuỗi cộng thêm một byte.
Trong thực tế, để thêm một byte vào chuỗi 100 byte, phải mất 101 byte công việc. Hãy phân tích ngắn gọn chi phí tính toán để tạo một chuỗi N
byte. Chi phí thêm byte đầu tiên là 1 đơn vị nỗ lực tính toán. Chi phí thêm byte thứ hai không phải là một đơn vị mà là 2 đơn vị (sao chép byte đầu tiên sang đối tượng chuỗi mới cũng như thêm byte thứ hai). Byte thứ ba yêu cầu chi phí là 3 đơn vị, v.v.
C(N) = 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N^2)
. Biểu tượng O(N^2)
được phát âm là Big O của N bình phương, và nó có nghĩa là chi phí tính toán trong thời gian dài tỷ lệ với bình phương của độ dài chuỗi. Để tạo 100 ký tự cần 10.000 đơn vị công việc và để tạo 200 ký tự cần 40.000 đơn vị công việc.
Đây là lý do tại sao phải mất hơn gấp đôi thời gian để tạo 200 ký tự hơn 100 ký tự. Trong thực tế, nó đã phải mất bốn lần dài như vậy. Kinh nghiệm lập trình của chúng tôi là chính xác ở chỗ công việc đang được thực hiện hiệu quả hơn một chút đối với các chuỗi dài hơn và do đó chỉ mất khoảng ba lần thời gian. Khi tổng phí của lệnh gọi hàm không đáng kể về thời gian tạo chuỗi, thực tế sẽ mất bốn lần thời gian để tạo chuỗi dài gấp đôi.
(Lưu ý lịch sử: Phân tích này không nhất thiết phải áp dụng cho các chuỗi trong mã nguồn, chẳng hạn như html = 'abcd\n' + 'efgh\n' + ... + 'xyz.\n'
, vì trình biên dịch mã nguồn JavaScript có thể nối các chuỗi lại với nhau trước khi biến chúng thành một đối tượng chuỗi JavaScript. Chỉ vài năm trước, việc triển khai KJS của JavaScript sẽ đóng băng hoặc sập khi tải các chuỗi mã nguồn dài được nối bằng dấu cộng. Vì thời gian tính toán O(N^2)
không khó để tạo các trang web làm quá tải trình duyệt Web Konqueror hoặc Safari, sử dụng lõi công cụ JavaScript KJS. đã gặp phải vấn đề này khi tôi đang phát triển một ngôn ngữ đánh dấu và trình phân tích cú pháp ngôn ngữ đánh dấu JavaScript và sau đó tôi phát hiện ra nguyên nhân gây ra sự cố khi tôi viết tập lệnh của mình cho JavaScript Bao gồm.)
Rõ ràng sự xuống cấp nhanh chóng của hiệu suất này là một vấn đề rất lớn. Làm thế nào chúng ta có thể đối phó với nó, với điều kiện là chúng ta không thể thay đổi cách xử lý chuỗi của JavaScript thành các đối tượng bất biến? Giải pháp là sử dụng thuật toán tạo lại chuỗi càng ít lần càng tốt.
Để làm rõ, mục tiêu của chúng tôi là tránh thêm các chuỗi ngắn vào các chuỗi dài, vì để thêm chuỗi ngắn, toàn bộ chuỗi dài cũng phải được sao chép.
Cách thuật toán hoạt động để tránh thêm chuỗi ngắn vào chuỗi dài
Đây là một cách tốt để giảm số lần các đối tượng chuỗi mới được tạo. Ghép các chuỗi dài hơn với nhau để nhiều hơn một byte tại một thời điểm được thêm vào đầu ra.
Chẳng hạn, để tạo một chuỗi độ dài 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 */
Làm điều này đòi hỏi phải tạo chuỗi có độ dài 1, tạo chuỗi có độ dài 2, tạo chuỗi có độ dài 4, tạo chuỗi có độ dài 8 và cuối cùng, tạo chuỗi có độ dài 9. Chúng ta đã tiết kiệm được bao nhiêu chi phí?
Chi phí cũ C(9) = 1 + 2 + 3 + 4 + 5 + 6 + 7 + 9 = 45
.
Chi phí mới C(9) = 1 + 2 + 4 + 8 + 9 = 24
.
Lưu ý rằng chúng ta phải thêm một chuỗi có độ dài 1 vào chuỗi có độ dài 0, sau đó là chuỗi có độ dài 1 thành chuỗi có độ dài 1, sau đó là chuỗi có độ dài 2 thành chuỗi có độ dài 2, sau đó là chuỗi có độ dài 4 đến một chuỗi có độ dài 4, sau đó là một chuỗi có độ dài 8 đến một chuỗi có độ dài 1, để có được một chuỗi có độ dài 9. Những gì chúng ta đang làm có thể được tóm tắt là tránh thêm chuỗi ngắn vào chuỗi dài hoặc khác các từ, cố gắng nối các chuỗi với nhau có độ dài bằng hoặc gần bằng nhau.
Đối với chi phí tính toán cũ, chúng tôi tìm thấy một công thức N(N+1)/2
. Có một công thức cho chi phí mới? Vâng, nhưng nó phức tạp. Điều quan trọng là nó là O(N)
như vậy, và vì vậy, nhân đôi độ dài chuỗi sẽ xấp xỉ gấp đôi số lượng công việc thay vì tăng gấp bốn lần.
Mã thực hiện ý tưởng mới này gần như phức tạp như công thức tính chi phí tính toán. Khi bạn đọc nó, hãy nhớ điều đó >>= 1
có nghĩa là dịch chuyển sang phải 1 byte. Vì vậy, nếu n = 10011
là một số nhị phân, sau đó n >>= 1
kết quả trong giá trị n = 1001
.
Phần khác của mã bạn có thể không nhận ra là bitwise và toán tử, được viết &
. Biểu thức n & 1
đánh giá đúng nếu chữ số nhị phân cuối cùng n
là 1 và sai nếu chữ số nhị phân cuối cùng n
là 0.
stringFill3()
Chức năng mới hiệu quả cao
function stringFill3(x, n) {
var s = '';
for (;;) {
if (n & 1) s += x;
n >>= 1;
if (n) x += x;
else break;
}
return s;
}
Nó trông xấu xí đối với mắt chưa được huấn luyện, nhưng hiệu suất của nó không kém gì đáng yêu.
Chúng ta hãy xem chức năng này hoạt động tốt như thế nào. Sau khi xem kết quả, có khả năng bạn sẽ không bao giờ quên sự khác biệt giữa O(N^2)
thuật toán và O(N)
thuật toán.
stringFill1()
mất 88,7 micro giây (một phần triệu giây) để tạo chuỗi 200 byte, stringFill2()
mất 62,54 và stringFill3()
chỉ mất 4,608. Điều gì làm cho thuật toán này tốt hơn nhiều? Tất cả các hàm đã tận dụng lợi thế của việc sử dụng các biến chức năng cục bộ, nhưng tận dụng các kỹ thuật tối ưu hóa thứ hai và thứ ba đã thêm một cải tiến gấp hai lần vào hiệu suất của stringFill3()
.
Phân tích sâu hơn
Điều gì làm cho chức năng đặc biệt này thổi sự cạnh tranh ra khỏi nước?
Như tôi đã đề cập, lý do cả hai hàm này stringFill1()
và stringFill2()
chạy chậm là vì các chuỗi JavaScript là bất biến. Bộ nhớ không thể được phân bổ lại để cho phép thêm một byte mỗi lần được thêm vào dữ liệu chuỗi được lưu trữ bởi JavaScript. Mỗi lần thêm một byte vào cuối chuỗi, toàn bộ chuỗi được tạo lại từ đầu đến cuối.
Do đó, để cải thiện hiệu suất của tập lệnh, người ta phải tính toán trước các chuỗi có độ dài dài hơn bằng cách nối hai chuỗi với nhau trước thời hạn và sau đó xây dựng đệ quy độ dài chuỗi mong muốn.
Ví dụ, để tạo một chuỗi byte 16 ký tự, đầu tiên một chuỗi hai byte sẽ được tính toán trước. Sau đó, chuỗi hai byte sẽ được sử dụng lại để tính toán trước chuỗi bốn byte. Sau đó, chuỗi bốn byte sẽ được sử dụng lại để tính toán trước chuỗi tám byte. Cuối cùng, hai chuỗi tám byte sẽ được sử dụng lại để tạo chuỗi 16 byte mới mong muốn. Tổng cộng bốn chuỗi mới đã được tạo, một chiều dài 2, một chiều dài 4, một chiều dài 8 và một chiều dài 16. Tổng chi phí là 2 + 4 + 8 + 16 = 30.
Về lâu dài, hiệu quả này có thể được tính bằng cách thêm theo thứ tự ngược lại và sử dụng chuỗi hình học bắt đầu bằng số hạng đầu tiên a1 = N và có tỷ lệ chung là r = 1/2. Tổng của một loạt hình học được đưa ra bởi a_1 / (1-r) = 2N
.
Điều này hiệu quả hơn so với việc thêm một ký tự để tạo một chuỗi mới có độ dài 2, tạo ra một chuỗi mới có độ dài 3, 4, 5, v.v., cho đến khi 16. Thuật toán trước đó sử dụng quy trình thêm một byte mỗi lần và tổng chi phí của nó sẽ là n (n + 1) / 2 = 16 (17) / 2 = 8 (17) = 136
.
Rõ ràng, 136 là một con số lớn hơn nhiều so với 30, và vì vậy thuật toán trước đó mất nhiều thời gian hơn nhiều để xây dựng một chuỗi.
Để so sánh hai phương pháp, bạn có thể thấy thuật toán đệ quy nhanh hơn (còn gọi là "chia và chinh phục") trên một chuỗi có độ dài 123.457. Trên máy tính FreeBSD của tôi, thuật toán này, được triển khai trong stringFill3()
hàm, tạo ra chuỗi trong 0,001058 giây, trong khi stringFill1()
chức năng ban đầu tạo ra chuỗi trong 0,0809 giây. Chức năng mới nhanh hơn 76 lần.
Sự khác biệt về hiệu suất tăng lên khi chiều dài của chuỗi trở nên lớn hơn. Trong giới hạn khi các chuỗi lớn hơn và lớn hơn được tạo ra, hàm ban đầu hoạt động gần như C1
thời gian (không đổi) N^2
và hàm mới hoạt động như C2
thời gian (không đổi) N
.
Từ thí nghiệm của chúng tôi, chúng tôi có thể xác định giá trị C1
được C1 = 0.0808 / (123457)2 = .00000000000530126997
, và giá trị của C2
được C2 = 0.001058 / 123457 = .00000000856978543136
. Trong 10 giây, chức năng mới có thể tạo ra một chuỗi chứa 1.166.890.359 ký tự. Để tạo cùng chuỗi này, hàm cũ sẽ cần 7.218.384 giây.
Đây là gần ba tháng so với mười giây!
Tôi chỉ trả lời (trễ vài năm) vì giải pháp ban đầu của tôi cho vấn đề này đã trôi nổi trên Internet hơn 10 năm, và rõ ràng vẫn còn ít người hiểu được nó. Tôi nghĩ rằng bằng cách viết một bài báo về nó ở đây tôi sẽ giúp:
Tối ưu hóa hiệu suất cho JavaScript tốc độ cao / Trang 3
Thật không may, một số giải pháp khác được trình bày ở đây vẫn là một số giải pháp sẽ mất ba tháng để tạo ra cùng một lượng đầu ra mà một giải pháp thích hợp tạo ra trong 10 giây.
Tôi muốn dành thời gian để sao chép một phần của bài viết ở đây như một câu trả lời chính tắc trên Stack Overflow.
Lưu ý rằng thuật toán hoạt động tốt nhất ở đây rõ ràng dựa trên thuật toán của tôi và có thể được kế thừa từ sự thích ứng thế hệ thứ 3 hoặc thứ 4 của người khác. Thật không may, các sửa đổi dẫn đến giảm hiệu suất của nó. Biến thể của giải pháp của tôi được trình bày ở đây có lẽ không hiểu for (;;)
biểu thức khó hiểu của tôi trông giống như vòng lặp vô hạn chính của máy chủ được viết bằng C và được thiết kế đơn giản để cho phép một câu lệnh ngắt được định vị cẩn thận để điều khiển vòng lặp, cách nhỏ gọn nhất để tránh nhân rộng chuỗi theo thời gian thêm một lần không cần thiết.