Nhập môn

Các thuật toán sắp xếp cơ bản: từ O(n²) đến O(n log n), code C++ và Python

Thuật toán sắp xếp (sorting algorithm) đặt lại các phần tử của mảng theo thứ tự tăng dần hoặc giảm dần để giúp việc tìm kiếm, gom nhóm và chọn lựa tối ưu trở nên dễ dàng. Người học cần nắm 2 nhóm chính: nhóm cơ bản O(n²) (Selection, Insertion, Bubble Sort) để hiểu cơ chế hoán đổi, và nhóm chia để trị O(n log n) (Merge Sort, Quick Sort) cùng hàm sort có sẵn trong thư viện.

Ý chính
  • Với mảng lớn (n = 10⁵), các thuật toán O(n²) như Bubble Sort, Selection Sort, Insertion Sort không chạy kịp 1 giây; bạn phải dùng thuật toán O(n log n) hoặc hàm thư viện std::sort / sorted().
  • Một thuật toán sắp xếp gọi là ổn định (stable) nếu các phần tử có khóa bằng nhau vẫn giữ nguyên thứ tự đứng trước – sau như lúc ban đầu.
  • Khi viết hàm so sánh tùy chỉnh (comparator) trong C++, điều kiện so sánh bắt buộc phải là nhỏ hơn hoặc lớn hơn nghiêm ngặt (< hoặc >), tuyệt đối không dùng <= hay >=.

Vì sao sắp xếp là bước mở khóa của hàng loạt bài toán?

Khi các phần tử trong mảng nằm lộn xộn, bạn hầu như không có thông tin gì nếu chưa kiểm tra từng phần tử một. Nhưng ngay khi mảng được sắp xếp tăng dần (chỉ mất O(n log n) một lần ở đầu chương trình), hàng loạt tính chất mạnh mẽ lập tức xuất hiện:

  1. Cho phép dùng Tìm kiếm nhị phân và Hai con trỏ: Tìm một giá trị giảm từ O(n) xuống O(log n); tìm cặp có tổng bằng X giảm từ O(n²) xuống O(n).
  2. Các phần tử bằng nhau tự động đứng cạnh nhau: Việc đếm số giá trị phân biệt, tìm phần tử xuất hiện nhiều nhất hay gom nhóm chỉ cần một vòng lặp duyệt qua mảng.
  3. Phần tử nhỏ nhất và lớn nhất nằm ngay ở hai đầu: Là bước khởi đầu quen thuộc của hầu hết các bài thuật toán tham lam (ví dụ: xếp lịch công việc theo thời điểm kết thúc tăng dần).

Nhóm 1: Ba thuật toán sắp xếp cơ bản O(n²)

Dù không dùng cho mảng hàng trăm nghìn phần tử, 3 thuật toán cơ bản này rất ngắn gọn và giúp bạn rèn kỹ năng điều khiển hai vòng lặp lồng nhau:

  • Sắp xếp chọn (Selection Sort): Ở lượt thứ i, tìm phần tử nhỏ nhất trong đoạn chưa sắp xếp [i .. n - 1] rồi đổi chỗ nó về đúng vị trí i. Ưu điểm là tốn ít lần đổi chỗ nhất (tối đa n - 1 lần), nhưng dù mảng ban đầu đã tăng dần sẵn thì nó vẫn mù quáng chạy đủ n(n - 1) / 2 phép so sánh.
  • Sắp xếp nổi bọt (Bubble Sort): Duyệt qua mảng nhiều lượt, mỗi lần thấy hai phần tử kề nhau bị ngược thứ tự (a[j] > a[j + 1]) thì đổi chỗ ngay để phần tử lớn nhất "nổi" dần về cuối mảng. Nếu trong một lượt không có lần đổi chỗ nào xảy ra, ta biết mảng đã có thứ tự và dừng sớm.
  • Sắp xếp chèn (Insertion Sort): Giống hệt cách bạn xếp các quân bài trên tay. Ở bước thứ i, đoạn đầu a[0 .. i - 1] đã tăng dần; ta rút phần tử key = a[i] ra, dồn các phần tử lớn hơn key sang phải một ô rồi chèn key vào đúng khe trống. Khi mảng gần như đã sắp xếp sẵn, vòng lặp trong hầu như không phải chạy nên Insertion Sort đạt tốc độ gần tuyến tính O(n).

Nhóm 2: Các thuật toán sắp xếp nhanh O(n log n)

Để vượt qua mốc n = 100.000, ta dùng tư duy Chia để trị (Divide and Conquer) kết hợp với đệ quy:

1. Sắp xếp trộn (Merge Sort)

Merge Sort chia đôi mảng thành hai nửa bằng nhau, gọi đệ quy sắp xếp từng nửa, rồi dùng hai con trỏ trên hai mảng để trộn hai nửa đã tăng dần thành một mảng tăng dần hoàn chỉnh:

