Chuyển tới nội dung chính

Đếm chi phí thay vì đoán

Đếm khi chạyThứ tự dữ liệu đổi giáCó mốc đảo chiều

Đếm chi phí thay vì đoán

Câu nói tốn kém nhất trong nghề này là 'thuật toán kia nhanh hơn' khi chưa ai đếm. Bài này bắt bạn đếm, trên đúng dữ liệu đang có, từng bước một.

Bạn sẽ nghe câu này rất nhiều trong bốn năm tới: thuật toán A nhanh hơn thuật toán B. Hỏi lại một câu là mọi thứ thường sụp: nhanh hơn trên dữ liệu nào, ở kích thước bao nhiêu, và ai đã đếm? Phần lớn thời gian câu trả lời là không ai cả. Người ta nhớ một dòng trong sách, thấy O(n) nhỏ hơn O(n²), rồi dừng lại ở đó.

Bài này không cấm bạn dùng bậc tăng trưởng. Nó chỉ bắt bạn làm một việc trước đã: chạy thuật toán và đếm. Ba con số được đếm là số phép so sánh, số phép gán, và số lần truy cập mảng. Không con số nào trong sim dưới đây đến từ một công thức đoán trước, tất cả đều là cái mà một lượt chạy thật để lại. Bạn đổi dữ liệu thì nó chạy lại và đếm lại.

Đếm chi phí · ba con số, đếm khi chạy chứ không đoán
Dãy 24 phần tửThuật toán đang xem 103 phépMốc đảo chiều n = 8

1 · Dãy số đang xét ✎ sửa được

0411628364851763788497102119121131114101561661711181719122092112220235
Ô có viền đậm kèm dấu là vị trí mà thuật toán đang xem (tìm cặp có tổng bằng k · hai vòng lặp) trả về. Bảng băm đang có 48 ô cho 24 phần tử.

2 · Đếm khi chạy, trên đúng dãy ở trên nhấp để xem từng bước

thuật toánso sánhgántruy cập mảngtổng phépkết quả
tìm phần tử lớn nhất23272474lớn nhất là 20, ở vị trí 22
đếm số lần xuất hiện242824768 xuất hiện 3 lần
kiểm tra đã sắp chưa24410chưa sắp: nghịch thế đầu tiên ở vị trí 1 và 2
tìm cặp có tổng bằng k · hai vòng lặp333535103tìm thấy cặp (1, 11): 16 + 9 = 25
tìm cặp có tổng bằng k · bảng băm17660137tìm thấy cặp (2, 5): 8 + 17 = 25
Hai hàng cuối trả lời cùng một câu hỏi. Với k = 25 chúng tốn 103 137 phép. Nhưng đó là ca may rủi: cả hai đều dừng ngay khi gặp cặp đầu tiên, mà chúng gặp hai cặp khác nhau. Muốn so cho công bằng thì phải bắt cả hai chạy hết, tức lấy một tổng mà không cặp nào đạt tới: ở dãy này là k = 41, vì phần tử lớn nhất chỉ có 20 nên không cặp nào vượt quá 40. Lúc đó hai vòng lặp tốn 874 phép còn bảng băm tốn 264, chênh 3,31 lần.

3 · Từng bước của tìm cặp có tổng bằng k · hai vòng lặp ✎ chọn ở bảng trên

