Nhập môn

Sắp xếp nổi bọt (Bubble Sort): giải thích từng bước, tối ưu dừng sớm và code C++, Python

Sắp xếp nổi bọt (Bubble Sort) là thuật toán sắp xếp mảng bằng cách duyệt lặp đi lặp lại từ đầu đến cuối, so sánh hai phần tử đứng kề nhau `a[j]` và `a[j + 1]` rồi đổi chỗ nếu chúng ngược thứ tự. Sau mỗi lượt thứ `i`, phần tử lớn nhất còn lại sẽ “nổi” dần về đúng vị trí `n - 1 - i` ở cuối mảng giống như bọt khí nổi lên mặt nước. Khi kết hợp thêm biến cờ `swapped` để dừng sớm, thuật toán chỉ mất `O(n)` nếu mảng đầu vào đã có thứ tự.

Ý chính
  • Ở mỗi lượt thứ i (tính từ 0), vòng lặp trong chỉ cần xét các cặp kề nhau từ 0 đến n - 2 - i, vì i phần tử cuối mảng đã nằm cố định ở đúng vị trí.
  • Luôn đặt biến cờ swapped trong mỗi lượt: nếu đi hết một lượt mà không có cặp nào đổi chỗ, mảng đã tăng dần và ta dừng ngay trong O(n).
  • Tổng số lần hoán đổi (swap) của Bubble Sort luôn bằng chính xác số cặp nghịch thế (inversion count) của mảng ban đầu.
  • Bubble Sort là thuật toán sắp xếp ổn định (stable sort) vì chỉ đổi chỗ khi a[j] lớn hơn hẳn a[j + 1], không bao giờ đảo vị trí hai phần tử bằng nhau.

Sắp xếp nổi bọt (Bubble Sort) hoạt động theo nguyên lý nào?

Khi mới làm quen với tư duy thiết kế thuật toán, một mảng a gồm n số đã tăng dần khi và chỉ khi mọi cặp phần tử đứng kề nhau đều thỏa mãn a[j] ≤ a[j + 1]. Từ nhận xét đó, sắp xếp nổi bọt (Bubble Sort) sửa lỗi tại chỗ theo 3 bước:

  1. Xuất phát từ đầu mảng (j = 0), xét hai phần tử đứng sát nhau a[j] và a[j + 1].
  2. Nếu a[j] lớn hơn a[j + 1] (ngược thứ tự), hoán đổi (swap) hai phần tử cho nhau; ngược lại giữ nguyên.
  3. Tăng j lên 1 và lặp lại cho tới hết đoạn chưa sắp xếp.

Hệ quả sau mỗi lượt quét từ trái sang phải: phần tử lớn nhất trong đoạn đang xét sẽ liên tục thắng trong mọi phép so sánh và được đẩy từng bước về cuối đoạn:

  • Kết thúc lượt 1 (i = 0): phần tử lớn nhất toàn mảng nằm cố định ở n - 1.
  • Kết thúc lượt i: phần tử lớn nhất còn lại trong đoạn đầu nổi về đúng vị trí n - 1 - i. Sau tối đa n - 1 lượt, toàn bộ mảng đã có thứ tự tăng dần.

Chạy tay từng bước trên mảng [5, 1, 4, 2, 8]

Để thấy rõ cách các phần tử lớn “nổi” dần về cuối và lý do ta có thể dừng sớm trước khi hết n - 1 lượt, hãy theo dõi thuật toán chạy trên mảng 5 phần tử a = [5, 1, 4, 2, 8] (chỉ số đánh từ 0 đến 4).

a[0] a[1] a[2] a[3] a[4] Ban đầu 5 1 4 2 8 4 cặp nghịch thế Sau lượt 1 1 4 2 5 8 3 lần đổi, cố định 8 Sau lượt 2 1 2 4 5 8 1 lần đổi, cố định 5 Lượt 3 (Dừng) 1 2 4 5 8 swapped = false
Ban đầu (4 cặp nghịch thế) 5 1 4 2 8 Sau lượt 1: đổi 3 lần, cố định 8 1 4 2 5 8 Sau lượt 2: đổi (4, 2), cố định 5 1 2 4 5 8 Lượt 3: swapped = false -> dừng sớm 1 2 4 5 8
Hình 1. Quá trình sắp xếp nổi bọt trên mảng [5, 1, 4, 2, 8]: ô cam là cặp vừa đổi chỗ, ô xanh lá là các phần tử đã cố định ở cuối mảng.

Lượt 1 (i = 0, xét j từ 0 đến 3)

