Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 27.324 phút đọc

Insertion sort

Sau bài này bạn sẽ làm được

  • Cài insertion sort bằng phép dịch
  • Giải thích vì sao nó nhanh với dữ liệu gần sắp
  • Đo ngưỡng n mà nó thắng quicksort
  • Ghép nó vào quicksort cho các đoạn nhỏ

Insertion sort là thuật toán O(n bình phương) duy nhất còn được dùng trong mã sản xuất. Lý do: với dữ liệu gần sắp nó chạy gần như tuyến tính, và với mảng nhỏ nó nhanh hơn cả quick sort.

#Ý tưởng: chèn vào chỗ đúng

Insertion sort
Giống cách người ta xếp bài trên tay. Giữ phần đầu mảng luôn đã sắp, rồi lấy phần tử kế tiếp và chèn nó vào đúng chỗ trong phần đã sắp đó.
insertion.c
#include <stddef.h>

void insertion_sort(int *a, size_t n)
{
    for (size_t i = 1; i < n; ++i) {
        int    khoa = a[i];      /* phần tử cần chèn */
        size_t j    = i;

        /* Dịch mọi phần tử LỚN HƠN khoa sang phải một ô */
        while (j > 0 && a[j - 1] > khoa) {
            a[j] = a[j - 1];
            --j;
        }

        a[j] = khoa;             /* đặt khoa vào chỗ vừa mở ra */
    }
}
ikhoaPhần đã sắp trướcSố phép dịchSau khi chèn
12512 5 | 4 6 1 3
242 512 4 5 | 6 1 3
362 4 502 4 5 6 | 1 3
412 4 5 641 2 4 5 6 | 3
531 2 4 5 631 2 3 4 5 6

#Dịch thay vì hoán đổi

Hoán đổi
/* Bản dùng hoán đổi: mỗi bước ba phép gán */
for (size_t i = 1; i < n; ++i)
    for (size_t j = i; j > 0 && a[j - 1] > a[j]; --j) {
        int t = a[j]; a[j] = a[j - 1]; a[j - 1] = t;
    }
Dịch
/* Bản dùng dịch: một phép gán mỗi bước, cộng hai phép ở đầu và cuối */
for (size_t i = 1; i < n; ++i) {
    int    khoa = a[i];
    size_t j    = i;

    while (j > 0 && a[j - 1] > khoa) { a[j] = a[j - 1]; --j; }

    a[j] = khoa;
}
terminal
./do-dich-vs-doi 20000
ngau nhien:
  hoan doi : 0.412 s, 299968512 phep gan
  dich     : 0.184 s, 100029504 phep gan
  nhanh hon: 2.24 lan

#Vì sao nó nhanh với dữ liệu gần sắp

/* Số phép dịch bằng đúng số cặp NGHỊCH THẾ của mảng vào.

   Mảng đã sắp     : 0 cặp     -> n - 1 phép so sánh, 0 phép dịch, O(n)
   Gần sắp, k cặp  : k cặp     -> O(n + k)
   Ngẫu nhiên      : ~n^2/4    -> O(n^2)
   Sắp ngược       : n(n-1)/2  -> O(n^2), xấu nhất

   Với mảng 1 triệu phần tử đã sắp, chỉ đổi 100 phần tử ngẫu nhiên,
   số cặp nghịch thế vẫn rất nhỏ so với n, nên insertion sort
   gần như chạy tuyến tính.                                          */
terminal
./do-gan-sap 1000000
mang 1000000 phan tu:

da sap hoan toan     : insertion 0.003 s, qsort 0.088 s
doi 100 phan tu      : insertion 0.008 s, qsort 0.089 s
doi 10000 phan tu    : insertion 1.412 s, qsort 0.094 s
ngau nhien hoan toan : insertion 412.0 s, qsort 0.162 s
giu-luon-sap.c
/* Giữ một mảng luôn sắp khi dữ liệu tới dần.
   Mỗi phần tử mới chỉ tốn O(số phần tử lớn hơn nó). */
int them_giu_sap(int *a, size_t *n, size_t suc_chua, int x)
{
    if (*n >= suc_chua) return -1;

    size_t j = *n;

    while (j > 0 && a[j - 1] > x) { a[j] = a[j - 1]; --j; }

    a[j] = x;
    ++*n;

    return 0;
}

/* Nếu dữ liệu tới gần như đã sắp thì mỗi lần thêm gần như O(1).
   Nếu tới ngẫu nhiên thì mỗi lần O(n), và tổng là O(n^2).
   Lúc đó nên dùng heap ở Bài 23.4 hoặc cây ở Bài 24.7. */

#Insertion sort có tìm nhị phân

Phần đã sắp a[0..i) cho phép tìm chỗ chèn bằng tìm nhị phân thay vì quét tuyến tính. Điều đó giảm số phép so sánh nhưng không giảm số phép dịch.

insertion-nhi-phan.c
#include <string.h>

/* Chỉ số đầu tiên trong a[0..n) mà a[i] > x.
   Chú ý dấu lớn hơn nghiêm ngặt: nó giữ tính ổn định. */
static size_t cho_chen(const int *a, size_t n, int x)
{
    size_t lo = 0, hi = n;

    while (lo < hi) {
        size_t giua = lo + (hi - lo) / 2;

        if (a[giua] <= x) lo = giua + 1;      /* bằng thì chèn SAU */
        else              hi = giua;
    }

    return lo;
}