bướcviệcso sánhgántruy cậpcộng dồn
1lấy x = a[0] = 4·112
2a[0] + a[1] = 4 + 16 khác 251115
3a[0] + a[2] = 4 + 8 khác 251118
4a[0] + a[3] = 4 + 6 khác 2511111
5a[0] + a[4] = 4 + 8 khác 2511114
6a[0] + a[5] = 4 + 17 khác 2511117
7a[0] + a[6] = 4 + 3 khác 2511120
8a[0] + a[7] = 4 + 8 khác 2511123
9a[0] + a[8] = 4 + 4 khác 2511126
10a[0] + a[9] = 4 + 7 khác 2511129
11a[0] + a[10] = 4 + 2 khác 2511132
12a[0] + a[11] = 4 + 9 khác 2511135
13a[0] + a[12] = 4 + 1 khác 2511138
14a[0] + a[13] = 4 + 11 khác 2511141
15a[0] + a[14] = 4 + 10 khác 2511144
16a[0] + a[15] = 4 + 6 khác 2511147
17a[0] + a[16] = 4 + 6 khác 2511150
18a[0] + a[17] = 4 + 11 khác 2511153
19a[0] + a[18] = 4 + 17 khác 2511156
20a[0] + a[19] = 4 + 12 khác 2511159
21a[0] + a[20] = 4 + 9 khác 2511162
22a[0] + a[21] = 4 + 1 khác 2511165
23a[0] + a[22] = 4 + 20 khác 2511168
24a[0] + a[23] = 4 + 5 khác 2511171
25lấy x = a[1] = 16·1173
26a[1] + a[2] = 16 + 8 khác 2511176
27a[1] + a[3] = 16 + 6 khác 2511179
28a[1] + a[4] = 16 + 8 khác 2511182
29a[1] + a[5] = 16 + 17 khác 2511185
30a[1] + a[6] = 16 + 3 khác 2511188
31a[1] + a[7] = 16 + 8 khác 2511191
32a[1] + a[8] = 16 + 4 khác 2511194
33a[1] + a[9] = 16 + 7 khác 2511197
34a[1] + a[10] = 16 + 2 khác 25111100
35a[1] + a[11] = 16 + 9 = 25, tìm thấy111103
Chạy hết 35 bước, bảng in đủ, không cắt bớt gì. Cộng cột lại bạn sẽ ra đúng ba con số ở mục 2.

4 · Cùng bấy nhiêu số, chỉ đổi thứ tự

Bốn dòng dưới đây chứa đúng cùng một tập số, không thêm không bớt, chỉ khác thứ tự. Câu trả lời của mọi thuật toán vì thế không đổi. Chỉ có chi phí đổi.

thứ tựtìm max: số phép gánđã sắp chưa: số so sánhcặp tổng 25: tổng phépđã sắp chưa: kết quảcùng tập số
thứ tự sinh ra272103chưa sắp: nghịch thế đầu tiên ở vị trí 1 và 2✓ đúng
tăng dần3823434đã sắp tăng dần, phải xét hết mọi cặp kề nhau✓ đúng
giảm dần24153chưa sắp: nghịch thế đầu tiên ở vị trí 0 và 1✓ đúng
gần như đã sắp376381chưa sắp: nghịch thế đầu tiên ở vị trí 5 và 6✓ đúng
Cột giữa là chỗ dễ thấy nhất. Kiểm tra một dãy đã sắp ca xấu nhất của phép kiểm tra ấy: không có nghịch thế nào để dừng sớm, nên nó phải xét hết 23 cặp kề nhau. Dãy giảm dầnca tốt nhất: nghịch thế nằm ngay cặp đầu tiên, 1 lần so sánh là xong. Đó chính là ba chữ tốt nhất, trung bình, xấu nhất, và chúng nói về dữ liệu chứ không nói về thuật toán.

5 · Mốc mà thứ tự đảo chiều ca phản trực giác

Ở đây cả hai thuật toán tìm cặp đều bị bắt chạy hết: mỗi độ dài dùng một tổng mà không cặp nào đạt tới, nên không ai được dừng sớm nhờ may. Với mỗi n từ 3 tới 24, sim dựng lại dãy theo đúng mẫu đang chọn rồi chạy thật và đếm.

