CÁC THUẬT TOÁN SẮP XẾP
1. Giới thiệu bài toán
Ta sẽ xem xét các thuật toán để sắp xếp một tập các bản ghi theo giá trị của một trường nào đó. Thứ
tự sắp xếp là một quy luật đã được định nghĩa rõ ràng: thường là thứ tự tăng dần (hay giảm dần) đối
với dãy số, thứ tự từ điển đối với các chữ, ...
Bài toán đặt ra ở đây là sắp xếp đối với một tập gồm n bản ghi r
1
, r
2
, ..., r
n
. Tuy nhiên không phải toàn
bộ các trường dữ liệu trong bản ghi đều được xét đến trong quá trình sắp xếp mà chỉ một hoặc vài
trường nào đó thôi. Trường như vậy gọi là trường khoá. Sắp xếp sẽ được tiến hành dựa vào giá trị của
khoá này. Do khoá có vai trò đặc biệt như vậy nên sau này khi trình bày các thuật toán hay trong các
ví dụ minh hoạ, ta sẽ coi nó như đại diện cho bản ghi và để đơn giản hơn ta chỉ nói đến khoá thôi.
Thực ra phép đổi chỗ được tác động lên các bản ghi và ở đây ta cũng chỉ nói đến phép đổi chỗ với
các khoá. Giá trị khoá có thể là số hay chữ và thứ tự sắp xếp cũng được quy định tương ứng với khoá.
Ở đây để minh hoạ cho các thuật toán sắp xếp ta sẽ coi giá trị khoá là số và thứ tự sắp xếp là tăng
dần.
Bây giờ bài toán sắp xếp được đặt ra đơn giản nhưng vẫn không làm mất tính tổng quát như sau: cho
một dãy các khoá a
1
, a
2
, ..., a
n
là các số nguyên (tương ứng với các bản ghi r
1
, r
2
, ..., r
n
). Hãy sắp xếp
các khoá trên theo thứ tự tăng dần.
2. Các thuật toán sắp xếp đơn giản
a. Sắp xếp kiểu lựa chọn (Selection sort)
Một trong những phương pháp đơn giản nhất để sắp xếp là dựa trên phép lựa chọn: đầu tiên tìm phần
tử nhỏ nhất trong dãy và hoán vị nó với phần tử trong vị trí đầu tiên. Sau đó tìm phần tử nhỏ nhất kế
tiếp và hoán vị nó với phần tử trong vị trí thứ hai và tiếp tục theo phương pháp này cho đến khi toàn
bộ dãy được sắp xếp.
Cách sắp xếp mà ta vẫn thường sử dụng trước đây chính là phương pháp này. Để tìm phần tử nhỏ
nhất thứ i (i = 1, 2, ..., n-1) ta so sánh nó với các phần tử thứ j đứng sau nó (j = i+1, ..., n), vì các phần
tử đứng trước đã được đứng đúng vị trí. Nếu phần tử thứ j nào nhỏ hơn phần tử thứ i thì ta đổi chỗ 2
phần tử này với nhau. Như vậy phần tử thứ i luôn giữ lại phần tử nhỏ nhất, vì vậy sau một lượt duyệt
đối với j thì phần tử nhỏ nhất thứ i sẽ nằm ở vị trí thứ i.
Thủ tục sắp xếp kiểu lựa chọn được cài đặt như sau:
procedure selection;
var
i, j, t : integer;
begin
for i := 1 to n do
for j := i+1 to n do
if a[i] > a[j] then
begin
t := a[i]; a[i] := a[j]; a[j] := t;
end;
end;
b. Sắp xếp kiểu thêm dần (Insertion sort)
Nguyên tắc sắp xếp ở đây dựa theo kinh nghiệm của những người chơi bài. Khi có i-1 lá bài đã được
sắp xếp ở trên tay, nay rút thêm lá bài thứ i nữa thì sắp xếp lại như thế nào? Có thể so sánh lá bài mới
rút lần lượt với các lá bài đã có ở trên tay để tìm ra “chỗ” thích hợp và “chèn” nó vào chỗ đó.
1
Dựa vào nguyên tắc trên ta có thể xây dựng thuật toán sắp xếp như sau: lúc đầu dãy coi như chỉ có
một khoá là a
1
đã được sắp xếp. Xét thêm a
2
, so sánh nó với a
1
để xác định chỗ chèn nó vào, sau đó ta
có một dãy gồm hai khoá đã được sắp xếp. Cứ tiếp tục như vậy đối với a
3
, a
4
, ... Cuối cùng sau khi
xét xong a
n
thì dãy khoá đã được sắp xếp hoàn toàn.
Hình ảnh của thuật toán sắp xếp kiểu thêm dần với dãy khóa 42, 23, 74, 11, 65, 58 được minh hoạ
qua bảng sau:
Lượt 1 2 3 4 5 6
Khoá đưa vào 42 23 74 11 65 58
1 42 23 23 11 11 11
2 42 42 23 23 23
3 74 42 42 42
4 74 65 58
5 74 65
6 74
Thủ tục sắp xếp kiểu thêm dần được cài đặt như sau:
procedure insertion;
var
i, j, t : integer;
begin
for i := 2 to n do
begin
t := a[i];
j := i - 1;
while (j >= 1) and (a[j] > t) do
begin
a[j+1] := a[j];
j := j - 1;
end;
a[j+1] := t;
end;
end;
c. Sắp xếp nổi bọt (Bubble sort)
Ý tưởng của thuật toán này như sau: dãy các khoá sẽ được duyệt từ đáy lên đỉnh. Dọc đường nếu gặp
hai khoá kế cận ngược thứ tự thì đổi chỗ chúng cho nhau. Như vậy trong lượt đầu tiên khoá có giá trị
nhỏ nhất sẽ chuyển dần lên đỉnh. Đến lượt thứ hai, khoá có giá trị nhỏ thứ hai sẽ được chuyển đến vị
trí thứ hai, ... Nếu hình dung khoá được đặt thẳng đứng thì sau từng lượt sắp xếp các giá trị khoá nhỏ
sẽ “nổi” dần lên giống như các bọt nước nổi lên trong nồi nước đang sôi. Vì vậy thuật toán này được
gọi với tên khá đặc trưng là sắp xếp kiểu nổi bọt.
Quá trình sắp xếp của thuật toán nổi bọt có thể minh hoạ qua bảng sau:
i a
i
Lượt
1 2 3 4 5
1 42 11 11 11 11 11
2 23 42 23 23 23 23
3 74 23 42 42 42 42
4 11 74 58 58 58 58
5 65 58 74 65 65 65
2
6 58 65 65 74 74 74
Thủ tục sắp xếp nổi bọt được cài đặt như sau:
procedure bubble;
var
i, j, t : integer;
begin
for i := 1 to n-1 do
for j := n downto i+1 do
if a[j] < a[j-1] then
begin
t := a[j]; a[j] := a[j-1]; a[j-1] := t;
end;
end;
d. Phân tích tính hiệu quả của các thuật toán sắp xếp đơn giản
Cả ba thuật toán đều có độ phức tạp thời gian cỡ O(n
2
) và độ phức tạp bộ nhớ là O(n). Vì vậy, ta sẽ
lấy thuật toán sắp xếp kiểu lựa chọn đại diện cho các thuật toán sắp xếp đơn giản để thực hiện với các
bộ test sau trên máy tính PentiumIV 3.0Ghz.
Để thuật tiện cho quá trình test với dữ liệu lớn, ta có chút thay đổi một chút khi cài đặt là thay tất cả
các biến kiểu interger thành các biến kiểu longint. Sau đây là bảng kết quả khi thực hiện thuật toán:
TT n Mô tả Thời gian (giây)
1 10 Kích thước nhỏ (tạo bằng tay) 0.00
2 100 Kích thước nhỏ (tạo ngẫu nhiên) 0.00
3 1.000 Kích thước nhỏ (tạo ngẫu nhiên) 0.00
4 10.000 Kích thước trung bình (tạo ngẫu nhiên) 0.61
5 15.000 Kích thước trung bình (tạo ngẫu nhiên) 1.32
6 50.000 Kích thước trung bình (tạo ngẫu nhiên) 14.21
7 100.000
Kích thước lớn (tạo có chủ định: nửa đầu giảm,
nửa sau tăng)
57.97
8 500.000 Kích thước lớn (tạo ngẫu nhiên) 1426.07
9 1.000.000 Kích thước lớn (tạo ngẫu nhiên) Quá lâu
10 1.000.000
Kích thước lớn (tạo có chủ định: nửa đầu tăng,
nửa sau giảm)
Quá lâu
Nhìn vào bảng kết quả trên, ta thấy rằng các thuật toán cơ bản chỉ áp dụng với n ≤ 10.000. Tuy nhiên
với n lớn thì chi phí về thời gian thực hiện của cả ba thuật toán trên là một chi phí cao so với một số
thuật toán mà ta sẽ xét sau đây.
3. Thuật toán sắp xếp nhanh (Quick sort)
Sắp xếp nhanh Quick sort là thuật toán được A.R. Hoare phát minh vào năm 1960. Quick sort là
phương pháp sắp xếp phổ biến vì việc cài đặt nó không khó khăn.
Ý tưởng của thuật toán như sau: chọn một khoá ngẫu nhiên nào đó làm “chốt”, thường là phần tử nằm
giữa dãy. Mọi phần tử nhỏ hơn “khoá chốt” phải được xếp vào vị trí ở trước “chốt” (đầu dãy), mọi
phần tử lớn hơn khoá “chốt” phải được xếp sau “chốt” (cuối dãy). Muốn vậy các phần tử trong dãy
phải được so sánh với khoá chốt và sẽ đổi vị trí cho nhau, nếu nó lớn hơn chốt mà lại nằm trước chốt
hoặc nhỏ hơn chốt mà lại nằm sau chốt.
3
Khi thực hiện việc đổi chỗ xong thì dãy khoá được chia làm hai đoạn: một đoạn gồm các khoá nhỏ
hơn chốt, một đoạn gồm các khoá lớn hơn hoặc bằng chốt. ở các bước tiếp theo ta cũng áp dụng kỹ
thuật trên cho các phân đoạn. Quá trình xử lý một phân đoạn dừng lại khi ta gặp một phân đoạn chỉ
gồm một phần tử. Việc sắp xếp sẽ kết thúc khi phân đoạn cuối cùng đã được xử lý xong.
Thuật toán Quick sort được mô tả như sau:
Bước 1:
• Chọn khoá đứng giữa dãy làm khoá chốt x := a[(l+r) div 2]; (l, r là 2 biến chỉ số đầu, cuối của
một phân đoạn)
• Dùng 2 biến chỉ số i, j để phát hiện ra hai khoá cần đổi chỗ. Khởi tạo giá trị ban đầu cho hai
biến này: i := l; j := r;
Bước 2:
• Duyệt từ trái sang phải để tìm chỉ số i sao cho a[i] >= x.
• Duyệt từ phải sang trái để tìm chỉ số j sao cho a[j] <= x.
• Nếu i <= j thì:
Đổi chỗ a[i] và a[j].
i := i + 1.
j := j - 1.
Lặp lại bước 2 cho đến khi i > j.
Bước 3:
• Nếu l < j thì lặp lại bước 1, 2 cho phân đoạn a[l], ..., a[j].
• Nếu i < r thì lặp lại bước 1, 2 cho phân đoạn a[i], ..., a[r].
Hình ảnh sau đây minh họa diễn biến trong lượt đầu của thuật toán với dãy số: 42, 23, 65, 74, 11, 58.
Khóa chốt x = 65.
i↓ j↓
42 23 65 74 11 58
↑_____________↑
i↓ j↓
42 23 58 74 11 65
↑___↑
j↓ i↓
42 23 58 11 74 65
Dãy khoá được chia làm hai phân đoạn: (42, 23, 58 11) và (74, 65). Công việc tiếp theo lại tiến hành
lần lượt trên hai phân đoạn mới này.
Cài đặt:
procedure qSort(l, r : integer);
var
i, j, x, y : integer;
begin
i := l; j := r; x := a[(l+r) div 2];
repeat
while a[i] < x do i := i + 1;
while x < a[j] do j := j – 1;
if i <= j then
begin
4
y := a[i]; a[i] := a[j]; a[j] := y;
i := i + 1; j := j – 1;
end;
until i > j;
if l < j then qSort(l, j);
if i < r then qSort(i, r);
end;
Khi đó trong thân của chương trình chính ta chỉ gọi qSort(1, n);
Bây giờ ta sẽ đánh giá độ phức tạp thời gian của thuật toán Quick sort. Trường hợp tốt nhất của
Quick sort xảy ra khi dãy khoá luôn được chia đôi thì độ phức tạp tính toán của thuật toán là
O(n.log
2
n). Như vậy khi n khá lớn thì Quick sort tỏ ra hiệu lực hơn các thuật toán đơn giản mà ta đã
xét. Trường hợp xấu nhất của Quick sort xảy ra khi nửa đầu của dãy khoá đã có thứ tự sắp xếp và nửa
dãy còn lại có thứ tự ngược lại hoặc nửa đầu có thứ tự ngược với thứ tự cần sắp xếp và nửa sau đã
được sắp xếp thì độ phức tạp của thuật toán là O(n
2
). Trường hợp này Quick sort không hơn gì các
thuật toán đơn giản đã nêu.
Một trong những yếu điểm của Quick sort là tính đệ quy.
Sau đây là kết quả thực hiện thuật toán Quick sort với các bộ dữ liệu đã dùng cho các thuật toán sắp
xếp đơn giản để tiện theo dõi và so sánh:
TT n Mô tả
Thời gian (giây)
Đơn giản Phân đoạn
1 10 Kích thước nhỏ (tạo bằng tay) 0.00 0.00
2 100 Kích thước nhỏ (tạo ngẫu nhiên) 0.00 0.00
3 1.000 Kích thước nhỏ (tạo ngẫu nhiên) 0.00 0.00
4 10.000 Kích thước trung bình (tạo ngẫu nhiên) 0.61 0.01
5 15.000 Kích thước trung bình (tạo ngẫu nhiên) 1.32 0.02
6 50.000 Kích thước trung bình (tạo ngẫu nhiên) 14.21 0.05
7 100.000
Kích thước lớn (tạo có chủ định: nửa đầu
giảm, nửa sau tăng – trường hợp xấu của
Quick sort)
27.42 6.25
8 500.000 Kích thước lớn (tạo ngẫu nhiên) 1426.07 0.57
9 1.000.000 Kích thước lớn (tạo ngẫu nhiên) Quá lâu 1.11
10 1.000.000
Kích thước lớn (tạo có chủ định: nửa đầu
tăng, nửa sau giảm – trường hợp xấu của
Quick sort)
Quá lâu Quá lâu
4. Thuật toán sắp xếp kiểu vun đống (Heap sort)
Sắp xếp kiểu vun đống được chia làm 2 giai đoạn:
• Giai đoạn đầu người ta coi dãy khoá cần sắp như là cấu trúc của một cây nhị phân hoàn chỉnh:
nếu i chỉ vị trí nút con thì (i div 2) chỉ vị trí nút cha, còn nếu j chỉ vị trí nút cha thì 2.j và 2.j + 1
chỉ nút con. Sau đó cây nhị phân biểu diễn dãy khoá này được biến đổi để trở thành một đống. ở
đây đống là một cây nhị phân hoàn chỉnh mà mỗi nút được gán cho một giá trị khoá sao cho khoá
của nút cha bao giờ cũng lớn hơn khoá của nút con nó. Do đó khoá của nút gốc của đống chính là
khoá lớn nhất (khoá trội) so với mọi khoá trên cây. Giai đoạn này gọi là giai đoạn tạo đống.
• Giai đoạn thứ hai là sắp xếp. Ta thực hiện lặp các việc sau cho đến khi dãy khoá được sắp:
Đưa khoá trội về vị trí thực của nó bằng cách đổi chỗ với khoá hiện đang ở vị trí đó.
Sau đó “vun lại thành đống” đối với các khoá còn lại (sau khi đã loại khoá trội ra ngoài).
5
Điểm mấu chốt của thuật toán là việc tạo đống hay vun đống. Mặt khác ta nhận thấy rằng một nút lá
có thể coi là một cây con đã thoả mãn tính chất của đống rồi. Như vậy việc tạo đống hay vun đống có
thể được tiến hành theo kiểu từ đáy lên (bottom-up). Khi đó bài toán này sẽ được quy về một phép xử
lý chung sau đây: chuyển đổi thành đống cho một cây mà cây con trái và cây con phải đã là đống rồi.
Thủ tục adjust(i, n : integer) sau đây sẽ chỉnh sửa một cây nhị phân với gốc i để nó thành
đống. Giả sử cây con trái và cây con phải của i, tức là cây với gốc 2.i và 2.i + 1 đã thỏa mãn điều kiện
của đống. Không có nút nào ứng với chỉ số lớn hơn n cả.
procedure adjust(i, n : integer);
var
t : integer;
begin
repeat
i := i * 2;
if i > n then break;
if (i < n) and (a[i] < a[i+1]) then i := i + 1; { tìm nút con lớn hơn }
if a[i div 2] >= a[i] then break;
t := a[i div 2]; a[i div 2] := a[i]; a[i] := t;
until false;
end;
Khi đó thuật toán sắp xếp kiểu vun đống được cài đặt như sau:
procedure heapSort;
var
i, t : integer;
begin
{ 1. Tạo đống }
for i := (n div 2) downto 1 do adjust(i, n);
{ 2. Sắp xếp }
for i := n downto 2 do
begin
{ đưa khoá trội về vị trí thực của nó }
t := a[1]; a[1] := a[i]; a[i] := t;
{ vun các khoá còn lại thành đống }
adjust(1, i-1);
end;
end;
Ví dụ sau đây minh họa các bước của thuật toán vun đống với dãy khoá 42, 23, 74, 11, 65, 58.
Bước 1. Tạo đống
6
i = 3
42
23
11
65
74
58
42
23
11 65
74
58
i = 1
74
65
11 23
58
42
42
65
11
23
74
58
i = 2
Bước 2. Sắp xếp
Đối với thuật toán sắp xếp kiểu vun đống, người ta đã chứng minh được trong tất cả các trường hợp
chi phí về thời gian đều là O(n.log
2
n). Đây là một chi phí tốt hơn so với các phương pháp sắp xếp đơn
giản.
Dưới đây là bảng thực hiện thuật toán sắp xếp kiểu vun đống:
TT n Mô tả
Thời gian (giây)
Đơn
giản
Phân
đoạn
Vun
đống
1 6 Kích thước nhỏ (tạo bằng tay) 0.00 0.00 0.00
2 100 Kích thước nhỏ (tạo ngẫu nhiên) 0.00 0.00 0.00
3 1.000 Kích thước nhỏ (tạo ngẫu nhiên) 0.00 0.00 0.00
4 10.000 Kích thước trung bình (tạo ngẫu nhiên) 0.61 0.01 0.00
5 15.000 Kích thước trung bình (tạo ngẫu nhiên) 1.32 0.02 0.02
6 50.000 Kích thước trung bình (tạo ngẫu nhiên) 14.21 0.05 0.07
7 100.000
Kích thước lớn (tạo có chủ định: nửa
đầu giảm, nửa sau tăng – trường hợp
xấu của Quick sort)
27.42 6.25 0.11
8 500.000 Kích thước lớn (tạo ngẫu nhiên) 1426.07 0.57 0.70
9 1.000.000 Kích thước lớn (tạo ngẫu nhiên) Quá lâu 1.11 1.45
10 1.000.000
Kích thước lớn (tạo có chủ định: nửa
đầu tăng, nửa sau giảm – trường hợp
xấu của Quick sort)
Quá lâu Quá lâu 1.09
7
65
42
11 23
58
74
i = 6
58
42
11 65
23
74
i = 5
42
11
58 65
23
74
i = 4
23
11
58 65
42
74
i = 3
11
23
58 65
42
74
i = 2