Thuật toán tinh chế mã
Một số kỹ thuật tinh chế mãĐặng Quang HưngMộtphiên bản tốt hơn của tìm nhị phân Trong số báo tháng trước tôi đã giới thiệu cho các bạnchương Tinh chế mã của tác giả Jon Bentley trong cuốn 'ProgrammingPearls' (Những viên ngọc trong kỹ thuật lập trình). Với số này chúng ta sẽ cùng nhau tìm hiểu một trong những ví dụ tinh tế nhấtcủa kỹ thuật tinh chế mã, đó là kỹ thuật tăng tốc độ cho chươngtrình tìm nhị phân. Chươngtrình tìm nhị phân nguyên thủy L:= 1; R := N; WHILE(L<=R) DO BEGIN M:=(L+R) DIV 2; IF (A[M] < X) THEN L:=M+1; ELSE IF (A[M] > X) THEN R:=M-1; ELSE BREAK; END; IF(L<=R) HEN P:=M; ELSE P := 0; Chươngtrình tìm nhị phân tổng quát này đã rất hiệu quả, hiệu quả đến nỗimọi nỗ lực tinh chế mã để nhằm tăng tốc nó đều không mang lại mộtsự khác biệt đáng kể nào! Như vậy thì tại sao chúng ta lại đề cậpđến vấn đề cải thiện chương trình này ở đây? Chúng ta sẽ khôngtìm cách tinh chế chương trình tìm nhị phân tổng quát với giá trịN không giới hạn, mà chúng ta sẽ tinh chế chương trình tìm nhị phânvới giá trị N = 1000. Vềmặt khoa học, bài toán với giá trị N = 1000 có thể được xem là đặcbiệt nhưng trong thực tế chúng lại thường xảy ra. Chẳng hạn: tìm kiếmmột mã số nhân viên trong một danh sách mã số nhân viên được sắpthứ tự, trong đó, số lượng nhân viên của một công ty trong đa sốtrường hợp thường nhỏ hơn 1000! Phiênbản thứ nhất ýtưởng cải tiến đầu tiên là giảm bớt số phép so sánh giữa X(phần tử cần tìm) với phần tử giữa A[M]. Trong chương trình tổng quátở trên, ta nhận thấy rằng trong một vòng lặp, đôi lúc cần phải thựchiện hai phép so sánh. Chương trình dưới đây sẽ cải thiện đượcđiều này. L:= 0; R := N+1; WHILE(L+1 <> N) DO BEGIN M:=(L+R) DIV 2; IF (A[M] < X) THEN L:=M; ELSE { X <= A[M] } R:=M; END; P:=U; IF(P>N) OR (X[P] <> T) THEN P:=0; Điểmđầu tiên cần lưu ý là chương trình có dùng thêm hai phần tử'cầm canh' ở đầu và cuối mảng. Quansát đoạn chương trình ở trên, bạn cũng có thể dễ nhận thấy rằngtrong mỗi lần lặp, chương trình chỉ phải thực hiện một phép so sánhduy nhất là A[M] < X. Điểmkhác biệt đầu tiên giữa hai chương trình là việc 'thu hẹp' khoảngtìm kiếm. Trong chương trình nguyên thủy, khoảng tìm kiếm mới khôngbao gồm phần tử giữa (A[M]). Còn trong chương trình ở trên, khoảngtìm kiếm mới vẫn bao gồm phần tử giữa. Điểmkhác biệt kế tiếp nằm ở việc xử lý trường hợp X[M] = A: chươngtrình tổng quát sẽ lập tức thoát khỏi vòng lặp (câu lệnh IF (A[M]= X) THEN BREAK;), còn chương trình mới sẽ không thoát khỏivòng lặp mà vẫn tiếp tục thực hiện (câu lệnh ELSE R := M), nóchỉ thoát khi khoảng tìm kiếm đã được thu hẹp lại chỉ còn 2 phầntử kế nhau (điều kiện L+1 <> U của vòng lặp WHILE) Đểdễ hiểu hơn, mời các bạn quan sát ví dụ sau: TìmX = 8 trong mảng A: 1 3 5 7 8 10 20 22 23 Đốivới chương trình nguyên thủy, ngay từ bước lặp đầu tiên (M= (1+9)/2= 5), điều kiện A[M] = X đã được thỏa mãn, do đó, quá trình tìmkiếm kết thúc. Đốivới chương trình tiếp theo, trong vòng lặp đầu tiên (M = (0+10)/2 =5), câu lệnh ELSE được thỏa mãn nên R được gán trị bằng 5.Vòng lặp tiếp tục với M = (0+5)/2 = 2, sau đó L được gán trị bằng 2.Bước kế tiếp, M = (2+5)/2 = 3, L sẽ có trị bằng 3. Cuốicùng, M=(3+5)/2= 4 , L có trị bằng 4. Lúc này, điều kiện L+1<> R không còn thỏa mãn nữa nên quá trình tìm kiếm kết thúc. Nhưvậy qua hoạt động của chương trình trên, ta có thể nói rằng nếu Xcó xuất hiện trong mảng A thì sau khi kết thúc vòng lặp thì A[R] =X. Nếu A[R] <> X thì suy ra X không có trong A. Cũng vậy, nếu Xcó trong A thì chắc chắn R phải nằm trong khoảng 1 N, ngược lại thìsuy ra được X không có trong A. Đếnđây, ta thấy rằng, rằng chương trình mới không chắc là tốt hơn chươngtrình tổng quát trong mọi trường hợp. Chương trình mới, tuy giảmđược số phép so sánh trong một bước lặp nhưng nó lại không dừnglại khi đã xác định được vị trí X trong mảng A. Kết quả là nólại tiếp tục 'phung phí' các phép so sánh A[M] < X trong những lầnlặp sau. Vậythì điểm tốt hơn của chương trình sau nằm ở điểm nào? Phiênbản thứ hai Versiontiếp theo của chương trình sẽ dùng cách biểu diễn khác của giá trịL và R. Thay vì biểu diễn L và R gồm hai giá trị riêng rẽ, ta biểu diễnsố R bằng một cận dưới và một gia số I nào đó. Nghĩa là R = L + I.Chúng ta sửa lại chương trình một tí cho phù hợp với kiểu biểu diễnmới, thay thế các vị trí xuất hiện của R bằng biểu thức L+I và rútgọn biểu thức cuối cùng lại. L:= 0; I := N+1; WHILE(I <> 1) DO {Điều diện I<>1 tương đương với L+1 <> R) BEGIN {(L+R)DIV 2 = (L + L + I) DIV 2 = (2L + I ) DIV 2 =L + (I DIV 2) } I := I DIV 2; {Tương đương với M := (L+R) DIV 2 } IF (A[L + I] < X) THEN L:=L+I; {Đoạn lệnh này được bỏ đi vì giá trị I và L đã xác định R} ELSE { X <= A[M] } R:=M; } END; P:=L+1;{ Tương đương với P := R } IF(P>N) OR (A[P] <> X) THEN P:=0; Chươngtrình này lại cho ta một cách nhìn mới. Trong hai chương trình đầu,L và R đại diện cho vùng tìm kiếm còn lại. Còn trong chương trìnhtrên, vùng tìm kiếm được đại diện bởi L là vị trí đầu khoảng tìmkiếm và I là độ dài (hay số phần tử) của khoảng tìm kiếm cònlại. Nếu độ dài này bằng 1 thì quá trình tìm kiếm kết thúc. Trongmỗi vòng lặp, độ dài I luôn được chia đôi và L được cập nhậthay không tùy thuộc vào phần tử giữa. Đoạn chương trình này khôngcó gì khá hơn đoạn chương trình đầu tiên, chỉ khác là ta không cầnphải quan tâm đến việc cập nhật lại giá trị của R (vì sự thay đổicủa I và L cũng đã xác định R}. Nhờ đó, lệnh IF của chúng ta đãbỏ bớt được nhánh ELSE. Đếnđây, ta nhận xét rằng, nếu số phần tử của mảng A là một hằng số vàlà một lũy thừa của 2 thì các giá trị của I qua các vòng lặpcũng sẽ xác định.và phép chia nguyên luôn cho kết quả có số dư bằng0. Chẳng hạn với trường hợp N = 1024 thì các giá trị của I qua các lầnlặp sẽ lần lượt là: 512, 256, 128, 64, 32, 16, 8, 4, 2, 1. Với những giátrị xác định như thế, ta sẽ có thể bỏ đi phép toán DIV 2 và thaythế vòng lặp WHILE bằng một dãy lệnh IF tương ứng như chương trìnhsau L:=0; IF(A[512] < X) THEN L:=L + 512; { I = 512 } IF(A[L+ 256] < X) THEN L:=L + 256; { I = 256 } IF(A[L+ 128] < X) THEN L:=L + 128; { I = 128 } IF(A[L+ 64] < X) THEN L:=L + 64; { I = 64 } IF(A[L+ 32] < X) THEN L:=L + 32; { I = 32 } IF(A[L+ 16] < X) THEN L:=L + 16; { I = 16 } IF(A[L+ 8] < X) THEN L:=L + 8; { I = 8 } IF(A[L+ 4] < X) THEN L:=L + 4; { I = 4 } IF(A[L+ 2] < X) THEN L:=L + 2; { I = 2 } IF(A[L+ 1] < X) THEN L:=L + 1; { I = 1 } P:=L+1; IF(P>1024) OR (A[P]<>X) THEN P :=0; Vấnđề cuối cùng của chúng ta là xử lý trường hợp số phần tử N khôngphải là một lũy thừa của 2. Chẳng hạn N = 1000. ý tưởng rất đơn giản,nếu N không phải là một lũy thừa của 2 thì ta cố gắng đưa nó về lũythừa của 2. Có hai cách để làm điều này. Cách đầu tiên là thêm cácphần tử phụ vào các vị trí còn trống trong A sao cho các phần tử cógiá trị tăng và luôn lớn hơn A[1000]. Cách thứ hai là chọn lựa khoảngtìm kiếm ở bước lặp thứ hai sao cho khoảng tìm kiếm này có độ dàibằng 512. Ban đầu, ta cũng so sánh X với phần tử A[512], nếu X <A[512] ta chọn khoảng tìm kiếm từ [0 512], ngược lại ta chọnkhoảng tìm kiếm từ [489…1001]. Phươngpháp này được thể hiện trong dòng lệnh IF đầu tiên L:=0; IFA[512] < X THEN L := 489; { = 1000 + 1 − 512 } … IF(P>1000) OR (A[P]<>X) THEN P:=0; Vớisửa đổi này, ta có được đoạn chương trình tìm nhị phân đãđược tối ưu. Vậy thì hiệu quả của chương trình này so với chươngtrình ban đầu như thế nào? Nhận xét đầu tiên là một lần tìm kiếmsẽ luôn luôn dùng 10 phép so sánh A[L + … ] < X và ít hơn 10 lệnh gán L:=L+….Điểm quan trọng nhất là đoạn chương trình này hoàn toàn không dùng phép chia và không có vòng lặp. Chúng tôi đã đo đạc và nhận thấy rằng chương trình trên thi hành nhanh gấp từ 2 cho đến 4.5 lần chương trình tìm nhị phân nguyên thuỷ!. Nếu không tận mắt chứng kiến kết quả này qua các công cụ đo đạc đáng tin cậy thì tôi và cả bạn đều cho rằng chương trình ở trên là một phép "ma thuật"! Nhữnglời cuối Tuychúng ta đã chứng kiến nhiều ví dụ rất đẹp mắt của kỹ thuật tinhchế mã nhưng một nguyên lý mà bạn vẫn cần phải nhớ là: đừngthực hiện việc tinh chế mã thường xuyên. Sau đây là mộtvài thông tin để bạn suy nghĩ: Tính hiệu quả: bên cạnh tốc độ, nhiều tính chất kháccủa chương trình cũng rất quan trọng, nếu không muốn nói là quan trọnghơn. Don Knuth đã nhận xét rằng việc tối ưu hóa không đúng lúc lànguồn gốc của nhiều tai họa trong lập trình; nó có thể tổn thương tínhđúng đắn của chương trình, làm cho chương trình khó hoạt động vàkhó bảo trì. (chẳng hạn việc tinh chế mã của chương trình nhị phânở trên đã biến đổi hoàn toàn chương trình tìm kiếm nhị phân vàlàm cho chương trình trở nên khó hiểu đến mức bí ẩn và có lẽ bạncũng sẽ lúng túng nếu không có giải thích chi tiết như trên!). Bạnchỉ nên quan tâm đến tốc độ khi và chỉ khi thực sự nó đã thực sựảnh hưởng đến tính hữu ích của chương trình. Thaotác profiling: Khi vấn đề tối ưu tốc độ đã được đặt ra, thao tácđầu tiên bạn cần làm là thực hiện thao tác profile hệ thống để tìmra những điểm nóng của hệ thống (là những điểm mà tại đó thờigian xử lý hay thi hành chương trình bị tiêu tốn phần lớn vào đó). Sau đó, tập trung cải tiến những vị trí này, còn những chỗ khác thì'nếu nó không hỏng thì đừng chạm vào!'. Cácmức thiết kế: có nhiều phương pháp khác nhau để giải quyết bài toántốc độ (thuật toán, nén không gian,…, bạn hãy thử tất cả các phươngpháp khác trước, nếu tất cả đều không thành công khi đó mới nghĩđến kỹ thuật tinh chế mã. Quacác ví dụ đã trình bày, chúng ta rút ra một số quy tắc chính trongviệc tinh chế mã: Chươngtrình vẽ hình của Wan Wyk. Chiến lược chung là khai thác các trườnghợp thông dụng. Điều đó dẫn đến việc caching các loại bản ghithông dụng nhất. Bàitoán 1: phân loại ký tự. Trong lời giải của bài toán này, chúngta sử dụng một mảng được đánh chỉ số bằng các ký tự, quy tắc rútra từ bài toán này là: 'tính trước các hàm logic' Bàitoán 2: đếm số bit. Chúng ta đã sử dụng một mảngđể lưu số các bit trong một con số. Quy tắc là 'Lưu trữ cáckết quả đã được tính trước'. Tìmnhị phân: Việc kết hợp các phép thử đã giảm đi một nửa sốcác lần so sánh mảng trong mỗi vòng lặp, việc khai thác tươngđương đại số làm thay đổi cách biểu diễn cận dưới và cận trênthành cận dưới và độ dài, việc thay thế vòng lặp bằng dãy cáclệnh tương đương giúp ta bỏ được nhiều thao tác không cần thiết(phép toán DIV). Chúngta đã xem qua kỹ thuật tinh chế mã để tăng tốc độ chương trình.Tuy nhiên, tinh chế mã còn có thể dùng vào nhiều mục đích khác, chẳnghạn như giảm thao tác phân trang bộ nhớ hoặc để tăng tỷ số cachehit trong bộ nhớ cache, ….Ngoài ra, nó còn có thể được sử dụngđể làm giảm kích thước chương trình. . thao tác không cần thiết(phép toán DIV). Chúngta đã xem qua kỹ thuật tinh chế mã để tăng tốc độ chương trình.Tuy nhiên, tinh chế mã còn có thể dùng vào nhiều. ngọc trong kỹ thuật lập trình). Với số này chúng ta sẽ cùng nhau tìm hiểu một trong những ví dụ tinh tế nhấtcủa kỹ thuật tinh chế mã, đó là kỹ thuật tăng