nk xấu nhấtô bảng bămhai vòng lặpbảng bămai ít phép hơn
33361333◀ hai vòng lặp
43382444◀ hai vòng lặp
533103855◀ hai vòng lặp
635125568◀ hai vòng lặp
735147581◀ hai vòng lặp
835169890▶ bảng băm · mốc đảo chiều
9351812499▶ bảng băm
103520153116▶ bảng băm
113522185123▶ bảng băm
123524220144▶ bảng băm
133526258153▶ bảng băm
143528299160▶ bảng băm
153530343171▶ bảng băm
163532390178▶ bảng băm
173534440187▶ bảng băm
183536493198▶ bảng băm
193538549209▶ bảng băm
203540608220▶ bảng băm
213542670231▶ bảng băm
223544735242▶ bảng băm
234146803253▶ bảng băm
244148874264▶ bảng băm
Mốc đảo chiều là n = 8. Ở n = 7, thuật toán “chậm hơn về bậc” tốn 75 phép còn bảng băm tốn 81: hai vòng lặp ít hơn. Ở n = 8 thì đảo lại, 98 so với 90. Lý do rất đời thường: bảng băm phải dọn 16 ô trước khi làm bất cứ việc gì, rồi mỗi phần tử còn trả năm phép gán và hai lần truy cập, trong khi vòng lặp trong của cách ngây thơ chỉ trả đúng một phép so sánh. Bậc tăng trưởng chỉ nói ai thắng khi n đủ lớn, nó không nói “đủ lớn” là bao nhiêu. Từ mốc này trở lên bảng băm giữ được ưu thế ở mọi độ dài còn lại trong khoảng quét.
Kéo núm ô bảng băm mỗi phần tử mà xem: bảng càng rộng thì phần dọn bảng càng đắt và mốc đảo chiều càng lùi ra xa. Mốc này không phải một hằng số của vũ trụ, nó là hệ quả của những hằng số bạn vừa chọn.
Sim này không làm được gì
  • đếm phép tính, nó không đo giây. Một phép so sánh hai chuỗi đắt hơn hẳn một phép so sánh hai số nguyên, mà ở đây cả hai đều tính là 1. Bộ nhớ đệm của máy làm mọi dự đoán lệch: một vòng lặp quét liên tiếp thường nhanh hơn nhiều so với một bảng băm nhảy lung tung, dù bảng băm ít phép hơn. Và trình biên dịch có quyền bỏ hẳn phần mã bạn tưởng nó chạy.
  • Ba con số cộng lại thành tổng phép là một quy ước của bài này, không phải một đại lượng chuẩn. Nó coi một phép gán ngang giá một lần đọc mảng, điều không đúng trên máy thật.
  • Điều khiển vòng lặp không được tính: phép so j với n và phép tăng j ở mỗi vòng đều bị bỏ qua, cho cả hai thuật toán. Tính vào thì mọi con số đội lên và phần khác nhau giữa chúng bị lấp bớt.
  • Cùng một thuật toán, viết khác nhau thì đếm khác nhau. Phép kiểm tra đã sắp ở đây đọc cả hai phần tử kề nhau mỗi bước, tức 2 lần truy cập; nếu giữ lại phần tử trước trong một biến thì chỉ còn 1, mà số so sánh không đổi một chút nào. Số so sánh thuộc về thuật toán, số truy cập thuộc về cách viết.
  • Bộ sinh “ngẫu nhiên” ở đây là xorshift32 rồi lấy phần dư, nên phân bố lệch nhẹ về phía các giá trị nhỏ. Đủ dùng để dạy, không đủ dùng cho việc cần ngẫu nhiên thật sự.
  • Bảng băm ở đây không nở ra: nó được dọn đúng một lần theo n. Bảng thật thường tự nhân đôi khi đầy, và lúc nhân đôi thì chi phí nhảy vọt thành bậc thang, chứ không mượt như bảng trong mục 5.

Ba con số, và vì sao phải là ba

Ở trạng thái mở bài, dãy có 24 số sinh từ hạt giống 7, mỗi số nằm trong khoảng 1 tới 20: 4 16 8 6 8 17 3 8 4 7 2 9 1 11 10 6 6 11 17 12 9 1 20 5. Đây không phải một dãy tôi bịa ra rồi dán vào bài. Nó là đầu ra của bộ sinh giả ngẫu nhiên viết ngay trong phần tính, và hạt giống là một núm bạn sửa được: đổi 7 thành 8 thì cả bài đổi theo, đổi ngược về 7 thì mọi con số quay lại y nguyên.

