Bài 27.824 phút đọc
So sánh và cách chọn
Sau bài này bạn sẽ làm được
- Đọc được bảng so sánh tám thuật toán
- Chọn thuật toán theo tình huống cụ thể
- Hiểu introsort mà qsort của thư viện chuẩn thường dùng
- Thiết kế một phép đo trung thực
Tám thuật toán, mỗi cái mạnh ở một chỗ. Bài này gom lại thành một bảng và một quy trình chọn, rồi nói về thứ mà thư viện thật sự dùng: không phải một thuật toán mà là sự kết hợp của ba.
#Bảng tổng hợp tám thuật toán
| Thuật toán | Tốt nhất | Trung bình | Xấu nhất | Bộ nhớ | Ổn định |
|---|---|---|---|---|---|
| Bubble | O(n) | O(n²) | O(n²) | O(1) | Có |
| Selection | O(n²) | O(n²) | O(n²) | O(1) | Không |
| Insertion | O(n) | O(n²) | O(n²) | O(1) | Có |
| Shell | O(n log n) | O(n^1.5) | O(n^1.5) | O(1) | Không |
| Merge | O(n log n) | O(n log n) | O(n log n) | O(n) | Có |
| Quick | O(n log n) | O(n log n) | O(n²) | O(log n) | Không |
| Heap | O(n log n) | O(n log n) | O(n log n) | O(1) | Không |
| Counting | O(n+k) | O(n+k) | O(n+k) | O(n+k) | Có |
| Radix | O(dn) | O(dn) | O(dn) | O(n+b) | Có |
Đo thực tế trên cùng máy
terminal
./benchmark 1000000
1000000 phan tu int, don vi mili giay
ngau nhien da sap sap nguoc nhieu trung
insertion 412000.0 2.1 824000.0 206000.0
shell 284.0 18.4 291.0 142.0
merge 284.0 62.0 281.0 278.0
quick (intro) 162.0 84.0 164.0 98.0
heap 412.0 398.0 409.0 402.0
radix 18.4 18.2 18.4 18.1
qsort thu vien 184.0 92.0 186.0 112.0#Chọn theo tình huống
| Tình huống | Chọn | Vì sao |
|---|---|---|
| Mặc định, không có ràng buộc gì đặc biệt | qsort của thư viện chuẩn | Đã là introsort, nhanh và có bảo đảm |
| Mảng nhỏ, dưới 50 phần tử | Insertion sort | Hằng số nhỏ nhất, mã ngắn nhất |
| Cần ổn định | Merge sort | Thuật toán O(n log n) ổn định duy nhất |
| Cần O(1) bộ nhớ và bảo đảm O(n log n) | Heap sort | Ô duy nhất có cả hai |
| Dữ liệu gần như đã sắp | Insertion sort hoặc timsort | Gần như O(n) |
| Số nguyên trong khoảng nhỏ | Counting sort | O(n + k), không so sánh cặp nào |
| Rất nhiều số nguyên | Radix sort | O(dn), nhanh hơn qsort gần mười lần |
| Dữ liệu lớn hơn RAM | Merge sort ngoài | Chỉ đọc ghi tuần tự |
| Danh sách liên kết | Merge sort | Không cần bộ nhớ phụ, chỉ nối con trỏ |
| Chỉ cần k phần tử lớn nhất | Heap kích thước k | O(n log k), bộ nhớ O(k) |
| Hệ nhúng, RAM vài kilobyte | Shell sort | Tại chỗ, không đệ quy, mã ngắn |
#Introsort và timsort
Introsort
Quick sort có hai lưới an toàn: chuyển sang insertion sort cho đoạn nhỏ, và chuyển sang heap sort khi đệ quy quá sâu. Do David Musser đề xuất năm 1997, và nay là thuật toán mặc định của
std::sort.introsort.c
#include <math.h>
#include <stddef.h>
#define NGUONG_NHO 24
static void intro(int *a, size_t lo, size_t hi, int gioi_han)
{
while (hi > lo) {
/* Lưới 1: đoạn nhỏ dùng insertion sort */
if (hi - lo + 1 <= NGUONG_NHO) {
insertion_sort(a + lo, hi - lo + 1);
return;
}
/* Lưới 2: quá sâu thì đổi sang heap sort */
if (gioi_han == 0) {
heap_sort(a + lo, hi - lo + 1);
return;
}
--gioi_han;
trung_vi_ba(a, lo, hi);
size_t p = lomuto(a, lo, hi);
if (p - lo < hi - p) {
if (p > lo) intro(a, lo, p - 1, gioi_han);
lo = p + 1;
} else {
intro(a, p + 1, hi, gioi_han);
if (p == lo) return;
hi = p - 1;
}
}
}
void introsort(int *a, size_t n)
{
if (n < 2) return;
intro(a, 0, n - 1, 2 * (int)log2((double)n));
}| Thành phần | Xử lý vấn đề gì | Chi phí |
|---|---|---|
| Quick sort | Trường hợp thường, cần nhanh nhất | Không có bảo đảm xấu nhất |
| Insertion sort cho đoạn nhỏ | Hằng số lớn của quick sort ở n nhỏ | Một nhánh if mỗi lần gọi |
| Heap sort khi quá sâu | Trường hợp xấu O(n bình phương) | Một biến đếm và một nhánh if |
terminal
./do-introsort 1000000
ngau nhien du lieu doc hai da sap quick thuan 0.158 s SAP SAP quick + insertion 0.148 s SAP SAP introsort day du 0.162 s 0.284 s 0.084 s heap sort 0.412 s 0.409 s 0.398 s du lieu doc hai = mang dung co y de quick sort thanh O(n^2)
Timsort
Merge sort ghép với insertion sort, thiết kế để tận dụng những đoạn đã sắp sẵn trong dữ liệu thật. Do Tim Peters viết cho Python năm 2002, nay là thuật toán mặc định của Python, Java cho kiểu đối tượng, Android và Rust.
/* Ba ý tưởng chính của timsort:
1. TÌM RUN. Quét mảng tìm những đoạn đã sắp sẵn, xuôi hoặc ngược.
Đoạn ngược thì đảo lại tại chỗ. Dữ liệu thật thường có nhiều
đoạn như vậy, ví dụ nhật ký gộp từ nhiều nguồn.
2. MỞ RỘNG RUN NGẮN. Đoạn ngắn hơn ngưỡng thì dùng insertion sort
mở rộng nó tới độ dài tối thiểu, thường 32 hoặc 64.
3. TRỘN THÔNG MINH. Giữ một ngăn xếp các đoạn và trộn chúng theo
quy tắc bảo đảm các đoạn được trộn có kích thước gần nhau.
Nhờ vậy tổng chi phí trộn vẫn là O(n log n). */terminal
./do-timsort 1000000
ngau nhien gan sap nhieu doan da sap da sap merge sort 0.284 s 0.278 s 0.281 s 0.062 s timsort 0.312 s 0.084 s 0.041 s 0.004 s introsort 0.162 s 0.084 s 0.148 s 0.084 s timsort cham hon merge 10% voi du lieu ngau nhien, nhung nhanh hon 7 lan voi du lieu co cau truc
#qsort của thư viện chuẩn
dung-qsort.c
#include <stdlib.h>
/* Hàm so sánh: âm nếu a trước b, 0 nếu tương đương, dương nếu a sau b. */
static int so_sanh_int(const void *pa, const void *pb)
{
int a = *(const int *)pa;
int b = *(const int *)pb;
return (a > b) - (a < b); /* KHÔNG viết a - b, xem Bài 26.2 */
}
qsort(a, n, sizeof *a, so_sanh_int);terminal
gcc -std=c17 -g -fsanitize=address so-sanh-khong-nhat-quan.c -o t && ./t
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000598
READ of size 4 at 0x602000000598 thread T0
#0 in so_sanh_bay so-sanh-khong-nhat-quan.c:8
#1 in msort_with_tmp
0x602000000598 is located 8 bytes to the right of 400-byte region| qsort của C | std::sort của C++ | |
|---|---|---|
| Thuật toán | Thường là introsort | Introsort |
| Hàm so sánh | Con trỏ hàm, không nội tuyến được | Đối tượng hàm, nội tuyến được |
| Tốc độ | Chuẩn | Nhanh hơn 2 tới 3 lần |
| Kiểm tra kiểu | Không, dùng void * | Có, dùng khuôn mẫu |
| Chép phần tử | Qua memcpy tổng quát | Bằng phép gán của kiểu |
terminal
./do-qsort-vs-stdsort 10000000
10000000 int ngau nhien: qsort : 1.620 s std::sort : 0.584 s nhanh hon 2.77 lan Nguyen nhan: moi phep so sanh cua qsort la mot loi goi qua con tro ham, khong noi tuyen duoc, va moi phep chep phan tu di qua memcpy voi kich thuoc chi biet luc chay.
#Thiết kế một phép đo trung thực
benchmark.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef void (*HamSap)(int *, size_t);
typedef enum { DL_NGAU_NHIEN, DL_DA_SAP, DL_SAP_NGUOC, DL_NHIEU_TRUNG,
DL_SO_LUONG } LoaiDL;
static const char *TEN_DL[DL_SO_LUONG] = {
"ngau nhien", "da sap", "sap nguoc", "nhieu trung"
};
static void sinh(int *a, size_t n, LoaiDL loai, unsigned hat)
{
srand(hat);
for (size_t i = 0; i < n; ++i)
switch (loai) {
case DL_NGAU_NHIEN: a[i] = rand(); break;
case DL_DA_SAP: a[i] = (int)i; break;
case DL_SAP_NGUOC: a[i] = (int)(n - i); break;
case DL_NHIEU_TRUNG: a[i] = rand() % 100; break;
default: a[i] = 0; break;
}
}
static int so_sanh_double(const void *pa, const void *pb)
{
double a = *(const double *)pa, b = *(const double *)pb;
return (a > b) - (a < b);
}
/* Chạy lan lần, mỗi lần dựng lại dữ liệu, trả về TRUNG VỊ. */
static double do_mot(HamSap f, const int *goc, size_t n, int lan)
{
int *ban = malloc(n * sizeof *ban);
double *kq = malloc((size_t)lan * sizeof *kq);
if (ban == NULL || kq == NULL) { free(ban); free(kq); return -1.0; }
for (int k = 0; k < lan; ++k) {
memcpy(ban, goc, n * sizeof *ban); /* dựng lại, KHÔNG đo */
clock_t t0 = clock();
f(ban, n);
kq[k] = (double)(clock() - t0) / CLOCKS_PER_SEC;
}
/* Kiểm tra kết quả đúng, và cũng để trình biên dịch không bỏ lời gọi */
for (size_t i = 1; i < n; ++i)
if (ban[i - 1] > ban[i]) {
fprintf(stderr, "SAI: khong sap dung\n");
free(ban); free(kq);
return -1.0;
}
qsort(kq, (size_t)lan, sizeof *kq, so_sanh_double);
double trung_vi = kq[lan / 2];
free(ban);
free(kq);
return trung_vi;
}benchmark.c (tiếp)
int main(int argc, char **argv)
{
size_t n = (argc > 1) ? strtoul(argv[1], NULL, 10) : 100000;
int lan = (argc > 2) ? atoi(argv[2]) : 7;
struct { const char *ten; HamSap f; } bang[] = {
{ "insertion", insertion_sort },
{ "shell", shell_sort },
{ "merge", merge_sort_v },
{ "intro", introsort },
{ "heap", heap_sort },
};
int *goc = malloc(n * sizeof *goc);
if (goc == NULL) return 1;
printf("n = %zu, %d lan moi phep do, lay trung vi\n\n", n, lan);
printf("%-12s", "");
for (int d = 0; d < DL_SO_LUONG; ++d) printf("%14s", TEN_DL[d]);
printf("\n");
for (size_t t = 0; t < sizeof bang / sizeof bang[0]; ++t) {
printf("%-12s", bang[t].ten);
for (int d = 0; d < DL_SO_LUONG; ++d) {
sinh(goc, n, (LoaiDL)d, 2024);
double s = do_mot(bang[t].f, goc, n, lan);
if (s < 0.0) printf("%14s", "loi");
else printf("%14.4f", s * 1000.0);
}
printf("\n");
}
free(goc);
return 0;
}terminal
gcc -std=c17 -O2 -Wall -Wextra benchmark.c *.c -o bm -lm && ./bm 200000 7
n = 200000, 7 lan moi phep do, lay trung vi
ngau nhien da sap sap nguoc nhieu trung
insertion 16480.2000 0.4120 32840.0000 8210.0000
shell 42.8000 3.1200 44.1000 21.4000
merge 48.2000 11.4000 47.8000 47.1000
intro 28.4000 14.2000 28.8000 17.2000
heap 72.4000 69.8000 71.2000 70.4000Tự làm thử
- Cài đủ tám thuật toán trong chương và chạy chúng qua khung đo trong bài, với bốn loại dữ liệu.
- Đo lại với
nbằng 1000, 100 nghìn và mười triệu, rồi giải thích những thay đổi về thứ hạng. - Đổi phần tử thành struct 256 byte và đo lại, rồi giải thích vì sao selection sort thắng.
- Viết một hàm so sánh không nhất quán, gọi
qsortvới nó dưới-fsanitize=addressvà đọc báo cáo. - Cài introsort đầy đủ, dựng một mảng độc hại làm quick sort thuần thành O(n bình phương), rồi xác nhận introsort vẫn nhanh.
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
- Không thuật toán nào thắng ở mọi tiêu chí. Merge tốt nhất về bảo đảm và ổn định, quick nhanh nhất thực tế, heap tiết kiệm bộ nhớ nhất.
- Introsort ghép quick sort với insertion sort cho đoạn nhỏ và heap sort khi đệ quy quá sâu, nên nhanh mà vẫn có bảo đảm.
- Timsort tận dụng những đoạn đã sắp sẵn trong dữ liệu thật, nên nó là mặc định của Python, Java và Rust.
- Hàm so sánh của
qsortphải là thứ tự yếu chặt và không được viếta - b. Vi phạm là hành vi không xác định. - Phép đo trung thực cần nhiều loại dữ liệu, dựng lại dữ liệu ngoài vùng đo, lấy trung vị của nhiều lần, và kiểm tra kết quả đúng.