Bỏ qua điều hướng, tới nội dung chính
Học C
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.
Chiều cao cây là log n tầng, mỗi tầng trộn tốn O(n), nên tổng là O(n log n) trong mọi trường hợp.
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;
}
  1. 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ự.

  2. 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.

  3. 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 a chứa dữ liệu đúng, không phải tmp.

#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ánBộ nhớ phụGhi chú
Merge sort mảngO(n)Một mảng phụ cùng cỡ
Merge sort danh sáchO(log n)Chỉ ngăn xếp đệ quy
Quick sortO(log n)Ngăn xếp, nếu đệ quy vào nửa nhỏ
Heap sortO(1)Sắp hoàn toàn tại chỗ
Insertion sortO(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ảngMerge sort danh sách
Bộ nhớ phụO(n)O(log n) ngăn xếp
Số phép chép dữ liệun log n0, chỉ nối con trỏ
Tìm điểm giữaO(1), tính chỉ sốO(n), phải duyệt
Thân thiện bộ nhớ đệmTốtKém
Ổn địnhCó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ử

  1. Cài merge_sort vớ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.
  2. Đổ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ả.
  3. Cài bản đổi vai trò hai mảng để bỏ memcpy, đo trên năm triệu phần tử.
  4. 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.
  5. 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ự.