Tìm phần tử lớn nhất tốn 23 phép so sánh, 27 phép gán, 24 lần truy cập mảng, tổng 74. Con số 23 chính là n - 1, vì mỗi phần tử từ vị trí 1 trở đi bị so đúng một lần với giá trị lớn nhất đang giữ. Con số 24 là mỗi ô đọc đúng một lần. Còn 27 mới là chỗ thú vị: nó bằng 24 lần đọc cộng 3 lần thật sự ghi lại best. Trên dãy này chỉ có ba lần một số mới phá kỷ lục, là 16, 17 và 20.

Đếm số 8 xuất hiện mấy lần tốn 24 so sánh, 28 gán, 24 truy cập, tổng 76, và kết quả là 3 lần. Chú ý số so sánh ở đây là 24 chứ không phải 23: phép đếm không được dừng sớm. Muốn chắc chắn 8 xuất hiện đúng 3 lần thì phải nhìn hết, kể cả khi đã gặp cả ba từ sớm.

Kiểm tra dãy đã sắp chưa chỉ tốn 2 phép so sánh, tổng 10. Vì 16 đứng trước 8, chỉ cần tới cặp thứ hai là biết chưa sắp và dừng luôn. Hãy nhớ con số 2 này, lát nữa nó sẽ thành 23.

Tìm cặp có tổng bằng 25 thì có hai cách, và đây là chỗ bài học bắt đầu. Cách ngây thơ với hai vòng lặp lồng nhau tốn 33 so sánh, tổng 103 phép, và nó nêu ra cặp ở vị trí 1 và 11, tức 16 + 9. Cách dùng bảng băm tốn 1 so sánh nhưng 76 phép gán và 60 lần truy cập, tổng 137 phép, và nó nêu ra cặp ở vị trí 2 và 5, tức 8 + 17.

Hai chuyện đáng dừng lại ở đây. Thứ nhất, hai cách trả lời hai cặp khác nhau, và cả hai đều đúng: câu hỏi là có tồn tại một cặp hay không, chứ không phải cặp nào. Thứ hai, cách được coi là nhanh hơn lại tốn nhiều phép hơn, 137 so với 103, dù dãy đã dài 24 phần tử. Chuyện đó xảy ra vì cả hai đều dừng ngay khi gặp cặp đầu tiên, mà chúng gặp ở chỗ khác nhau. So như vậy là so may rủi.

Muốn so cho công bằng thì phải bắt cả hai chạy hết. Sim làm việc đó bằng cách lấy một tổng mà không cặp nào với tới: phần tử lớn nhất là 20, nên không cặp nào vượt quá 40, và tổng k = 41 là an toàn. Lúc đó hai vòng lặp tốn 874 phép còn bảng băm tốn 264 phép, chênh 3,31 lần. Đó mới là con số nói lên điều gì.

Cùng bấy nhiêu số, đổi thứ tự thì đổi giá

Mục 4 của sim là chỗ tôi muốn bạn ngồi lâu nhất. Bốn dòng ở đó chứa đúng cùng một tập số, không thêm một số nào, không bớt một số nào, chỉ khác thứ tự. Sim kiểm điều đó và đánh dấu ở cột cuối, nên bạn không phải tin lời tôi.

Kết quả thì y hệt nhau ở cả bốn dòng: phần tử lớn nhất vẫn là 20, số 8 vẫn xuất hiện 3 lần. Chi phí thì không.

  • Kiểm tra đã sắp chưa: dãy đã sắp tăng dần tốn 23 phép so sánh, dãy giảm dần tốn đúng 1. Chênh 23 lần, trên đúng cùng những con số ấy.
  • Tìm phần tử lớn nhất: dãy tăng dần tốn 38 phép gán, dãy giảm dần tốn 24. Số so sánh và số lần truy cập thì hai bên bằng nhau khít.
  • Tìm cặp có tổng 25 bằng hai vòng lặp: 434 phép trên dãy tăng dần, 53 phép trên dãy giảm dần.

