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

Sắp xếp cơ bản

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

  • Cài đúng sắp xếp nổi bọt, chọn và chèn
  • Giải thích tính ổn định và vì sao nó quan trọng
  • So sánh ba thuật toán trên ba loại dữ liệu đầu vào
  • Dùng qsort của thư viện chuẩn cho dữ liệu lớn

Ba thuật toán trong bài này đều chậm, đều tốn thời gian tỷ lệ với bình phương số phần tử, và trong công việc thật bạn sẽ dùng qsort thay vì chúng. Nhưng đây là ba thuật toán bạn phải tự cài được, vì chúng dạy ba ý tưởng khác nhau mà mọi thuật toán sắp xếp nhanh sau này đều dựa vào.

#Sắp xếp nổi bọt

Ý tưởng
Duyệt qua mảng, cứ thấy hai phần tử liền nhau sai thứ tự thì đổi chỗ. Sau một lượt, phần tử lớn nhất chắc chắn nổi lên cuối. Lặp lại với phần còn lại.
Trong lượt đầu, số 5 được đẩy dần sang phải cho tới khi gặp số 8 lớn hơn. Kết thúc lượt, số lớn nhất đã đúng chỗ.
noi-bot.c
void noi_bot(int *a, size_t n)
{
    for (size_t i = 0; i + 1 < n; ++i) {
        int da_doi = 0;

        /* Sau lượt i, i phần tử cuối đã đúng chỗ nên không cần xét nữa. */
        for (size_t j = 0; j + 1 < n - i; ++j)
            if (a[j] > a[j + 1]) {
                int t = a[j];

                a[j] = a[j + 1];
                a[j + 1] = t;
                da_doi = 1;
            }

        if (!da_doi)
            break;      /* không đổi lần nào nghĩa là đã sắp xong */
    }
}

Chạy tay đủ mọi lượt

LượtMảng sau lượtSố phép so sánh
đầu vào5 1 4 2 8
11 4 2 5 84
21 2 4 5 83
31 2 4 5 82, không đổi lần nào nên dừng

#Sắp xếp chọn

Ý tưởng
Tìm phần tử nhỏ nhất trong phần chưa sắp, đưa nó về đầu phần đó. Lặp lại với phần còn lại.
chon.c
void sap_xep_chon(int *a, size_t n)
{
    for (size_t i = 0; i + 1 < n; ++i) {
        size_t vt_nho = i;

        for (size_t j = i + 1; j < n; ++j)
            if (a[j] < a[vt_nho])
                vt_nho = j;

        if (vt_nho != i) {
            int t = a[i];

            a[i] = a[vt_nho];
            a[vt_nho] = t;
        }
    }
}
LượtPhần đã sắpPhần chưa sắpNhỏ nhất tìm được
05 1 4 2 81, đổi với 5
115 4 2 82, đổi với 5
21 24 5 84, đã đúng chỗ
31 2 45 85, đã đúng chỗ
xong1 2 4 5 8

Nhược điểm lớn nhất là nó không nhanh lên với dữ liệu đã sắp sẵn. Vòng lặp trong luôn phải quét hết phần còn lại để chắc chắn đã tìm đúng phần tử nhỏ nhất, nên cả ba trường hợp tốt nhất, trung bình và xấu nhất đều là .

#Sắp xếp chèn

