Bài 27.724 phút đọc
Sắp xếp không so sánh
Sau bài này bạn sẽ làm được
- Cài counting sort giữ được tính ổn định
- Cài radix sort LSD dùng counting sort làm nền
- Biết điều kiện áp dụng của mỗi thuật toán
- Giải thích vì sao chúng không vi phạm giới hạn dưới
Mọi thuật toán ở năm bài trước đều dựa vào phép so sánh hai phần tử, và không thuật toán so sánh nào nhanh hơn O(n log n). Nhưng nếu ta biết thêm về dữ liệu thì có thể bỏ hẳn phép so sánh và đạt O(n).
#Giới hạn dưới n log n
Cây quyết định
Mọi thuật toán sắp xếp dựa trên so sánh đều mô tả được bằng một cây nhị phân: mỗi nút trong là một phép so sánh, hai nhánh là hai kết quả có thể, và mỗi lá là một hoán vị kết quả.
/* Với n phần tử có n! hoán vị khác nhau.
Cây quyết định phải có ít nhất n! lá để phân biệt được hết.
Cây nhị phân có L lá thì chiều cao ít nhất log2(L).
Chiều cao cây = số phép so sánh trong trường hợp XẤU NHẤT.
h >= log2(n!)
Theo xấp xỉ Stirling: log2(n!) xấp xỉ n log2(n) - 1.44 n
Nên mọi thuật toán so sánh cần ÍT NHẤT khoảng n log2(n) phép
so sánh ở trường hợp xấu nhất. Không có ngoại lệ. */| n | n giai thừa | log2(n!) | n log2(n) |
|---|---|---|---|
| 5 | 120 | 6.9 | 11.6 |
| 10 | 3 628 800 | 21.8 | 33.2 |
| 20 | 2.4 nhân 10 mũ 18 | 61.1 | 86.4 |
| 100 | 9.3 nhân 10 mũ 157 | 524.8 | 664.4 |
#Counting sort
Counting sort
Đếm số lần xuất hiện của từng giá trị, rồi từ bảng đếm suy ra vị trí cuối cùng của mỗi phần tử. Điều kiện: khóa là số nguyên trong khoảng
[0, k) với k không quá lớn.counting.c
#include <stdlib.h>
#include <string.h>
/* Sắp mảng a có n phần tử, mọi giá trị nằm trong [0, k).
Trả về 0 nếu ổn, -1 nếu hết bộ nhớ.
Độ phức tạp O(n + k) thời gian, O(n + k) bộ nhớ. */
int counting_sort(int *a, size_t n, size_t k)
{
if (n < 2) return 0;
size_t *dem = calloc(k, sizeof *dem);
if (dem == NULL) return -1;
int *ra = malloc(n * sizeof *ra);
if (ra == NULL) { free(dem); return -1; }
/* Bước 1: đếm số lần xuất hiện */
for (size_t i = 0; i < n; ++i)
++dem[a[i]];
/* Bước 2: tổng tiền tố. dem[v] thành SỐ PHẦN TỬ nhỏ hơn hoặc bằng v,
tức vị trí ngay sau ô cuối cùng dành cho giá trị v. */
for (size_t v = 1; v < k; ++v)
dem[v] += dem[v - 1];
/* Bước 3: duyệt NGƯỢC để giữ tính ổn định */
for (size_t i = n; i-- > 0; )
ra[--dem[a[i]]] = a[i];
memcpy(a, ra, n * sizeof *a);
free(ra);
free(dem);
return 0;
}Đếm
Một lượt qua mảng, tăng
dem[gia_tri]. Sau bước nàydem[v]là số lần giá trịvxuất hiện.Tổng tiền tố
Cộng dồn. Sau bước này
dem[v]là số phần tử nhỏ hơn hoặc bằngv, tức chỉ số ngay sau vị trí cuối cùng dành cho giá trịv.Đặt vào chỗ
Duyệt ngược mảng vào. Với mỗi phần tử, giảm
dem[gia_tri]rồi dùng nó làm chỉ số đích. Duyệt ngược là thứ giữ tính ổn định.
Vết đầy đủ
Mang vao : 4 2 2 8 3 3 1 n = 7, k = 9
Buoc 1, dem so lan xuat hien:
gia tri : 0 1 2 3 4 5 6 7 8
dem : 0 1 2 2 1 0 0 0 1
Buoc 2, tong tien to:
gia tri : 0 1 2 3 4 5 6 7 8
dem : 0 1 3 5 6 6 6 6 7
Doc: co 3 phan tu <= 2, nen gia tri 2 chiem cac o 1 va 2.
Buoc 3, duyet NGUOC mang vao:
i=6, a[6]=1 : dem[1]=1 -> 0, ra[0] = 1
i=5, a[5]=3 : dem[3]=5 -> 4, ra[4] = 3
i=4, a[4]=3 : dem[3]=4 -> 3, ra[3] = 3
i=3, a[3]=8 : dem[8]=7 -> 6, ra[6] = 8
i=2, a[2]=2 : dem[2]=3 -> 2, ra[2] = 2
i=1, a[1]=2 : dem[2]=2 -> 1, ra[1] = 2
i=0, a[0]=4 : dem[4]=6 -> 5, ra[5] = 4
Ket qua : 1 2 2 3 3 4 8terminal
gcc -std=c17 -Wall -Wextra counting.c main.c -o t && ./t
truoc: 4 2 2 8 3 3 1 sau : 1 2 2 3 3 4 8
#Giữ tính ổn định
Duyệt xuôi
for (size_t i = 0; i < n; ++i) /* duyệt XUÔI */
ra[--dem[a[i]]] = a[i];
/* Phần tử ĐẦU TIÊN trong mảng vào nhận vị trí CUỐI CÙNG
trong nhóm giá trị của nó, tức thứ tự bị đảo. */Duyệt ngược
for (size_t i = n; i-- > 0; ) /* duyệt NGƯỢC */
ra[--dem[a[i]]] = a[i];
/* Phần tử CUỐI CÙNG nhận vị trí cuối cùng trong nhóm,
nên thứ tự vào được giữ nguyên. */thu-on-dinh.c
#include <stdio.h>
typedef struct { int khoa; char nhan; } Muc;
/* Counting sort cho struct, sắp theo trường khoa. */
int counting_muc(Muc *a, size_t n, size_t k)
{
size_t *dem = calloc(k, sizeof *dem);
Muc *ra = malloc(n * sizeof *ra);
if (dem == NULL || ra == NULL) { free(dem); free(ra); return -1; }
for (size_t i = 0; i < n; ++i) ++dem[a[i].khoa];
for (size_t v = 1; v < k; ++v) dem[v] += dem[v - 1];
for (size_t i = n; i-- > 0; )
ra[--dem[a[i].khoa]] = a[i];
memcpy(a, ra, n * sizeof *a);
free(ra);
free(dem);
return 0;
}
int main(void)
{
Muc a[] = {
{ 2, 'a' }, { 1, 'b' }, { 2, 'c' }, { 1, 'd' }, { 3, 'e' },
};
size_t n = sizeof a / sizeof a[0];
printf("truoc: ");
for (size_t i = 0; i < n; ++i) printf("%d%c ", a[i].khoa, a[i].nhan);
counting_muc(a, n, 4);
printf("\nsau : ");
for (size_t i = 0; i < n; ++i) printf("%d%c ", a[i].khoa, a[i].nhan);
printf("\n");
return 0;
}terminal
gcc -std=c17 -Wall -Wextra thu-on-dinh.c -o t && ./t
truoc: 2a 1b 2c 1d 3e sau : 1b 1d 2a 2c 3e
# Bản duyệt xuôi
./thu-on-dinh-xuoi
truoc: 2a 1b 2c 1d 3e sau : 1d 1b 2c 2a 3e <- thu tu bi dao
#Radix sort
Radix sort LSD
Sắp theo từng chữ số, bắt đầu từ chữ số ít quan trọng nhất. Mỗi lượt dùng counting sort ổn định theo một chữ số. Sau khi xử lý hết chữ số, mảng đã sắp hoàn toàn.
radix.c
#include <stdlib.h>
#include <string.h>
/* Counting sort theo một chữ số cơ số 256, dịch phải shift bit. */
static int theo_byte(unsigned *a, unsigned *tam, size_t n, int shift)
{
size_t dem[256] = { 0 };
for (size_t i = 0; i < n; ++i)
++dem[(a[i] >> shift) & 0xFFu];
for (size_t v = 1; v < 256; ++v)
dem[v] += dem[v - 1];
for (size_t i = n; i-- > 0; ) /* NGƯỢC, giữ ổn định */
tam[--dem[(a[i] >> shift) & 0xFFu]] = a[i];
memcpy(a, tam, n * sizeof *a);
return 0;
}
/* Radix sort cho unsigned 32 bit: bốn lượt, mỗi lượt một byte. */
int radix_sort(unsigned *a, size_t n)
{
if (n < 2) return 0;
unsigned *tam = malloc(n * sizeof *tam);
if (tam == NULL) return -1;
for (int shift = 0; shift < 32; shift += 8)
theo_byte(a, tam, n, shift);
free(tam);
return 0;
}Vet voi cac so 170 45 75 90 802 24 2 66, co so 10:
Luot 1, chu so hang DON VI:
170 90 802 2 24 45 75 66
(170 va 90 cung chu so 0, 170 vao truoc nen van truoc: ON DINH)
Luot 2, chu so hang CHUC:
802 2 24 45 66 170 75 90
Luot 3, chu so hang TRAM:
2 24 45 66 75 90 170 802
Da sap.terminal
gcc -std=c17 -Wall -Wextra radix.c main.c -o t && ./t
truoc: 170 45 75 90 802 24 2 66 sau : 2 24 45 66 75 90 170 802
| Cơ số | Số lượt cho 32 bit | Kích thước bảng đếm | Đánh giá |
|---|---|---|---|
| 2 | 32 | 2 | Quá nhiều lượt |
| 16 | 8 | 16 | Nhiều lượt, bảng nhỏ |
| 256 | 4 | 256 | Cân bằng tốt nhất trong thực tế |
| 65536 | 2 | 65536 | Ít lượt nhưng bảng 512 KB vượt bộ nhớ đệm |
terminal
./do-co-so 10000000
co so so luot thoi gian
16 8 0.412 s
256 4 0.184 s <- tot nhat
65536 2 0.284 s
qsort thu vien 1.620 s
radix nhanh hon 8.8 lan#Bucket sort
bucket.c
/* Chia khoảng giá trị thành m nhóm, rải phần tử vào nhóm,
sắp từng nhóm, rồi nối lại.
Chỉ nhanh khi dữ liệu phân bố ĐỀU trên khoảng giá trị. */
int bucket_sort(double *a, size_t n)
{
if (n < 2) return 0;
double nho = a[0], lon = a[0];
for (size_t i = 1; i < n; ++i) {
if (a[i] < nho) nho = a[i];
if (a[i] > lon) lon = a[i];
}
if (lon == nho) return 0; /* mọi phần tử bằng nhau */
size_t m = n; /* số nhóm bằng số phần tử */
/* Đếm số phần tử mỗi nhóm để cấp phát chính xác */
size_t *dem = calloc(m + 1, sizeof *dem);
if (dem == NULL) return -1;
for (size_t i = 0; i < n; ++i) {
size_t g = (size_t)((a[i] - nho) / (lon - nho) * (double)(m - 1));
++dem[g];
}
/* ... rải vào nhóm, gọi insertion sort cho từng nhóm, nối lại ... */
free(dem);
return 0;
}| Phân bố dữ liệu | Độ phức tạp | Ghi chú |
|---|---|---|
| Đều trên khoảng | O(n) | Mỗi nhóm khoảng một phần tử |
| Lệch nhẹ | O(n) | Vài nhóm đông hơn, vẫn ổn |
| Tất cả dồn vào một nhóm | O(n bình phương) | Insertion sort trên cả n phần tử |
terminal
./do-bucket 10000000
phan bo DEU tren [0, 1): bucket : 0.284 s qsort : 1.620 s nhanh hon 5.7 lan phan bo chuan (hinh chuong): bucket : 0.612 s qsort : 1.618 s nhanh hon 2.6 lan phan bo mu (rat lech): bucket : 48.412 s qsort : 1.621 s cham hon 29.9 lan
#Khi nào dùng được
| Thuật toán | Điều kiện | Độ phức tạp | Bộ nhớ phụ |
|---|---|---|---|
| Counting sort | Khóa là số nguyên trong [0, k), k không quá lớn | O(n + k) | O(n + k) |
| Radix sort LSD | Khóa là số nguyên hoặc chuỗi độ dài cố định | O(d nhân (n + b)) với d chữ số, cơ số b | O(n + b) |
| Bucket sort | Khóa phân bố đều trên một khoảng biết trước | O(n) trung bình, O(n bình phương) xấu nhất | O(n) |
terminal
./so-sanh-tat-ca 10000000
10000000 so nguyen 32 bit ngau nhien: radix (co so 256) : 0.184 s qsort thu vien : 1.620 s merge sort : 2.840 s heap sort : 4.120 s radix nhanh hon qsort 8.8 lan Nhung voi 1000 phan tu: radix : 0.021 ms qsort : 0.084 ms chi nhanh hon 4 lan, va ton them 4 KB bo nho
Tự làm thử
- Cài
counting_sortvà kiểm vết của nó khớp với ví dụ trong bài. - Đổi vòng lặp bước 3 thành duyệt xuôi, rồi chạy phép thử ổn định với struct và giải thích kết quả.
- Cài
radix_sortcơ số 256, đo với mười triệu số và so vớiqsort. - Thử radix với cơ số 16, 256 và 65536, ghi lại thời gian và giải thích vì sao 256 tốt nhất.
- Cài bucket sort và đo với ba phân bố: đều, chuẩn và mũ. Giải thích chênh lệch.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Mọi thuật toán dựa trên so sánh cần ít nhất khoảng
n log2(n)phép so sánh ở trường hợp xấu nhất, do lập luận cây quyết định. - Counting sort không so sánh cặp nào mà dùng chính giá trị làm chỉ số, nên nó đạt O(n + k) mà không mâu thuẫn giới hạn dưới.
- Duyệt ngược ở bước ba là thứ giữ tính ổn định, và radix sort bắt buộc cần tính ổn định đó.
- Radix sort cơ số 256 sắp số nguyên 32 bit trong bốn lượt, nhanh hơn
qsortkhoảng chín lần với dữ liệu lớn. - Bucket sort rất nhạy với phân bố và có thể tệ hơn cả insertion sort. Radix an toàn hơn vì nó không quan tâm phân bố.