- Ở mỗi lượt thứ
i(từ0đếnn - 2), tìm vị trímin_idxcủa phần tử nhỏ nhất trong đoạna[i .. n - 1], rồi gọiswap(a[i], a[min_idx])đúng 1 lần ở cuối lượt. - Số lần hoán đổi (
swap) tối đa của Selection Sort chỉ làn - 1lần (O(n)), ít nhất trong các thuật toán sắp xếp cơ bản. - Số phép so sánh luôn cố định là
n(n - 1) / 2(O(n²)) trong mọi trường hợp, kể cả khi mảng ban đầu đã tăng dần sẵn. - Selection Sort cơ bản không ổn định (unstable): phép hoán đổi nhảy cóc từ đầu đoạn tới
min_idxcó thể làm đảo thứ tự hai phần tử bằng nhau.
Ý tưởng cốt lõi: Tìm phần tử nhỏ nhất và đặt thẳng vào vị trí đích
Khi xếp một dãy số trên bảng theo thứ tự tăng dần từ trái sang phải, cách làm tự nhiên nhất thường không phải là đổi chỗ hai số cạnh nhau như Sắp xếp nổi bọt (Bubble Sort), mà là:
- Nhìn lướt qua toàn bộ dãy số để tìm số nhỏ nhất, rồi đưa nó về vị trí đầu tiên (
a[0]). - Bỏ qua vị trí đầu tiên đã xong, nhìn lướt qua các số còn lại từ vị trí
1đến cuối để tìm số nhỏ nhất tiếp theo, rồi đưa nó về vị trí thứ hai (a[1]). - Lặp lại quy trình “chọn số nhỏ nhất trong phần còn lại” cho tới khi chỉ còn 1 số cuối cùng ở vị trí
n - 1.
Đó chính xác là nguyên lý của Sắp xếp chọn (Selection Sort). Tại đầu mỗi lượt thứ i (với i chạy từ 0 đến n - 2), mảng luôn được tách thành hai vùng rõ rệt:
- Vùng bên trái
a[0 .. i - 1]: chứaiphần tử nhỏ nhất của toàn mảng, đã nằm cố định ở vị trí cuối cùng. - Vùng bên phải
a[i .. n - 1]: đoạn chưa sắp xếp. Ta dùng một vòng lặp tìm kiếm tuyến tính chojchạy từi + 1đếnn - 1để tìm chỉ sốmin_idxcủa phần tử nhỏ nhất trong vùng này, rồi đổi chỗa[i]vớia[min_idx].
Chạy tay từng bước trên mảng [64, 25, 12, 22, 11]
Hãy theo dõi từng lượt của Selection Sort trên mảng 5 phần tử a = [64, 25, 12, 22, 11]. Vì n = 5, vòng lặp ngoài chỉ cần chạy n - 1 = 4 lượt (i = 0, 1, 2, 3):
Lượt thứ i |
Đoạn đang xét a[i .. 4] |
Phần tử nhỏ nhất tìm được | Phép hoán đổi cuối lượt | Trạng thái mảng sau lượt i |
|---|---|---|---|---|
Ban đầu (i = 0) |
[64, 25, 12, 22, 11] |
a[4] = 11 (min_idx = 4) |
Đổi a[0] (64) và a[4] (11) |
[11 | 25, 12, 22, 64] |
i = 1 |
[25, 12, 22, 64] |
a[2] = 12 (min_idx = 2) |
Đổi a[1] (25) và a[2] (12) |
[11, 12 | 25, 22, 64] |
i = 2 |
[25, 22, 64] |
a[3] = 22 (min_idx = 3) |
Đổi a[2] (25) và a[3] (22) |
[11, 12, 22 | 25, 64] |
i = 3 |
[25, 64] |
a[3] = 25 (min_idx = 3) |
Không đổi (vì min_idx == i) |
[11, 12, 22, 25, 64] |
Quan sát cột “Phép hoán đổi cuối lượt”, bạn sẽ thấy điểm đặc biệt nhất của thuật toán này: suốt cả quá trình sắp xếp 5 phần tử, dù thực hiện 4 + 3 + 2 + 1 = 10 phép so sánh, chương trình chỉ thực hiện tổng cộng 3 lần đổi chỗ (swap).
Code Sắp xếp chọn (Selection Sort) bằng C++ và Python
Khi cài đặt Selection Sort, trong vòng lặp trong j ta chỉ cập nhật chỉ số min_idx = j chứ tuyệt đối không gọi swap bên trong vòng lặp j. Chỉ khi vòng lặp j kết thúc và nếu min_idx != i, ta mới hoán đổi một lần duy nhất:
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
// Sắp xếp chọn (Selection Sort): trả về số lần hoán đổi thực sự (khi min_idx != i)
int selection_sort(vector<int>& a) {
int n = (int)a.size();
int so_lan_swap = 0;
for (int i = 0; i < n - 1; ++i) {
int min_idx = i;
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[min_idx]) {
min_idx = j;
}
}
if (min_idx != i) {
swap(a[i], a[min_idx]);
++so_lan_swap;
}
}
return so_lan_swap;
}
void in_ket_qua(const vector<int>& a, int so_lan_swap) {
cout << "Swaps: " << so_lan_swap << " | 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 [64, 25, 12, 22, 11]
vector<int> a1 = {64, 25, 12, 22, 11};
int s1 = selection_sort(a1);
in_ket_qua(a1, s1); // Swaps: 3 | Mang: 11 12 22 25 64
// Test 2: Mảng đã tăng dần sẵn -> 0 lần swap (nhưng vẫn chạy đủ phép so sánh)
vector<int> a2 = {3, 7, 11, 15};
int s2 = selection_sort(a2);
in_ket_qua(a2, s2); // Swaps: 0 | Mang: 3 7 11 15
// Test 3: Mảng có phần tử âm và trùng nhau
vector<int> a3 = {5, -3, 5, 0, -8};
int s3 = selection_sort(a3);
in_ket_qua(a3, s3); // Swaps: 2 | Mang: -8 -3 0 5 5
return 0;
}
def selection_sort(a: list[int]) -> int:
"""Sắp xếp chọn (Selection Sort): trả về số lần hoán đổi thực sự (khi min_idx != i)."""
n = len(a)
so_lan_swap = 0
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n):
if a[j] < a[min_idx]:
min_idx = j
if min_idx != i:
a[i], a[min_idx] = a[min_idx], a[i]
so_lan_swap += 1
return so_lan_swap
def in_ket_qua(a: list[int], so_lan_swap: int) -> None:
print(f"Swaps: {so_lan_swap} | Mang: {' '.join(map(str, a))}")
if __name__ == "__main__":
# Test 1: Mảng ví dụ trong bài [64, 25, 12, 22, 11]
a1 = [64, 25, 12, 22, 11]
s1 = selection_sort(a1)
in_ket_qua(a1, s1) # Swaps: 3 | Mang: 11 12 22 25 64
# Test 2: Mảng đã tăng dần sẵn -> 0 lần swap (nhưng vẫn chạy đủ phép so sánh)
a2 = [3, 7, 11, 15]
s2 = selection_sort(a2)
in_ket_qua(a2, s2) # Swaps: 0 | Mang: 3 7 11 15
# Test 3: Mảng có phần tử âm và trùng nhau
a3 = [5, -3, 5, 0, -8]
s3 = selection_sort(a3)
in_ket_qua(a3, s3) # Swaps: 2 | Mang: -8 -3 0 5 5
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
Ưu điểm và nhược điểm đặc trưng của Selection Sort
1. Ưu điểm độc nhất: Tối thiểu hóa số lần ghi vào bộ nhớ (O(n) lần swap)
Trong cả Bubble Sort lẫn Insertion Sort, một phần tử muốn di chuyển về đúng chỗ phải trượt qua từng ô một, nên trong trường hợp xấu nhất chúng cần tới O(n²) phép ghi vào mảng.
Ngược lại, Selection Sort đưa thẳng phần tử nhỏ nhất về đích chỉ bằng 1 lần hoán đổi mỗi lượt. Với mảng n phần tử, tổng số lần gọi swap không bao giờ vượt quá n - 1 lần (tối đa 2(n - 1) lần ghi vào mảng). Tính chất này rất có lợi khi làm việc trên bộ nhớ Flash, EEPROM hoặc khi các phần tử là bản ghi lớn mà chi phí sao chép đắt hơn nhiều lần chi phí so sánh khóa.
Đoạn chương trình dưới đây đếm trực tiếp số lần ghi vào mảng a[...] của cả 3 thuật toán trên cùng bộ dữ liệu:
#include <iostream>
#include <utility>
#include <vector>
using namespace std;
// Đếm số phép ghi vào mảng a[...] của Selection Sort (mỗi lần swap ghi 2 ô nhớ)
int ghi_selection(vector<int> a) {
int n = (int)a.size(), ghi = 0;
for (int i = 0; i < n - 1; ++i) {
int min_idx = i;
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[min_idx]) min_idx = j;
}
if (min_idx != i) {
swap(a[i], a[min_idx]);
ghi += 2;
}
}
return ghi;
}
// Đếm số phép ghi vào mảng a[...] của Insertion Sort
int ghi_insertion(vector<int> a) {
int n = (int)a.size(), ghi = 0;
for (int i = 1; i < n; ++i) {
int key = a[i], j = i - 1;
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
--j;
++ghi;
}
if (j + 1 != i) {
a[j + 1] = key;
++ghi;
}
}
return ghi;
}
// Đếm số phép ghi vào mảng a[...] của Bubble Sort (mỗi lần swap ghi 2 ô nhớ)
int ghi_bubble(vector<int> a) {
int n = (int)a.size(), ghi = 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]);
ghi += 2;
swapped = true;
}
}
if (!swapped) break;
}
return ghi;
}
int main() {
vector<vector<int>> cac_mang = {
{64, 25, 12, 22, 11}, // Mảng ví dụ 5 phần tử
{6, 5, 4, 3, 2, 1}, // Mảng giảm dần 6 phần tử
{1, 2, 3, 4, 5, 6} // Mảng đã tăng dần
};
for (const auto& a : cac_mang) {
cout << "Selection: " << ghi_selection(a)
<< " | Insertion: " << ghi_insertion(a)
<< " | Bubble: " << ghi_bubble(a) << "\n";
}
return 0;
}
def ghi_selection(mang_goc: list[int]) -> int:
"""Đếm số phép ghi vào mảng a[...] của Selection Sort (mỗi swap ghi 2 ô)."""
a = mang_goc[:]
n, ghi = len(a), 0
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n):
if a[j] < a[min_idx]:
min_idx = j
if min_idx != i:
a[i], a[min_idx] = a[min_idx], a[i]
ghi += 2
return ghi
def ghi_insertion(mang_goc: list[int]) -> int:
"""Đếm số phép ghi vào mảng a[...] của Insertion Sort."""
a = mang_goc[:]
n, ghi = len(a), 0
for i in range(1, n):
key, j = a[i], i - 1
while j >= 0 and a[j] > key:
a[j + 1] = a[j]
j -= 1
ghi += 1
if j + 1 != i:
a[j + 1] = key
ghi += 1
return ghi
def ghi_bubble(mang_goc: list[int]) -> int:
"""Đếm số phép ghi vào mảng a[...] của Bubble Sort (mỗi swap ghi 2 ô)."""
a = mang_goc[:]
n, ghi = len(a), 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]
ghi += 2
swapped = True
if not swapped:
break
return ghi
if __name__ == "__main__":
cac_mang = [
[64, 25, 12, 22, 11], # Mảng ví dụ 5 phần tử
[6, 5, 4, 3, 2, 1], # Mảng giảm dần 6 phần tử
[1, 2, 3, 4, 5, 6], # Mảng đã tăng dần
]
for a in cac_mang:
print(
f"Selection: {ghi_selection(a)} | "
f"Insertion: {ghi_insertion(a)} | "
f"Bubble: {ghi_bubble(a)}"
)
Khi chạy với mảng giảm dần 6 phần tử [6, 5, 4, 3, 2, 1], Bubble Sort phải ghi vào mảng 30 lần, Insertion Sort ghi 20 lần, trong khi Selection Sort chỉ ghi đúng 6 lần (tương ứng 3 phép đổi chỗ (6, 1), (5, 2) và (4, 3)).
2. Nhược điểm 1: Luôn tốn cố định n(n - 1) / 2 phép so sánh
Để khẳng định một phần tử là nhỏ nhất trong đoạn a[i .. n - 1], vòng lặp trong bắt buộc phải xem xét hết mọi phần tử từ i + 1 đến n - 1 mà không có cách nào dừng sớm. Ngay cả khi truyền vào một mảng đã tăng dần hoàn hảo [1, 2, 3, ..., n], Selection Sort vẫn chạy đủ n(n - 1) / 2 phép so sánh (O(n²)), trong khi Insertion Sort và Bubble Sort (có cờ swapped) đã dừng lại chỉ sau n - 1 phép so sánh (O(n)).
3. Nhược điểm 2: Không ổn định (Unstable Sort) — Phản ví dụ [4a, 4b, 1]
Một thuật toán sắp xếp được gọi là ổn định (stable) nếu hai phần tử bằng nhau vẫn giữ nguyên thứ tự đứng trước – đứng sau như ban đầu. Selection Sort không ổn định vì phép swap(a[i], a[min_idx]) ở cuối lượt là một cú nhảy xa qua đầu các phần tử ở giữa.
Xét phản ví dụ gồm 3 phần tử a = [4a, 4b, 1] (trong đó 4a và 4b cùng bằng 4, nhưng 4a đứng trước 4b):
- Ở lượt
i = 0, phần tử nhỏ nhất trong[4a, 4b, 1]làa[2] = 1(min_idx = 2). - Thuật toán đổi chỗ
a[0](4a) vớia[2](1), mảng trở thành[1, 4b, 4a]. - Lúc này
4ađã bị đẩy ra đứng sau4b— thứ tự ban đầu của hai số4đã bị đảo ngược!
Đặt cạnh nhau 3 thuật toán cơ bản: Selection, Insertion và Bubble Sort
| Tiêu chí so sánh | Selection Sort (Sắp xếp chọn) | Insertion Sort (Sắp xếp chèn) | Bubble Sort (Sắp xếp nổi bọt) |
|---|---|---|---|
Cơ chế chính ở bước i |
Chọn min trong a[i..n-1], đổi về a[i] |
Lấy key = a[i], dịch các số lớn hơn trong a[0..i-1] sang phải |
Đổi chỗ các cặp kề nhau ngược thứ tự, đẩy max về a[n-1-i] |
| Số phép so sánh (Tốt nhất) | n(n - 1) / 2 — O(n²) |
n - 1 — O(n) |
n - 1 — O(n) (có cờ swapped) |
| Số phép so sánh (Xấu nhất) | n(n - 1) / 2 — O(n²) |
n(n - 1) / 2 — O(n²) |
n(n - 1) / 2 — O(n²) |
| Số lần swap / dịch (Xấu nhất) | Tối đa n - 1 lần swap — O(n) |
n(n - 1) / 2 lần dịch — O(n²) |
n(n - 1) / 2 lần swap — O(n²) |
| Tính ổn định (Stable) | Không ổn định (Unstable) | Ổn định (Stable) | Ổn định (Stable) |
| Khi nào nên chọn? | Chi phí ghi bộ nhớ rất đắt hoặc cần k phần tử nhỏ nhất |
Mảng nhỏ, mảng gần như đã sắp xếp, dữ liệu đến liên tục | Kiểm tra mảng đã sắp xếp hoặc mô phỏng đổi chỗ kề nhau |
Lỗi hay gặp khi cài đặt Selection Sort
- Gọi
swap(a[i], a[j])ngay bên trong vòng lặp trongj. Nếu mỗi lần thấya[j]nhỏ hơna[i]bạn lại đổi chỗ ngay thay vì chỉ cập nhật biến chỉ sốmin_idx = j, số lầnswapsẽ vọt từO(n)lênO(n²). - Chỉ lưu giá trị
min_valmà quên lưu chỉ sốmin_idx. Nếu không biết phần tử nhỏ nhất nằm ở ô nào để hoán đổi mà chỉ gán đèa[i] = min_val, giá trị cũ tạia[i]sẽ bị mất. Chỉ cần lưu duy nhất chỉ sốmin_idx, giá trị nhỏ nhất chính làa[min_idx]. - Cập nhật
min_idxcả khia[j] == a[min_idx]. Việc dờimin_idxsang phần tử bằng nó ở xa hơn là không cần thiết và làm phát sinh phépswapthừa.
Luyện ngay trên Big-O Coding
Thực hành ngay các bài toán chọn phần tử tối ưu và sắp xếp cơ bản trên hệ thống Big-O Coding:
Câu hỏi thường gặp
Có cách nào biến Selection Sort thành thuật toán sắp xếp ổn định (stable) không?
Có. Sau khi tìm được min_idx, thay vì gọi swap(a[i], a[min_idx]), bạn lưu key = a[min_idx], dịch các phần tử từ a[i] đến a[min_idx - 1] sang phải 1 ô rồi gán a[i] = key. Tuy nhiên làm vậy sẽ khiến số phép ghi tăng lên O(n²), lúc đó dùng thẳng Insertion Sort sẽ gọn hơn.
Làm sao nâng cấp Selection Sort từ O(n²) lên O(n log n)?
Nếu thay vòng lặp tìm min_idx tuyến tính O(n) ở mỗi lượt bằng cấu trúc Đống nhị phân (Binary Heap) để lấy phần tử cực trị trong O(log n), thuật toán sẽ trở thành Sắp xếp vun đống (Heap Sort) với độ phức tạp O(n log n).
Nếu chỉ cần tìm 3 phần tử nhỏ nhất trong mảng 1.000 phần tử thì làm thế nào?
Bạn chỉ cần cho vòng lặp ngoài i của Selection Sort chạy đúng 3 lượt (i = 0, 1, 2) rồi dừng lại! Khi đó 3 phần tử nhỏ nhất nằm ở a[0], a[1], a[2] chỉ sau O(3n) = O(n) phép so sánh, nhanh hơn cả việc sắp xếp toàn mảng bằng Quick Sort hay Merge Sort (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í.


