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

Heap sort

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

  • Cài thao tác đẩy xuống cho đúng
  • Dựng max heap trong O(n)
  • Cài heap sort hoàn chỉnh
  • Giải thích vì sao nó chậm hơn quicksort dù cùng O(n log n)

Heap sort là thuật toán duy nhất vừa bảo đảm O(n log n) trong mọi trường hợp, vừa sắp hoàn toàn tại chỗ. Đổi lại nó chậm hơn quick sort khoảng hai tới ba lần trong thực tế, và lý do rất đáng hiểu.

#Dựng heap rồi rút gốc

Heap sort
Biến mảng thành một max heap, rồi lặp lại: đổi chỗ gốc với phần tử cuối vùng heap, thu nhỏ vùng heap một ô, và đẩy gốc mới xuống đúng chỗ. Sau n - 1 lần, mảng đã sắp tăng dần.
Heap nhị phân nằm gọn trên mảng, nối bằng ba công thức chỉ số. Bài 23.4 đã dựng cấu trúc này.
/* Hai giai đoạn:

     Giai đoạn 1: dựng max heap từ mảng bất kỳ.       O(n)
     Giai đoạn 2: rút gốc n-1 lần.                    O(n log n)

   Tổng: O(n log n) trong MỌI trường hợp,
         và KHÔNG dùng byte bộ nhớ phụ nào.

   Dùng MAX heap chứ không phải min heap, vì ta muốn
   phần tử lớn nhất đi về CUỐI mảng.                            */
Merge sortQuick sortHeap sort
Tốt nhấtO(n log n)O(n log n)O(n log n)
Trung bìnhO(n log n)O(n log n)O(n log n)
Xấu nhấtO(n log n)O(n bình phương)O(n log n)
Bộ nhớ phụO(n)O(log n)O(1)
Ổn địnhCóKhôngKhông
Tốc độ thực tếChuẩnNhanh nhấtChậm nhất trong ba

#Hàm đẩy xuống

heapsort.c
#include <stddef.h>

static void doi_cho(int *a, int *b) { int t = *a; *a = *b; *b = t; }

/* Đẩy phần tử ở vị trí i xuống cho tới khi nó lớn hơn hoặc bằng cả hai con.
   Chỉ xét vùng a[0..n), tức n là kích thước heap HIỆN TẠI.
   Chạy nhiều nhất log2(n) bước. */
static void day_xuong(int *a, size_t i, size_t n)
{
    for (;;) {
        size_t trai = 2 * i + 1;
        size_t phai = 2 * i + 2;
        size_t lon  = i;

        if (trai < n && a[trai] > a[lon]) lon = trai;
        if (phai < n && a[phai] > a[lon]) lon = phai;

        if (lon == i) break;      /* đã đúng chỗ */

        doi_cho(&a[i], &a[lon]);
        i = lon;
    }
}

Bản dùng dịch thay vì đổi chỗ

Tối ưu giống insertion sort
/* Mỗi phép đổi chỗ tốn 3 phép gán. Dịch thì chỉ tốn 1. */
static void day_xuong_dich(int *a, size_t i, size_t n)
{
    int    khoa = a[i];
    size_t con;

    while ((con = 2 * i + 1) < n) {
        if (con + 1 < n && a[con + 1] > a[con]) ++con;   /* con lớn hơn */

        if (a[con] <= khoa) break;

        a[i] = a[con];      /* dịch con lên, chưa ghi khoa */
        i    = con;
    }

    a[i] = khoa;            /* ghi khoa vào chỗ cuối cùng */
}
terminal
./do-dich 5000000
doi cho : 0.618 s
dich    : 0.442 s
nhanh hon: 1.40 lan

#Dựng heap tại chỗ trong O(n)

heapsort.c (tiếp)
/* Giai đoạn 1: biến mảng thành max heap.
   Đi từ nút không phải lá cuối cùng lên gốc.
   Chi phí O(n), không phải O(n log n). Bài 23.4 đã chứng minh. */
static void dung_heap(int *a, size_t n)
{
    for (size_t i = n / 2; i-- > 0; )
        day_xuong(a, i, n);
}

Vết dựng heap

iMảng trướcViệc làmMảng sau
-9 4 7 1 8 20 3bắt đầu, n/2 = 3 nên i chạy 2, 1, 09 4 7 1 8 20 3
29 4 7 1 8 20 3a[2]=7, con lớn hơn là a[5]=20, đổi9 4 20 1 8 7 3
19 4 20 1 8 7 3a[1]=4, con lớn hơn là a[4]=8, đổi9 8 20 1 4 7 3
09 8 20 1 4 7 3a[0]=9, con lớn hơn là a[2]=20, đổi20 8 9 1 4 7 3

Kết quả 20 8 9 1 4 7 3 là max heap hợp lệ: 20 lớn hơn 8 và 9, 8 lớn hơn 1 và 4, 9 lớn hơn 7 và 3. Nó không phải dãy sắp xếp, và không cần phải vậy.

#Rút gốc n trừ một lần

heapsort.c (tiếp)
void heap_sort(int *a, size_t n)
{
    if (n < 2) return;

    dung_heap(a, n);                    /* giai đoạn 1: O(n) */

    for (size_t i = n; i-- > 1; ) {     /* giai đoạn 2: O(n log n) */
        doi_cho(&a[0], &a[i]);          /* lớn nhất về cuối vùng heap */
        day_xuong(a, 0, i);             /* vùng heap thu nhỏ còn i */
    }
}
  1. Gốc là phần tử lớn nhất

    Theo bất biến max heap. Nó thuộc về vị trí cuối cùng của vùng chưa sắp.

  2. Đổi chỗ gốc với phần tử cuối vùng heap

    Phần tử lớn nhất về đúng chỗ vĩnh viễn. Phần tử vừa lên gốc gần như chắc chắn sai chỗ.

  3. Thu nhỏ vùng heap một ô

    Vị trí vừa được điền không còn thuộc heap nữa. Đó chính là ý nghĩa của tham số i truyền cho day_xuong.

  4. Đẩy gốc mới xuống

    Khôi phục bất biến max heap cho vùng còn lại. Tốn nhiều nhất log2(i) bước.

Vết giai đoạn 2

iMảng trướcSau khi đổi chỗSau khi đẩy xuống
620 8 9 1 4 7 33 8 9 1 4 7 | 209 8 7 1 4 3 | 20
59 8 7 1 4 3 | 203 8 7 1 4 | 9 208 4 7 1 3 | 9 20
48 4 7 1 3 | 9 203 4 7 1 | 8 9 207 4 3 1 | 8 9 20
37 4 3 1 | 8 9 201 4 3 | 7 8 9 204 1 3 | 7 8 9 20
24 1 3 | 7 8 9 203 1 | 4 7 8 9 203 1 | 4 7 8 9 20
13 1 | 4 7 8 9 201 | 3 4 7 8 9 201 3 4 7 8 9 20
main.c
#include <stdio.h>

void heap_sort(int *a, size_t n);

int main(void)
{
    int    a[] = { 9, 4, 7, 1, 8, 20, 3 };
    size_t n   = sizeof a / sizeof a[0];

    printf("truoc: ");

    for (size_t i = 0; i < n; ++i) printf("%d ", a[i]);

    heap_sort(a, n);

    printf("\nsau  : ");

    for (size_t i = 0; i < n; ++i) printf("%d ", a[i]);

    printf("\n");

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra heapsort.c main.c -o t && ./t
truoc: 9 4 7 1 8 20 3 
sau  : 1 3 4 7 8 9 20 

#Vì sao nó chậm hơn quick sort

terminal
./do-ba-thuat-toan 10000000
10000000 phan tu ngau nhien:

              thoi gian   so sanh      hoan doi
quick sort      1.62 s   238412047     79470682
merge sort      2.84 s   220352104            0
heap sort       4.12 s   478241093    239120546

heap sort cham hon quick sort 2.54 lan
Nguyên nhânGiải thích
Truy cập bộ nhớ nhảy xaTừ nút i xuống con 2i+1 là nhảy gấp đôi khoảng cách. Với heap lớn, mỗi bước là một lần trượt bộ nhớ đệm.
Số phép so sánh gấp đôiMỗi bước đẩy xuống cần hai phép so sánh: tìm con lớn hơn, rồi so với cha.
Nhánh khó dự đoánCon nào lớn hơn là gần như ngẫu nhiên, nên bộ dự đoán nhánh sai khoảng một nửa số lần.
Số phép ghi gấp baMỗi lần rút gốc gây ra một chuỗi đổi chỗ dài log n.

#Khi nào nên dùng

k-lon-nhat.c
/* k phần tử lớn nhất, đã sắp, nằm ở cuối mảng.
   O(n) dựng heap, cộng O(k log n) rút gốc. */
void k_lon_nhat(int *a, size_t n, size_t k)
{
    if (n < 2 || k == 0) return;

    if (k > n) k = n;

    dung_heap(a, n);

    for (size_t i = n; i-- > n - k; ) {
        doi_cho(&a[0], &a[i]);
        day_xuong(a, 0, i);
    }

    /* a[n-k..n) là k phần tử lớn nhất, đã sắp tăng dần. */
}
terminal
./do-k-lon-nhat 10000000
n = 10000000

     k   heap cat ngan   sap ca mang
     1        0.094 s       1.620 s
    10        0.095 s       1.620 s
   100        0.098 s       1.620 s
  1000        0.112 s       1.620 s
100000        0.284 s       1.620 s

Tự làm thử

  1. Cài day_xuong và heap_sort, kiểm vết dựng heap khớp với bảng trong bài.
  2. Truyền nhầm kích thước mảng thay vì kích thước heap cho day_xuong, rồi giải thích kết quả nhận được.
  3. Cài bản day_xuong_dich và đo chênh lệch so với bản đổi chỗ trên năm triệu phần tử.
  4. Đo thời gian quick sort và heap sort với n từ một nghìn tới mười triệu, rồi giải thích vì sao tỷ lệ tăng dần.
  5. Cài k_lon_nhat bằng heap sort cắt ngắn và so với heap kích thước k ở Bài 23.4, với k bằng 10, 1000 và 100 nghì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

  • Heap sort dựng max heap trong O(n) rồi rút gốc n - 1 lần, tổng O(n log n) trong mọi trường hợp.
  • Tham số n của day_xuong là kích thước heap hiện tại, không phải kích thước mảng. Đó là thứ giữ cho vùng đã sắp không bị đụng vào.
  • Nó là thuật toán duy nhất vừa bảo đảm O(n log n) vừa sắp tại chỗ với O(1) bộ nhớ phụ.
  • Nó chậm hơn quick sort hai tới ba lần, chủ yếu vì truy cập nhảy xa làm trượt bộ nhớ đệm gấp mười ba lần.
  • Công dụng phổ biến nhất của nó là làm lưới an toàn trong introsort, chứ không phải làm thuật toán sắp xếp chính.