Ở lượt đầu tiên, chưa có phần tử nào cố định nên j trượt qua toàn bộ 4 cặp kề nhau từ đầu đến cuối mảng:

Bước trong lượt 1 Cặp (a[j], a[j+1]) So sánh a[j] và a[j+1] Hành động Trạng thái mảng a
Ban đầu — — Khởi tạo swapped = false [5, 1, 4, 2, 8]
j = 0 (5, 1) 5 lớn hơn 1 Đổi chỗ 5 và 1, swapped = true [1, 5, 4, 2, 8]
j = 1 (5, 4) 5 lớn hơn 4 Đổi chỗ 5 và 4, swapped = true [1, 4, 5, 2, 8]
j = 2 (5, 2) 5 lớn hơn 2 Đổi chỗ 5 và 2, swapped = true [1, 4, 2, 5, 8]
j = 3 (5, 8) 5 ≤ 8 Giữ nguyên [1, 4, 2, 5, 8]

Số 5 ban đầu đứng ở a[0] đã liên tục đổi chỗ qua 1, 4, 2 để lùi về a[3], còn vị trí cuối cùng a[4] = 8 đã cố định cho phần tử lớn nhất mảng. Vì swapped = true, thuật toán bước sang lượt 2.

Lượt 2 (i = 1, xét j từ 0 đến 2) và Lượt 3 dừng sớm

Lúc này a[4] = 8 đã cố định, vòng lặp trong ở lượt 2 chỉ cần xét j từ 0 đến n - 2 - i = 2:

Lượt và bước Cặp (a[j], a[j+1]) So sánh Hành động Trạng thái mảng a
Lượt 2, j = 0 (1, 4) 1 ≤ 4 Giữ nguyên (swapped = false) [1, 4, 2, 5, 8]
Lượt 2, j = 1 (4, 2) 4 lớn hơn 2 Đổi chỗ 4 và 2, swapped = true [1, 2, 4, 5, 8]
Lượt 2, j = 2 (4, 5) 4 ≤ 5 Giữ nguyên, cố định a[3] = 5 [1, 2, 4, 5, 8]
Lượt 3, j = 0..1 (1, 2) và (2, 4) Đều đúng thứ tự Không đổi chỗ (swapped = false) Dừng sớm ngay lập tức

Đi hết lượt 3 mà swapped vẫn bằng false, chứng tỏ đoạn đầu không còn cặp nào ngược thứ tự; thuật toán break ngay mà không cần chạy lượt 4.

Cải tiến quan trọng: biến cờ swapped giúp dừng sớm như thế nào?

Nếu chỉ viết hai vòng lặp for lồng nhau mà không kiểm tra, chương trình luôn chạy đủ n(n - 1) / 2 lần so sánh, kể cả khi mảng đầu vào đã tăng dần sẵn. Thêm biến cờ swapped (đặt bằng false ở đầu mỗi lượt i, bật thành true khi có swap) mang lại hai lợi ích lớn:

  1. Dừng ngay sau 1 lượt nếu mảng đã tăng dần: Lượt i = 0 duyệt qua n - 1 cặp kề nhau mà không đổi chỗ lần nào (swapped == false) sẽ thoát ngay, giảm độ phức tạp trường hợp tốt nhất từ O(n²) xuống O(n).
  2. Cắt bỏ các lượt thừa ở cuối quá trình sắp xếp: Như trên mảng [5, 1, 4, 2, 8], mảng đã đúng thứ tự từ cuối lượt 2; nhờ cờ swapped, lượt 3 chỉ xác nhận rồi dừng.

Dưới đây là code đối chiếu bản cơ bản và bản tối ưu có cờ swapped bằng C++ và Python:

#include <iostream>
#include <utility>
#include <vector>
using namespace std;

// Bản cơ bản: luôn chạy đủ n - 1 lượt ngoài
void bubble_sort_co_ban(vector<int>& a) {
    int n = (int)a.size();
    for (int i = 0; i < n - 1; ++i) {
        for (int j = 0; j < n - 1 - i; ++j) {
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
            }
        }
    }
}

// Bản tối ưu: dùng cờ swapped để dừng ngay khi mảng đã có thứ tự
// Trả về số lượt ngoài thực tế đã chạy
int bubble_sort_toi_uu(vector<int>& a) {
    int n = (int)a.size();
    int so_luot = 0;
    for (int i = 0; i < n - 1; ++i) {
        bool swapped = false;
        ++so_luot;
        for (int j = 0; j < n - 1 - i; ++j) {
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                swapped = true;
            }
        }
        if (!swapped) {
            break; // Không có cặp nào nghịch thế -> mảng đã tăng dần
        }
    }
    return so_luot;
}