Ý tưởng
Giống cách bạn xếp bài trên tay. Lấy từng lá bài mới, đẩy các lá lớn hơn sang phải, rồi đặt lá mới vào chỗ trống.
chen.c
void sap_xep_chen(int *a, size_t n)
{
    for (size_t i = 1; i < n; ++i) {
        int    khoa = a[i];
        size_t j = i;

        /* Đẩy 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;
    }
}

Chú ý thuật toán này không đổi chỗdịch chuyển. Nó lấy phần tử ra giữ trong biến khoa, dịch cả một dãy sang phải bằng các phép gán đơn, rồi đặt khoa vào đúng chỗ. Cách này tốn ít phép ghi hơn so với đổi chỗ liên tiếp.

ikhoaMảng trướcMảng sau
115 1 4 2 81 5 4 2 8
241 5 4 2 81 4 5 2 8
321 4 5 2 81 2 4 5 8
481 2 4 5 81 2 4 5 8

#Tính ổn định

Thuật toán ổn định
Thuật toán giữ nguyên thứ tự tương đối của các phần tử có khóa bằng nhau. Nếu A đứng trước B và hai phần tử này bằng nhau theo tiêu chí so sánh, thì sau khi sắp xếp A vẫn đứng trước B.
on-dinh.c
typedef struct {
    char ten[16];
    int  lop;
} SinhVien;

SinhVien sv[4] = {
    { "An",  2 },
    { "Binh", 1 },
    { "Cuong", 2 },
    { "Dung", 1 },
};

/* Sắp theo lớp. Kết quả với thuật toán ổn định:
     Binh 1, Dung 1, An 2, Cuong 2      <- An vẫn trước Cuong

   Kết quả với thuật toán không ổn định có thể là:
     Dung 1, Binh 1, Cuong 2, An 2      <- thứ tự cũ bị đảo */

Tính ổn định trở nên quan trọng khi bạn sắp xếp theo nhiều tiêu chí. Muốn sắp danh sách theo lớp, rồi trong mỗi lớp sắp theo tên, bạn chỉ cần sắp theo tên trước, rồi sắp theo lớp bằng một thuật toán ổn định. Thứ tự tên trong mỗi lớp sẽ được giữ nguyên.

Thuật toánỔn địnhVì sao
Nổi bọtChỉ đổi khi a[j] lớn hơn thật sự a[j+1], bằng nhau thì giữ nguyên
ChènVòng while dừng khi gặp phần tử bằng, nên phần tử mới đứng sau
ChọnKhôngPhép đổi chỗ xa có thể nhảy một phần tử qua phần tử bằng nó
qsort của thư viện chuẩnKhông được bảo đảmChuẩn C không yêu cầu, tùy cài đặt của từng thư viện

#So sánh ba thuật toán

Thuật toánTốt nhấtTrung bìnhXấu nhấtBộ nhớỔn địnhSố phép đổi chỗ
Nổi bọtO(n)O(n²)O(n²)O(1)O(n²)
ChọnO(n²)O(n²)O(n²)O(1)KhôngO(n)
ChènO(n)O(n²)O(n²)O(1)O(n²)

Bài đo thời gian thật

do-thoi-gian.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

static void sinh_ngau_nhien(int *a, size_t n)
{
    for (size_t i = 0; i < n; ++i) a[i] = rand();
}

static void sinh_da_sap(int *a, size_t n)
{
    for (size_t i = 0; i < n; ++i) a[i] = (int)i;
}

static void sinh_sap_nguoc(int *a, size_t n)
{
    for (size_t i = 0; i < n; ++i) a[i] = (int)(n - i);
}

static double do_thoi_gian(void (*sap)(int *, size_t),
                           const int *goc, size_t n)
{
    int *a = malloc(n * sizeof *a);

    if (a == NULL) return -1.0;

    memcpy(a, goc, n * sizeof *a);

    clock_t t0 = clock();

    sap(a, n);

    double giay = (double)(clock() - t0) / CLOCKS_PER_SEC;

    /* Dùng kết quả để trình biên dịch không xóa mất lời gọi khi bật -O2. */
    volatile int chan = a[n / 2];

    (void)chan;
    free(a);

    return giay;
}
terminal
gcc -std=c17 -O2 -Wall do-thoi-gian.c -o do && ./do
n = 50000, du lieu ngau nhien
  noi bot   : 4.812 s
  chon      : 1.934 s
  chen      : 0.951 s

n = 50000, da sap xep san
  noi bot   : 0.000 s
  chon      : 1.928 s
  chen      : 0.000 s

n = 50000, sap nguoc
  noi bot   : 5.104 s
  chon      : 1.931 s
  chen      : 1.903 s

#qsort của thư viện chuẩn

Với dữ liệu thật, hãy dùng qsort. Nó chạy nhanh hơn hàng nghìn lần trên mảng lớn và đã được kiểm thử kỹ hơn bất cứ thứ gì bạn tự viết.

qsort.c
#include <stdlib.h>

static int so_sanh_int(const void *x, const void *y)
{
    int a = *(const int *)x;
    int b = *(const int *)y;

    return (a > b) - (a < b);
}

int a[] = { 5, 1, 4, 2, 8 };
size_t n = sizeof a / sizeof a[0];

qsort(a, n, sizeof a[0], so_sanh_int);
terminal
# Với một triệu số nguyên ngẫu nhiên
./so-sanh-qsort
noi bot : bo qua, uoc tinh hon 30 phut
qsort   : 0.118 s

Sắp xếp mảng struct theo nhiều tiêu chí

nhieu-tieu-chi.c
typedef struct {
    char   ten[32];
    int    lop;
    double diem;
} SinhVien;

/* Sắp theo lớp tăng dần, cùng lớp thì theo điểm giảm dần,
   cùng điểm thì theo tên. */
static int so_sanh_sv(const void *x, const void *y)
{
    const SinhVien *a = x;
    const SinhVien *b = y;

    if (a->lop != b->lop)
        return (a->lop > b->lop) - (a->lop < b->lop);

    if (a->diem != b->diem)
        return (a->diem < b->diem) - (a->diem > b->diem);   /* giảm dần */

    return strcmp(a->ten, b->ten);
}

qsort(sv, n, sizeof sv[0], so_sanh_sv);

Chương 27 sẽ dạy các thuật toán sắp xếp nhanh: sắp xếp trộn, sắp xếp nhanh và sắp xếp vun đống, cùng cách chứng minh giới hạn dưới n log n cho mọi thuật toán dựa trên so sánh.

Tự làm thử

  1. Cài cả ba thuật toán, thêm biến đếm số phép so sánh và số phép đổi chỗ, in ra sau mỗi lần chạy.
  2. Chạy bài đo thời gian với n bằng 1 000, 10 000 và 50 000 trên ba loại dữ liệu. Lập bảng và giải thích từng ô.
  3. Bỏ biến da_doi khỏi sắp xếp nổi bọt, đo lại với mảng đã sắp sẵn và so sánh.
  4. Chứng minh bằng thực nghiệm rằng sắp xếp chọn không ổn định: sắp mảng struct có khóa trùng nhau và in thứ tự trước sau.
  5. Sắp mảng sinh viên theo ba tiêu chí như ví dụ trong bài bằng qsort.
  6. Cài hàm so sánh sai kiểu return a - b; với hai giá trị INT_MAX và âm một, chạy dưới -fsanitize=undefined.
  7. Đo ngưỡng n mà sắp xếp chèn còn nhanh hơn qsort trên máy bạ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

  • Ba thuật toán đều là O(n bình phương) trung bình, dùng O(1) bộ nhớ phụ, và đều sắp xếp tại chỗ.
  • Nổi bọt và chèn về O(n) với dữ liệu đã sắp sẵn, sắp xếp chọn thì không bao giờ nhanh lên.
  • Sắp xếp chọn thực hiện ít phép đổi chỗ nhất, hữu ích khi phép ghi đắt.
  • Sắp xếp chèn nhanh nhất với n nhỏ và với dữ liệu gần sắp xếp, nên nó là bước cuối của các thuật toán nhanh.
  • Ổn định nghĩa là giữ nguyên thứ tự tương đối của các phần tử bằng nhau, quan trọng khi sắp theo nhiều tiêu chí.
  • Với dữ liệu thật hãy dùng qsort, và viết hàm so sánh bằng (a > b) - (a < b).