Ba chữ tốt nhất, trung bình, xấu nhất mà bạn sẽ gặp suốt học kỳ chính là chuyện này, không hơn. Chúng không mô tả thuật toán, chúng mô tả thuật toán gặp phải loại dữ liệu nào.

Và chú ý cái bẫy: với phép kiểm tra đã sắp, ca xấu nhất lại là dãy đã sắp sẵn. Nghe ngược đời nhưng đúng, vì không có nghịch thế nào để dừng sớm, nên nó buộc phải xét hết n - 1 cặp kề nhau. Còn dãy đảo ngược hoàn toàn, cái nghe như tệ nhất, lại là ca rẻ nhất: nghịch thế nằm ngay cặp đầu tiên. Con số 2 lúc nãy thành 23 chỉ vì thứ tự.

Tại sao 38 chứ không phải 47 cho dãy tăng dần? Vì dãy có số lặp lại. Trong 24 số chỉ có 15 giá trị khác nhau, mà best chỉ được ghi lại khi gặp số lớn hơn hẳn, chứ không phải bằng. Nên có 14 lần ghi lại, cộng 24 lần đọc, ra 38. Bạn tăng núm giá trị lớn nhất lên thì số lặp giảm và con số này bò lên gần 47.

Ca phản trực giác: khi cách chậm hơn lại ít phép hơn

Mục 5 là điểm dạy học quan trọng nhất của bài. Ở đó cả hai cách tìm cặp đều bị bắt chạy hết, mỗi độ dài dùng một tổng mà không cặp nào với tới, rồi sim dựng lại dãy ở từng độ dài từ 3 tới 24 và chạy thật để đếm.

Kết quả ở trạng thái mở bài:

  • n = 7, hai vòng lặp tốn 75 phép, bảng băm tốn 81 phép
  • n = 8, hai vòng lặp tốn 98 phép, bảng băm tốn 90 phép
  • n = 9, khoảng cách đã nới rộng: 124 so với 99

Mốc đảo chiều là n = 8. Ở mọi độ dài nhỏ hơn 8, cách bị coi là chậm hơn về bậc lại làm ít việc hơn. Chuyện này không hề bí ẩn. Ở n = 8 bảng băm có 16 ô, và chỉ riêng việc dọn 16 ô đó đã ăn 32 trong tổng số 90 phép, trước khi nó nhìn vào phần tử đầu tiên. Sau đó mỗi phần tử còn trả 5 phép gán và ít nhất 2 lần truy cập. Trong khi đó vòng lặp trong của cách ngây thơ chỉ trả đúng 1 phép so sánh mỗi lần, và ở n = 7 nó chỉ phải chạy 21 lần.

Nói cách khác, bậc tăng trưởng cho bạn biết ai thắng khi n đủ lớn. Nó tuyệt đối không nói cho bạn biết đủ lớn là bao nhiêu. Con số đó nằm ở các hằng số, mà hằng số thì phụ thuộc vào cách cài đặt.

Muốn thấy điều đó rõ hơn nữa, kéo núm ô bảng băm mỗi phần tử. Bảng càng rộng thì phần dọn bảng càng đắt, và mốc lùi ra theo:

ô bảng cho mỗi phần tửmốc đảo chiều
1n = 7
2 (mặc định)n = 8
3n = 9
4n = 10

Mốc này không phải một hằng số của vũ trụ. Nó là hệ quả của những hằng số bạn vừa chọn, và nếu bạn đổi cách cài đặt thì nó đi chỗ khác. Đây chính là lý do các thư viện sắp xếp thật ngoài đời chuyển sang một thuật toán "tệ hơn về bậc" khi mảng con còn ngắn.

Đếm phép không phải đo thời gian

Chỗ này phải nói thẳng, vì đây là hiểu nhầm dễ mắc nhất sau khi học xong một bài như bài này.