void insertion_nhi_phan(int *a, size_t n)
{
    for (size_t i = 1; i < n; ++i) {
        int    khoa = a[i];
        size_t j    = cho_chen(a, i, khoa);

        if (j < i) {
            memmove(&a[j + 1], &a[j], (i - j) * sizeof *a);
            a[j] = khoa;
        }
    }
}
Insertion thườngInsertion nhị phân
Số phép so sánh xấu nhấtn(n-1)/2n log2(n)
Số phép dịch xấu nhấtn(n-1)/2n(n-1)/2
Độ phức tạp tổngO(n bình phương)O(n bình phương)
Với dữ liệu đã sắpO(n) so sánhO(n log n) so sánh
Khi nào thắngDữ liệu gần sắpPhép so sánh rất đắt

#Vai trò trong introsort

Mọi thư viện sắp xếp nghiêm túc đều chuyển sang insertion sort khi đoạn cần sắp đủ nhỏ. Bài 27.5 và 27.8 nói kỹ, ở đây là con số.

terminal
./tim-nguong
     n   insertion      quick   ben nao nhanh hon
     4     0.04 us    0.21 us   insertion
     8     0.09 us    0.41 us   insertion
    16     0.28 us    0.72 us   insertion
    24     0.58 us    1.02 us   insertion
    32     1.02 us    1.38 us   insertion
    48     2.24 us    2.18 us   quick (sat nut)
    64     3.81 us    2.94 us   quick
   128    14.90 us    6.12 us   quick
quick-co-insertion.c
#define NGUONG 24

void quick_sort(int *a, size_t lo, size_t hi)
{
    while (lo < hi) {
        if (hi - lo + 1 <= NGUONG) {
            insertion_sort(a + lo, hi - lo + 1);      /* đoạn nhỏ */

            return;
        }

        size_t p = phan_hoach(a, lo, hi);

        /* Đệ quy vào nửa nhỏ hơn, lặp trên nửa lớn, xem Bài 27.5 */
        if (p - lo < hi - p) {
            quick_sort(a, lo, p ? p - 1 : 0);
            lo = p + 1;
        } else {
            quick_sort(a, p + 1, hi);
            hi = p ? p - 1 : 0;
        }
    }
}

#Shell sort: insertion sort có bước nhảy

Shell sort
Chạy insertion sort nhiều lần với bước nhảy giảm dần: đầu tiên so các phần tử cách nhau h ô, rồi giảm h dần tới 1. Lượt cuối chính là insertion sort thường, nhưng lúc đó mảng đã gần sắp.
shell.c
void shell_sort(int *a, size_t n)
{
    /* Dãy bước nhảy của Knuth: 1, 4, 13, 40, 121, 364, ...
       tức h = 3h + 1. Đơn giản và hiệu quả tốt. */
    size_t h = 1;

    while (h < n / 3) h = 3 * h + 1;

    while (h >= 1) {
        /* Insertion sort với bước nhảy h thay vì 1 */
        for (size_t i = h; i < n; ++i) {
            int    khoa = a[i];
            size_t j    = i;

            while (j >= h && a[j - h] > khoa) {
                a[j] = a[j - h];
                j   -= h;
            }

            a[j] = khoa;
        }

        h /= 3;
    }
}
terminal
./do-shell 100000
n = 100000, ngau nhien:
  insertion : 4.120 s
  shell     : 0.021 s   <- nhanh hon 196 lan
  qsort     : 0.011 s
Dãy bước nhảyĐộ phức tạp xấu nhấtGhi chú
Shell gốc: n/2, n/4, ..., 1O(n bình phương)Dãy tệ nhất, đừng dùng
Knuth: 1, 4, 13, 40, ...O(n mũ 1.5)Đơn giản, thường dùng
SedgewickO(n mũ 4/3)Tốt nhất được biết trong thực nghiệm
Ciura: 1, 4, 10, 23, 57, 132, 301, 701Chưa chứng minhTìm bằng thực nghiệm, rất nhanh với n vừa

Tự làm thử

  1. Cài insertion_sort bản dịch và bản hoán đổi, đếm số phép gán của cả hai và xác nhận tỷ lệ khoảng ba lần.
  2. Đo thời gian trên mảng một triệu phần tử đã sắp có 0, 100, 10 nghìn phần tử bị đổi ngẫu nhiên.
  3. Cài insertion_nhi_phan, đếm số phép so sánh, và tìm kiểu dữ liệu mà nó thật sự thắng bản thường.
  4. Ghép insertion sort vào quick sort cho đoạn nhỏ, thử ngưỡng 8, 16, 24, 32 và tìm ngưỡng tốt nhất trên máy bạn.
  5. Cài shell sort với dãy Knuth và dãy Ciura, đo trên một trăm nghìn phần tử ngẫu nhiên.

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

  • Insertion sort là O(n) với dữ liệu đã sắp và O(n bình phương) với dữ liệu ngẫu nhiên. Số phép dịch bằng số cặp nghịch thế.
  • Dùng phép dịch thay vì hoán đổi tiết kiệm hai phần ba số phép gán.
  • Bản có tìm nhị phân giảm số phép so sánh nhưng không giảm số phép dịch, nên chỉ thắng khi phép so sánh rất đắt.
  • Mọi thư viện sắp xếp đều chuyển sang insertion sort cho đoạn dưới khoảng 16 tới 32 phần tử, vì hằng số của nó nhỏ nhất.
  • Shell sort là insertion sort có bước nhảy giảm dần, sắp tại chỗ, không đệ quy, và nhanh hơn insertion sort hàng trăm lần.