- Bất biến quan trọng nhất: trước khi bắt đầu bước thứ
i, đoạn tiền tốa[0 .. i - 1]luôn luôn đã được sắp xếp tăng dần. - Thay vì gọi
swap(a[j], a[j + 1])liên tục (tốn 3 phép gán mỗi bước), hãy lưukey = a[i], dịch phảia[j + 1] = a[j](chỉ tốn 1 phép gán) rồi gána[j + 1] = keyở cuối bước. - Số phép dịch sang phải của Insertion Sort bằng đúng số cặp nghịch thế
k, nên thời gian thực thi là O(n + k) — cực nhanh khi mảng gần như đã có thứ tự. - Các thư viện hiện đại như
std::sort(C++) vàsorted()(Python) đều tự động chuyển sang dùng Insertion Sort cho các đoạn mảng nhỏ dưới 16–32 phần tử.
Ý tưởng cốt lõi: Xếp bài trên tay và bất biến đoạn đầu tăng dần
Hãy hình dung bạn đang cầm một xấp bài trên tay trái (đã được xếp tăng dần từ trái qua phải) và rút thêm từng quân bài mới từ bàn lên bằng tay phải:
- Khi mới cầm quân bài đầu tiên (
a[0]), một mình nó hiển nhiên đã có thứ tự. - Khi rút quân bài thứ hai (
a[1]), bạn so nó vớia[0]: nếu nhỏ hơn thì đẩya[0]sang phải một nấc rồi nhéta[1]vào đầu; nếu lớn hơn hoặc bằng thì đặt nguyên ở vị trí thứ hai. Lúc này 2 quân bài đầua[0 .. 1]đã tăng dần. - Tổng quát ở bước thứ
i(vớiichạy từ1đếnn - 1):- Đoạn bên trái
a[0 .. i - 1]là xấp bài trên tay đã được sắp xếp tăng dần. - Phần tử
key = a[i]là quân bài mới vừa rút lên. - Bạn dò ngược từ
j = i - 1về0: chừng nàoa[j]còn lớn hơnkey, bạn đẩya[j]sang phải một ô (a[j + 1] = a[j]) để mở rộng khoảng trống. - Ngay khi gặp phần tử
a[j] ≤ key(hoặc đã lùi hết đầu mảngj = -1), bạn đặtkeyvào ô trốnga[j + 1].
- Đoạn bên trái
Tính chất “đoạn a[0 .. i - 1] luôn có thứ tự tăng dần trước bước i” được gọi là bất biến vòng lặp (loop invariant). Khi i chạy hết n - 1, đoạn đã sắp xếp mở rộng thành a[0 .. n - 1], tức là toàn bộ mảng.
Kỹ thuật tối ưu: Dịch phần tử (shift) thay vì đổi chỗ (swap)
Nhiều người mới học thường viết vòng lặp trong của Insertion Sort bằng cách gọi swap(a[j], a[j + 1]) lùi dần về đầu mảng, trông khá giống Sắp xếp nổi bọt (Bubble Sort). Cách viết đó vẫn cho kết quả đúng, nhưng lại lãng phí thao tác bộ nhớ:
- Mỗi lệnh
swap(x, y)phải thực hiện 3 phép gán:temp = x; x = y; y = temp;. Nếu phần tửkeycần lùi quadvị trí, dùngswapsẽ tốn3dphép gán. - Ngược lại, nếu bạn nhấc
key = a[i]ra ngoài trước vòng lặpwhile, mỗi bước lùi chỉ cần 1 phép gán duy nhấta[j + 1] = a[j]để trượt phần tử lớn hơn sang phải. Khi tìm được chỗ đứng cuối cùng, bạn mới gána[j + 1] = key. Tổng số phép gán giảm xuống chỉ cònd + 2— tức là giảm gần hai phần ba số thao tác ghi bộ nhớ.
Chạy tay từng bước trên mảng [7, 3, 5, 2, 6]
Ta xét mảng 5 phần tử a = [7, 3, 5, 2, 6]. Ban đầu, đoạn đã sắp xếp chỉ gồm phần tử đầu tiên a[0] = 7. Vòng lặp ngoài cho i chạy từ 1 đến 4:
Bước i |
Giá trị key = a[i] |
Đoạn đã xếp a[0 .. i-1] |
Các phần tử lớn hơn key bị dịch phải |
Vị trí chèn key |
Mảng sau bước i |
|---|---|---|---|---|---|
| Ban đầu | — | [7] |
— | — | [7 | 3, 5, 2, 6] |
i = 1 |
key = 3 |
[7] |
Dịch 7 từ a[0] sang a[1] (1 lần dịch) |
a[0] = 3 |
[3, 7 | 5, 2, 6] |
i = 2 |
key = 5 |
[3, 7] |
Dịch 7 sang a[2]; gặp 3 ≤ 5 thì dừng (1 lần dịch) |
a[1] = 5 |
[3, 5, 7 | 2, 6] |
i = 3 |
key = 2 |
[3, 5, 7] |
Dịch lần lượt 7, 5, 3 sang phải 1 ô (3 lần dịch) |
a[0] = 2 |
[2, 3, 5, 7 | 6] |
i = 4 |
key = 6 |
[2, 3, 5, 7] |
Dịch 7 sang a[4]; gặp 5 ≤ 6 thì dừng (1 lần dịch) |
a[3] = 6 |
[2, 3, 5, 6, 7] |
Hãy chú ý ở bước i = 2 (key = 5) và i = 4 (key = 6): vòng lặp while bên trong dừng lại ngay lập tức khi chạm tới phần tử 3 (ở bước 2) hoặc 5 (ở bước 4), không cần kiểm tra các phần tử đứng trước đó nữa. Bởi vì đoạn bên trái đã tăng dần sẵn, nếu a[j] ≤ key thì mọi phần tử đứng bên trái a[j] chắc chắn cũng nhỏ hơn hoặc bằng key.
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
Code Sắp xếp chèn bằng C++ và Python
Dưới đây là cài đặt chuẩn của Insertion Sort sử dụng kỹ thuật lưu key và dịch phải, kèm bộ đếm số lần dịch để bạn kiểm chứng trực tiếp với bảng chạy tay ở trên:
#include <iostream>
#include <vector>
using namespace std;
// Sắp xếp chèn chuẩn: dùng biến tạm key và phép dịch phải, trả về tổng số lần dịch
long long insertion_sort(vector<int>& a) {
int n = (int)a.size();
long long so_lan_dich = 0;
for (int i = 1; i < n; ++i) {
int key = a[i];
int j = i - 1;
// Dịch các phần tử trong đoạn [0 .. i-1] lớn hơn key sang phải 1 vị trí
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
--j;
++so_lan_dich;
}
a[j + 1] = key; // Đặt key vào đúng khe trống
}
return so_lan_dich;
}
void in_ket_qua(const vector<int>& a, long long so_lan_dich) {
cout << "Dich: " << so_lan_dich << " | 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 [7, 3, 5, 2, 6]
vector<int> a1 = {7, 3, 5, 2, 6};
long long d1 = insertion_sort(a1);
in_ket_qua(a1, d1); // Dich: 6 | Mang: 2 3 5 6 7
// Test 2: Mảng gần như đã sắp xếp (chỉ có 1 cặp nghịch thế)
vector<int> a2 = {1, 2, 4, 3, 5};
long long d2 = insertion_sort(a2);
in_ket_qua(a2, d2); // Dich: 1 | Mang: 1 2 3 4 5
// Test 3: Mảng có số âm và phần tử trùng nhau
vector<int> a3 = {4, -1, 4, 0, -5};
long long d3 = insertion_sort(a3);
in_ket_qua(a3, d3); // Dich: 7 | Mang: -5 -1 0 4 4
return 0;
}
def insertion_sort(a: list[int]) -> int:
"""Sắp xếp chèn chuẩn: dùng biến key và phép dịch phải, trả về tổng số lần dịch."""
n = len(a)
so_lan_dich = 0
for i in range(1, n):
key = a[i]
j = i - 1
# Dịch các phần tử trong đoạn [0 .. i-1] lớn hơn key sang phải 1 vị trí
while j >= 0 and a[j] > key:
a[j + 1] = a[j]
j -= 1
so_lan_dich += 1
a[j + 1] = key # Đặt key vào đúng khe trống
return so_lan_dich
def in_ket_qua(a: list[int], so_lan_dich: int) -> None:
print(f"Dich: {so_lan_dich} | Mang: {' '.join(map(str, a))}")
if __name__ == "__main__":
# Test 1: Mảng ví dụ trong bài [7, 3, 5, 2, 6]
a1 = [7, 3, 5, 2, 6]
d1 = insertion_sort(a1)
in_ket_qua(a1, d1) # Dich: 6 | Mang: 2 3 5 6 7
# Test 2: Mảng gần như đã sắp xếp (chỉ có 1 cặp nghịch thế)
a2 = [1, 2, 4, 3, 5]
d2 = insertion_sort(a2)
in_ket_qua(a2, d2) # Dich: 1 | Mang: 1 2 3 4 5
# Test 3: Mảng có số âm và phần tử trùng nhau
a3 = [4, -1, 4, 0, -5]
d3 = insertion_sort(a3)
in_ket_qua(a3, d3) # Dich: 7 | Mang: -5 -1 0 4 4
Bước lõi của Insertion Sort còn là một thuật toán trực tuyến (online algorithm): khi dữ liệu được gửi tới từng số một theo thời gian thực và bạn muốn danh sách hiện tại luôn giữ thứ tự tăng dần sau mỗi lần nhận số mới, bạn chỉ cần chạy đúng một lượt chèn O(n) cho phần tử vừa mới đến:
#include <iostream>
#include <vector>
using namespace std;
// Chèn thêm phần tử x vào mảng a đang tăng dần sao cho mảng vẫn giữ thứ tự tăng dần
// Trả về vị trí đã chèn x
int chen_giu_thu_tu(vector<int>& a, int x) {
a.push_back(x); // Mở rộng thêm 1 ô ở cuối mảng
int j = (int)a.size() - 2;
while (j >= 0 && a[j] > x) {
a[j + 1] = a[j];
--j;
}
a[j + 1] = x;
return j + 1;
}
void in_mang(int vi_tri, const vector<int>& a) {
cout << "Vi tri chen: " << vi_tri << " | Mang: ";
for (int i = 0; i < (int)a.size(); ++i) {
cout << a[i] << (i + 1 == (int)a.size() ? "" : " ");
}
cout << "\n";
}
int main() {
vector<int> a = {2, 5, 8, 12};
// Chèn 6 vào giữa mảng
int pos1 = chen_giu_thu_tu(a, 6);
in_mang(pos1, a); // Vi tri chen: 2 | Mang: 2 5 6 8 12
// Chèn 1 vào đầu mảng (nhỏ hơn mọi phần tử hiện có)
int pos2 = chen_giu_thu_tu(a, 1);
in_mang(pos2, a); // Vi tri chen: 0 | Mang: 1 2 5 6 8 12
// Chèn 15 vào cuối mảng (lớn hơn mọi phần tử hiện có -> không cần dịch)
int pos3 = chen_giu_thu_tu(a, 15);
in_mang(pos3, a); // Vi tri chen: 6 | Mang: 1 2 5 6 8 12 15
return 0;
}
def chen_giu_thu_tu(a: list[int], x: int) -> int:
"""Chèn thêm phần tử x vào danh sách a đang tăng dần, trả về vị trí đã chèn."""
a.append(x) # Mở rộng thêm 1 ô ở cuối danh sách
j = len(a) - 2
while j >= 0 and a[j] > x:
a[j + 1] = a[j]
j -= 1
a[j + 1] = x
return j + 1
def in_mang(vi_tri: int, a: list[int]) -> None:
print(f"Vi tri chen: {vi_tri} | Mang: {' '.join(map(str, a))}")
if __name__ == "__main__":
a = [2, 5, 8, 12]
# Chèn 6 vào giữa mảng
pos1 = chen_giu_thu_tu(a, 6)
in_mang(pos1, a) # Vi tri chen: 2 | Mang: 2 5 6 8 12
# Chèn 1 vào đầu mảng (nhỏ hơn mọi phần tử hiện có)
pos2 = chen_giu_thu_tu(a, 1)
in_mang(pos2, a) # Vi tri chen: 0 | Mang: 1 2 5 6 8 12
# Chèn 15 vào cuối mảng (lớn hơn mọi phần tử hiện có -> không cần dịch)
pos3 = chen_giu_thu_tu(a, 15)
in_mang(pos3, a) # Vi tri chen: 6 | Mang: 1 2 5 6 8 12 15
Phân tích độ phức tạp O(n + k) và vì sao Insertion Sort cực nhanh trên mảng nhỏ?
1. Công thức chính xác O(n + k) theo số cặp nghịch thế k
Giả sử mảng ban đầu có k cặp nghịch thế (các cặp chỉ số (u, v) mà u đứng trước v nhưng a[u] lớn hơn a[v]). Ở bước thứ i, mỗi lần lệnh dịch a[j + 1] = a[j] thực thi là vì a[j] lớn hơn key = a[i], tức là phần tử a[j] tạo với key đúng một cặp nghịch thế. Do đó, tổng số lần dịch phần tử trong suốt quá trình chạy Insertion Sort bằng chính xác k, và tổng thời gian thực thi tỷ lệ với O(n + k):
| Tình huống đầu vào | Số cặp nghịch thế k |
Độ phức tạp thời gian | Bộ nhớ phụ |
|---|---|---|---|
| Tốt nhất (mảng đã tăng dần) | k = 0 |
O(n) (mỗi bước i chỉ so sánh 1 lần rồi dừng) |
O(1) |
Gần như đã sắp xếp (k = O(n)) |
Mỗi phần tử lệch vị trí đúng tối đa vài ô | O(n) | O(1) |
| Trung bình (mảng ngẫu nhiên) | k ≈ n(n - 1) / 4 |
O(n²) | O(1) |
| Xấu nhất (mảng giảm dần) | k = n(n - 1) / 2 |
O(n²) | O(1) |
2. Vì sao std::sort của C++ và Timsort của Python vẫn dùng Insertion Sort?
Dù Quick Sort và Merge Sort đạt O(n log n), các kỹ sư viết thư viện chuẩn C++ (GCC, Clang) và Python (CPython) vẫn nhúng Insertion Sort vào bên trong vì hai lý do phần cứng:
- Không tốn chi phí gọi hàm đệ quy và thân thiện với CPU Cache: Trên đoạn mảng nhỏ (16 đến 32 phần tử chỉ chiếm 64–128 byte, nằm gọn trong bộ nhớ đệm L1 của CPU), vòng lặp dịch ô nhớ liên tiếp của Insertion Sort chạy nhanh hơn hẳn việc đẩy ngăn xếp đệ quy.
- Kết hợp trong Introsort và Timsort: Hàm
std::sortcủa C++ dùng Quick Sort chia nhỏ mảng tới khi đoạn con còn dưới16phần tử thì chuyển sang Insertion Sort; cònTimsortcủa Python dùng Insertion Sort để mở rộng các đoạn con tự nhiên (run) lên đủ kích thước tối thiểu32–64phần tử trước khi trộn.
Lỗi hay gặp khi viết Insertion Sort
- Kiểm tra giá trị
a[j]trước khi kiểm tra biênj ≥ 0trong vòng lặpwhile. Nếu viết ngược thứ tự điều kiện, khijlùi về-1, chương trình sẽ đọca[-1]trước: trong C++ gây lỗi truy cập ngoài mảng (Runtime Error), còn trong Pythona[-1]lại âm thầm lấy phần tử cuối cùng của mảng làm kết quả sai hoàn toàn. - Quên lưu biến
key = a[i]trước vòng lặp dịch. Ngay ở lần dịch đầu tiên khij = i - 1, lệnha[j + 1] = a[j]sẽ ghi đè lêna[i]làm mất giá trị gốc nếu chưa cất vàokey. - Dịch cả những phần tử bằng
key. Nếu dịch cả khia[j] == key, phần tử đứng sau sẽ chèn lên trước phần tử bằng nó đứng trước, làm mất tính ổn định (stability) của Insertion Sort.
Luyện ngay trên Big-O Coding
Thực hành ngay kỹ năng chèn phần tử giữ thứ tự và sắp xếp mảng trên hệ thống Big-O Coding:
Câu hỏi thường gặp
Vì sao vòng lặp ngoài của Insertion Sort bắt đầu từ i = 1 chứ không phải i = 0?
Vì ở thời điểm ban đầu, đoạn con chỉ chứa một phần tử duy nhất a[0] tự thân nó đã có thứ tự tăng dần. Ta chỉ cần lần lượt chèn các phần tử từ vị trí thứ hai (i = 1) đến n - 1 vào đoạn đầu đó.
Dùng Tìm kiếm nhị phân (Binary Search) có giúp Insertion Sort giảm xuống O(n log n) không?
Dùng Tìm kiếm nhị phân (Binary Insertion Sort) giúp giảm số phép so sánh xuống O(n log n), nhưng bạn vẫn phải dịch các phần tử lớn hơn sang phải mất O(i) phép gán ở mỗi bước, nên tổng thời gian trường hợp xấu nhất vẫn là O(n²).
Giữa Insertion Sort, Bubble Sort và Selection Sort thì thuật toán nào tốt nhất?
Trong thực tế trên mảng nhỏ hoặc mảng gần như đã có thứ tự, Insertion Sort là lựa chọn tốt nhất vì vừa ổn định, vừa đạt O(n) khi mảng gần tăng dần, lại tốn ít phép gán hơn Bubble Sort. Bạn chỉ ưu tiên Selection Sort khi chi phí ghi bộ nhớ đắt gấp nhiều lần chi phí đọc.
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í.


