Nhập môn

Sắp xếp trộn (Merge Sort): giải thích dễ hiểu, đếm nghịch thế, code C++ và Python

Sắp xếp trộn (Merge Sort) là thuật toán sắp xếp chia để trị do nhà toán học John von Neumann phát minh năm 1945: thuật toán liên tục chia đôi mảng cho tới khi mỗi đoạn chỉ còn 1 phần tử, sau đó trộn (merge) từng cặp đoạn đã tăng dần lại với nhau bằng kỹ thuật hai con trỏ. Không giống Quick Sort có thể suy biến, Merge Sort đảm bảo độ phức tạp thời gian luôn là O(n log n) trong mọi trường hợp và giữ nguyên thứ tự ban đầu của các phần tử bằng nhau (stable sort).

Ý chính
  • Merge Sort hoạt động theo nguyên lý Chia để trị: chia đôi mảng đến khi còn 1 phần tử, rồi dùng hai con trỏ trộn hai nửa tăng dần thành một đoạn tăng dần trong O(n).
  • Độ phức tạp thời gian luôn luôn là O(n log n) ở cả trường hợp tốt nhất, trung bình và xấu nhất; đánh đổi bằng O(n) bộ nhớ phụ cho mảng tạm.
  • Là thuật toán sắp xếp ổn định (stable sort) nhờ ưu tiên lấy phần tử bên trái khi hai bên bằng nhau (trai[i] <= phai[j], chỉ lấy phải khi trai[i] > phai[j]).
  • Bước trộn của Merge Sort giúp giải bài toán kinh điển đếm số cặp nghịch thế (i < j mà a[i] > a[j]) trong O(n log n).

Tư duy chia đôi và trộn của Merge Sort

Giả sử bạn có hai chồng bài đã được xếp tăng dần sẵn đặt cạnh nhau trên bàn. Làm sao để gộp chúng thành một chồng bài duy nhất cũng tăng dần? Bạn chỉ cần nhìn vào hai lá bài nằm trên cùng của hai chồng, lá nào nhỏ hơn thì rút ra đặt vào chồng kết quả, rồi lặp lại cho tới khi hết bài. Vì mỗi bước chỉ so sánh một lần để rút ra 1 lá bài, việc trộn tổng cộng n lá bài chỉ tốn đúng O(n) thao tác!

Sắp xếp trộn (Merge Sort) biến nhận xét đơn giản đó thành một thuật toán hoàn chỉnh gồm 3 bước trên đoạn a[lo .. hi]:

  1. Chia (Divide): Tìm vị trí chính giữa mid = lo + (hi - lo) / 2 và tách đoạn thành hai nửa gần bằng nhau: nửa trái a[lo .. mid] và nửa phải a[mid + 1 .. hi].
  2. Trị (Conquer): Gọi đệ quy sắp xếp nửa trái và nửa phải độc lập. Khi đoạn chỉ còn 1 phần tử (lo >= hi), bản thân đoạn đó hiển nhiên đã có thứ tự nên dừng đệ quy.
  3. Trộn (Combine / Merge): Dùng hai con trỏ i (chạy trên nửa trái) và j (chạy trên nửa phải) để trộn hai nửa đã tăng dần thành một đoạn a[lo .. hi] tăng dần hoàn chỉnh.

Khác với sắp xếp nhanh (Quick Sort) vốn vất vả phân hoạch ngay từ lúc chia, bước chia của Merge Sort chỉ tốn O(1) để tính chỉ số mid, còn toàn bộ công việc so sánh nằm ở bước trộn ngược từ dưới lên.

Ví dụ từng bước trên mảng 7 phần tử

Xét mảng a = [38, 27, 43, 3, 9, 82, 10]. Sơ đồ dưới đây minh họa toàn bộ quá trình tách đôi từ trên xuống (nửa trên) và trộn tăng dần từ dưới lên (nửa dưới):

