Download as pdf or txt
Download as pdf or txt
You are on page 1of 27

Các giải thuật sắp xếp trong C++

Các giải thuật sort trong C++

Mục lục
1 Định nghĩa 2

2 Cài đặt 2
2.1 Hàm main . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
2.2 Exchange sort - Sắp xếp đổi chỗ trực tiếp . . . . . . . . . . . . . . . . . . . 4
2.3 Bubble Sort - Sắp xếp nổi bọt . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.4 Odd even sort - Biến thể của Bubble sort . . . . . . . . . . . . . . . . . . . 7
2.5 Insertion sort - Sắp xếp chèn . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.5.1 Không sử dụng thêm STL (Standard Template Library) . . . . . . 8
2.5.2 Sử dụng std::rotate và std::upper_bound của thư viện <algorithm> 9
2.6 Shell sort - Biến thể của Insertion sort . . . . . . . . . . . . . . . . . . . . 11
2.7 Selection sort - Sắp xếp chọn . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.8 Merge sort - Sắp xếp trộn . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2.8.1 Không sử dụng thêm STL . . . . . . . . . . . . . . . . . . . . . . . 14
2.8.2 Sử dụng std::inplace_merge của thư viện <algorithm> . . . . . . . 15
2.9 3-way Merge sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.10 Quick sort - Sắp xếp nhanh . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.10.1 Chọn phần tử cuối cùng làm pivot . . . . . . . . . . . . . . . . . . 18
2.10.2 Chọn phần tử đầu tiên làm pivot . . . . . . . . . . . . . . . . . . . 19
2.10.3 Median of Three . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
2.10.4 Chọn pivot ngẫu nhiên . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.11 Heap sort - Sắp xếp vun đống . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.11.1 Heap là gì? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.11.2 Các thao tác trên heap . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.11.3 Heap sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23

3 Các hàm sort trong C++ STL 24


3.1 qsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
3.2 std::sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
3.3 std::stable_sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
3.4 std::sort_heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26

4 Lời kết 26

Page 1/26
Các giải thuật sort trong C++

1 Định nghĩa
Giải thuật sắp xếp (Sorting algorithm) là giải thuật dùng để đảo thứ tự các phần tử
của 1 cấu trúc dữ liệu không có thứ tự nào đó (mảng: array, danh sách liên kết: linked
list,...) để các phần tử có một thứ tự nhất định (tăng dần hoặc giảm dần). Sau đây là các
giải thuật sắp xếp cơ bản và cách cài đặt chúng trên C++.
Trong bài viết này, mảng sẽ được thay thế bằng vector, là một cấu trúc dữ liệu với
khả năng tự ghi nhớ độ dài của chính nó. Để sử dụng cấu trúc dữ liệu này, ta phải thêm
thư viện <vector> vào. Do mỗi vector là 1 object của class vector (không như mảng là
1 dãy các vùng nhớ liên tục với con trỏ mảng giữ địa chỉ của vùng nhớ đầu tiên) nên việc
truyền tham chiếu cho vector là bắt buộc.
Thứ tự sắp xếp dùng cho các giải thuật dưới đây sẽ là sắp xếp tăng dần (giảm dần
tương tự).
Kiểu của các phần tử là kiểu pair<int, int> của thư viện <utility>, trong đó pair
là cấu trúc dữ liệu key - value, với giá trị đầu tiên là key - vị trí ban đầu của các phần tử
và giá trị thứ hai là value - là data cần để sắp xếp (có thể thay int bằng các kiểu dữ liệu
có thể so sánh được). Việc này giúp chúng ta đánh giá độ ổn định (stable) của giải thuật.
Giải thuật sắp xếp được gọi là ổn định nếu nó bảo toàn thứ tự trước sau của các phần tử
có giá trị trùng nhau.
Sử dụng hàm swap(a, b) để hoán đổi giá trị của a, b.

2 Cài đặt
2.1 Hàm main
1 int main () {
2 random_device rd ;
3 mt19937 gen ( rd () ) ;
4 uniform_int_distribution < int > dis (1 , 1 e5 - 1) ;
5 vector < pair < int , int > > v ;
6 const int N = 1 e5 ;
7 for ( int i = 0; i < N ; i ++) {
8 v . push_back ( make_pair (i , dis ( gen ) ) ) ;
9 }

Page 2/26
Các giải thuật sort trong C++

10 void (* f ) ( vector < pair < int , int > >&) = func ;
11 auto start = chrono :: h i g h _ r e s o l u t i o n _ c l o c k :: now () ;
12 f(v);
13 auto stop = chrono :: h i g h _ r e s o l u t i o n _ c l o c k :: now () ;
14 auto duration =
chrono :: duration_cast < chrono :: milliseconds >( stop -
start ) ;
15 bool flag = true ;
16 for ( int i = 0; i < N - 1; i ++) {
17 for ( int j = i + 1; j < N ; j ++) {
18 if ( v [ i ]. second == v [ j ]. second && v [ i ]. first >
v [ j ]. first ) {
19 flag = false ;
20 break ;
21 }
22 }
23 }
24 cout << " This algorithm is " << ( flag == true ? " stable "
: " not stable " ) << ’\ n ’;
25 cout << " This algorithm takes " << duration . count () <<
" ms to sort " ;
26 }

Đây là chương trình để kiểm tra độ ổn định cũng như tính thời gian chạy của các thuật
toán sắp xếp. Đừng hoảng loạn khi có nhiều hàm mới mà các bạn chưa được học, mình sẽ
giải thích đoạn chương trình này:

1. Dòng 2 và 3: khởi tạo bộ sinh ngẫu nhiên bằng thuật toán sinh số ngẫu nhiên
Mersenne Twister. Các bạn nhớ include thư viện random nhé.

2. Dòng 4: Khởi tạo khoảng sinh số ngẫu nhiên cho các phần tử trong mảng trong
khoảng từ 1 đến 105 − 1 sử dụng phân phối đồng nhất. Cái này cũng thuộc thư viện
random.

