Bỏ qua điều hướng, tới nội dung chính
Học C
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ệ.            */
nn giai thừalog2(n!)n log2(n)
51206.911.6
103 628 80021.833.2
202.4 nhân 10 mũ 1861.186.4
1009.3 nhân 10 mũ 157524.8664.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;
}
  1. Đếm

    Một lượt qua mảng, tăng dem[gia_tri]. Sau bước này dem[v] là số lần giá trị v xuất hiện.

  2. 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ằng v, tức chỉ số ngay sau vị trí cuối cùng dành cho giá trị v.

  3. Đặ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 8
terminal
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 bitKích thước bảng đếmĐánh giá
2322Quá nhiều lượt
16816Nhiều lượt, bảng nhỏ
2564256Cân bằng tốt nhất trong thực tế
65536265536Í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ạpGhi chú
Đều trên khoảngO(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ómO(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ạpBộ nhớ phụ
Counting sortKhóa là số nguyên trong [0, k), k không quá lớnO(n + k)O(n + k)
Radix sort LSDKhóa là số nguyên hoặc chuỗi độ dài cố địnhO(d nhân (n + b)) với d chữ số, cơ số bO(n + b)
Bucket sortKhóa phân bố đều trên một khoảng biết trướcO(n) trung bình, O(n bình phương) xấu nhấtO(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ử

  1. Cài counting_sort và kiểm vết của nó khớp với ví dụ trong bài.
  2. Đổ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ả.
  3. Cài radix_sort cơ số 256, đo với mười triệu số và so với qsort.
  4. 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.
  5. 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 qsort khoả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ố.