Bài 27.428 phút đọc
Merge sort
Sau bài này bạn sẽ làm được
- Cài merge sort với một mảng phụ duy nhất
- Giải thích vì sao dấu nhỏ hơn hoặc bằng giữ được tính ổn định
- Cài merge sort cho danh sách liên kết không cần bộ nhớ phụ
- Biết vì sao nó là lựa chọn cho sắp xếp ngoài
Merge sort là thuật toán đầu tiên trong chương đạt O(n log n) trong mọi trường hợp. Nó ổn định, nó sắp danh sách liên kết mà không cần bộ nhớ phụ, và nó là nền tảng của mọi cách sắp dữ liệu lớn hơn RAM.
#Chia đôi rồi trộn
Chia để trị
Chia bài toán thành hai bài toán con cỡ một nửa, giải chúng bằng đệ quy, rồi gộp hai kết quả. Bài 28.1 sẽ bàn kỹ về khuôn này.
merge.c
#include <stdlib.h>
#include <string.h>
/* Trộn hai đoạn ĐÃ SẮP a[lo..giua) và a[giua..hi) vào tmp,
rồi chép ngược lại vào a. */
static void tron(int *a, int *tmp, size_t lo, size_t giua, size_t hi)
{
size_t i = lo, j = giua, k = lo;
while (i < giua && j < hi)
tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; /* dấu bằng giữ ổn định */
while (i < giua) tmp[k++] = a[i++];
while (j < hi) tmp[k++] = a[j++];
memcpy(&a[lo], &tmp[lo], (hi - lo) * sizeof *a);
}
/* Sắp nửa khoảng a[lo..hi). */
static void ms(int *a, int *tmp, size_t lo, size_t hi)
{
if (hi - lo < 2) return; /* 0 hoặc 1 phần tử, đã sắp */
size_t giua = lo + (hi - lo) / 2;
ms(a, tmp, lo, giua);
ms(a, tmp, giua, hi);
tron(a, tmp, lo, giua, hi);
}
/* Trả về 0 nếu ổn, -1 nếu hết bộ nhớ. */
int merge_sort(int *a, size_t n)
{
if (n < 2) return 0;
int *tmp = malloc(n * sizeof *tmp);
if (tmp == NULL) return -1;
ms(a, tmp, 0, n);
free(tmp);
return 0;
}Chia đôi tới khi còn một phần tử
Một phần tử thì tự nó đã sắp. Đây là trường hợp cơ sở, và nó luôn đạt được vì mỗi lần chia thu hẹp khoảng thật sự.
Trộn hai nửa đã sắp thành một đoạn đã sắp
Mỗi bước lấy phần tử nhỏ hơn trong hai đầu. Chi phí O(số phần tử) vì mỗi phần tử được chép đúng một lần.
Chép kết quả trở lại mảng gốc
Cần thiết vì lời gọi đệ quy ở tầng trên mong
achứa dữ liệu đúng, không phảitmp.
#Hàm trộn
Vết của một lần trộn
/* Trộn [3, 27, 38, 43] với [9, 10, 82]
i -> 3 27 38 43 j -> 9 10 82 tmp:
3 <= 9 -> lay 3 tmp: 3
27 > 9 -> lay 9 tmp: 3 9
27 > 10 -> lay 10 tmp: 3 9 10
27 <= 82 -> lay 27 tmp: 3 9 10 27
38 <= 82 -> lay 38 tmp: 3 9 10 27 38
43 <= 82 -> lay 43 tmp: 3 9 10 27 38 43
het nua trai tmp: 3 9 10 27 38 43 82
Moi phan tu duoc chep dung MOT lan. */#Một dấu bằng giữ tính ổn định
Dùng nhỏ hơn nghiêm ngặt
tmp[k++] = (a[i] < a[j]) ? a[i++] : a[j++];
/* Khi hai phần tử BẰNG NHAU, điều kiện sai, nên lấy từ nửa PHẢI.
Nhưng nửa phải nằm SAU trong mảng gốc, nên thứ tự bị đảo. */Dùng nhỏ hơn hoặc bằng
tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
/* Khi bằng nhau, lấy từ nửa TRÁI trước. Nửa trái nằm trước
trong mảng gốc, nên thứ tự vào được giữ nguyên. */thu-on-dinh.c
#include <stdio.h>
typedef struct { int khoa; char nhan; } Muc;
/* Mảng vào: 3a 1a 3b 1b
Chia đôi: [3a 1a] [3b 1b]
Sắp từng nửa: [1a 3a] [1b 3b]
Trộn với <= : 1a <= 1b -> lay 1a
3a > 1b -> lay 1b
3a <= 3b -> lay 3a
con 3b
Ket qua: 1a 1b 3a 3b ON DINH
Trộn với < : 1a < 1b sai (bang) -> lay 1b
1a < 3b -> lay 1a
3a < 3b sai (bang) -> lay 3b
con 3a
Ket qua: 1b 1a 3b 3a DAO THU TU */terminal
./thu-on-dinh
vao : 3a 1a 3b 1b voi <= : 1a 1b 3a 3b on dinh voi < : 1b 1a 3b 3a khong on dinh
#Bộ nhớ phụ
| Thuật toán | Bộ nhớ phụ | Ghi chú |
|---|---|---|
| Merge sort mảng | O(n) | Một mảng phụ cùng cỡ |
| Merge sort danh sách | O(log n) | Chỉ ngăn xếp đệ quy |
| Quick sort | O(log n) | Ngăn xếp, nếu đệ quy vào nửa nhỏ |
| Heap sort | O(1) | Sắp hoàn toàn tại chỗ |
| Insertion sort | O(1) | Sắp tại chỗ |
#Merge sort cho danh sách liên kết
Đây là chỗ merge sort thật sự tỏa sáng. Với danh sách, trộn chỉ là nối lại con trỏ, nên không cần bộ nhớ phụ nào.
merge-list.c
#include <stddef.h>
typedef struct Node { int data; struct Node *next; } Node;
/* Tách danh sách làm đôi bằng hai con trỏ nhanh chậm, xem Bài 21.6.
Trả về nửa sau, và cắt nửa trước bằng cách đặt next của nút cuối là NULL. */
static Node *tach_doi(Node *head)
{
if (head == NULL || head->next == NULL) return NULL;
Node *cham = head;
Node *nhanh = head->next;
while (nhanh != NULL && nhanh->next != NULL) {
cham = cham->next;
nhanh = nhanh->next->next;
}
Node *sau = cham->next;
cham->next = NULL; /* cắt */
return sau;
}
/* Trộn hai danh sách đã sắp. Chỉ nối lại con trỏ, KHÔNG cấp phát gì. */
static Node *tron_list(Node *a, Node *b)
{
Node gia; /* nút giả để bỏ trường hợp riêng cho nút đầu */
Node *duoi = &gia;
gia.next = NULL;
while (a != NULL && b != NULL) {
if (a->data <= b->data) { duoi->next = a; a = a->next; }
else { duoi->next = b; b = b->next; }
duoi = duoi->next;
}
duoi->next = (a != NULL) ? a : b; /* nối nốt phần còn lại */
return gia.next;
}
/* Sắp danh sách. Trả về đầu mới. */
Node *merge_sort_list(Node *head)
{
if (head == NULL || head->next == NULL) return head;
Node *sau = tach_doi(head);
head = merge_sort_list(head);
sau = merge_sort_list(sau);
return tron_list(head, sau);
}| Merge sort mảng | Merge sort danh sách | |
|---|---|---|
| Bộ nhớ phụ | O(n) | O(log n) ngăn xếp |
| Số phép chép dữ liệu | n log n | 0, chỉ nối con trỏ |
| Tìm điểm giữa | O(1), tính chỉ số | O(n), phải duyệt |
| Thân thiện bộ nhớ đệm | Tốt | Kém |
| Ổn định | Có | Có |
terminal
./do-list 1000000
danh sach 1000000 nut: merge sort list : 0.418 s, 0 byte bo nho phu chuyen sang mang, qsort, chuyen lai : 0.302 s, 8 MB bo nho phu voi 10000000 nut, khong du bo nho cho cach thu hai: merge sort list : 5.812 s
#Sắp xếp ngoài
Sắp xếp ngoài
Sắp dữ liệu lớn hơn RAM. Không đọc hết vào bộ nhớ được, nên phải chia thành các khối vừa RAM, sắp từng khối, ghi ra tệp tạm, rồi trộn nhiều đường.
sap-ngoai.c
/* Giai đoạn 1: tạo các đoạn đã sắp.
Đọc M phần tử vừa RAM, sắp trong bộ nhớ, ghi ra một tệp tạm. */
int tao_doan(const char *tep_vao, size_t M, size_t *so_doan)
{
FILE *f = fopen(tep_vao, "rb");
if (f == NULL) return -1;
int *bo_dem = malloc(M * sizeof *bo_dem);
if (bo_dem == NULL) { fclose(f); return -1; }
size_t k = 0;
size_t doc;
while ((doc = fread(bo_dem, sizeof *bo_dem, M, f)) > 0) {
qsort(bo_dem, doc, sizeof *bo_dem, so_sanh_int);
char ten[64];
snprintf(ten, sizeof ten, "doan-%zu.tmp", k);
FILE *g = fopen(ten, "wb");
if (g == NULL) { free(bo_dem); fclose(f); return -1; }
fwrite(bo_dem, sizeof *bo_dem, doc, g);
fclose(g);
++k;
}
free(bo_dem);
fclose(f);
*so_doan = k;
return 0;
}
/* Giai đoạn 2: trộn k đoạn cùng lúc bằng một heap nhỏ nhất kích thước k.
Xem Bài 23.4. Mỗi lần lấy phần tử nhỏ nhất trong k đầu đoạn,
ghi ra, rồi đọc phần tử kế tiếp của đoạn đó vào heap. */terminal
./sap-ngoai du-lieu.bin 4000000000 --ram 500000000
tep vao : 4.0 GB (1000000000 so int) RAM cho phep: 500 MB giai doan 1: tao 8 doan, moi doan 500 MB 62.4 s giai doan 2: tron 8 duong 84.1 s tong 146.5 s doc tuan tu tu 8 tep + ghi tuan tu ra 1 tep dinh bo nho: 512 MB
Tự làm thử
- Cài
merge_sortvới một mảng phụ duy nhất, kiểm với mảng rỗng, một phần tử, và mảng đã sắp. - Đổi dấu
<=thành<trong hàm trộn, rồi chạy phép thử ổn định và giải thích kết quả. - Cài bản đổi vai trò hai mảng để bỏ
memcpy, đo trên năm triệu phần tử. - Thêm mẹo bỏ qua khi hai nửa đã đúng thứ tự, rồi đo trên dữ liệu đã sắp và dữ liệu ngẫu nhiên.
- Cài merge sort cho danh sách liên kết và xác nhận nó không cấp phát gì thêm, kiểm bằng valgrind.
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
- Merge sort là O(n log n) trong mọi trường hợp, vì điểm chia luôn ở giữa bất kể dữ liệu.
- Dấu
<=trong hàm trộn là thứ duy nhất giữ tính ổn định. Đổi thành<là mất ngay. - Mảng phụ phải cấp phát một lần ở ngoài cùng, không phải trong mỗi lần trộn.
- Với danh sách liên kết, trộn chỉ là nối con trỏ nên không cần bộ nhớ phụ, và đó là lợi thế lớn nhất của merge sort.
- Merge sort là thuật toán duy nhất làm được sắp xếp ngoài, vì nó chỉ cần đọc và ghi tuần tự.