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

Bubble sort

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

  • Cài bubble sort có cờ dừng sớm
  • Đếm số phép so sánh và số phép hoán đổi
  • Chứng minh tính ổn định
  • Biết vì sao không dùng nó trong mã thật

Bubble sort là thuật toán sắp xếp mà ai cũng nghĩ ra được, và cũng là thuật toán không ai nên dùng. Nó đáng học vì nó dạy đúng hai khái niệm quan trọng: tính ổn định và tối ưu dừng sớm.

#Ý tưởng và bản ngây thơ

Bubble sort
Đi qua mảng, so từng cặp phần tử kề nhau, cặp nào sai thứ tự thì đổi chỗ. Sau một lượt, phần tử lớn nhất nổi lên cuối như bọt khí. Lặp lại cho phần còn lại.
bubble.c
#include <stddef.h>

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

/* Bản ngây thơ: luôn chạy đủ n-1 lượt. */
void bubble_ngay_tho(int *a, size_t n)
{
    for (size_t i = 0; i + 1 < n; ++i)
        for (size_t j = 0; j + 1 < n - i; ++j)
            if (a[j] > a[j + 1])
                doi_cho(&a[j], &a[j + 1]);
}

Vết trên một mảng nhỏ

LượtMảng trước lượtCác phép đổi chỗMảng sau lượt
15 1 4 2 8(5,1) (5,4) (5,2)1 4 2 5 8
21 4 2 5 8(4,2)1 2 4 5 8
31 2 4 5 8không có1 2 4 5 8

Sau lượt thứ nhất, số 8 đã ở đúng chỗ cuối cùng. Sau lượt thứ hai, cả 8 và 5 đã đúng chỗ. Đó là lý do vòng lặp trong chỉ cần chạy tới n - i - 1: phần đuôi đã sắp xong rồi.

#Cờ dừng sớm

bubble.c (tiếp)
/* Bản có cờ: dừng ngay khi một lượt không đổi chỗ lần nào,
   vì lúc đó mảng đã sắp xong. */
void bubble_sort(int *a, size_t n)
{
    for (size_t i = 0; i + 1 < n; ++i) {
        int da_doi = 0;

        for (size_t j = 0; j + 1 < n - i; ++j)
            if (a[j] > a[j + 1]) {
                doi_cho(&a[j], &a[j + 1]);
                da_doi = 1;
            }

        if (!da_doi) break;      /* đã sắp xong, không cần lượt nào nữa */
    }
}
Dữ liệu vàoKhông có cờCó cờGhi chú
Đã sắp sẵnO(n bình phương)O(n)Một lượt là biết xong
Gần như đã sắpO(n bình phương)Gần O(n)Vài lượt là đủ
Ngẫu nhiênO(n bình phương)O(n bình phương)Không giúp gì
Sắp ngượcO(n bình phương)O(n bình phương)Trường hợp xấu nhất
terminal
./do-co-dung 20000
               khong co co    co co
da sap             1.412 s   0.000 s
gan nhu da sap     1.398 s   0.084 s
ngau nhien         1.421 s   1.402 s
sap nguoc          1.435 s   1.428 s

#Tính ổn định

Thuật toán sắp xếp ổn định
Hai phần tử có khóa bằng nhau giữ nguyên thứ tự tương đối như trong dữ liệu vào. Tính chất này quan trọng khi sắp xếp theo nhiều tiêu chí liên tiếp.
on-dinh.c
#include <stdio.h>
#include <string.h>

typedef struct { char ten[16]; int diem; } SV;

/* So sánh chỉ theo điểm, không theo tên. */
static int theo_diem(const SV *a, const SV *b)
{
    return (a->diem > b->diem) - (a->diem < b->diem);
}

static void bubble_sv(SV *a, size_t n)
{
    for (size_t i = 0; i + 1 < n; ++i) {
        int da_doi = 0;

        for (size_t j = 0; j + 1 < n - i; ++j)
            if (theo_diem(&a[j], &a[j + 1]) > 0) {   /* CHỈ đổi khi LỚN HƠN */
                SV t = a[j]; a[j] = a[j + 1]; a[j + 1] = t;
                da_doi = 1;
            }

        if (!da_doi) break;
    }
}