Mảng gốc [ 38 , 27 , 43 , 3 ] 1. Chia đôi [ 38 , 27 ] [ 43 , 3 ] 2. Đơn vị (n=1) [ 38 ] [ 27 ] [ 43 ] [ 3 ] 3. Trộn từng cặp [ 27 , 38 ] [ 3 , 43 ] 4. Trộn cuối [ 3 , 27 , 38 , 43 ]
Gốc: [ 38 , 27 , 43 , 3 ] Chia: [ 38 , 27 ] Chia: [ 43 , 3 ] [38] và [27] [43] và [3] Trộn: [ 27 , 38 ] Trộn: [ 3 , 43 ] Kết quả: [ 3 , 27 , 38 , 43 ]
Hình 1. Bốn bước chia đôi và trộn ngược lên của Merge Sort trên mảng [38, 27, 43, 3].

Vì mảng bị chia đôi liên tiếp nên cây đệ quy chỉ cao log2 n tầng; ở mỗi tầng, việc trộn tất cả các đoạn tốn tổng cộng O(n) phép so sánh. Nhân lại, độ phức tạp thời gian của Merge Sort luôn luôn là O(n log n) trong mọi trường hợp (tốt nhất, trung bình lẫn xấu nhất), đổi lại nó cần thêm O(n) bộ nhớ cho mảng tạm lúc trộn.

2. Sắp xếp nhanh (Quick Sort)

Thay vì trộn ở bước cuối như Merge Sort, Quick Sort làm việc nặng ngay ở bước chia (gọi là phân hoạch — partition):

  • Chọn một phần tử trong đoạn làm chốt (pivot).
  • Đưa mọi phần tử nhỏ hơn chốt về bên trái, mọi phần tử lớn hơn chốt về bên phải. Khi đó chốt đã nằm đúng vị trí cuối cùng của nó.
  • Gọi đệ quy sắp xếp nửa bên trái và nửa bên phải chốt (không cần bước trộn).

Quick Sort sắp xếp trực tiếp trên mảng gốc (bộ nhớ phụ chỉ tốn O(log n) cho ngăn xếp đệ quy) và có hằng số thực thi rất nhỏ. Tuy nhiên, nếu chọn chốt không khéo (ví dụ luôn chọn phần tử đầu làm chốt mà mảng đầu vào lại đã tăng dần), một bên phân hoạch sẽ có 0 phần tử và bên kia có n - 1 phần tử, làm Quick Sort suy biến thành O(n²). Trong thực tế, người ta chọn chốt ngẫu nhiên hoặc lấy trung vị của 3 phần tử đầu – giữa – cuối.

Hãy xem code cài đặt hoàn chỉnh của Insertion Sort và Merge Sort bằng C++ và Python:

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

// 1. Sắp xếp chèn (Insertion Sort) — O(n^2), rất nhanh khi mảng nhỏ hoặc gần có thứ tự
void insertionSort(vector<int>& a) {
    for (int i = 1; i < (int)a.size(); ++i) {
        int key = a[i];
        int j = i - 1;
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];  // dồn phần tử lớn hơn sang phải
            j--;
        }
        a[j + 1] = key;
    }
}

// 2. Sắp xếp trộn (Merge Sort) — luôn đảm bảo O(n log n)
void mergeSort(vector<int>& a, int l, int r) {
    if (l >= r) return;
    int mid = l + (r - l) / 2;
    mergeSort(a, l, mid);
    mergeSort(a, mid + 1, r);

    vector<int> tam;
    tam.reserve(r - l + 1);
    int i = l, j = mid + 1;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) tam.push_back(a[i++]);
        else tam.push_back(a[j++]);
    }
    while (i <= mid) tam.push_back(a[i++]);
    while (j <= r) tam.push_back(a[j++]);
    for (int k = 0; k < (int)tam.size(); ++k) {
        a[l + k] = tam[k];
    }
}

int main() {
    vector<int> a1 = {38, 27, 43, 3, 9, 82, 10};
    vector<int> a2 = a1;
    insertionSort(a1);
    mergeSort(a2, 0, (int)a2.size() - 1);
    for (int i = 0; i < (int)a1.size(); ++i) {
        cout << a1[i] << (i + 1 == (int)a1.size() ? "\n" : " ");  // 3 9 10 27 38 43 82
    }
    cout << (a1 == a2) << "\n";  // 1
    return 0;
}
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

Bảng so sánh tổng hợp các thuật toán sắp xếp

Thuật toán Thời gian tốt nhất Thời gian trung bình Thời gian xấu nhất Bộ nhớ phụ Có ổn định (Stable)?
Selection Sort O(n²) O(n²) O(n²) O(1) Không
Bubble Sort (có cờ dừng) O(n) O(n²) O(n²) O(1) Có
Insertion Sort O(n) O(n²) O(n²) O(1) Có
Merge Sort O(n log n) O(n log n) O(n log n) O(n) Có
Quick Sort O(n log n) O(n log n) O(n²) O(log n) Không
Heap Sort O(n log n) O(n log n) O(n log n) O(1) Không

(Nếu bạn tò mò về những thuật toán sắp xếp hài hước kiểu "tung xúc xắc tới khi mảng tự tăng dần", hãy đọc thêm bài Bogosort: những thuật toán sắp xếp “pha ke”).

Cách dùng hàm sort có sẵn và viết tiêu chí so sánh tùy chỉnh