Sim đếm phép tính. Nó không đo giây. Bốn lý do, tất cả đều là chuyện thật:

  1. Không phải phép nào cũng đắt như nhau. So sánh hai số nguyên là một lệnh máy. So sánh hai chuỗi có thể phải duyệt hàng chục ký tự. Ở đây cả hai đều tính là 1.
  2. Bộ nhớ đệm của máy quyết định phần lớn thời gian thật. Một vòng lặp quét mảng liên tiếp đọc dữ liệu đã nằm sẵn trong bộ đệm, còn một bảng băm thì nhảy lung tung khắp bộ nhớ. Rất thường xuyên, cái ít phép hơn lại chậm hơn khi bấm đồng hồ.
  3. Trình biên dịch có quyền bỏ hẳn phần bạn tưởng nó chạy. Nếu kết quả không được dùng vào đâu, cả vòng lặp có thể biến mất khỏi mã máy.
  4. Ba con số cộng lại thành "tổng phép" là quy ước của bài này, không phải một đại lượng chuẩn của ngành. Nó coi một phép gán ngang giá một lần đọc mảng, điều không đúng trên máy thật.

Thêm hai điều mà sim tự nhận, bạn nên đọc kỹ vì chúng dạy nhiều hơn phần kết quả:

  • Điều khiển vòng lặp không được tính. Phép so j với n và phép tăng j bị bỏ qua, cho cả hai thuật toán. Tính vào thì mọi con số đội lên và phần khác nhau giữa chúng bị lấp bớt. Đó là một lựa chọn, và lựa chọn thì phải nói ra.
  • Cùng một thuật toán, viết khác nhau thì đếm khác nhau. Phép kiểm tra đã sắp ở đây đọc cả hai phần tử kề nhau mỗi bước, tức 2 lần truy cập; nếu giữ lại phần tử trước trong một biến thì chỉ còn 1, mà số so sánh không đổi một chút nào. Số so sánh thuộc về thuật toán, số truy cập thuộc về cách viết.

Vậy đếm phép để làm gì, nếu nó không cho biết thời gian? Để bạn có một con số kiểm chứng được thay cho một cảm giác. Nó phát hiện được chuyện thứ tự dữ liệu đổi chi phí 23 lần, chuyện mốc đảo chiều nằm ở 8 chứ không phải ở 3, và chuyện cách được ca ngợi lại thua ở kích thước nhỏ. Không cảm giác nào cho bạn những thứ đó.

Điều rút ra

Trước khi nói thuật toán nào nhanh hơn, hãy chạy cả hai và đếm. Ba con số so sánh, gán, truy cập mảng không phải là thời gian chạy, nhưng chúng kiểm chứng được, còn cảm giác thì không. Cùng một tập số chỉ đổi thứ tự đã làm phép kiểm tra đã sắp chênh 23 lần, nên tốt nhất, trung bình, xấu nhất là chuyện của dữ liệu chứ không phải của thuật toán. Và bậc tăng trưởng chỉ nói ai thắng khi n đủ lớn: trên dãy này, ở mọi độ dài dưới mốc n = 8 thì cách hai vòng lặp lại làm ít việc hơn cách bảng băm. Đủ lớn là bao nhiêu thì phải đếm mới biết.

Câu hỏi tự kiểm0/3 đúngchưa trả lời
  1. 1Với phép kiểm tra "dãy đã sắp tăng dần chưa" viết theo kiểu dừng ngay khi gặp nghịch thế đầu tiên, dãy nào là ca xấu nhất?
  2. 2Ở n = 7, cách hai vòng lặp lồng nhau tốn 75 phép còn cách bảng băm tốn 81 phép. Giải thích nào đúng?
  3. 3Ở ca xấu nhất trên dãy 24 phần tử, sim đếm 874 phép cho hai vòng lặp và 264 phép cho bảng băm, tức chênh 3,31 lần. Kết luận nào là kết luận đúng?