3. Dòng 5, 6: Khởi tạo mảng (dùng vector) và cố định số phần tử của mảng là 105 .
Với 105 phần tử và các phần tử đều thuộc đoạn [1; 105 − 1] thì sẽ luôn tồn tại ít nhất
2 phần tử bằng nhau (nguyên lý Dirichlet).

4. Dòng 7, 8, 9: Đưa các số ngẫu nhiên vào mảng v, chúng ta chọn key là index của
mảng luôn, value sẽ là cái chúng ta cần để sắp xếp.

Page 3/26
Các giải thuật sort trong C++

5. Dòng 10: Khởi tạo con trỏ hàm f trỏ đến các hàm sắp xếp (ở đây mình để tạm là
func)

6. Dòng 11, 12, 13, 14: Đo thời gian chạy của hàm f với đối số truyền vào là v, do có
sử dụng chrono:: nên các bạn nhớ include thư viện chrono nhé.

7. Từ dòng 15 đến 23: Kiểm tra tính ổn định của giải thuật sắp xếp.

Ngoài ra, tại các hàm sort để test, mình sẽ luôn thêm 1 câu lệnh ở đầu hàm:

1 cout << " Running " +


string ( source_location :: current () . function_name () ) <<
’\ n ’;

std::source_location chỉ có ở phiên bản C++20, các bạn nhớ setup trình biên dịch
để có thể chạy phiên bản C++20 nhé.
Setup chương trình test xong rồi, chúng ta cùng đến với các giải thuật sắp xếp sau đây:

2.2 Exchange sort - Sắp xếp đổi chỗ trực tiếp


Đây là thuật toán đổi chỗ ngây thơ (naive sorting algorithm), cũng là thuật toán đổi
chỗ đầu tiên mà các bạn mới học C++ đều được tiếp xúc và làm quen. Chắc hẳn các bạn
không còn lạ gì với đoạn code này:

1 void exchangeSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 for ( int i = 0; i < ( int ) v . size () ; i ++) {
4 for ( int j = i + 1; j < ( int ) v . size () ; j ++) {
5 if ( v [ i ]. second > v [ j ]. second ) {
6 swap ( v [ i ] , v [ j ]) ;
7 }
8 }
9 }
10 }

Page 4/26
Các giải thuật sort trong C++

Chạy hàm main với func là hàm exchangeSort ta được kết quả:

Hình 1: Kết quả sau khi chạy hàm exchangeSort

Note: Ở đây mình biên dịch với optimization -O3 và -std=c++20 để tăng tốc độ chạy
chương trình và biên dịch bằng c++20
Các bạn có thể thấy là giải thuật này không có tính ổn định và thời gian chạy lâu (21.7s
đã optimize bằng -O3 với 105 phần tử) nên không phù hợp để cài đặt khi số phần tử lớn
hơn. Tuy nhiên thuật toán này rất dễ hiểu cũng như đơn giản với người mới học cũng như
việc thuật toán này là thuật toán sort - in - place algorithm (sắp xếp tại chỗ) nên tiết kiệm
bộ nhớ hơn.
Độ phức tạp thời gian (mọi trường hợp): O(N 2 ).
Độ phức tạp không gian (mọi trường hợp): O(1).

2.3 Bubble Sort - Sắp xếp nổi bọt


Đúng như tên gọi của nó, Bubble sort sắp xếp các phần tử bằng cách so sánh 2 số liên
tiếp nhau để đưa phần tử nhỏ hơn trong sắp xếp giảm dần (hoặc lớn hơn trong sắp xếp
tăng dần) lên các vị trí đầu mảng. Việc so sánh này sẽ giúp đưa số nhỏ nhất (nếu sort
giảm dần) hoặc đưa số lớn nhất (nếu sort tăng dần) về cuối mảng chỉ trong lần lặp đầu
tiên, các lần lặp sau đó chúng ta không cần xét phần tử đó nữa. Và cũng có thể nhận ra
rằng Bubble sort và Exchange sort có đôi nét tương đồng với nhau, chỉ khác về cách so
sánh.
Dưới đây là chương trình tham khảo:

1 void bubbleSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 for ( int i = 0; i < ( int ) v . size () ; i ++) {
4 for ( int j = 0; j < ( int ) v . size () - i - 1; j ++) {

Page 5/26
Các giải thuật sort trong C++

5 if ( v [ j ]. second > v [ j + 1]. second ) {


6 swap ( v [ j ] , v [ j + 1]) ;
7 }
8 }
9 }
10 }

Chạy hàm main với func là bubbleSort ta được kết quả:

Hình 2: Kết quả sau khi chạy hàm bubbleSort

Với 105 phần tử và optimization -O3, bubble sort tốn gần 17.4s để sắp xếp, do đó
Bubble sort không tối ưu khi sắp xếp mảng với số lượng phần tử lớn. Nhưng ngược lại đây
là giải thuật sort ổn định, bảo toàn thứ tự các phần tử giống nhau. Bubble sort, cũng như
Exchange sort, đều là các sort - in - place algorithm.
Có thể nhìn ra rằng hàm trên không tối ưu nếu mảng đã sắp xếp có thứ tự trùng với
thứ tự mà ta mong muốn, khi này hai vòng lặp vẫn phải chạy hết. Để tối ưu, ta sẽ thêm
cờ swapped ở ngoài cả 2 vòng lặp để kiểm tra xem có sự hoán đổi giá trị của 2 phần tử
liên tiếp hay không. Nếu không có thì ta dừng việc lặp lại và kết thúc hàm.
Đoạn code tối ưu cho chương trình trên như sau:

