Bài 15.1: Nguyên lý và cài đặt Merge Sort

Bài 15.1: Nguyên lý và cài đặt Merge Sort
Chào mừng các bạn đã quay trở lại với chuỗi bài viết về Cấu trúc dữ liệu và Giải thuật! Hôm nay, chúng ta sẽ khám phá một trong những giải thuật sắp xếp kinh điển và mạnh mẽ nhất: Merge Sort (Sắp xếp trộn). Đây là một giải thuật dựa trên nguyên lý "Chia để trị" (Divide and Conquer), nổi tiếng với hiệu suất ổn định và đáng tin cậy, khác biệt với nhiều giải thuật khác có thể bị suy giảm hiệu suất trong trường hợp xấu nhất.
Nếu bạn đã từng tự hỏi làm thế nào để sắp xếp một lượng dữ liệu lớn một cách hiệu quả và đảm bảo, thì Merge Sort chính là câu trả lời. Hãy cùng đi sâu tìm hiểu nguyên lý hoạt động đầy thú vị của nó nhé!
1. Nguyên lý hoạt động của Merge Sort: Chia để trị
Merge Sort hoạt động dựa trên ba bước cốt lõi của nguyên lý Divide and Conquer:
- Chia (Divide): Liên tục chia mảng/danh sách cần sắp xếp thành hai nửa cho đến khi chỉ còn các mảng con chứa một phần tử. Một mảng chứa một phần tử được coi là đã được sắp xếp.
- Trị (Conquer): Sắp xếp độc lập các mảng con này (trong Merge Sort, bước này chỉ đơn giản là đạt đến mảng con có 1 phần tử, nó đã được "sắp xếp" sẵn).
- Kết hợp (Combine/Merge): Trộn (merge) các mảng con đã được sắp xếp lại với nhau để tạo thành các mảng lớn hơn đã được sắp xếp, cho đến khi toàn bộ mảng ban đầu được sắp xếp hoàn chỉnh.
Trái tim của Merge Sort nằm ở bước thứ ba: thủ tục trộn (merge procedure). Đây là nơi hai (hoặc nhiều hơn) mảng con đã được sắp xếp được kết hợp thành một mảng lớn hơn duy nhất, cũng đã được sắp xếp.
2. Đi sâu vào thủ tục Trộn (Merge)
Giả sử chúng ta có hai mảng con đã được sắp xếp:
- Mảng con 1:
[1, 3, 5, 7]
- Mảng con 2:
[2, 4, 6, 8]
Và chúng ta muốn trộn chúng thành một mảng đã được sắp xếp duy nhất: [1, 2, 3, 4, 5, 6, 7, 8]
.
Thủ tục trộn hoạt động như sau:
- Sử dụng con trỏ (hoặc chỉ số) để theo dõi vị trí hiện tại ở mỗi mảng con và ở mảng kết quả (thường là một mảng tạm thời). Bắt đầu các con trỏ ở vị trí đầu tiên của mỗi mảng con.
- So sánh phần tử mà các con trỏ đang chỉ tới ở hai mảng con.
- Sao chép phần tử nhỏ hơn vào mảng tạm thời kết quả, và tăng con trỏ của mảng con chứa phần tử vừa được sao chép lên một vị trí. Tăng con trỏ của mảng tạm thời lên một vị trí.
- Lặp lại bước 2 và 3 cho đến khi một trong hai mảng con đã được sao chép hết các phần tử.
- Sao chép tất cả các phần tử còn lại từ mảng con kia (mảng chưa hết phần tử) vào mảng tạm thời.
- Cuối cùng, sao chép toàn bộ các phần tử từ mảng tạm thời trở lại vào vị trí tương ứng trong mảng gốc.
Ví dụ minh họa thủ tục Merge:
Trộn [2, 5, 7]
và [3, 6, 9]
vào một mảng tạm thời:
Bước | Mảng 1 (chỉ số) | Mảng 2 (chỉ số) | Mảng tạm thời | So sánh | Hành động |
---|---|---|---|---|---|
BĐ | [2*, 5, 7] (0) |
[3*, 6, 9] (0) |
[] |
2 vs 3 | Chọn 2, copy 2 |
1 | [2, 5*, 7] (1) |
[3*, 6, 9] (0) |
[2] |
5 vs 3 | Chọn 3, copy 3 |
2 | [2, 5*, 7] (1) |
[3, 6*, 9] (1) |
[2, 3] |
5 vs 6 | Chọn 5, copy 5 |
3 | [2, 5, 7*] (2) |
[3, 6*, 9] (1) |
[2, 3, 5] |
7 vs 6 | Chọn 6, copy 6 |
4 | [2, 5, 7*] (2) |
[3, 6, 9*] (2) |
[2, 3, 5, 6] |
7 vs 9 | Chọn 7, copy 7 |
5 | [2, 5, 7] (3) |
[3, 6, 9*] (2) |
[2, 3, 5, 6, 7] |
Mảng 1 hết | Copy phần tử còn lại của Mảng 2 |
6 | [2, 5, 7] (3) |
[3, 6, 9] (3) |
[2, 3, 5, 6, 7, 9] |
Cả hai hết | Xong |
Mảng tạm thời [2, 3, 5, 6, 7, 9]
sau đó sẽ được sao chép lại vào vị trí ban đầu của hai mảng con trong mảng gốc.
3. Cài đặt Merge Sort bằng C++
Bây giờ, chúng ta sẽ chuyển nguyên lý trên thành code C++. Chúng ta cần hai hàm:
- Hàm
merge
: Thực hiện việc trộn hai mảng con đã sắp xếp. - Hàm
mergeSort
: Thực hiện việc chia mảng và gọi đệ quymergeSort
cho các mảng con, sau đó gọimerge
để kết hợp kết quả.
#include <iostream>
#include <vector>
#include <algorithm> // Thường không cần thiết cho logic merge sort cơ bản, nhưng hữu ích
// Hàm để trộn hai mảng con đã được sắp xếp
// Mảng con thứ nhất là arr[left...mid]
// Mảng con thứ hai là arr[mid+1...right]
void merge(std::vector<int>& arr, int left, int mid, int right) {
// Kích thước của hai mảng con
int n1 = mid - left + 1;
int n2 = right - mid;
// Tạo mảng tạm thời để lưu trữ hai mảng con
// Thay vì tạo hai mảng L và R riêng biệt, chúng ta có thể sử dụng
// một mảng tạm thời duy nhất có kích thước bằng tổng kích thước
// của hai mảng con. Điều này giúp giảm số lượng cấp phát bộ nhớ.
// Tuy nhiên, cách tạo hai mảng L và R rõ ràng hơn trong việc giải thích
// nguyên lý. Ta sẽ dùng cách tạo 2 mảng tạm cho dễ hiểu.
// Tạo mảng tạm L[] và R[]
std::vector<int> L(n1);
std::vector<int> R(n2);
// Sao chép dữ liệu từ mảng gốc vào mảng tạm L[] và R[]
for (int i = 0; i < n1; i++)
L[i] = arr[left + i];
for (int j = 0; j < n2; j++)
R[j] = arr[mid + 1 + j];
// Chỉ mục ban đầu của mảng con đầu tiên, mảng con thứ hai, và mảng gốc
int i = 0; // Chỉ mục ban đầu của mảng con L[]
int j = 0; // Chỉ mục ban đầu của mảng con R[]
int k = left; // Chỉ mục ban đầu của mảng gốc arr[] để bắt đầu trộn
// Trộn các mảng tạm L[] và R[] vào mảng gốc arr[]
// So sánh các phần tử từ L và R, chọn phần tử nhỏ hơn
while (i < n1 && j < n2) {
if (L[i] <= R[j]) { // Dấu <= đảm bảo tính ổn định (stable)
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++; // Luôn tăng chỉ mục của mảng gốc
}
// Sao chép các phần tử còn lại của mảng L[] (nếu có)
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// Sao chép các phần tử còn lại của mảng R[] (nếu có)
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
// Hàm đệ quy thực hiện Merge Sort
// arr[] là mảng cần sắp xếp
// left là chỉ mục bắt đầu
// right là chỉ mục kết thúc
void mergeSort(std::vector<int>& arr, int left, int right) {
// Điều kiện dừng: Nếu mảng con chỉ có 0 hoặc 1 phần tử
if (left >= right) {
return; // Đã sắp xếp (mảng 0 hoặc 1 phần tử là tự nó đã sắp xếp)
}
// Tìm điểm giữa của mảng
// Sử dụng left + (right - left) / 2 để tránh tràn số (integer overflow)
// so với (left + right) / 2 khi left và right rất lớn
int mid = left + (right - left) / 2;
// Gọi đệ quy mergeSort cho nửa bên trái
mergeSort(arr, left, mid);
// Gọi đệ quy mergeSort cho nửa bên phải
mergeSort(arr, mid + 1, right);
// Trộn hai nửa đã được sắp xếp lại với nhau
merge(arr, left, mid, right);
}
// Hàm tiện ích để in mảng (không bắt buộc, chỉ để kiểm tra)
void printArray(const std::vector<int>& arr) {
for (int x : arr) {
std::cout << x << " ";
}
std::cout << std::endl;
}
// Chương trình chính để minh họa
int main() {
std::vector<int> arr = {12, 11, 13, 5, 6, 7};
int arr_size = arr.size();
std::cout << "Mang ban dau:" << std::endl;
printArray(arr);
// Bắt đầu sắp xếp từ chỉ mục 0 đến kích thước - 1
mergeSort(arr, 0, arr_size - 1);
std::cout << "\nMang sau khi sap xep boi Merge Sort:" << std::endl;
printArray(arr);
// Ví dụ khác phức tạp hơn một chút
std::vector<int> arr2 = {38, 27, 43, 3, 9, 82, 10};
int arr2_size = arr2.size();
std::cout << "\nMang ban dau (vi du 2):" << std::endl;
printArray(arr2);
mergeSort(arr2, 0, arr2_size - 1);
std::cout << "\nMang sau khi sap xep boi Merge Sort (vi du 2):" << std::endl;
printArray(arr2);
return 0;
}
4. Giải thích Code C++
Hàm
merge(std::vector<int>& arr, int left, int mid, int right)
:- Tham số: Nhận mảng
arr
, chỉ mục bắt đầuleft
, chỉ mục giữamid
, và chỉ mục kết thúcright
của phần mảng cần trộn. Chú ýarr
được truyền bằng tham chiếu (&
) để có thể thay đổi trực tiếp mảng gốc. n1
,n2
: Tính kích thước của hai mảng con bên trái (left
đếnmid
) và bên phải (mid+1
đếnright
).std::vector<int> L(n1), R(n2);
: Tạo hai mảng tạmL
vàR
để lưu trữ dữ liệu của hai mảng con.- Hai vòng lặp
for
: Sao chép dữ liệu từarr
vào hai mảng tạmL
vàR
. int i = 0, j = 0; int k = left;
: Khởi tạo các chỉ mục.i
cho mảngL
,j
cho mảngR
, vàk
cho vị trí hiện tại trong mảng gốcarr
mà ta sẽ ghi vào.- Vòng lặp
while (i < n1 && j < n2)
: Đây là trái tim của thủ tục trộn. Nó so sánh phần tử hiện tại củaL
(L[i]
) vàR
(R[j]
). Phần tử nào nhỏ hơn (hoặc bằng để đảm bảo tính ổn định), sẽ được sao chép vàoarr[k]
. Sau đó, chỉ mục tương ứng (i
hoặcj
) và chỉ mụck
đều tăng lên. - Hai vòng lặp
while
còn lại: Sau khi một trong hai mảngL
hoặcR
đã hết phần tử, vòng lặp chính sẽ dừng. Các vòng lặp này có nhiệm vụ sao chép nốt tất cả các phần tử còn lại từ mảng con chưa hết vào mảng gốcarr
. - Sau khi hàm
merge
hoàn thành, phần của mảng gốc từleft
đếnright
đã được sắp xếp.
- Tham số: Nhận mảng
Hàm
mergeSort(std::vector<int>& arr, int left, int right)
:- Tham số: Nhận mảng
arr
(tham chiếu), chỉ mục bắt đầuleft
, và chỉ mục kết thúcright
. if (left >= right) return;
: Đây là điều kiện dừng của đệ quy. Khileft
lớn hơn hoặc bằngright
, điều đó có nghĩa là mảng con hiện tại chỉ chứa 0 hoặc 1 phần tử. Một mảng con như vậy hiển nhiên đã được sắp xếp, nên ta dừng đệ quy cho nhánh này.int mid = left + (right - left) / 2;
: Tính chỉ mục giữa. Công thức này giúp tránh tràn số khileft
vàright
là các số nguyên rất lớn.mergeSort(arr, left, mid);
: Gọi đệ quy để sắp xếp nửa bên trái của mảng con hiện tại. Khi lệnh này kết thúc, phầnarr[left...mid]
được đảm bảo đã sắp xếp.mergeSort(arr, mid + 1, right);
: Gọi đệ quy để sắp xếp nửa bên phải của mảng con hiện tại. Khi lệnh này kết thúc, phầnarr[mid+1...right]
được đảm bảo đã sắp xếp.merge(arr, left, mid, right);
: Sau khi cả hai nửa trái và phải đã được sắp xếp độc lập thông qua các lệnh gọi đệ quy, hàmmerge
được gọi để trộn hai nửa đã sắp xếp này lại thành một mảng con duy nhất đã được sắp xếp, bao phủ toàn bộ phạm vi từleft
đếnright
.
- Tham số: Nhận mảng
Hàm
printArray
vàmain
:printArray
: Một hàm tiện ích đơn giản để hiển thị nội dung của mảng.main
: Nơi chương trình bắt đầu. Nó tạo một vector, in ra trạng thái ban đầu, gọimergeSort
với toàn bộ phạm vi của vector (từ 0 đến kích thước - 1), và sau đó in ra vector đã được sắp xếp.
5. Phân tích độ phức tạp
Hiểu rõ độ phức tạp là điều quan trọng để biết khi nào nên sử dụng Merge Sort.
Độ phức tạp về Thời gian (Time Complexity):
- Merge Sort luôn chia mảng thành hai nửa bằng nhau. Số lần chia cần thiết để đạt được mảng con 1 phần tử là
log base 2 của n
, hay O(log n), trong đón
là kích thước mảng. Đây là chiều sâu của cây đệ quy. - Tại mỗi cấp của cây đệ quy (trừ cấp lá), chúng ta thực hiện thủ tục
merge
trên các mảng con. Tổng số phần tử được xử lý bởi thủ tụcmerge
ở mỗi cấp luôn làn
(ví dụ: ở cấp trên cùng là trộn 2 mảng n/2, ở cấp dưới hơn là trộn 4 mảng n/4, tổng cộng vẫn là n). Thủ tụcmerge
cho hai mảng con có tổng kích thướck
mất O(k) thời gian vì nó chỉ duyệt qua các phần tử một lần. Do đó, tổng thời gian cho việc trộn ở mỗi cấp là O(n). - Tổng thời gian = (Số cấp đệ quy) (Thời gian trộn mỗi cấp) = **O(log n) O(n) = O(n log n)**.
- Điều đặc biệt là độ phức tạp O(n log n) này là như nhau cho trường hợp tốt nhất, trung bình và xấu nhất. Đây là ưu điểm lớn của Merge Sort so với các thuật toán như Quick Sort (có thể tệ nhất là O(n^2)).
- Merge Sort luôn chia mảng thành hai nửa bằng nhau. Số lần chia cần thiết để đạt được mảng con 1 phần tử là
Độ phức tạp về Không gian (Space Complexity):
- Hàm
merge
cần một hoặc hai mảng tạm thời để lưu trữ các phần tử trong quá trình trộn. Trong cài đặt trên, chúng ta dùng hai mảng tạmL
vàR
có tổng kích thước làn1 + n2 = (mid - left + 1) + (right - mid) = right - left + 1
, tức là kích thước của mảng con hiện tại đang xử lý. Ở cấp trên cùng của đệ quy, mảng con này có kích thướcn
. Do đó, Merge Sort yêu cầu không gian phụ O(n) cho mảng tạm. - Không gian cho stack đệ quy cũng cần xem xét, nhưng với chiều sâu O(log n), nó chỉ tốn O(log n) không gian, thường ít hơn đáng kể so với không gian O(n) của mảng tạm.
- Vì vậy, độ phức tạp về không gian của Merge Sort là O(n). Đây là một nhược điểm so với các thuật toán "in-place" như Heap Sort hoặc phiên bản tối ưu của Quick Sort, chỉ cần O(log n) hoặc O(1) không gian phụ (cho stack đệ quy hoặc không).
- Hàm
6. Ưu điểm và Nhược điểm
Ưu điểm:
- Độ phức tạp thời gian ổn định: Luôn là O(n log n), bất kể dữ liệu ban đầu được sắp xếp như thế nào. Rất phù hợp khi cần đảm bảo hiệu suất.
- Ổn định (Stable): Merge Sort là một giải thuật sắp xếp ổn định. Điều này có nghĩa là nó giữ nguyên thứ tự tương đối của các phần tử bằng nhau. Nếu có hai phần tử có giá trị bằng nhau, phần tử xuất hiện trước trong mảng gốc sẽ vẫn xuất hiện trước trong mảng đã sắp xếp.
- Hiệu quả cho danh sách liên kết: Merge Sort rất phù hợp để sắp xếp danh sách liên kết vì thủ tục trộn có thể được thực hiện mà không cần mảng tạm riêng biệt chỉ bằng cách thay đổi các con trỏ, giúp tiết kiệm chi phí sao chép dữ liệu.
Nhược điểm:
- Yêu cầu không gian phụ O(n): Đây là nhược điểm đáng kể nhất. Nó cần bộ nhớ phụ bằng kích thước của mảng gốc (hoặc ít nhất là bằng nửa mảng gốc tùy cài đặt). Với các tập dữ liệu rất lớn trên hệ thống có bộ nhớ hạn chế, điều này có thể là vấn đề.
- Thủ tục trộn có thể chậm hơn cho mảng nhỏ: Đối với các mảng rất nhỏ, chi phí quản lý đệ quy và thủ tục trộn có thể khiến Merge Sort chậm hơn các thuật toán đơn giản hơn như Insertion Sort.
- Không "in-place" theo định nghĩa chặt chẽ: Mặc dù có thể tối ưu không gian, phiên bản phổ biến và dễ cài đặt nhất không phải là "in-place" do cần mảng tạm.
7. Khi nào nên sử dụng Merge Sort?
Merge Sort là một lựa chọn tuyệt vời khi:
- Bạn cần một giải thuật sắp xếp với hiệu suất đảm bảo O(n log n) trong mọi trường hợp.
- Tính ổn định của thứ tự sắp xếp là yêu cầu bắt buộc.
- Bạn đang làm việc với danh sách liên kết hoặc các cấu trúc dữ liệu mà việc truy cập tuần tự hiệu quả hơn truy cập ngẫu nhiên.
- Bạn cần sắp xếp dữ liệu quá lớn không thể chứa hoàn toàn trong bộ nhớ chính (gọi là sắp xếp ngoài - external sorting).
Bài tập ví dụ:
Tổng chữ số
Trong một buổi họp kỹ thuật, FullHouse Dev được giám đốc kỹ thuật đưa ra một bài toán thú vị để rèn luyện tư duy logic. Bài toán này yêu cầu họ phải tính toán số lần thay đổi tối thiểu để cân bằng tổng các chữ số trong một chuỗi số. Với sự sáng tạo, FullHouse Dev đã bắt tay vào giải quyết thử thách này.
Bài toán
Cho một chuỗi \(S\) có độ dài \(2N\) chỉ gồm các chữ số từ 0 đến 9. Bạn có thể thực hiện một thao tác là chọn một vị trí bất kỳ và thay thế chữ số ở vị trí đó bằng một chữ số khác từ 0 đến 9. Nhiệm vụ của bạn là xác định số lần thao tác tối thiểu cần thực hiện để tổng của \(N\) chữ số đầu tiên bằng tổng của \(N\) chữ số tiếp theo.
INPUT FORMAT:
- Dòng đầu tiên chứa số nguyên \(T\) - số lượng test case.
- Với mỗi test case:
- Dòng đầu tiên chứa một số nguyên \(N\).
- Dòng tiếp theo chứa một chuỗi \(S\) có độ dài \(2N\).
OUTPUT FORMAT:
- Với mỗi test case, in ra số lần thao tác tối thiểu cần thực hiện trên một dòng mới.
Ràng buộc:
- \(1 \leq T \leq 100\)
- \(1 \leq N \leq 10^5\)
- \(S\) chỉ chứa các chữ số từ 0 đến 9
Ví dụ
INPUT
1
2
1325
OUTPUT
1
Giải thích
Ở test case này:
- \(N = 2\)
- \(S = 1325\)
FullHouse Dev có thể thực hiện một thao tác:
- Thay đổi \(S[1]\) từ 1 thành 4. Sau khi thay đổi, \(S = 4325\).
Khi đó, tổng của \(S[1]\) đến \(S[2]\) = 4 + 3 = 7 và tổng của \(S[3]\) đến \(S[4]\) = 2 + 5 = 7.
Vì vậy, số lần thao tác tối thiểu cần thực hiện là 1.
Lưu ý: Có thể có các cách khác để đạt được kết quả, nhưng không thể thực hiện ít hơn 1 thao tác để đạt được yêu cầu. Tuyệt vời, đây là hướng dẫn ngắn gọn để giải bài này bằng C++:
- Mục tiêu: Cần tìm số thao tác tối thiểu để tổng N chữ số đầu tiên bằng tổng N chữ số tiếp theo.
- Tính tổng ban đầu: Đầu tiên, tính tổng của N chữ số đầu tiên (gọi là
sum1
) và tổng của N chữ số tiếp theo (gọi làsum2
). Duyệt qua chuỗiS
, từS[0]
đếnS[N-1]
để tínhsum1
, và từS[N]
đếnS[2N-1]
để tínhsum2
. Nhớ chuyển ký tự chữ số sang giá trị số nguyên (ví dụ:S[i] - '0'
). - Tính chênh lệch: Tìm trị tuyệt đối của hiệu hai tổng:
diff = abs(sum1 - sum2)
. Đây là "lượng" tổng cần cân bằng. - Phân tích thao tác: Một thao tác là thay đổi một chữ số bất kỳ. Khi thay đổi một chữ số
d_old
thànhd_new
, tổng của nửa đó thay đổi một lượng làd_new - d_old
. - Hiệu quả tối đa của một thao tác: Để giảm số thao tác, mỗi thao tác nên đóng góp nhiều nhất có thể vào việc giảm
diff
.- Nếu
sum1 > sum2
, ta cần giảmsum1
hoặc tăngsum2
. Giảmsum1
tối đa bằng cách đổi '9' thành '0' (-9). Tăngsum2
tối đa bằng cách đổi '0' thành '9' (+9). Cả hai đều làm giảmsum1 - sum2
(hoặc tăngsum2 - sum1
) một lượng tối đa là 9. - Nếu
sum1 < sum2
, ta cần tăngsum1
hoặc giảmsum2
. Tăngsum1
tối đa bằng cách đổi '0' thành '9' (+9). Giảmsum2
tối đa bằng cách đổi '9' thành '0' (-9). Cả hai đều làm tăngsum1 - sum2
(hoặc giảmsum2 - sum1
) một lượng tối đa là 9. - Như vậy, một thao tác có thể làm giảm trị tuyệt đối của chênh lệch
|sum1 - sum2|
tối đa là 9.
- Nếu
- Số thao tác tối thiểu: Số thao tác tối thiểu cần thiết để giảm chênh lệch
diff
về 0 làdiff
chia cho hiệu quả tối đa của một thao tác (là 9), làm tròn lên. Công thức tính làm tròn lênceil(a/b)
cho số nguyên dươnga, b
là(a + b - 1) / b
khi dùng phép chia nguyên. Vậy, số thao tác tối thiểu =(diff + 9 - 1) / 9
hay(diff + 8) / 9
sử dụng phép chia nguyên. - Cấu trúc code:
- Đọc số test case
T
. - Lặp
T
lần:- Đọc
N
. - Đọc chuỗi
S
. - Tính
sum1
vàsum2
. - Tính
diff = abs(sum1 - sum2)
. - Tính và in kết quả
(diff + 8) / 9
.
- Đọc
- Đọc số test case
Comments