- Ở 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đếnn - 2 - i, vìiphần tử cuối mảng đã nằm cố định ở đúng vị trí. - Luôn đặt biến cờ
swappedtrong 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ẳna[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:
- Xuất phát từ đầu mảng (
j = 0), xét hai phần tử đứng sát nhaua[j]vàa[j + 1]. - Nếu
a[j]lớn hơna[j + 1](ngược thứ tự), hoán đổi (swap) hai phần tử cho nhau; ngược lại giữ nguyên. - Tăng
jlên1và 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 đan - 1lượ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).
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:
- Dừng ngay sau 1 lượt nếu mảng đã tăng dần: Lượt
i = 0duyệt quan - 1cặ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). - 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;
}
def bubble_sort_co_ban(a: list[int]) -> None:
"""Bản cơ bản: luôn chạy đủ n - 1 lượt ngoài."""
n = len(a)
for i in range(n - 1):
for j in range(n - 1 - i):
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
def bubble_sort_toi_uu(a: list[int]) -> int:
"""Bản tối ưu: dùng cờ swapped dừng sớm, trả về số lượt ngoài đã chạy."""
n = len(a)
so_luot = 0
for i in range(n - 1):
swapped = False
so_luot += 1
for j in range(n - 1 - i):
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped:
break # Không có cặp nào nghịch thế -> mảng đã tăng dần
return so_luot
def in_mang(a: list[int], so_luot: int) -> None:
print(f"So luot: {so_luot} | Mang: {' '.join(map(str, a))}")
if __name__ == "__main__":
# Test 1: Mảng ví dụ trong bài [5, 1, 4, 2, 8]
a1 = [5, 1, 4, 2, 8]
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
a2 = [10, 20, 30, 40, 50]
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
a3 = [9, 4, 4, -2, -5]
luot3 = bubble_sort_toi_uu(a3)
in_mang(a3, luot3) # So luot: 4 | Mang: -5 -2 4 4 9
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.
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;
}
def dem_hoan_doi_bubble(mang_goc: list[int]) -> int:
"""Đếm tổng số lần hoán đổi khi chạy Bubble Sort."""
a = mang_goc[:]
n = len(a)
so_lan_doi = 0
for i in range(n - 1):
swapped = False
for j in range(n - 1 - i):
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
so_lan_doi += 1
swapped = True
if not swapped:
break
return so_lan_doi
def dem_cap_nghich_the(a: list[int]) -> int:
"""Đếm trực tiếp số cặp nghịch thế (u < v nhưng a[u] > a[v])."""
n = len(a)
nghich_the = 0
for u in range(n):
for v in range(u + 1, n):
if a[u] > a[v]:
nghich_the += 1
return nghich_the
if __name__ == "__main__":
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 a in cac_mang:
print(f"Swaps: {dem_hoan_doi_bubble(a)} | Inversions: {dem_cap_nghich_the(a)}")
(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
- Viết giới hạn vòng lặp trong là
nthay vìn - 1 - i. Vì bên trong vòng lặp có truy cậpa[j + 1], nếu đểjchạm tớin - 1thìa[j + 1]sẽ truy cập vàoa[n]vượt ra ngoài phạm vi mảng (Out of bounds), gây lỗiRuntime Errortrong C++. - Dùng dấu lớn hơn hoặc bằng (
≥) khi đổi chỗ. Đổi chỗ cả khia[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ặpi. Nếu không đặt lạiswapped = falseở đầu mỗi lượti, sau lượt đầu tiên có đổi chỗ biến này sẽ giữ giá trịtruemã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).
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í.