1 void b ubb le So rtO pt im ize ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 bool swapped ;
4 for ( int i = 0; i < ( int ) v . size () ; i ++) {
5 swapped = false ;
6 for ( int j = 0; j < ( int ) v . size () - i - 1; j ++) {
7 if ( v [ j ]. second > v [ j + 1]. second ) {
8 swap ( v [ j ] , v [ j + 1]) ;
9 swapped = true ;
10 }
11 }

Page 6/26
Các giải thuật sort trong C++

12 if (! swapped ) {
13 return ;
14 }
15 }
16 }

Độ phức tạp thời gian: O(N ) (trường hợp tốt nhất), O(N 2 ) (trung bình), O(N 2 ) (trường
hợp tệ nhất khi mảng sắp xếp ngược thứ tự mà ta mong muốn).
Độ phức tạp không gian (mọi trường hợp): O(1)

2.4 Odd even sort - Biến thể của Bubble sort


Như tên gọi của nó, Odd event sort sẽ bắt đầu từ các vị trí có index lẻ, sau đó là vị
trí có index chẵn, bằng cách so sánh với phần tử kế tiếp của nó. Và mình sẽ cải tiến thuật
toán để nhận diện mảng đã sắp xếp theo đúng thứ tự mà ta mong muốn bằng biến sorted
(tương tự như biến swapped của Bubble Sort).
Đoạn code tham khảo giải thuật Odd even sort:

1 void o d d Ev e nS o rt O p ti m iz e ( vector < pair < int , int > >& v ) {


2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 bool sorted = false ;
4 while (! sorted ) {
5 sorted = true ;
6 for ( int i = 1; i <= ( int ) v . size () - 2; i += 2) {
7 if ( v [ i ]. second > v [ i + 1]. second ) {
8 sorted = false ;
9 swap ( v [ i ] , v [ i + 1]) ;
10 }
11 }
12 for ( int i = 0; i <= ( int ) v . size () - 2; i += 2) {
13 if ( v [ i ]. second > v [ i + 1]. second ) {
14 sorted = false ;
15 swap ( v [ i ] , v [ i + 1]) ;
16 }
17 }
18 }
19 }

Page 7/26
Các giải thuật sort trong C++

Chạy hàm main với hàm func là oddEvenSortOptimize ta được:

Hình 3: Kết quả sau khi chạy hàm oddEventSortOptimize

Độ phức tạp thời gian: O(N ) (trường hợp tốt nhất), O(N 2 ) (trung bình), O(N 2 ) (trường
hợp tệ nhất khi mảng sắp xếp ngược thứ tự mà ta mong muốn).
Độ phức tạp không gian (mọi trường hợp): O(1)

2.5 Insertion sort - Sắp xếp chèn


Đúng như tên gọi, Insertion sort thực hiện hai giai đoạn:

1. Chia mảng thành hai nửa, mảng đã sắp xếp và mảng chưa sắp xếp, ban đầu thì phần
tử đứng đầu mảng gốc sẽ thuộc về mảng đã sắp xếp, còn lại thuộc về mảng chưa sắp
xếp.

2. Lặp qua từng phần tử trong phần mảng chưa sắp xếp và chèn phần tử đó vào đúng
vị trí của nó trong mảng đã sắp xếp.

Ở đây mình xin trình bày hai phương pháp để cài đặt giải thuật này

2.5.1 Không sử dụng thêm STL (Standard Template Library)

Đoạn code sau đây cũng khá dễ hiểu, các bạn có thể đọc sơ qua:

1 void insertionSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 for ( int i = 1; i < ( int ) v . size () ; i ++) {
4 pair < int , int > temp = v [ i ];
5 int idx = i - 1;
6 while ( idx >= 0 && v [ idx ]. second > temp . second ) {
7 v [ idx + 1] = v [ idx ];
8 idx - -;

Page 8/26
Các giải thuật sort trong C++

9 }
10 v [ idx + 1] = temp ;
11 }
12 }

Thử test thời gian và độ ổn định của giải thuật, ta có kết quả sau:

Hình 4: Kết quả khi chạy giải thuật Insertion sort (không sử dụng STL)

Thời gian cải thiện rất nhiều so với giải thuật Bubble sort, đồng thời Insertion sort
cũng ổn định nên được cài đặt trong một số giải thuật sắp xếp khác (sẽ nói sau).
Độ phức tạp thời gian: O(N ) (trường hợp tốt nhất), O(N 2 ) (trung bình), O(N 2 ) (trường
hợp tệ nhất).
Độ phức tạp không gian (mọi trường hợp): O(1)

2.5.2 Sử dụng std::rotate và std::upper_bound của thư viện <algorithm>

1 void insertionSortSTL ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 for ( auto it = v . begin () ; it != v . end () ; it ++) {
4 rotate ( upper_bound ( v . begin () , it , it - > second ,
[]( const int x , pair < int , int > p ) { return p . second < x ;
}) , it , it + 1) ;
5 }
6 }

Không cần phải hoảng loạn khi nhìn đoạn code tuy cực ngắn, nhưng cũng khó hiểu
hơn. Đừng lo, mình sẽ giải thích từng dòng code cho các bạn:

1. Dòng 2: Vòng lặp này sẽ lặp từ đầu mảng đến cuối mảng, ở đây sử dụng cấu trúc dữ
liệu là vector<pair<int, int>>::iterator làm biến con trỏ để lặp trong vector.

Page 9/26
Các giải thuật sort trong C++

Ở đây begin là iterator trỏ đến vị trí index 0 của mảng, còn end trỏ đến vị trí index
v.size() của mảng.

2. Dòng 3: Đây là sự kết hợp giữa 2 hàm trong thư viện <algorithm>:

• upper_bound(first, last, ele, comp = less): trả về iterator (ở đây itera-


tor có kiểu là vector<pair<int, int>>::iterator) trong vector bắt đầu từ
iterator first (tính luôn first) đến iterator last (không tính last) của phần
tử có giá trị mà khi tương tác với ele thông qua hàm comp, hàm comp trả về
true. Nếu không tìm thấy, hàm trả về iterator last. Hàm này chỉ được dùng
khi mảng đã sắp xếp.
Trong đoạn code trên, hàm này sẽ trả về iterator của phần tử có thuộc tính
second lớn hơn it->second (do it là iterator, là 1 con trỏ nên dùng toán tử
-> để truy cập đến value) trong vector v tính từ v.begin() đến iterator it.
Sở dĩ chúng ta chọn lớn hơn do chúng ta đã sử dụng hàm comp là một lambda
function để quy định việc so sánh một số với một pair (ghi ngược lại là chương
trình báo lỗi) và chúng ta set quy tắc để so sánh là data của pair (thông qua
thuộc tính second) nhỏ hơn số cần so sánh.

• rotate(first, middle, last): thực hiện swap - in - place 2 mảng con: mảng
1 từ iterator first đến iterator middle (không tính middle), mảng 2 từ iterator
middle đến iterator last (không tính last), đồng nghĩa với việc mảng con đầu
tiên sẽ luôn nằm sau mảng con thứ 2. Phép rotate này bảo toàn thứ tự của các
giá trị trùng nhau trong mảng.
Trong đoạn code trên, kết quả của hàm upper_bound sẽ được dùng làm iterator
first của hàm rotate với middle = it và last = it + 1.

Test độ ổn định của giải thuật và thời gian chạy hàm insertionSortSTL, ta có kết
quả:

Page 10/26
Các giải thuật sort trong C++

Hình 5: Kết quả sau khi chạy hàm insertionSort có sử dụng thư viện <algorithm>

Độ phức tạp thời gian: Không như hàm insertionSort, insertionSortSTL có độ


phức tạp thời gian trong trường hợp tệ nhất là O(N 2 logN ) (gồm O(N ) vòng lặp + O(N )
của rotate + O(logN ) của hàm upper_bound). Trong trường hợp tốt nhất, việc rotate sẽ
không diễn ra nên độ phức tạp sẽ là O(N logN ). Với mọi trường hợp, độ phức tạp trung
bình là O(N 2 logN )
Độ phức tạp không gian (mọi trường hợp): O(1)

2.6 Shell sort - Biến thể của Insertion sort


Nếu nhìn lại Insertion sort, ta thấy rằng các phần tử sẽ dịch chuyển mỗi lần là 1 bước
nhảy (ở vòng while). Vậy nếu phần tử cần dịch chuyển ở cuối mảng và ta cần dịch chuyển
lên đầu mảng thì sao? Rõ ràng việc di chuyển sẽ tốn rất nhiều thời gian, do vậy Shell sort
ra đời như là một cách để khắc phục nhược điểm trên.
Shell sort thực hiện bằng cách chọn gap bất kỳ, sau đó thực hiện sort các mảng con
chứa các phần tử ở vị trí cách nhau gap đơn vị (sử dụng Insertion sort) và thực hiện giảm
gap xuống. Lặp lại quá trình trên cho đến khi gap = 1.
Đoạn code tham khảo cho giải thuật Shell sort với gap bằng một nửa số phần tử của
mảng và mỗi lần giảm gap đi một nửa:

1 void shellSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 int N = ( int ) v . size () ;
4 for ( int gap = N / 2; gap > 0; gap /= 2) {
5 for ( int i = gap ; i < N ; i ++) {
6 auto temp = v [ i ];
7 int j ;
8 for ( j = i ; j >= gap && v [ j - gap ]. second >
temp . second ; j -= gap ) {

Page 11/26
Các giải thuật sort trong C++

9 v [ j ] = v [ j - gap ];
10 }
11 v [ j ] = temp ;
12 }
13 }
14 }

Tất nhiên là tùy tình huống mà chọn gap và chọn cách giảm gap xuống cho phù hợp.
Với đoạn code trên, khi test độ ổn định và thời gian chạy, ta được kết quả:

Hình 6: Kết quả khi chạy hàm shellSort

Mặc dù thời gian chạy rất nhanh (chỉ có 27ms) nhưng Shell sort lại không ổn định (ai
quan tâm chứ, nhanh là được).
Độ phức tạp về thời gian: dựa trên việc chọn gap và cách giảm gap (với việc chọn gap
như đoạn code trên thì độ phức tạp trung bình là O(N 2 ). Trường hợp tốt nhất thì độ phức
tạp là O(N logN ) và trường hợp tệ nhất là O(N 2 )).
Có thể tham khảo các kỹ thuật chọn gap qua link: https://en.wikipedia.org/wiki/
Shellsort#Gap_sequences. Hiện tại thì Shell sort sử dụng Hibbard’s gap có độ phức
tạp trung bình rất ấn tượng: O(N 1.25 ) (có lẽ đây là cách chọn gap tối ưu nhất).
Độ phức tạp không gian (mọi trường hợp): O(1)

2.7 Selection sort - Sắp xếp chọn


Selection sort cũng như Insertion sort, chia mảng ra làm 2 nửa: mảng đã sắp xếp và
mảng chưa sắp xếp. Tại mỗi lần lặp, ta chọn phần tử nhỏ nhất của phần chưa sắp xếp
(hoặc phần tử lớn nhất) và đưa vào cuối phần đã sắp xếp.
Để dễ hình dung, đây là hàm mô phỏng giải thuật Selection sort:

1 void selectionSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<

Page 12/26
Các giải thuật sort trong C++

’\ n ’;
3 for ( int i = 0; i < v . size () ; i ++) {
4 auto it = min_element ( v . begin () + i + 1 , v . end () ,
[]( pair < int , int > a , pair < int , int > b ) { return a . second
< b . second ; }) ;
5 if ( it != v . end () && v [ i ]. second > it - > second ) {
6 swap ( v [ i ] , v [ it - v . begin () ]) ;
7 }
8 }
9 }

Do mình lười viết vòng lặp tìm min nên mình dùng tạm hàm min_element của thư
viện algorithm. Hàm min_element(first, last, comp = less) dùng để tìm phần tử
có thứ hạng cao nhất (được quy định bởi hàm comp, mặc định comp là hàm less của C++)
trong vector bắt đầu từ iterator first đến iterator last (không tính last).Mình đã set
hàm comp là hàm lambda để so sánh 2 pair bằng cách so sánh 2 thuộc tính second của
chúng.
Kiểm tra độ ổn định và thời gian chạy của giải thuật, ta được kết quả sau:

Hình 7: Kết quả khi chạy hàm selectionSort

Độ phức tạp thời gian (mọi trường hợp): O(N 2 )


Độ phức tạp không gian (mọi trường hợp): O(1)

2.8 Merge sort - Sắp xếp trộn


Merge sort là một ví dụ điển hình của phương pháp Chia để trị (Divide and Conquer
Algorithm), tức là phương pháp sort sử dụng đệ quy để chia mảng ban đầu thành 2 mảng
con có kích thước bằng nhau cho đến khi các mảng con đều có kích thước bằng 1, sau đó
thực hiện merge các mảng con lại để được một mảng lớn hơn đã được sắp xếp.

Page 13/26
Các giải thuật sort trong C++

2.8.1 Không sử dụng thêm STL

Khi này, ta phải hiện thực thêm hàm merge để trộn 2 mảng con đã sắp xếp để thành
một mảng lớn hơn đã sắp xếp.
Hàm merge được hiện thực như sau:

1 void merge ( vector < pair < int , int > >& v , int left , int mid ,
int right ) {
2 // Assume that mid and right are the same as interator
end in vector , we do not include it in the range of sub
arrays ( mid in sub 1 and right in sub 2)
3 vector < pair < int , int > > sub1 ( v . begin () + left ,
v . begin () + mid ) , sub2 ( v . begin () + mid , v . begin () +
right ) ;
4 int i = 0 , j = 0 , k = left ;
5 while ( i < ( int ) sub1 . size () && j < ( int ) sub2 . size () ) {
6 if ( sub1 [ i ]. second <= sub2 [ j ]. second ) {
7 v [ k ] = sub1 [ i ];
8 i ++;
9 }
10 else {
11 v [ k ] = sub2 [ j ];
12 j ++;
13 }
14 k ++;
15 }
16 while ( i < ( int ) sub1 . size () ) {
17 v [ k ] = sub1 [ i ];
18 i ++;
19 k ++;
20 }
21 while ( j < ( int ) sub2 . size () ) {
22 v [ k ] = sub2 [ j ];
23 j ++;
24 k ++;
25 }
26 }

Hàm mergeSortHelper (không cần thiết, nhưng để thuận tiện để test thì mình hiện
thực hàm này) sử dụng đệ quy để chia mảng làm đôi, sau đó gọi hàm merge để gộp các
mảng con lại. Đoạn code tham khảo dưới đây:

Page 14/26
Các giải thuật sort trong C++

1 void mergeSortHelper ( vector < pair < int , int > >& v , int left ,
int right ) {
2 if ( right - left <= 1) {
3 return ;
4 }
5 int mid = left + ( right - left ) / 2;
6 mergeSortHelper (v , left , mid ) ;
7 mergeSortHelper (v , mid , right ) ;
8 merge (v , left , mid , right ) ;
9 }

Hàm mergeSort gọi hàm mergeSortHelper với left = 0 và right = kích thước của
mảng truyền vào:

1 void mergeSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 mergeSortHelper (v , 0 , ( int ) v . size () ) ;
4 }

Test thử giải thuật này, ta thu được kết quả:

Hình 8: Kết quả sau khi chạy hàm mergeSort

Độ phức tạp thời gian (mọi trường hợp): O(N logN ) (do cứ mỗi lần chúng ta chia đôi
mảng ra và tốn O(N ) để trộn tất cả các mảng con kích thước 1 lại).
Độ phức tạp không gian (mọi trường hợp): Khác với các giải thuật trước đó, Merge
sort cần O(N ) không gian để tạo các mảng con.

2.8.2 Sử dụng std::inplace_merge của thư viện <algorithm>

Với hàm này, việc merge được thực hiện tại chỗ, không sử dụng thêm mảng phụ nên
độ phức tạp không gian được rút xuống O(1). Đồng thời ta không cần hàm merge nữa.

Page 15/26
Các giải thuật sort trong C++

Đoạn code tham khảo:

1 void m erg eS or tHe lp er STL ( vector < pair < int , int > >& v , int
left , int right ) {
2 if ( right - left <= 1) {
3 return ;
4 }
5 int mid = left + ( right - left ) / 2;
6 mergeSortHelper (v , left , mid ) ;
7 mergeSortHelper (v , mid , right ) ;
8 inplace_merge ( v . begin () , v . begin () + mid , v . begin () +
right , []( const pair < int , int >& a , const pair < int , int >&
b ) { return a . second < b . second ; }) ;
9 }
10

11 void mergeSortSTL ( vector < pair < int , int > >& v ) {
12 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
13 me rg eS ort He lp erS TL (v , 0 , ( int ) v . size () ) ;
14 }

Hàm inplace_merge(first, mid, last, comp = less) sẽ gộp 2 mảng con đã được
sắp xếp tăng dần (tùy theo hàm comp): mảng 1 từ iterator first đến iterator mid (không
tính mid), mảng 2 từ iterator mid đến iterator last (không tính last) và việc merge này
sẽ thực hiện trên chính mảng từ iterator first đến last (không tính last).
Test thử giải thuật này, ta thu được kết quả:

Hình 9: Kết quả sau khi chạy hàm mergeSort có sử dụng thư viện algorithm

. Độ phức tạp thời gian (mọi trường hợp): O(N logN ) (do cứ mỗi lần chúng ta chia đôi
mảng ra và tốn O(N ) để trộn tất cả các mảng con kích thước 1 lại).
Độ phức tạp không gian (mọi trường hợp): O(1).

Page 16/26
Các giải thuật sort trong C++

2.9 3-way Merge sort


3-way Merge sort là biến thể của Merge sort. Thay vì chia đôi mảng như phiên bản
gốc, 3-way Merge sort sẽ chia mảng ra làm 3 phần, sau đó gộp tất cả mảng con lại để tạo
thành mảng đã sắp xếp.
Do tính chất tương tự của 3-way Merge sort so với Merge sort thông thường cho nên
việc hiện thực giải thuật này xin dành cho bạn đọc.

2.10 Quick sort - Sắp xếp nhanh


Quick sort, cũng như Merge sort, là giải thuật sắp xếp dựa trên phương pháp Chia để
trị. Tuy nhiên, Quick sort chia mảng thành 2 nửa xung quanh phần tử pivot. Nửa bên
trái sẽ chứa toàn bộ phần tử có giá trị nhỏ hơn hoặc bằng pivot còn nửa bên phải có giá
trị lớn hơn hoặc bằng pivot (hoặc ngược lại).
Khi thực hiện giải thuật sắp xếp nhanh, chúng ta cần thêm 1 hàm hỗ trợ:

1 int partition ( vector < pair < int , int > >& v , int low , int high ) ;

Chúng ta giả sử high không nằm trong khoảng đang xét, để truy cập phần tử cuối
cùng ta sẽ phải truy cập index high - 1.
Khi chạy hàm partition, pivot sẽ được đưa đến đúng vị trí của nó trong mảng đã
sắp xếp và vị trí đó sẽ được trả về để thực hiện chia mảng.
Với hàm quickSortHelper để gọi đệ quy, chúng ta hiện thực như sau:

1 void quickSortHelper ( vector < pair < int , int > >& v , int low ,
int high ) {
2 if ( high - left > 1) {
3 int par_idx = partition (v , low , high ) ;
4 quickSortHelper (v , low , par_idx ) ;
5 quickSortHelper (v , par_idx + 1 , high ) ;
6 }
7 }

Khi tìm ra par_idx, hàm sẽ gọi đệ quy để chia mảng ra làm 2 nửa và sort mỗi nửa

Page 17/26
Các giải thuật sort trong C++

mảng (chúng ta không cần ghép lại mảng do việc sắp xếp mỗi nửa mảng đều thực hiện
trên chính mảng gốc).
Với hàm quickSort để test, đơn giản là gọi hàm quickSortHelper với đối số low là 0,
đối số high là kích thước mảng:

1 void quickSort ( vector < pair < int , int > >& v ) {
2 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
3 quickSortHelper (v , 0 , ( int ) v . size () ) ;
4 }

Trong bài viết này mình xin chia sẻ 4 phương pháp chọn pivot như sau:

2.10.1 Chọn phần tử cuối cùng làm pivot

Do chọn phần tử pivot ở cuối cho nên ta cần phải đưa pivot này về đúng vị trí của
nó bằng cách hoán vị trước các phần tử không phải pivot để thỏa mãn các phần tử lớn
hơn pivot ở cùng 1 bên, các phần tử nhỏ hơn pivot ở cùng 1 bên. Cuối cùng đưa pivot
vào vị trí để chia 2 phần mảng đó. Hơi khó hiểu đúng không? Chúng ta đọc đoạn code sau
để tiện theo dõi:

1 int partition ( vector < pair < int , int > >& v , int low , int high )
{
2 pair < int , int > pivot = v [ high - 1];
3 int i = low - 1;
4 for ( int j = low ; j <= high - 2; j ++) {
5 if ( v [ j ]. second < pivot . second ) {
6 i ++;
7 swap ( v [ i ] , v [ j ]) ;
8 }
9 }
10 swap ( v [ i + 1] , v [ high - 1]) ;
11 return i + 1;
12 }

Thử test chương trình này, ta được kết quả sau:

Page 18/26
Các giải thuật sort trong C++

Hình 10: Kết quả khi chạy hàm quickSort với pivot ở cuối mảng

Thời gian chạy được cải thiện đáng kể nên mới có tên gọi là Quick sort.
Độ phức tạp về thời gian: O(N logN ) (trường hợp tốt nhất khi mảng luôn được chia
đều), O(N logN ) (trung bình), O(N 2 ) (trường hợp tệ nhất khi pivot sau khi partition luôn
ở cuối mảng hoặc đầu mảng, khi này những dãy như thế gọi là Quick sort killer sequence).
Độ phức tạp không gian (mọi trường hợp): O(1)

2.10.2 Chọn phần tử đầu tiên làm pivot

Với việc chọn pivot ở đầu, chúng ta cần thay đổi 1 chút hàm partition như sau:

1 int partition ( vector < pair < int , int > >& v , int low , int high )
{
2 pair < int , int > pivot = v [ low ];
3 int i = low ;
4 for ( int j = low + 1; j < high ; j ++) {
5 if ( v [ j ]. second < pivot . second ) {
6 i ++;
7 swap ( v [ i ] , v [ j ]) ;
8 }
9 }
10 swap ( v [ i ] , v [ low ]) ;
11 return i ;
12 }

Vì sao ở đây ta chỉ dùng i thay vì i + 1? Hãy tưởng tượng pivot là số lớn nhất mảng
xem, khi này i sẽ chạy đến high - 1, và v[i + 1] bị lỗi out_of_bound. Test thử chương
trình, ta được:

Page 19/26
Các giải thuật sort trong C++

Hình 11: Kết quả khi chạy hàm quickSort với pivot ở đầu mảng

Độ phức tạp: Cũng giống như việc chọn pivot ở cuối mảng

2.10.3 Median of Three

Ý tưởng của phương pháp này là chọn số trung vị trong 3 phần tử: đầu mảng, cuối
mảng và phần tử chính giữa mảng để làm pivot. Để cho dễ thì ta swap pivot với phần
tử ở cuối mảng xong chạy hàm partition là được.
Code tham khảo:

1 int pivotLast ( vector < pair < int , int > >& v , int low , int high )
{
2 pair < int , int > pivot = v [ high - 1];
3 int i = low - 1;
4 for ( int j = low ; j < high - 1; j ++) {
5 if ( v [ j ]. second < pivot . second ) {
6 i ++;
7 swap ( v [ i ] , v [ j ]) ;
8 }
9 }
10 swap ( v [ i + 1] , v [ high - 1]) ;
11 return i + 1;
12 }
13

14 int partition ( vector < pair < int , int > >& v , int low , int high )
{
15 int first = v [ low ]. second , last = v [ high - 1]. second ,
mid = v [( high + low ) /2]. second ;
16 int median = ( first > last ? ( mid > first ? low : ( mid
> last ? ( high + low ) /2 : high - 1) ) : ( mid > last ? high
- 1 : ( first < mid ? ( high + low ) /2 : low ) ) ) ;
17 swap ( v [ high - 1] , v [ median ]) ;
18 return pivotLast (v , low , high ) ;
19 }

Page 20/26
Các giải thuật sort trong C++

Chạy test kiểm tra ta được kết quả:

Hình 12: Kết quả khi chạy hàm quickSort sử dụng Median of Three làm pivot

Độ phức tạp: Giống với việc chọn pivot ở đầu hoặc cuối mảng. Những dãy khiến giải
thuật này rơi vào trường hợp tệ nhất gọi là Median of Three killer sequence.

2.10.4 Chọn pivot ngẫu nhiên

Ta sẽ random chỉ số của index, swap nó với phần tử cuối cùng của mảng, và thực hiện
partition mảng như bình thường.
Code tham khảo (sử dụng mt19937 và uniform_int_distribution sẽ rất lâu cho nên
mình dùng tạm hàm rand của thư viện random):

1 int pivotLast ( vector < pair < int , int > >& v , int low , int high )
{
2 pair < int , int > pivot = v [ high - 1];
3 int i = low - 1;
4 for ( int j = low ; j < high - 1; j ++) {
5 if ( v [ j ]. second < pivot . second ) {
6 i ++;
7 swap ( v [ i ] , v [ j ]) ;
8 }
9 }
10 swap ( v [ i + 1] , v [ high - 1]) ;
11 return i + 1;
12 }
13

14 int partition ( vector < pair < int , int > >& v , int low , int high )
{
15 int idx = rand () % ( high - low ) + low ;
16 swap ( v [ high - 1] , v [ idx ]) ;
17 return pivotLast (v , low , high ) ;
18 }

Page 21/26
Các giải thuật sort trong C++

Test độ ổn định và thời gian chạy của hàm, ta được:

Hình 13: Kết quả khi chạy hàm quickSort khi chọn pivot random

Độ phức tạp: giống như việc chọn pivot ở đầu, cuối hay Median of Three, tuy nhiên sẽ
giảm thiểu được tình trạng gặp phải killer sequence

2.11 Heap sort - Sắp xếp vun đống


2.11.1 Heap là gì?

Heap là trường hợp đặc biệt của cây nhỉ phân cân bằng, trong đó khóa của mỗi node
được so sánh với con của nó. Nếu khóa của node ≥ khóa của các node con của nó thì ta
gọi là Max heap, ngược lại nếu khóa của node ≤ khóa của các node con của nó thì ta gọi
là Min heap.
Để biểu diễn heap, chúng ta có thể sử dụng Binary Tree hoặc mảng. Trong phạm vi
bài này, chúng ta sẽ sử dụng mảng:

1. Node gốc của cây được đặt tại index 0.

2. Với mỗi node tại index i, node con của nó được đặt tại index 2 ∗ i + 1 và 2 ∗ i + 2.

2.11.2 Các thao tác trên heap

Đối với heap (chúng ta sẽ lấy min heap làm ví dụ, max heap tương tự), chúng ta có
các thao tác chính như sau:

1. reheapUp: khi có 1 node có giá trị nhỏ hơn node cha của nó thì ta di chuyển lên trên

2. reheapDown: khi có 1 node có giá trị lớn hơn node cha của nó thì ta di chuyển xuống
dưới

3. heapify: thực hiện build heap từ 1 cây nhị phân (hoặc mảng)

Ở đây chúng ta sẽ chỉ nói về thao tác heapify.

Page 22/26
Các giải thuật sort trong C++

2.11.3 Heap sort

Heap sort gồm các thao tác sau:

1. Build lại mảng để thành max heap.

2. Loại bỏ node gốc (node lớn nhất khi sắp xếp mảng tăng dần) và đưa xuống cuối
mảng và thay thế node gốc bởi node cuối cùng của heap ban đầu.

3. Thực hiện heapify phần mảng còn lại (phần mảng đã bỏ phần tử cuối cùng) với gốc
mới để build lại heap

4. Lặp lại các bước trên cho đến khi mảng được sắp xếp.

Việc heapify tại index i được thực hiện như sau:

1. Gọi root là tại index i, khi đó con trái của root có index là 2 * i + 1 và con phải
của root có index là 2 * i + 2.

2. Kiểm tra xem root sẽ là con trái hay con phải, hay giữ nguyên (nhớ kiểm tra các
con trái và con phải của root có tồn tại hay không) bằng cách so giá trị của root với
2 con xem node nào có giá trị lớn hơn.

3. Nếu root không còn ở index i nữa, ta swap 2 phần tử ở vị trí root và vị trí i, thực
hiện đệ quy lại heapify với index mới là root.

Toàn bộ giải thuật Heap sort được mô phỏng lại thông qua đoạn code sau:

1 void heapify ( vector < pair < int , int > >:: iterator first ,
vector < pair < int , int > >:: iterator last , int idx ) {
2 int root = idx , left = 2 * idx + 1 , right = 2 * idx + 2;
3 int len = last - first ;
4 if ( left < len && ( first + root ) -> second < ( first +
left ) -> second ) {
5 root = left ;
6 }
7 if ( right < len && ( first + root ) -> second < ( first +
right ) -> second ) {
8 root = right ;
9 }
10 if ( root != idx ) {

Page 23/26
Các giải thuật sort trong C++

11 iter_swap ( first + root , first + idx ) ;


12 heapify ( first , last , root ) ;
13 }
14 }
15

16 void heapSort ( vector < pair < int , int > >& v ) {
17 cout << " Running " +
string ( source_location :: current () . function_name () ) <<
’\ n ’;
18 int N = ( int ) v . size () ;
19 for ( int i = N / 2 - 1; i >= 0; i - -) {
20 heapify ( v . begin () , v . end () , i ) ;
21 }
22 for ( int i = N - 1; i > 0; i - -) {
23 swap ( v [ i ] , v [0]) ;
24 heapify ( v . begin () , v . begin () + i , 0) ;
25 }
26 }

Hàm heapify nhận vào 2 iterator của vector (không tính last) và index để heapify.
Kết quả khi chạy giải thuật Heap sort:

Hình 14: Kết quả khi chạy hàm heapSort

Độ phức tạp thời gian (mọi người hợp): O(N logN ).


Độ phức tạp không gian (mọi trường hợp): O(1)
Còn 1 số thuật toán sắp xếp khác nhưng mà bài viết này đã dài, các bạn hãy tự tìm
hiểu và khám phá thêm.

3 Các hàm sort trong C++ STL


3.1 qsort
Cú pháp: void qsort(void* base, size_t numEle, size_t size, int (*comp)(const
void*, const void*))

Page 24/26
Các giải thuật sort trong C++

Hàm này nhận đầu vào là con trỏ mảng (không dùng được vector ở đây), số lượng phần
tử của mảng, kích thước của kiểu dữ liệu lúc khai báo mảng và hàm compare (bắt buộc
phải có dạng giống như con trỏ hàm trong cú pháp, có thể sử dụng lambda function).
Có thể xem qua ví dụ sau:

1 void testing ( int * arr , int N ) {


2 qsort ( arr , N , sizeof ( int ) , []( const void * a , const
void * b ) -> int { return *(( int *) a ) - *(( int *) b ) ; };
3 }

Hàm này sử dụng giải thuật Quick sort và nằm trong thư viện <cstdlib> (hoặc
<stdlib.h> trong C)

3.2 std::sort
Cú pháp: void sort(first, last, comp = less)
Hàm này nhận tham số đầu vào là 2 RandomAccessIterator từ first (tính first) đến
last (không tính last), việc sort như thế nào sẽ phụ thuộc vào hàm comp.
RandomAccessIterator là 1 iterator mà ta có thể truy cập được phần tử tại vị trí iterator
đó (con trỏ mảng hay iterator của vector đều là các RandomAccessIterator)
Có thể xem qua ví dụ sau:

1 void testing ( int * arr , int N ) {


2 sort ( arr , arr + N , []( int a , int b ) { return a > b ; }) ;
3 }

Hàm sort đảm bảo độ phức tạp về thời gian của giải thuật luôn là O(N logN ) trong
mọi trường hợp do sử dụng giải thuật Intro sort. Intro sort là một Hybrid sort algorithm sử
dụng 3 thuật toán sort: Quick sort (dùng Median of Three pivot), Heap sort và Insertion
sort. Insertion sort được dùng khi mảng có kích thước bé (do người dùng chọn, thường là
nhỏ hơn 16 phần tử), Heap sort và Quick sort được chọn dựa trên depth limit (ban đầu
được khởi tạo bằng 2 lần log của kích thước mảng). Nếu depth limit bằng 0, chuyển sang
Heap sort, ngược lại sử dụng Quick sort. Hàm này ở trong thư viện <algorithm>.

Page 25/26
Các giải thuật sort trong C++

3.3 std::stable_sort
Cú pháp: stable_sort(first, last, comp = less)
Tương tự như hàm sort, stable_sort cũng nhận vào 2 RandomAccessIterator (tính
first nhưng không tính last) và ta có thể quy định hàm sẽ sort như thế nào thông qua
hàm comp. Hàm này là hàm sort ổn định, tức là nó bảo toàn thứ tự giữa các phần tử giống
nhau trong mảng ban đầu. Hàm này cũng thuộc thư viện <algorithm>.

3.4 std::sort_heap
Cú pháp: sort_heap(first, last, comp = less)
Tương tự như hàm sort và stable_sort, sort_heap cũng nhận vào 2 RandomAcces-
sIterator (tính first nhưng không tính last) và ta có thể quy định hàm sẽ sort như thế
nào thông qua hàm comp.
Tuy nhiên, để hàm này chạy đúng kết quả thì ta cần 1 hàm phụ trợ nữa của C++, đó
là hàm make_heap.
Cú pháp hàm make_heap: make_heap(first, last, comp = less).
Hàm make_heap cũng nhận vào 2 RandomAccessIterator first và last (tính first và
không tính last) dùng để build 1 container bất kì (mảng, vector) thành heap. Mặc định
khi không chỉ định hàm comp, hàm này sẽ build Max heap. 2 hàm này đều thuộc thư viện
<algorithm>.

4 Lời kết
Hi vọng là qua bài viết này, bạn đọc có thể nắm được các giải thuật sắp xếp được nêu
trong bài viết, hiểu được các tính chất của nó cũng như độ phức tạp của giải thuật, qua
đó có thể lựa chọn cho mình giải thuật phù hợp với mình. Đây cũng là bài viết cuối cùng
của mình cho môn DSA này, hẹn gặp lại mọi người trong các bài viết sau.

Page 26/26

You might also like