Mảng gốc [ 38, 27, 43, 3, 9, 82, 10 ] 1. Chia đôi [ 38, 27, 43, 3 ] [ 9, 82, 10 ] 2. Chia nhỏ [ 38, 27 ] [ 43, 3 ] [ 9, 82 ] [ 10 ] 3. Trộn cặp [ 27, 38 ] [ 3, 43 ] [ 9, 82 ] [ 10 ] 4. Trộn 2 nửa [ 3, 27, 38, 43 ] [ 9, 10, 82 ] 5. Trộn cuối [ 3, 9, 10, 27, 38, 43, 82 ]
Gốc: [38, 27, 43, 3, 9, 82, 10] Chia: [38, 27, 43, 3] Chia: [9, 82, 10] [27, 38] và [3, 43] [9, 82] và [10] Trộn: [3, 27, 38, 43] Trộn: [9, 10, 82] Kết quả: [3, 9, 10, 27, 38, 43, 82]
Hình 1. Cây chia đôi và trộn ngược lên của Merge Sort trên mảng [38, 27, 43, 3, 9, 82, 10].

Hãy theo dõi chi tiết bước trộn cuối cùng khi hai nửa đã được sắp xếp riêng biệt thành trai = [3, 27, 38, 43] và phai = [9, 10, 82]. Ta đặt i = 0 ở đầu mảng trai, j = 0 ở đầu mảng phai:

Bước trai[i] phai[j] So sánh trai[i] ≤ phai[j] Phần tử được chọn Mảng kết quả sau bước này
1 3 (i=0) 9 (j=0) 3 ≤ 9 (Đúng) Lấy 3 bên trái, i → 1 [3]
2 27 (i=1) 9 (j=0) 27 ≤ 9 (Sai) Lấy 9 bên phải, j → 1 [3, 9]
3 27 (i=1) 10 (j=1) 27 ≤ 10 (Sai) Lấy 10 bên phải, j → 2 [3, 9, 10]
4 27 (i=1) 82 (j=2) 27 ≤ 82 (Đúng) Lấy 27 bên trái, i → 2 [3, 9, 10, 27]
5 38 (i=2) 82 (j=2) 38 ≤ 82 (Đúng) Lấy 38 bên trái, i → 3 [3, 9, 10, 27, 38]
6 43 (i=3) 82 (j=2) 43 ≤ 82 (Đúng) Lấy 43 bên trái, hết nửa trái [3, 9, 10, 27, 38, 43]
7 (Đã hết) 82 (j=2) Chép phần còn dư Chép nốt 82 bên phải [3, 9, 10, 27, 38, 43, 82]

Dưới đây là code cài đặt chuẩn mực của Merge Sort bằng C++ và Python:

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

// Trộn hai đoạn đã tăng dần a[lo .. mid] và a[mid + 1 .. hi]
void merge(vector<int>& a, int lo, int mid, int hi) {
    vector<int> trai(a.begin() + lo, a.begin() + mid + 1);
    vector<int> phai(a.begin() + mid + 1, a.begin() + hi + 1);

    size_t i = 0, j = 0;
    int k = lo;

    while (i < trai.size() && j < phai.size()) {
        // Dùng dấu <= để giữ nguyên thứ tự các phần tử bằng nhau (Stable Sort)
        if (trai[i] <= phai[j]) {
            a[k++] = trai[i++];
        } else {
            a[k++] = phai[j++];
        }
    }
    while (i < trai.size()) a[k++] = trai[i++];
    while (j < phai.size()) a[k++] = phai[j++];
}

// Sắp xếp trộn (Merge Sort) trên đoạn a[lo .. hi]
void mergeSort(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int mid = lo + (hi - lo) / 2;
    mergeSort(a, lo, mid);
    mergeSort(a, mid + 1, hi);
    merge(a, lo, mid, hi);
}

void inMang(const vector<int>& a) {
    for (size_t i = 0; i < a.size(); i++) {
        cout << a[i] << (i + 1 == a.size() ? "\n" : " ");
    }
}