void in_mang(const vector<int>& a, int so_luot) {
    cout << "So luot: " << so_luot << " | Mang: ";
    for (int i = 0; i < (int)a.size(); ++i) {
        cout << a[i] << (i + 1 == (int)a.size() ? "" : " ");
    }
    cout << "\n";
}

int main() {
    // Test 1: Mảng ví dụ trong bài [5, 1, 4, 2, 8]
    vector<int> a1 = {5, 1, 4, 2, 8};
    int luot1 = bubble_sort_toi_uu(a1);
    in_mang(a1, luot1); // So luot: 3 | Mang: 1 2 4 5 8

    // Test 2: Mảng đã tăng dần sẵn -> dừng ngay sau 1 lượt kiểm tra
    vector<int> a2 = {10, 20, 30, 40, 50};
    int luot2 = bubble_sort_toi_uu(a2);
    in_mang(a2, luot2); // So luot: 1 | Mang: 10 20 30 40 50

    // Test 3: Mảng giảm dần có phần tử âm và trùng nhau
    vector<int> a3 = {9, 4, 4, -2, -5};
    int luot3 = bubble_sort_toi_uu(a3);
    in_mang(a3, luot3); // So luot: 4 | Mang: -5 -2 4 4 9

    return 0;
}

Khi chạy chương trình, mảng [5, 1, 4, 2, 8] dừng sau 3 lượt, mảng đã tăng dần [10, 20, 30, 40, 50] dừng ngay sau 1 lượt, còn mảng giảm dần [9, 4, 4, -2, -5] chạy đủ 4 lượt.

Học cùng giảng viên

Chủ đề này nằm trong Lecture 11: Sorting (Thuật toán Sắp xếp) của Khóa Green. Xem đề cương

Liên hệ toán học: Số lần đổi chỗ chính bằng số cặp nghịch thế

Một câu hỏi thường gặp trong đề thi thuật toán: “Khi chạy Bubble Sort trên mảng a, tổng số lần gọi swap(a[j], a[j + 1]) bằng bao nhiêu?”

Ta dùng khái niệm cặp nghịch thế (inversion): một cặp chỉ số (u, v) với u đứng trước v là một cặp nghịch thế nếu a[u] lớn hơn a[v]. Khi mảng đã tăng dần, số cặp nghịch thế bằng 0. Trong Bubble Sort, mỗi phép hoán đổi hai phần tử đứng sát nhau a[j] và a[j + 1] xóa bỏ đúng 1 cặp nghịch thế là chính cặp (j, j + 1), mà không làm thay đổi thứ tự tương đối của bất kỳ cặp nào khác.

Do đó, tổng số lần hoán đổi của Bubble Sort luôn bằng chính xác số cặp nghịch thế của mảng ban đầu. Trên mảng [5, 1, 4, 2, 8], có 4 cặp nghịch thế là (5, 1), (5, 4), (5, 2), (4, 2), khớp với 4 lần đổi chỗ (3 lần ở lượt 1 cộng 1 lần ở lượt 2).

#include <iostream>
#include <utility>
#include <vector>
using namespace std;

// Đếm tổng số lần hoán đổi khi chạy Bubble Sort
long long dem_hoan_doi_bubble(vector<int> a) {
    int n = (int)a.size();
    long long so_lan_doi = 0;
    for (int i = 0; i < n - 1; ++i) {
        bool swapped = false;
        for (int j = 0; j < n - 1 - i; ++j) {
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                ++so_lan_doi;
                swapped = true;
            }
        }
        if (!swapped) break;
    }
    return so_lan_doi;
}

// Đếm trực tiếp số cặp nghịch thế (u < v nhưng a[u] > a[v])
long long dem_cap_nghich_the(const vector<int>& a) {
    int n = (int)a.size();
    long long nghich_the = 0;
    for (int u = 0; u < n; ++u) {
        for (int v = u + 1; v < n; ++v) {
            if (a[u] > a[v]) {
                ++nghich_the;
            }
        }
    }
    return nghich_the;
}

int main() {
    vector<vector<int>> cac_mang = {
        {5, 1, 4, 2, 8},       // 4 cặp: (5,1), (5,4), (5,2), (4,2)
        {4, 3, 2, 1},          // Mảng giảm dần 4 phần tử: 4*3/2 = 6 cặp
        {1, 2, 2, 5, 9}        // Đã tăng dần: 0 cặp
    };

    for (const auto& a : cac_mang) {
        cout << "Swaps: " << dem_hoan_doi_bubble(a)
             << " | Inversions: " << dem_cap_nghich_the(a) << "\n";
    }
    return 0;
}