Khi giải bài tập thuật toán hay đi phỏng vấn, bạn không cần tự viết lại Merge Sort hay Quick Sort từ đầu mà dùng thẳng hàm được tối ưu cực tốt trong thư viện chuẩn:

  • Trong C++ (<algorithm>):
    • std::sort(a.begin(), a.end()): dùng thuật toán lai Introsort (kết hợp Quick Sort, Heap Sort khi đệ quy quá sâu và Insertion Sort cho các đoạn nhỏ dưới 16 phần tử), đảm bảo luôn chạy trong O(n log n) nhưng không giữ tính ổn định.
    • std::stable_sort(a.begin(), a.end()): giữ nguyên thứ tự ban đầu của các phần tử bằng nhau.
  • Trong Python:
    • a.sort() (sửa trực tiếp danh sách a) hoặc sorted(a) (trả về danh sách mới): dùng thuật toán Timsort (lai giữa Merge Sort và Insertion Sort), vừa đạt O(n log n) vừa ổn định (stable).

Rất nhiều bài toán yêu cầu sắp xếp theo nhiều tiêu chí, ví dụ: "Sắp xếp danh sách thí sinh theo điểm từ cao xuống thấp; nếu hai thí sinh bằng điểm nhau thì người có mã số nhỏ hơn đứng trước". Dưới đây là cách viết chuẩn bằng hàm ẩn danh (lambda) trong C++ và tham số key trong Python:

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

struct ThiSinh {
    int maSo;
    int diem;
};

int main() {
    vector<ThiSinh> ds = {{102, 85}, {101, 90}, {105, 85}, {103, 90}};

    // Xếp điểm giảm dần; nếu điểm bằng nhau thì mã số tăng dần
    sort(ds.begin(), ds.end(), [](const ThiSinh& a, const ThiSinh& b) {
        if (a.diem != b.diem) return a.diem > b.diem;
        return a.maSo < b.maSo;
    });

    for (const auto& ts : ds) {
        cout << ts.maSo << ":" << ts.diem << " ";
    }
    cout << "\n";  // 101:90 103:90 102:85 105:85
    return 0;
}

Lỗi hay gặp

Cẩn thận
  • Viết return a <= b; trong hàm so sánh (comparator) của C++. std::sort của C++ yêu cầu quy tắc strict weak ordering: khi hai phần tử bằng nhau, hàm so sánh bắt buộc phải trả về false. Nếu bạn viết a.diem >= b.diem thay vì a.diem > b.diem, với mảng có nhiều phần tử bằng nhau std::sort có thể truy cập tràn ra ngoài mảng và gây lỗi sập chương trình (Runtime Error / Segmentation fault).
  • Gọi a = a.sort() trong Python. Phương thức list.sort() của Python sắp xếp tại chỗ và trả về None. Gán a = a.sort() sẽ làm biến a mất sạch dữ liệu và biến thành None! Hãy viết a.sort() riêng một dòng, hoặc dùng a = sorted(a).
  • Đặt std::sort bên trong một vòng lặp n lần. Sắp xếp lại mảng ở mỗi bước trong vòng lặp n lần sẽ làm độ phức tạp vọt lên O(n² log n). Thường bạn chỉ cần sắp xếp 1 lần trước vòng lặp, hoặc dùng hàng đợi ưu tiên (priority_queue / heapq) nếu dữ liệu thêm vào liên tục.

Luyện ngay trên Big-O Coding

Thực hành ngay các bài toán sắp xếp cơ bản và sắp xếp theo tiêu chí tùy chỉnh trên hệ thống Big-O Coding:

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

Có thuật toán sắp xếp nào nhanh hơn O(n log n) không?

Mọi thuật toán sắp xếp dựa trên việc so sánh hai phần tử với nhau đều cần ít nhất O(n log n) phép so sánh trong trường hợp xấu nhất (vì cây quyết định có n! lá nên chiều cao tối thiểu là log₂(n!) ≈ n log₂ n). Tuy nhiên, nếu các phần tử là số nguyên nằm trong một khoảng giá trị nhỏ từ 0 đến M, bạn có thể dùng Sắp xếp đếm phân phối (Counting Sort) để sắp xếp trong O(n + M) mà không cần so sánh!

Khi nào tính ổn định (stability) của thuật toán sắp xếp lại quan trọng?

Khi bạn sắp xếp một bảng dữ liệu nhiều lần theo các cột khác nhau. Ví dụ danh sách học sinh đã được xếp theo tên A → Z, bây giờ bạn xếp lại theo lớp bằng một thuật toán ổn định (như stable_sort hoặc sort() của Python), thì trong cùng một lớp các học sinh vẫn tự động giữ nguyên thứ tự tên A → Z.

Muốn sắp xếp mảng giảm dần trong C++ và Python một cách ngắn nhất thì viết thế nào?

Trong C++, bạn truyền greater<int>() vào tham số thứ ba: sort(a.begin(), a.end(), greater<int>()) (hoặc sort(a.rbegin(), a.rend())). Trong Python, bạn thêm reverse=True: a.sort(reverse=True).

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