int main() {
    vector<int> a1 = {38, 27, 43, 3, 9, 82, 10};
    mergeSort(a1, 0, (int)a1.size() - 1);
    inMang(a1);  // 3 9 10 27 38 43 82

    vector<int> a2 = {5, -2, 0, 5, -2, 1};
    mergeSort(a2, 0, (int)a2.size() - 1);
    inMang(a2);  // -2 -2 0 1 5 5

    vector<int> a3 = {100};
    mergeSort(a3, 0, (int)a3.size() - 1);
    inMang(a3);  // 100
    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

Độ phức tạp thời gian, bộ nhớ và tính ổn định (Stability)

1. Vì sao thời gian của Merge Sort luôn là O(n log n) trong mọi trường hợp?

Ở mỗi bước chia, kích thước đoạn giảm đi đúng một nửa: từ n xuống n/2, n/4, ..., cho tới 1. Do đó cây đệ quy luôn có chiều cao đúng bằng ⌈log2 n⌉ + 1 tầng, bất kể dữ liệu đầu vào đang tăng dần, giảm dần hay ngẫu nhiên.

Tại mỗi tầng của cây đệ quy, tổng độ dài của tất cả các đoạn con cộng lại đúng bằng n, và thao tác trộn hai con trỏ chỉ đi qua mỗi phần tử đúng 1 lần nên tốn O(n) phép so sánh. Nhân số tầng với chi phí mỗi tầng, ta có hệ thức truy hồi: \[T(n) = 2T(n / 2) + O(n) = O(n \log n)\]

Trường hợp Độ phức tạp thời gian Bộ nhớ phụ Tính ổn định (Stable)
Tốt nhất (Best Case) O(n log n) O(n) Có
Trung bình (Average Case) O(n log n) O(n) Có
Xấu nhất (Worst Case) O(n log n) O(n) Có

2. Vì sao điều kiện trai[i] ≤ phai[j] quyết định tính ổn định?

Một thuật toán sắp xếp được gọi là ổn định (stable) nếu hai phần tử có giá trị bằng nhau vẫn giữ nguyên thứ tự đứng trước – đứng sau như lúc ban đầu (khác với sắp xếp chọn hay Quick Sort vốn không ổn định).

Trong hàm merge, mọi phần tử ở mảng trai vốn đều đứng trước các phần tử ở mảng phai trong mảng gốc. Khi gặp trường hợp trai[i] == phai[j], việc viết dấu <= (if (trai[i] <= phai[j]), ngược lại trai[i] > phai[j]) buộc thuật toán ưu tiên rút phần tử ở nửa bên trái ra trước. Nếu bạn vô tình viết dấu < nghiêm ngặt, phần tử bên phải sẽ nhảy lên đứng trước phần tử bên trái và làm thuật toán mất tính ổn định!

3. Đánh đổi bộ nhớ phụ O(n)

Điểm yếu lớn nhất của Merge Sort trên mảng là bước merge không thể ghi đè trực tiếp lên mảng gốc mà phải dùng một mảng tạm có kích thước O(n) để chứa kết quả trộn trước khi chép ngược về. Tuy nhiên, nếu dữ liệu được lưu bằng danh sách liên kết (Linked List), Merge Sort có thể nối lại các con trỏ next để trộn trực tiếp chỉ với O(1) bộ nhớ phụ và trở thành thuật toán sắp xếp tốt nhất cho danh sách liên kết.

Ứng dụng nâng cao kinh điển: Đếm số cặp nghịch thế trong O(n log n)

Cho mảng a gồm n phần tử (n ≤ 10⁵). Một cặp chỉ số (i, j) được gọi là một cặp nghịch thế (inversion) nếu i < j nhưng a[i] > a[j] (tức là số đứng trước lại lớn hơn số đứng sau). Số cặp nghịch thế chính là số bước đổi chỗ hai phần tử kề nhau tối thiểu của sắp xếp nổi bọt (Bubble Sort) để đưa mảng về thứ tự tăng dần.

Nếu dùng 2 vòng lặp duyệt mọi cặp (i, j), bạn mất O(n²) — quá chậm khi n = 10⁵. Nhưng với Merge Sort, ta đếm được toàn bộ cặp nghịch thế chỉ trong O(n log n) nhờ nhận xét vàng ở bước trộn hai nửa đã tăng dần a[lo .. mid] (con trỏ i) và a[mid + 1 .. hi] (con trỏ j):

  • Nếu a[i] <= a[j] (không phải a[i] > a[j]): a[i] không tạo thành nghịch thế với a[j], ta đưa a[i] vào mảng tạm và tăng i++.
  • Nếu a[i] > a[j]: vì nửa trái a[lo .. mid] đã được sắp xếp tăng dần, nên nếu a[i] đã lớn hơn a[j] thì mọi phần tử từ vị trí i đến vị trí mid ở nửa trái đều lớn hơn a[j]! Vậy riêng phần tử a[j] này tạo thành đúng mid - i + 1 cặp nghịch thế mới với nửa bên trái. Ta chỉ cần cộng dem += (mid - i + 1) rồi đưa a[j] vào mảng tạm và tăng j++!
#include <iostream>
#include <vector>
using namespace std;

// Vừa sắp xếp đoạn a[lo .. hi] vừa trả về số cặp nghịch thế trong đoạn đó
long long demNghichThe(vector<int>& a, vector<int>& tam, int lo, int hi) {
    if (lo >= hi) return 0;

    int mid = lo + (hi - lo) / 2;
    long long dem = 0;
    dem += demNghichThe(a, tam, lo, mid);
    dem += demNghichThe(a, tam, mid + 1, hi);

    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (a[i] <= a[j]) {
            tam[k++] = a[i++];
        } else {
            // Vì a[lo .. mid] đã tăng dần, mọi phần tử từ a[i] đến a[mid] đều > a[j]
            dem += (mid - i + 1);
            tam[k++] = a[j++];
        }
    }
    while (i <= mid) tam[k++] = a[i++];
    while (j <= hi) tam[k++] = a[j++];
    for (int p = lo; p <= hi; p++) a[p] = tam[p];

    return dem;
}

