Nhập môn

Sắp xếp chèn (Insertion Sort): giải thích từng bước, tối ưu dịch phần tử và code C++, Python

Sắp xếp chèn (Insertion Sort) hoạt động giống hệt cách bạn xếp một xấp bài trên tay: ở bước thứ `i`, đoạn đầu `a[0 .. i - 1]` đã có thứ tự tăng dần; ta rút phần tử `key = a[i]` ra, dịch các phần tử lớn hơn `key` sang phải một ô rồi đặt `key` vào đúng khe trống. Dù có độ phức tạp xấu nhất là `O(n²)`, Insertion Sort lại đạt tốc độ tuyến tính `O(n)` trên mảng gần như đã sắp xếp và là thành phần lõi bên trong hàm `std::sort` của C++ lẫn `Timsort` của Python.

Ý chính
  • 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ưu key = a[i], dịch phải a[j + 1] = a[j] (chỉ tốn 1 phép gán) rồi gán a[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:

  1. 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ự.
  2. Khi rút quân bài thứ hai (a[1]), bạn so nó với a[0]: nếu nhỏ hơn thì đẩy a[0] sang phải một nấc rồi nhét a[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 đầu a[0 .. 1] đã tăng dần.
  3. Tổng quát ở bước thứ i (với i chạy từ 1 đến n - 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 - 1 về 0: chừng nào a[j] còn lớn hơn key, bạn đẩy a[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ảng j = -1), bạn đặt key vào ô trống a[j + 1].

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ử key cần lùi qua d vị trí, dùng swap sẽ tốn 3d phép gán.
  • Ngược lại, nếu bạn nhấc key = a[i] ra ngoài trước vòng lặp while, mỗi bước lùi chỉ cần 1 phép gán duy nhất a[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án a[j + 1] = key. Tổng số phép gán giảm xuống chỉ còn d + 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:

a[0] a[1] a[2] a[3] a[4] Ban đầu 7 3 5 2 6 [7] đã có thứ tự i = 1 (key = 3) 3 7 5 2 6 dịch 7, chèn 3 i = 2 (key = 5) 3 5 7 2 6 dịch 7, chèn 5 i = 3 (key = 2) 2 3 5 7 6 dịch 3, 5, 7 i = 4 (key = 6) 2 3 5 6 7 dịch 7, hoàn tất
Ban đầu: đoạn [7] đã có thứ tự 7 3 5 2 6 i = 1 (key = 3): dịch 7 sang phải, chèn 3 3 7 5 2 6 i = 2 (key = 5): dịch 7 sang phải, chèn 5 3 5 7 2 6 i = 3 (key = 2): dịch 3, 5, 7 sang phải, chèn 2 2 3 5 7 6 i = 4 (key = 6): dịch 7, chèn 6 -> Hoàn tất 2 3 5 6 7
Hình 1. Bốn bước chèn lần lượt các phần tử key = 3, 5, 2, 6 vào đoạn đầu đã tăng dần (màu xanh) của mảng [7, 3, 5, 2, 6].
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.

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

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;
}

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;
}

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:

  1. 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.
  2. Kết hợp trong Introsort và Timsort: Hàm std::sort của C++ dùng Quick Sort chia nhỏ mảng tới khi đoạn con còn dưới 16 phần tử thì chuyển sang Insertion Sort; còn Timsort củ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ểu 32–64 phần tử trước khi trộn.

Lỗi hay gặp khi viết Insertion Sort

Cẩn thận
  • Kiểm tra giá trị a[j] trước khi kiểm tra biên j ≥ 0 trong vòng lặp while. Nếu viết ngược thứ tự điều kiện, khi j lùi về -1, chương trình sẽ đọc a[-1] trước: trong C++ gây lỗi truy cập ngoài mảng (Runtime Error), còn trong Python a[-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 khi j = i - 1, lệnh a[j + 1] = a[j] sẽ ghi đè lên a[i] làm mất giá trị gốc nếu chưa cất vào key.
  • Dịch cả những phần tử bằng key. Nếu dịch cả khi a[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.

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