(Lưu ý: Nếu đề bài yêu cầu chỉ đếm số cặp nghịch thế của mảng lớn n = 100.000, bạn không chạy Bubble Sort vì sẽ quá thời gian O(n²); thay vào đó hãy dùng bước trộn của Sắp xếp trộn (Merge Sort) để đếm trong O(n log n)).

Phân tích độ phức tạp và tính ổn định (Stability)

Trong trường hợp xấu nhất (mảng giảm dần), tổng số phép so sánh qua n - 1 lượt là (n - 1) + (n - 2) + ... + 1 = n(n - 1) / 2 = O(n²):

Trường hợp Số phép so sánh Số lần hoán đổi (swap) Độ phức tạp thời gian Bộ nhớ phụ
Tốt nhất (đã tăng dần, có swapped) n - 1 0 O(n) O(1)
Trung bình (mảng ngẫu nhiên) ≈ n² / 2 ≈ n(n - 1) / 4 O(n²) O(1)
Xấu nhất (mảng giảm dần) n(n - 1) / 2 n(n - 1) / 2 O(n²) O(1)

Bubble Sort là thuật toán sắp xếp ổn định (stable sort) vì chỉ đổi chỗ khi a[j] lớn hơn nghiêm ngặt a[j + 1]. Khi hai phần tử bằng nhau đứng cạnh nhau, thứ tự ban đầu được bảo toàn. Trong thực tế, Sắp xếp chèn (Insertion Sort) thường nhanh hơn nhờ dịch phần tử tốn ít phép gán hơn swap, còn Sắp xếp chọn (Selection Sort) lại tốn tối đa n - 1 lần swap.

Lỗi hay gặp khi cài đặt Bubble Sort

Cẩn thận
  • Viết giới hạn vòng lặp trong là n thay vì n - 1 - i. Vì bên trong vòng lặp có truy cập a[j + 1], nếu để j chạm tới n - 1 thì a[j + 1] sẽ truy cập vào a[n] vượt ra ngoài phạm vi mảng (Out of bounds), gây lỗi Runtime Error trong C++.
  • Dùng dấu lớn hơn hoặc bằng (≥) khi đổi chỗ. Đổi chỗ cả khi a[j] == a[j + 1] không chỉ tốn thêm phép gán vô ích mà còn làm mất tính ổn định (stability) của Bubble Sort.
  • Đặt khai báo bool swapped = false; ở bên ngoài vòng lặp i. Nếu không đặt lại swapped = false ở đầu mỗi lượt i, sau lượt đầu tiên có đổi chỗ biến này sẽ giữ giá trị true mãi mãi và cơ chế dừng sớm hoàn toàn vô tác dụng.

Luyện ngay trên Big-O Coding

Hãy áp dụng tư duy sắp xếp và duyệt mảng vào các bài tập thực hành dưới đây trên hệ thống Big-O Coding:

Câu hỏi thường gặp

Vì sao thuật toán lại có tên là “Sắp xếp nổi bọt” (Bubble Sort)?

Vì trong mỗi lượt duyệt từ đầu đến cuối mảng, các phần tử có giá trị lớn sẽ liên tục được đẩy từng bước một về phía cuối mảng, giống như những bọt khí dưới đáy cốc nước nổi dần lên mặt nước.

Tại sao vòng lặp ngoài của Bubble Sort chỉ cần chạy n - 1 lượt?

Mỗi lượt ngoài đưa được 1 phần tử lớn nhất còn lại về đúng vị trí cuối. Sau n - 1 lượt, đã có n - 1 phần tử nằm đúng chỗ; phần tử duy nhất còn lại ở a[0] tự động là phần tử nhỏ nhất.

Khi mảng lớn (n = 100.000), có nên dùng Bubble Sort có cờ swapped không?

Không. Với dữ liệu ngẫu nhiên n = 100.000, Bubble Sort vẫn tốn khoảng 5 × 10⁹ phép tính và bị quá thời gian (TLE). Khi đó bạn cần dùng Quick Sort, Merge Sort hoặc hàm std::sort / sorted() có độ phức tạp O(n log n).

Muốn học bài bản cùng giảng viên?

Khóa Green cho người mới, sinh viên năm 1–2: xây nền tảng lập trình và thuật toán vững chắc, biết dùng AI hỗ trợ. 16 buổi online, học thử buổi 1 miễn phí.

Xem Khóa Green

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483