long long soCapNghichThe(vector<int> a) {
    if (a.empty()) return 0;
    vector<int> tam(a.size());
    return demNghichThe(a, tam, 0, (int)a.size() - 1);
}

int main() {
    cout << soCapNghichThe({38, 27, 43, 3, 9, 82, 10}) << "\n";  // 11
    cout << soCapNghichThe({2, 4, 1, 3, 5}) << "\n";             // 3
    cout << soCapNghichThe({5, 4, 3, 2, 1}) << "\n";             // 10
    return 0;
}

Lỗi hay gặp

Cẩn thận
  • Cấp phát vector tạm mới bên trong mỗi lần gọi hàm merge khi mảng cực lớn. Việc khởi tạo vector<int> hàng trăm nghìn lần trong cây đệ quy làm tốn nhiều thời gian cấp phát bộ nhớ động. Mẹo tối ưu (như trong code đếm nghịch thế ở trên): chỉ cấp phát duy nhất một mảng tam kích thước n ở ngoài rồi truyền tham chiếu vào hàm đệ quy.
  • Dùng kiểu int 32-bit để lưu số cặp nghịch thế. Với mảng giảm dần có n = 10⁵ phần tử, tổng số cặp nghịch thế đạt cực đại là n(n - 1) / 2 ≈ 5 × 10⁹, vượt quá giới hạn 2 × 10⁹ của kiểu int 32-bit trong C++. Luôn dùng kiểu long long cho biến đếm nghịch thế.
  • Viết mid = (lo + hi) / 2 dễ tràn số hoặc sai cận chia. Luôn viết mid = lo + (hi - lo) / 2, gọi đệ quy hai nhánh (lo, mid) và (mid + 1, hi) (giống quy tắc chia đôi trong tìm kiếm nhị phân). Nếu chia thành (lo, mid - 1) và (mid, hi) mà mid làm tròn xuống, khi đoạn có 2 phần tử sẽ bị lặp vô hạn!

Luyện ngay trên Big-O Coding

Áp dụng ngay Merge Sort và kỹ thuật đếm cặp nghịch thế qua các bài tập trên hệ thống Big-O Coding:

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

Khi nào nên dùng Merge Sort thay vì Quick Sort?

Bạn nên chọn Merge Sort khi: (1) cần tính ổn định (stable sort) để giữ thứ tự các phần tử bằng nhau; (2) cần sắp xếp danh sách liên kết (Linked List) hoặc sắp xếp dữ liệu khổng lồ nằm trên ổ cứng (External Sorting); hoặc (3) cần lồng ghép việc đếm cặp nghịch thế trong quá trình chia để trị.

Thuật toán Timsort trong Python và Java có liên quan gì đến Merge Sort?

Timsort (thuật toán đứng sau list.sort() của Python và Arrays.sort() cho đối tượng trong Java) chính là sự kết hợp thông minh giữa Merge Sort và Insertion Sort. Nó chia mảng thành các đoạn nhỏ, dùng Insertion Sort để tận dụng các đoạn đã có thứ tự sẵn (runs), rồi dùng bước merge của Merge Sort để gộp chúng lại trong O(n log n).

Có thể viết Merge Sort không dùng đệ quy (Bottom-Up Merge Sort) được không?

Hoàn toàn được! Thay vì chia đôi từ trên xuống bằng đệ quy, bạn dùng vòng lặp tăng kích thước đoạn sz = 1, 2, 4, 8, ... và trộn từng cặp đoạn kề nhau có độ dài sz từ dưới lên. Cách viết này loại bỏ hoàn toàn chi phí gọi hàm đệ quy.

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