int main(void)
{
    SV a[] = {
        { "An",    8 }, { "Binh",  7 }, { "Cuong", 8 },
        { "Dung",  7 }, { "Em",    9 },
    };
    size_t n = sizeof a / sizeof a[0];

    printf("truoc: ");

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

    bubble_sv(a, n);

    printf("\nsau  : ");

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

    printf("\n");

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra on-dinh.c -o t && ./t
truoc: An(8) Binh(7) Cuong(8) Dung(7) Em(9) 
sau  : Binh(7) Dung(7) An(8) Cuong(8) Em(9) 

Vì sao tính ổn định quan trọng

sap-nhieu-tieu-chi.c
/* Muốn sắp danh sách theo LỚP tăng dần, trong mỗi lớp theo ĐIỂM giảm dần.

   Cách 1: viết một hàm so sánh gộp cả hai tiêu chí.
   Cách 2: dùng thuật toán ỔN ĐỊNH và sắp hai lần, tiêu chí PHỤ trước.  */

/* Cách 2, chỉ đúng khi thuật toán ổn định */
sap_on_dinh(a, n, theo_diem_giam);      /* tiêu chí phụ, sắp TRƯỚC */
sap_on_dinh(a, n, theo_lop_tang);       /* tiêu chí chính, sắp SAU */

/* Sau lần sắp thứ hai, các phần tử cùng lớp giữ nguyên thứ tự
   mà lần sắp thứ nhất đã tạo ra, tức thứ tự theo điểm giảm dần. */
terminal
./sap-hai-lan
ban dau : An(10A,8) Binh(10B,9) Cuong(10A,9) Dung(10B,7) Em(10A,7)
sap diem: Binh(10B,9) Cuong(10A,9) An(10A,8) Dung(10B,7) Em(10A,7)
sap lop : Cuong(10A,9) An(10A,8) Em(10A,7) Binh(10B,9) Dung(10B,7)
# Với thuật toán KHÔNG ổn định thì kết quả sai
./sap-hai-lan-khong-on-dinh
sap lop : An(10A,8) Cuong(10A,9) Em(10A,7) Dung(10B,7) Binh(10B,9)
(trong lop 10A, diem khong con giam dan)

#Đếm so sánh và hoán đổi

dem-thao-tac.c
#include <stdio.h>
#include <stdlib.h>

static long so_sanh, hoan_doi;

static void bubble_dem(int *a, size_t n)
{
    for (size_t i = 0; i + 1 < n; ++i) {
        int da_doi = 0;

        for (size_t j = 0; j + 1 < n - i; ++j) {
            ++so_sanh;

            if (a[j] > a[j + 1]) {
                int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t;
                ++hoan_doi;
                da_doi = 1;
            }
        }

        if (!da_doi) break;
    }
}

static void dien(int *a, size_t n, int kieu)
{
    for (size_t i = 0; i < n; ++i)
        switch (kieu) {
            case 0: a[i] = rand();              break;   /* ngẫu nhiên */
            case 1: a[i] = (int)i;              break;   /* đã sắp */
            case 2: a[i] = (int)(n - i);        break;   /* sắp ngược */
            default: a[i] = rand() % 10;        break;   /* nhiều trùng */
        }
}

int main(void)
{
    const size_t n = 1000;
    int         *a = malloc(n * sizeof *a);

    if (a == NULL) return 1;

    const char *ten[] = { "ngau nhien", "da sap", "sap nguoc", "nhieu trung" };

    printf("n = %zu, ly thuyet xau nhat: %zu so sanh\n\n", n, n * (n - 1) / 2);
    printf("%-14s %12s %12s\n", "du lieu", "so sanh", "hoan doi");

    for (int k = 0; k < 4; ++k) {
        srand(2024);
        dien(a, n, k);

        so_sanh = hoan_doi = 0;
        bubble_dem(a, n);

        printf("%-14s %12ld %12ld\n", ten[k], so_sanh, hoan_doi);
    }

    free(a);

    return 0;
}
terminal
gcc -std=c17 -O2 dem-thao-tac.c -o t && ./t
n = 1000, ly thuyet xau nhat: 499500 so sanh

du lieu             so sanh     hoan doi
ngau nhien           498012       249481
da sap                  999            0
sap nguoc            499500       499500
nhieu trung          482571       243104

#Vì sao vẫn đáng học

Biến thể: cocktail sort

cocktail.c
/* Đi qua đi lại hai chiều. Sửa được vấn đề "rùa":
   phần tử NHỎ nằm gần cuối mảng chỉ tiến được MỘT vị trí mỗi lượt,
   nên bubble sort thường tốn rất nhiều lượt vì chúng. */
void cocktail_sort(int *a, size_t n)
{
    if (n < 2) return;

    size_t trai = 0, phai = n - 1;

    while (trai < phai) {
        size_t doi_cuoi = trai;

        for (size_t j = trai; j < phai; ++j)          /* đi sang phải */
            if (a[j] > a[j + 1]) {
                doi_cho(&a[j], &a[j + 1]);
                doi_cuoi = j;
            }

        phai = doi_cuoi;

        if (trai >= phai) break;

        doi_cuoi = phai;

        for (size_t j = phai; j > trai; --j)          /* đi sang trái */
            if (a[j - 1] > a[j]) {
                doi_cho(&a[j - 1], &a[j]);
                doi_cuoi = j;
            }

        trai = doi_cuoi;
    }
}
terminal
./do-cocktail 20000
mang co mot phan tu nho nhat o CUOI, con lai da sap:
  bubble   : 1.412 s  (19999 luot)
  cocktail : 0.000 s  (2 luot)

mang ngau nhien:
  bubble   : 1.421 s
  cocktail : 1.108 s  (nhanh hon 1.28 lan)

Tự làm thử

  1. Cài cả ba bản bubble sort và đo trên bốn loại dữ liệu với n bằng 20 nghìn.
  2. Đổi dấu so sánh thành >= rồi chạy chương trình ổn định và giải thích kết quả nhận được.
  3. Đếm số phép so sánh và hoán đổi, xác nhận số hoán đổi bằng số cặp nghịch thế đếm bằng vòng lặp hai lớp.
  4. Cài sắp xếp hai tiêu chí bằng cách sắp hai lần, rồi thử với một thuật toán không ổn định để thấy kết quả sai.
  5. Cài cocktail sort, dựng một mảng đã sắp trừ phần tử nhỏ nhất nằm ở cuối, và đo số lượt của hai thuật toá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

  • Bubble sort là O(n bình phương) trung bình và xấu nhất, O(n) khi dữ liệu đã sắp và có cờ dừng sớm.
  • Với size_t, viết i + 1 < n thay vì i < n - 1 để không quấn vòng khi n bằng 0.
  • Tính ổn định quyết định bởi đúng một dấu: chỉ đổi chỗ khi lớn hơn, không đổi khi bằng.
  • Số phép hoán đổi bằng đúng số cặp nghịch thế, và điều đó giải thích vì sao dữ liệu gần sắp thì nhanh.
  • Không có tình huống thực tế nào nên dùng bubble sort. Insertion sort ở Bài 27.3 luôn tốt hơn hoặc bằng.