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 */
}
}| i | khoa | Phần đã sắp trước | Số phép dịch | Sau khi chèn |
|---|---|---|---|---|
| 1 | 2 | 5 | 1 | 2 5 | 4 6 1 3 |
| 2 | 4 | 2 5 | 1 | 2 4 5 | 6 1 3 |
| 3 | 6 | 2 4 5 | 0 | 2 4 5 6 | 1 3 |
| 4 | 1 | 2 4 5 6 | 4 | 1 2 4 5 6 | 3 |
| 5 | 3 | 1 2 4 5 6 | 3 | 1 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ường | Insertion nhị phân | |
|---|---|---|
| Số phép so sánh xấu nhất | n(n-1)/2 | n log2(n) |
| Số phép dịch xấu nhất | n(n-1)/2 | n(n-1)/2 |
| Độ phức tạp tổng | O(n bình phương) | O(n bình phương) |
| Với dữ liệu đã sắp | O(n) so sánh | O(n log n) so sánh |
| Khi nào thắng | Dữ liệu gần sắp | Phé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 quickquick-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ất | Ghi chú |
|---|---|---|
| Shell gốc: n/2, n/4, ..., 1 | O(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 |
| Sedgewick | O(n mũ 4/3) | Tốt nhất được biết trong thực nghiệm |
| Ciura: 1, 4, 10, 23, 57, 132, 301, 701 | Chưa chứng minh | Tìm bằng thực nghiệm, rất nhanh với n vừa |
Tự làm thử
- Cài
insertion_sortbả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. - Đ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.
- 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. - 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.
- 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.