- 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 khitrai[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 < jmàa[i] > a[j]) trongO(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]:
- Chia (Divide): Tìm vị trí chính giữa
mid = lo + (hi - lo) / 2và tách đoạn thành hai nửa gần bằng nhau: nửa tráia[lo .. mid]và nửa phảia[mid + 1 .. hi]. - 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
1phần tử (lo >= hi), bản thân đoạn đó hiển nhiên đã có thứ tự nên dừng đệ quy. - 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ạna[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):
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;
}
def merge(trai, phai):
"""Trộn hai danh sách đã tăng dần thành một danh sách tăng dần (ổn định)."""
ket_qua = []
i, j = 0, 0
while i < len(trai) and j < len(phai):
if trai[i] <= phai[j]: # dùng <= để bảo toàn tính ổn định (stable)
ket_qua.append(trai[i])
i += 1
else:
ket_qua.append(phai[j])
j += 1
ket_qua.extend(trai[i:])
ket_qua.extend(phai[j:])
return ket_qua
def merge_sort(a):
"""Sắp xếp trộn (Merge Sort) trả về danh sách mới đã sắp xếp tăng dần."""
if len(a) <= 1:
return a[:]
mid = len(a) // 2
nua_trai = merge_sort(a[:mid])
nua_phai = merge_sort(a[mid:])
return merge(nua_trai, nua_phai)
for arr in ([38, 27, 43, 3, 9, 82, 10], [5, -2, 0, 5, -2, 1], [100]):
print(*merge_sort(arr))
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ảia[i] > a[j]):a[i]không tạo thành nghịch thế vớia[j], ta đưaa[i]vào mảng tạm và tăngi++. - Nếu
a[i] > a[j]: vì nửa tráia[lo .. mid]đã được sắp xếp tăng dần, nên nếua[i]đã lớn hơna[j]thì mọi phần tử từ vị tríiđến vị trímidở nửa trái đều lớn hơna[j]! Vậy riêng phần tửa[j]này tạo thành đúngmid - i + 1cặp nghịch thế mới với nửa bên trái. Ta chỉ cần cộngdem += (mid - i + 1)rồi đưaa[j]vào mảng tạm và tăngj++!
#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;
}
def so_cap_nghich_the(arr):
"""Đếm số cặp nghịch thế (i < j mà arr[i] > arr[j]) trong O(n log n) bằng Merge Sort."""
a = arr[:]
tam = [0] * len(a)
def _dem(lo, hi):
if lo >= hi:
return 0
mid = (lo + hi) // 2
dem = _dem(lo, mid) + _dem(mid + 1, hi)
i, j, k = lo, mid + 1, lo
while i <= mid and j <= hi:
if a[i] <= a[j]:
tam[k] = a[i]
i += 1
else:
# Mọi phần tử từ a[i] đến a[mid] đều lớn hơn a[j]
dem += mid - i + 1
tam[k] = a[j]
j += 1
k += 1
while i <= mid:
tam[k] = a[i]
i += 1
k += 1
while j <= hi:
tam[k] = a[j]
j += 1
k += 1
a[lo : hi + 1] = tam[lo : hi + 1]
return dem
return _dem(0, len(a) - 1) if a else 0
print(so_cap_nghich_the([38, 27, 43, 3, 9, 82, 10])) # 11
print(so_cap_nghich_the([2, 4, 1, 3, 5])) # 3
print(so_cap_nghich_the([5, 4, 3, 2, 1])) # 10
Lỗi hay gặp
- Cấp phát vector tạm mới bên trong mỗi lần gọi hàm
mergekhi mảng cực lớn. Việc khởi tạovector<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ảngtamkích thướcnở ngoài rồi truyền tham chiếu vào hàm đệ quy. - Dùng kiểu
int32-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ạn2 × 10⁹của kiểuint32-bit trong C++. Luôn dùng kiểulong longcho biến đếm nghịch thế. - Viết
mid = (lo + hi) / 2dễ tràn số hoặc sai cận chia. Luôn viếtmid = 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àmidlà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.
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í.


