Bỏ qua điều hướng, tới nội dung chính
Học C
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ánTốt nhấtTrung bìnhXấu nhấtBộ nhớỔn định
BubbleO(n)O(n²)O(n²)O(1)Có
SelectionO(n²)O(n²)O(n²)O(1)Không
InsertionO(n)O(n²)O(n²)O(1)Có
ShellO(n log n)O(n^1.5)O(n^1.5)O(1)Không
MergeO(n log n)O(n log n)O(n log n)O(n)Có
QuickO(n log n)O(n log n)O(n²)O(log n)Không
HeapO(n log n)O(n log n)O(n log n)O(1)Không
CountingO(n+k)O(n+k)O(n+k)O(n+k)Có
RadixO(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ốngChọnVì sao
Mặc định, không có ràng buộc gì đặc biệtqsort 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 sortHằng số nhỏ nhất, mã ngắn nhất
Cần ổn địnhMerge sortThuậ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ắpInsertion sort hoặc timsortGần như O(n)
Số nguyên trong khoảng nhỏCounting sortO(n + k), không so sánh cặp nào
Rất nhiều số nguyênRadix sortO(dn), nhanh hơn qsort gần mười lần
Dữ liệu lớn hơn RAMMerge sort ngoàiChỉ đọc ghi tuần tự
Danh sách liên kếtMerge sortKhông cần bộ nhớ phụ, chỉ nối con trỏ
Chỉ cần k phần tử lớn nhấtHeap kích thước kO(n log k), bộ nhớ O(k)
Hệ nhúng, RAM vài kilobyteShell sortTạ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ầnXử lý vấn đề gìChi phí
Quick sortTrường hợp thường, cần nhanh nhấtKhô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âuTrườ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 Cstd::sort của C++
Thuật toánThường là introsortIntrosort
Hàm so sánhCon trỏ hàm, không nội tuyến đượcĐối tượng hàm, nội tuyến được
Tốc độChuẩnNhanh hơn 2 tới 3 lần
Kiểm tra kiểuKhông, dùng void *Có, dùng khuôn mẫu
Chép phần tửQua memcpy tổng quátBằ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.4000

Tự làm thử

  1. 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.
  2. Đo lại với n bằng 1000, 100 nghìn và mười triệu, rồi giải thích những thay đổi về thứ hạng.
  3. Đổ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.
  4. Viết một hàm so sánh không nhất quán, gọi qsort với nó dưới -fsanitize=address và đọc báo cáo.
  5. 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 qsort phải là thứ tự yếu chặt và không được viết a - 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.