Nhược điểm của thuật toán sắp xếp kiểu chèn thể hiện khi mà ta luôn phải chèn một khóa vào vị trí gần đầu dãỵ Trong trường hợp đó, người ta sử dụng phương pháp Shell Sort.
Xét dãy khoá: k1, k2, ..., kn. Với một số nguyên dương h: 1 ≤ h ≤ n, ta có thể chia dãy đó thành h dãy con:
• Dãy con 1: k1, k1+h, k1 + 2h, ... • Dãy con 2: k2, k2+h, k2 + 2h, ... • ...
• Dãy con h: kh, k2h, k3h, ...
Ví dụ như dãy (4, 6, 7, 2, 3, 5, 1, 9, 8); n = 9; h = 3. Có 3 dãy con.
Dãy khoá chính: 4 6 7 2 3 5 1 9 8
Dãy con 1: 4 2 1
Dãy con 2: 6 3 9
Dãy con 3: 7 5 8
Những dãy con như vậy được gọi là dãy con xếp theo độ dài bước h. Tư tưởng của thuật toán Shell Sort là: Với một bước h, áp dụng thuật toán sắp xếp kiểu chèn từng dãy con độc lập để làm mịn dần dãy khoá chính. Rồi lại làm tương tự đối với bước h div 2 ... cho tới khi h = 1 thì ta được dãy khoá sắp xếp.
Như ở ví dụ trên, nếu dùng thuật toán sắp xếp kiểu chèn thì khi gặp khoá k7 = 1, là khoá nhỏ nhất trong dãy khoá, nó phải chèn vào vị trí 1, tức là phải thao tác trên 6 khoá đứng trước nó. Nhưng nếu coi 1 là khoá của dãy con 1 thì nó chỉ cần chèn vào trước 2 khoá trong dãy con đó mà thôị Đây chính là nguyên nhân Shell Sort hiệu quả hơn sắp xếp chèn: Khoá nhỏ được nhanh chóng đưa về
gần vị trí đúng của nó. procedure ShellSort; var i, j, h: Integer; tmp: TKey; begin
h := n div 2;
repeat {Làm mịn dãy với độ dài bước h}
for i := h + 1 to n do
begin {Sắp xếp chèn trên dãy con ai-h, ai, ai+h, ai+2h, ...}
tmp := ki; j := i - h; while (j > 0) and (kj > tmp) do begin kj+h := kj; j := j - h; end; kj+h := tmp; end; h := h div 2; until h = 0; end;
VỊ THUẬT TOÁN SẮP XẾP KIỂU PHÂN ĐOẠN (QUICK SORT) 1. Tư tưởng của Quick Sort
Quick Sort là một phương pháp sắp xếp tốt nhất, nghĩa là dù dãy khoá thuộc kiểu dữ liệu có thứ tự nào, Quick Sort cũng có thể sắp xếp được và không có một thuật toán sắp xếp nào nhanh hơn Quick Sort về mặt tốc độ trung bình (theo tôi biết). Người sáng lập ra nó là C.ẠR. Hoare đã mạnh dạn đặt tên cho nó là sắp xếp "NHANH".
Ý tưởng chủ đạo của phương pháp có thể tóm tắt như sau: Sắp xếp dãy khoá k1, k2, ..., kn thì có thể coi là sắp xếp đoạn từ chỉ số 1 tới chỉ số n trong dãy khoá đó. Để sắp xếp một đoạn trong dãy khoá, nếu đoạn đó có ≤ 1 phần tử thì không cần phải làm gì cả, còn nếu đoạn đó có ít nhất 2 phần tử, ta chọn một khoá ngẫu nhiên nào đó của đoạn làm "chốt". Mọi khoá nhỏ hơn khoá chốt được xếp vào vị trí đứng trước chốt, mọi khoá lớn hơn khoá chốt được xếp vào vị trí đứng sau chốt. Sau phép hoán chuyển như vậy thì đoạn đang xét được chia làm hai đoạn khác rỗng mà mọi khoá trong đoạn đầu đều ≤ chốt và mọi khoá trong đoạn sau đều ≥ chốt. Hay nói cách khác: Mỗi khoá trong đoạn đầu đều ≤ mọi khoá trong đoạn saụ Và vấn đề trở thành sắp xếp hai đoạn mới tạo ra (có độ dài ngắn hơn đoạn ban đầu) bằng phương pháp tương tự.
procedure QuickSort;
procedure Partition(L, H: Integer); {Sắp xếp đoạn từ kL, kL+1, ..., kH}
var
i, j: Integer;
Key: TKey; {Biến lưu giá trị khoá chốt}
begin
if L ≥ H then Exit; {Nếu đoạn chỉ có ≤ 1 phần tử thì không phải làm gì cả}
Key := kRandom(H-L+1)+L; {Chọn một khoá ngẫu nhiên trong đoạn làm khoá chốt}
i := L; j := H; {i := vị trí đầu đoạn; j := vị trí cuối đoạn}
repeat
while ki < Key do i := i + 1; {Tìm từ đầu đoạn khoá ≥ khoá chốt}
while kj > Key do j := j - 1; {Tìm từ cuối đoạn khoá ≤ khoá chốt}
{Đến đây ta tìm được hai khoá ki và kj mà ki≥ key ≥ kj}
if i ≤ j then begin
if i < j then {Nếu chỉ số i đứng trước chỉ số j thì đảo giá trị hai khoá ki và kj}
<Đảo giá trị ki và kj>; {Sau phép đảo này ta có: ki≤ key ≤ kj }
i := i + 1; j := j - 1; end;
until i > j;
end; begin
Partition(1, n); end;
Ta thử phân tích xem tại sao đoạn chương trình trên hoạt động đúng: Xét vòng lặp repeat...until trong lần lặp đầu tiên, vòng lặp while thứ nhất chắc chắn sẽ tìm được khoá ki ≥ khoá chốt bởi
chắc chắn tồn tại trong đoạn một khoá bằng khóa chốt. Tương tự như vậy, vòng lặp while thứ hai
chắc chắn tìm được khoá kj ≤ khoá chốt. Nếu như khoá ki đứng trước khoá kj thì ta đảo giá trị hai khoá, tăng i và giảm j. Khi đó ta có nhận xét rằng mọi khoá đứng trước vị trí i sẽ phải ≤ khoá chốt và mọi khoá đứng sau vị trí j sẽ phải ≥ khoá chốt.
kL ... ki ... kj ... kH
≤ khoá chốt ≥ khoá chốt
Điều này đảm bảo cho vòng lặp repeat...until tại bước sau, hai vòng lặp whilẹ..do bên trong chắc chắn lại tìm được hai khoá ki và kj mà ki ≥ khoá chốt ≥ kj, nếu khoá ki đứng trước khoá kj thì lại đảo giá trị của chúng, cho i tiến về cuối một bước và j lùi về đầu một bước. Vậy thì quá trình hoán chuyển phần tử trong vòng lặp repeat...until sẽ đảm bảo tại mỗi bước:
• Hai vòng lặp whilẹ..do bên trong luôn tìm được hai khoá ki, kj mà ki ≥ khoá chốt ≥ kj. Không có trường hợp hai chỉ số i, j chạy ra ngoài đoạn (luôn luôn có L ≤ i, j ≤ H).
• Sau mỗi phép hoán chuyển, mọi khoá đứng trước vị trí i luôn ≤ khoá chốt và mọi khoá đứng sau vị trí j luôn ≥ khoá chốt.
Vòng lặp repeat ...until sẽ kết thúc khi mà chỉ số i đứng phía sau chỉ số j.
≥ khoá chốt
kL ... kj ki ... kH
≤ khoá chốt
Theo những nhận xét trên, nếu có một khoá nằm giữa kj và ki thì khoá đó phải đúng bằng khoá chốt và nó đã được đặt ở vị trí đúng của nó, nên có thể bỏ qua khoá này mà chỉ xét hai đoạn ở hai đầụ Công việc còn lại là gọi đệ quy để làm tiếp với đoạn từ kL tới kj và đoạn từ ki tới kH. Hai đoạn này ngắn hơn đoạn đang xét bởi vì L ≤ j < i ≤ H. Vậy thuật toán không bao giờ bị rơi vào quá trình vô hạn mà sẽ dừng và cho kết quả đúng đắn.
Xét về độ phức tạp tính toán:
• Trường hợp tồi tệ nhất, là khi chọn khoá chốt, ta chọn phải khoá nhỏ nhất hay lớn nhất trong đoạn, khi đó phép phân đoạn sẽ chia thành một đoạn gồm n - 1 phần tử và đoạn còn lại chỉ có 1 phần tử. Có thể chứng minh trong trường hợp này, thời gian thực hiện giải thuật T(n) = O(n2) • Trường hợp tốt nhất, phép phân đoạn tại mỗi bước sẽ chia được thành hai đoạn bằng nhaụ Tức
là khi chọn khoá chốt, ta chọn đúng trung vị của dãy khoá. Có thể chứng minh trong trường hợp này, thời gian thực hiện giải thuật T(n) = O(nlog2n)
• Trường hợp các khoá được phân bố ngẫu nhiên, thì trung bình thời gian thực hiện giải thuật cũng là T(n) = O(nlog2n).
Việc tính toán chi tiết, đặc biệt là khi xác định T(n) trung bình, phải dùng các công cụ toán phức tạp, ta chỉ công nhận những kết quả trên.
2. Vài cải tiến của Quick Sort
Việc chọn chốt cho phép phân đoạn quyết định hiệu quả của Quick Sort, nếu chọn chốt không tốt, rất có thể việc phân đoạn bị suy biến thành trường hợp xấu khiến Quick Sort hoạt động chậm và
tràn ngăn xếp chương trình con khi gặp phải dây chuyền đệ qui quá dàị Một cải tiến sau có thể khắc phục được hiện tượng tràn ngăn xếp nhưng cũng hết sức chậm trong trường hợp xấu, kỹ thuật này khi đã phân được [L, H] được hai đoạn con [L, j] và [i, H] thì chỉ gọi đệ quy để tiếp tục đối với đoạn ngắn, và lặp lại quá trình phân đoạn đối với đoạn dàị
procedure QuickSort;
procedure Partition(L, H: Integer); {Sắp xếp đoạn từ kL, kL+1, ..., kH}
var
i, j: Integer; begin
repeat
if L ≥ H then Exit;
<Phân đoạn [L, H] được hai đoạn con [L, j] và [i, R]>; if <đoạn [L, j] ngắn hơn đoạn [i, R]> then
begin Partition(L, j); L := i; end else begin Partition(i, R); R := j; end; until False; end; begin Partition(1, n); end;
Cải tiến thứ hai đối với Quick Sort là quá trình phân đoạn nên chỉ làm đến một mức nào đó, đến khi đoạn đang xét có độ dài ≤ M (M là một số nguyên tự chọn nằm trong khoảng từ 9 tới 25) thì không phân đoạn tiếp mà nên áp dụng thuật toán sắp xếp kiểu chèn.
Cải tiến thứ ba của Quick Sort là: Nên lấy trung vị của một dãy con trong đoạn để làm chốt, (trung vị của một dãy n phần tử là phần tử đứng thứ n / 2 khi sắp thứ tự). Cách chọn được đánh giá cao nhất là chọn trung vị của ba phần tử đầu, giữa và cuối đoạn.
Cuối cùng, ta có nhận xét: Quick Sort là một công cụ sắp xếp mạnh, chỉ có điều khó chịu gặp phải là trường hợp suy biến của Quick Sort (quá trình phân đoạn chia thành một dãy rất ngắn và một dãy rất dài). Và điều này trên phương diện lý thuyết là không thể khắc phục được: Ví dụ với n = 10000. • Nếu như chọn chốt là khoá đầu đoạn (Thay dòng chọn khoá chốt bằng Key := kL) hay chọn chốt
là khoá cuối đoạn (Thay bằng Key := kH) thì với dãy sau, chương trình hoạt động rất chậm: (1, 2, 3, 4, 5, ..., 9999, 10000)
• Nếu như chọn chốt là khoá giữa đoạn (Thay dòng chọn khoá chốt bằng Key := k(L+H) div 2) thì với dãy sau, chương trình cũng rất chậm:
(1, 2, ..., 4999, 5000, 5000, 4999, ..., 2, 1)
• Trong trường hợp chọn chốt là trung vị dãy con hay chọn chốt ngẫu nhiên, thật khó có thể tìm ra một bộ dữ liệu khiến cho Quick Sort hoạt động chậm. Nhưng ta cũng cần hiểu rằng với mọi chiến lược chọn chốt, trong 10000! dãy hoán vị của dãy (1, 2, ... 10000) thế nào cũng có một dãy làm Quick Sort bị suy biến, tuy nhiên trong trường hợp chọn chốt ngẫu nhiên, xác suất xảy ra dãy này quá nhỏ tới mức ta không cần phải tính đến, như vậy khi đã chọn chốt ngẫu nhiên thì
ta không cần phải quan tâm tới ngăn xếp đệ quy, không cần quan tâm tới kỹ thuật khử đệ quy và vấn đề suy biến của Quick Sort.
VIỊ THUẬT TOÁN SẮP XẾP KIỂU VUN ĐỐNG (HEAP SORT) 1. Đống (heap)
Đống là một dạng cây nhị phân hoàn chỉnh đặc biệt mà giá trị lưu tại mọi nút nhánh đều lớn hơn hay bằng giá trị lưu trong hai nút con của nó. Ví dụ như cây sau là một đống:
10
9 6
7 8 4 1
3 2 5
2. Vun đống
Trong bài học về cây, ta đã biết một dãy khoá k1, k2, ..., kn là biểu diễn của một cây nhị phân hoàn chỉnh mà ki là giá trị lưu trong nút thứ i, nút con của nút thứ i là nút 2i và nút 2i + 1, nút cha của nút thứ j là nút j div 2. Vấn đề đặt ra là sắp lại dãy khoá đã cho để nó biểu diễn một đống.
Vì cây nhị phân chỉ gồm có một nút hiển nhiên là đống, nên để vun một nhánh cây gốc r thành
đống, ta có thể coi hai nhánh con của nó (nhánh gốc 2r và 2r + 1) đã là đống rồi. Và thuật toán
vun đống sẽ được tiến hành từ dưới lên (bottom-up) đối với cây: Gọi h là chiều cao của cây, nút ở mức h (nút lá) đã là gốc một đống, ta vun lên để những nút ở mức h - 1 cũng là gốc của đống, ... cứ như vậy cho tới nút ở mức 1 (nút gốc) cũng là gốc của đống.
Thuật toán vun thành đống đối với cây gốc r, hai nhánh con của r đã là đống rồi:
Giả sử ở nút r chứa giá trị V. Từ r, ta cứ đi tới nút con chứa giá trị lớn nhất trong 2 nút con, cho tới khi gặp phải một nút c mà mọi nút con của c đều chứa giá trị ≤ V (nút lá cũng là trường hợp riêng của điều kiện này). Dọc trên đường đi từ r tới c, ta đẩy giá trị chứa ở nút con lên nút cha và đặt giá trị V vào nút c. 4 10 9 7 8 6 1 3 5 2 10 8 9 7 4 6 1 3 5 2
3. Tư tưởng của Heap Sort
Đầu tiên, dãy khoá k1, k2, ..., kn được vun từ dưới lên để nó biểu diễn một đống, khi đó khoá k1
tương ứng với nút gốc của đống là khoá lớn nhất, ta đảo giá trị khoá đó cho kn và không tính tới kn
nữạ Còn lại dãy khoá k1, k2, ..., kn-1 tuy không còn là biểu diễn của một đống nữa nhưng nó lại biểu diễn cây nhị phân hoàn chỉnh mà hai nhánh cây ở nút thứ 2 và nút thứ 3 (hai nút con của nút 1) đã là
đống rồị Vậy chỉ cần vun một lần, ta lại được một đống, đảo giá trị k1 cho kn-1 và tiếp tục cho tới khi đống chỉ còn lại 1 nút. Ví dụ: 10 8 9 7 4 6 1 3 5 2 8 9 7 4 6 1 3 5 2 10
Đảo k1 cho kn và xét phần còn lại 9 8 6 7 4 2 1 3 5 5 8 6 7 4 2 1 3 9
Vun lại thành đống và đổi chỗ tiếp.
Thuật toán Heap Sort có hai thủ tục chính:
• Thủ tục Adjust(root, endnode) vun cây gốc root thành đống trong điều kiện hai cây gốc 2.root và 2.root +1 đã là đống rồị Các nút từ endnode + 1 tới n đã nằm ở vị trí đúng và không được tính tới nữạ
• Thủ tục Heap Sort mô tả lại quá trình vun đống và chọn phần tử theo ý tưởng trên:
procedure HeapSort; var
r, i: Integer;
procedure Adjust(root, endnode: Integer;) {Vun cây gốc Root thành đống}
var
i: Integer;
Key: TKey; {Biến lưu giá trị khoá ở nút Root}
begin
Key := kroot;
while root * 2 ≤ endnode do {Chừng nào root chưa phải là lá}
begin
{Xét nút con trái của Root, so sánh với giá trị nút con phải, chọn ra nút mang giá trị lớn nhất}
i := Root * 2;
if (i < endnode) and (ki < ki+1) then i := i + 1; if ki ≤ Key then{Cả hai nút con của Root đều mang giá trị ≤ Key}
begin
kroot := Key; Exit;
end;
kroot := ki; root := i; {Chuyển giá trị từ nút con i lên nút cha root và đi xuống xét nút con i}
end;
end;
begin {Bắt đầu thuật toán Heap Sort}
for r := n div 2 downto 1 do Adjust(r, n); {Vun cây từ dưới lên tạo thành đống}
for i := n downto 2 do begin
<Đảo giá trị k1 và ki>; {Khoá lớn nhất được chuyển ra cuối dãy}
Adjust(1, i - 1); {Vun phần còn lại thành đống}
end; end;
Về cấp độ của thuật toán, ta đã biết rằng cây nhị phân hoàn chỉnh có n nút thì chiều cao của nó không quá [log2(n + 1)] + 1. Cứ cho là trong trường hợp xấu nhất thủ tục Adjust phải thực hiện tìm đường đi từ nút gốc tới nút lá ở xa nhất thì đường đi tìm được cũng chỉ dài bằng chiều cao của cây và độ phức tạp của một lần gọi Adjust là O(log2n). Từ đó có thể suy ra, trong trường hợp xấu nhất,
cấp độ lớn của Heap Sort cũng chỉ là O(nlog2n). Việc đánh giá thời gian thực hiện trung bình
phức tạp hơn, ta chỉ ghi nhận một kết quả đã chứng minh được là cấp độ phức tạp trung bình của Heap Sort cũng là O(nlog2n).
Có thể nhận xét thêm là Quick Sort đệ quy cần thêm không gian nhớ cho Stack, còn Heap Sort ngoài một nút nhớ phụ để thực hiện việc đổi chỗ, nó không cần dùng thêm gì khác. Heap Sort tốt hơn Quick Sort về phương diện lý thuyết bởi không có trường hợp tồi tệ nào Heap Sort có thể mắc phảị Cũng nhờ có Heap Sort mà giờ đây khi giải mọi bài toán có chứa mô-đun sắp xếp, ta có thể nói rằng cấp độ phức tạp của thủ tục sắp xếp đó không quá O(nlog2n).
VIIỊ SẮP XẾP BẰNG PHÉP ĐẾM PHÂN PHỐI (DISTRIBUTION COUNTING)
Có một thuật toán sắp xếp đơn giản cho trường hợp đặc biệt: Dãy khoá k1, k2, ..., kn là các số nguyên nằm trong khoảng từ 0 tới M (TKey = 0..M).
Ta dựng dãy c0, c1, ..., cM các biến đếm, ở đây cV là số lần xuất hiện giá trị V trong dãy khoá: