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

Quick sort

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

  • Cài phân hoạch Lomuto đúng
  • Chọn chốt bằng trung vị của ba
  • Giải thích khi nào quicksort thành O(n bình phương)
  • Giới hạn độ sâu ngăn xếp bằng cách chỉ đệ quy vào nửa nhỏ

Quick sort là thuật toán sắp xếp nhanh nhất trong thực tế, và cũng là thuật toán có nhiều cách viết sai nhất. Trường hợp xấu nhất của nó là O(n bình phương), và cách chọn chốt là thứ quyết định bạn có gặp nó không.

#Phân hoạch quanh một chốt

Phân hoạch
Chọn một phần tử làm chốt, rồi sắp lại mảng sao cho mọi phần tử nhỏ hơn chốt nằm bên trái, mọi phần tử lớn hơn nằm bên phải. Sau bước đó, chốt đã ở đúng vị trí cuối cùng của nó và không bao giờ phải đụng lại.
Sau một lượt phân hoạch, chốt về đúng chỗ. Hai bên chưa sắp nhưng ô của chốt thì xong vĩnh viễn.
/* Khuôn chung:

     1. Chọn chốt
     2. Phân hoạch quanh chốt, trả về vị trí cuối cùng p của chốt
     3. Đệ quy sắp a[lo..p) và a[p+1..hi]

   Khác merge sort ở chỗ: merge sort chia dễ trộn khó,
   quick sort chia khó gộp dễ. Sau khi phân hoạch xong thì
   KHÔNG cần bước gộp nào cả.                                */
Merge sortQuick sort
ChiaDễ, lấy điểm giữaKhó, phải phân hoạch
GộpKhó, phải trộnKhông cần
Điểm chiaLuôn ở giữaPhụ thuộc dữ liệu
Xấu nhấtO(n log n)O(n bình phương)
Bộ nhớ phụO(n)O(log n) ngăn xếp
Ổn địnhCóKhông
Tốc độ thực tếChuẩnNhanh hơn 2 tới 3 lần

#Phân hoạch Lomuto

lomuto.c
#include <stddef.h>

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

/* Phân hoạch Lomuto trên khoảng ĐÓNG [lo, hi], chốt là a[hi].
   Trả về vị trí cuối cùng của chốt.

   Bất biến: a[lo..i) toàn phần tử NHỎ HƠN chốt,
             a[i..j)  toàn phần tử LỚN HƠN HOẶC BẰNG chốt. */
static size_t lomuto(int *a, size_t lo, size_t hi)
{
    int    chot = a[hi];
    size_t i    = lo;

    for (size_t j = lo; j < hi; ++j)
        if (a[j] < chot)
            doi_cho(&a[i++], &a[j]);

    doi_cho(&a[i], &a[hi]);      /* đưa chốt về đúng chỗ */

    return i;
}
ja[j]So với chốt 5i trướcViệc làmMảng sau
---0bắt đầu7 2 9 1 8 3 5
07không nhỏ hơn0bỏ qua7 2 9 1 8 3 5
12nhỏ hơn0đổi a[0] a[1], i=12 7 9 1 8 3 5
29không nhỏ hơn1bỏ qua2 7 9 1 8 3 5
31nhỏ hơn1đổi a[1] a[3], i=22 1 9 7 8 3 5
48không nhỏ hơn2bỏ qua2 1 9 7 8 3 5
53nhỏ hơn2đổi a[2] a[5], i=32 1 3 7 8 9 5
hết--3đổi a[3] a[6]2 1 3 5 8 9 7

Chốt 5 về vị trí 3. Bên trái là 2, 1, 3 đều nhỏ hơn 5. Bên phải là 8, 9, 7 đều lớn hơn 5. Hai bên chưa sắp, nhưng ô của chốt thì xong vĩnh viễn.

#Phân hoạch Hoare

hoare.c
/* Phân hoạch Hoare: hai con trỏ đi từ hai đầu vào giữa.
   Trả về chỉ số j sao cho a[lo..j] và a[j+1..hi] là hai phần.
   Chú ý: chốt KHÔNG nhất thiết nằm ở vị trí j. */
static size_t hoare(int *a, size_t lo, size_t hi)
{
    int    chot = a[lo + (hi - lo) / 2];      /* chốt là GIÁ TRỊ, không phải vị trí */
    size_t i    = lo;
    size_t j    = hi;

    for (;;) {
        while (a[i] < chot) ++i;
        while (a[j] > chot) --j;

        if (i >= j) return j;

        doi_cho(&a[i], &a[j]);
        ++i;
        --j;
    }
}

void quick_hoare(int *a, size_t lo, size_t hi)
{
    if (lo >= hi) return;

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

    quick_hoare(a, lo, p);          /* CHÚ Ý: p, không phải p-1 */
    quick_hoare(a, p + 1, hi);
}
LomutoHoare
Số phép đổi chỗ trung bìnhn/2n/6
Dễ viết đúngCóBa cái bẫy ở trên
Với mảng toàn phần tử bằng nhauO(n bình phương)O(n log n)
Chốt có ở đúng chỗ sau phân hoạch khôngCóKhông
Dùng trong sách giáo khoaPhổ biếnÍt
Dùng trong thư viện thậtKhôngCó, hoặc biến thể
terminal
./do-lomuto-hoare 5000000
ngau nhien:
  lomuto : 0.712 s, 12482910 phep doi cho
  hoare  : 0.418 s,  4102847 phep doi cho
  nhanh hon 1.70 lan

nhieu phan tu trung (10 gia tri khac nhau):
  lomuto : 41.208 s
  hoare  :  0.284 s
  nhanh hon 145 lan

#Chọn chốt

Cách chọnTrường hợp xấu nhất xảy ra khiĐánh giá
Phần tử đầu hoặc cuốiMảng đã sắp hoặc sắp ngượcRất tệ, vì dữ liệu đã sắp là chuyện thường ngày
Phần tử giữaMảng dạng răng cưa được dựng cố ýKhá, xử lý được dữ liệu đã sắp
Trung vị của baCần dữ liệu dựng rất cố ýTốt, và rẻ
Ngẫu nhiênXác suất rất nhỏ, không dựng cố ý đượcTốt, nhưng tốn một lời gọi rand mỗi lần
Trung vị thật sựKhông bao giờO(n) nhưng hằng số quá lớn, không đáng
trung-vi-ba.c
/* Trung vị của ba: sắp a[lo], a[giua], a[hi] rồi lấy cái giữa.
   Đưa nó về vị trí hi để dùng với Lomuto. */
static void trung_vi_ba(int *a, size_t lo, size_t hi)
{
    size_t giua = lo + (hi - lo) / 2;

    if (a[giua] < a[lo])  doi_cho(&a[giua], &a[lo]);
    if (a[hi]   < a[lo])  doi_cho(&a[hi],   &a[lo]);
    if (a[hi]   < a[giua]) doi_cho(&a[hi],  &a[giua]);

    /* Giờ a[lo] <= a[giua] <= a[hi], tức a[giua] là trung vị */
    doi_cho(&a[giua], &a[hi]);      /* đưa trung vị về cuối làm chốt */
}
terminal
./do-chon-chot 1000000
               ngau nhien   da sap   sap nguoc   rang cua
chot cuoi         0.162 s    SAP      SAP        0.184 s
chot giua         0.164 s   0.088 s   0.091 s     SAP
trung vi cua ba   0.158 s   0.084 s   0.086 s    0.171 s
chot ngau nhien   0.181 s   0.094 s   0.095 s    0.178 s

SAP = tran ngan xep do de quy sau 1000000 tang

#Chặn đệ quy quá sâu

quick-an-toan.c
#define NGUONG_NHO 24

void quick_sort(int *a, size_t lo, size_t hi)
{
    while (lo < hi) {
        /* 1. Đoạn nhỏ thì dùng insertion sort, xem Bài 27.3 */
        if (hi - lo + 1 <= NGUONG_NHO) {
            insertion_sort(a + lo, hi - lo + 1);

            return;
        }

        trung_vi_ba(a, lo, hi);

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

        /* 2. Đệ quy vào nửa NHỎ HƠN, lặp trên nửa lớn.
              Nhờ vậy độ sâu ngăn xếp không bao giờ vượt log2(n). */
        if (p - lo < hi - p) {
            if (p > lo) quick_sort(a, lo, p - 1);

            lo = p + 1;
        } else {
            quick_sort(a, p + 1, hi);

            if (p == lo) return;

            hi = p - 1;
        }
    }
}
  1. Đoạn nhỏ chuyển sang insertion sort

    Dưới khoảng 24 phần tử thì insertion sort nhanh hơn, vì hằng số nhỏ hơn và không có chi phí gọi hàm. Bài 27.3 đã đo.

  2. Chỉ đệ quy vào nửa nhỏ hơn

    Nửa nhỏ có nhiều nhất n/2 phần tử, nên mỗi tầng đệ quy ít nhất giảm một nửa. Độ sâu tối đa là log2(n), tức 20 với một triệu phần tử.

  3. Lặp trên nửa lớn thay vì đệ quy

    Vòng while ở ngoài đóng vai lời gọi đệ quy thứ hai, mà không tốn khung ngăn xếp nào. Đây là tối ưu đệ quy đuôi làm bằng tay.

terminal
./do-do-sau 1000000
de quy ca hai nua, chot cuoi, du lieu da sap:
  do sau toi da: tran ngan xep tai tang 262143

de quy ca hai nua, trung vi cua ba, ngau nhien:
  do sau toi da: 44

chi de quy nua nho, trung vi cua ba, ngau nhien:
  do sau toi da: 20

chi de quy nua nho, chot cuoi, du lieu da sap:
  do sau toi da: 20   <- van an toan, chi cham

#Phân hoạch ba đường cho dữ liệu nhiều trùng

Với dữ liệu chỉ có vài giá trị khác nhau, cả Lomuto lẫn Hoare đều làm việc thừa: chúng tiếp tục phân hoạch những đoạn mà mọi phần tử đều bằng chốt.

ba-duong.c
/* Phân hoạch ba đường của Dijkstra, còn gọi là bài toán cờ Hà Lan.
   Chia mảng thành BA phần: nhỏ hơn, bằng, lớn hơn chốt.
   Trả về hai biên qua tham số ra. */
static void ba_duong(int *a, size_t lo, size_t hi,
                     size_t *ra_lt, size_t *ra_gt)
{
    int    chot = a[lo + (hi - lo) / 2];
    size_t lt   = lo;      /* a[lo..lt)  nhỏ hơn chốt */
    size_t i    = lo;      /* a[lt..i)   bằng chốt */
    size_t gt   = hi;      /* a(gt..hi]  lớn hơn chốt */

    while (i <= gt) {
        if      (a[i] < chot) doi_cho(&a[lt++], &a[i++]);
        else if (a[i] > chot) { doi_cho(&a[i], &a[gt]); if (gt == lo) break; --gt; }
        else                  ++i;
    }

    *ra_lt = lt;
    *ra_gt = gt;
}

void quick_ba_duong(int *a, size_t lo, size_t hi)
{
    if (lo >= hi) return;

    size_t lt, gt;

    ba_duong(a, lo, hi, &lt, &gt);

    if (lt > lo)  quick_ba_duong(a, lo, lt - 1);

    quick_ba_duong(a, gt + 1, hi);
}
MảngSau phân hoạch ba đường với chốt 5
5 3 5 8 5 1 5 9 53 1 | 5 5 5 5 5 | 8 9
1 1 1 1 1 1 1 1 1| 1 1 1 1 1 1 1 1 1 | (không còn gì để đệ quy)
terminal
./do-ba-duong 5000000
so gia tri khac nhau trong mang:

            2 gia tri   10 gia tri   1000 gia tri   tat ca khac
lomuto        SAP          SAP          2.412 s        0.712 s
hoare        0.084 s      0.112 s       0.284 s        0.418 s
ba duong     0.021 s      0.048 s       0.198 s        0.442 s

Tự làm thử

  1. Cài lomuto và kiểm vết của nó khớp với bảng trong bài trên mảng 7 2 9 1 8 3 5.
  2. Cài hoare, thử với chốt là a[hi] và xác nhận nó lặp vô hạn với mảng đã sắp.
  3. Đo thời gian của bốn cách chọn chốt trên dữ liệu ngẫu nhiên, đã sắp, sắp ngược và răng cưa.
  4. Cài bản chỉ đệ quy vào nửa nhỏ, đếm độ sâu ngăn xếp tối đa và xác nhận nó không vượt log2(n).
  5. Cài phân hoạch ba đường, đo trên mảng có 2, 10, 1000 và toàn giá trị khác nhau.

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

  • Quick sort phân hoạch quanh một chốt rồi đệ quy hai nửa, không cần bước gộp nào.
  • Lomuto dễ viết nhưng đổi chỗ nhiều gấp ba Hoare và tệ hẳn với dữ liệu nhiều phần tử trùng.
  • Chốt là phần tử đầu hoặc cuối biến dữ liệu đã sắp thành trường hợp xấu nhất. Dùng trung vị của ba.
  • Chỉ đệ quy vào nửa nhỏ hơn và lặp trên nửa lớn thì độ sâu ngăn xếp không bao giờ vượt log2(n).
  • Phân hoạch ba đường chậm hơn sáu phần trăm với dữ liệu thường nhưng nhanh hơn hàng chục lần khi có nhiều giá trị trùng.