Khối xây dựng thuật toán
Chúng tôi bắt đầu bằng cách lắp ráp các khối xây dựng thuật toán từ Thư viện chuẩn:
#include <algorithm> // min_element, iter_swap,
// upper_bound, rotate,
// partition,
// inplace_merge,
// make_heap, sort_heap, push_heap, pop_heap,
// is_heap, is_sorted
#include <cassert> // assert
#include <functional> // less
#include <iterator> // distance, begin, end, next
- các công cụ lặp như không phải thành viên
std::begin()
/ std::end()
cũng như std::next()
chỉ có sẵn từ C ++ 11 trở lên. Đối với C ++ 98, người ta cần phải tự viết những thứ này. Có những sự thay thế từ Boost.Range in boost::begin()
/ boost::end()
và từ Boost.Utility in boost::next()
.
- các
std::is_sorted
thuật toán chỉ có sẵn cho C ++ 11 và xa hơn nữa. Đối với C ++ 98, điều này có thể được thực hiện dưới dạng std::adjacent_find
và một đối tượng hàm viết tay. Boost.Alacticm cũng cung cấp boost::algorithm::is_sorted
một thay thế.
- các
std::is_heap
thuật toán chỉ có sẵn cho C ++ 11 và xa hơn nữa.
Goodies thực tế
C ++ 14 cung cấp các bộ so sánh trong suốt của dạng std::less<>
hoạt động đa hình trên các đối số của chúng. Điều này tránh phải cung cấp loại lặp. Điều này có thể được sử dụng kết hợp với các đối số khuôn mẫu hàm mặc định của C ++ 11 để tạo ra một quá tải duy nhất để sắp xếp các thuật toán lấy <
so sánh và các thuật toán có đối tượng hàm so sánh do người dùng định nghĩa.
template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
Trong C ++ 11, người ta có thể định nghĩa bí danh mẫu có thể sử dụng lại để trích xuất loại giá trị của trình vòng lặp, điều này làm tăng thêm sự lộn xộn nhỏ cho chữ ký của thuật toán sắp xếp:
template<class It>
using value_type_t = typename std::iterator_traits<It>::value_type;
template<class It, class Compare = std::less<value_type_t<It>>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
Trong C ++ 98, người ta cần viết hai lần quá tải và sử dụng typename xxx<yyy>::type
cú pháp dài dòng
template<class It, class Compare>
void xxx_sort(It first, It last, Compare cmp); // general implementation
template<class It>
void xxx_sort(It first, It last)
{
xxx_sort(first, last, std::less<typename std::iterator_traits<It>::value_type>());
}
- Một đặc điểm cú pháp khác là C ++ 14 tạo điều kiện bao bọc các bộ so sánh do người dùng định nghĩa thông qua lambdas đa hình (với
auto
các tham số được suy ra như các đối số khuôn mẫu hàm).
- C ++ 11 chỉ có lambdas đơn hình, yêu cầu sử dụng bí danh mẫu ở trên
value_type_t
.
- Trong C ++ 98, một trong hai cần phải viết một hàm đối tượng độc lập hoặc dùng đến các tiết
std::bind1st
/ std::bind2nd
/ std::not1
kiểu cú pháp.
- Boost.Bind cải thiện điều này với
boost::bind
và _1
/ _2
cú pháp giữ chỗ.
- C ++ 11 và hơn thế nữa cũng có
std::find_if_not
, trong khi C ++ 98 cần std::find_if
có một std::not1
đối tượng chức năng xung quanh.
Phong cách C ++
Không có phong cách C ++ 14 thường được chấp nhận. Dù tốt hơn hay tồi tệ hơn, tôi theo sát bản dự thảo Hiệu quả hiện đại C ++ của Scott Meyers và GotW đã được tân trang lại của Herb Sutter . Tôi sử dụng các khuyến nghị phong cách sau đây:
- Khuyến nghị "Hầu như luôn luôn tự động" của Herb Sutter và khuyến nghị "Ưu tiên tự động cho các khai báo loại cụ thể" của Scott Meyers , trong đó sự ngắn gọn là không thể vượt qua, mặc dù sự rõ ràng của nó đôi khi bị tranh cãi .
- "Phân biệt
()
và {}
khi tạo đối tượng" của Scott Meyers và luôn chọn cách khởi tạo giằng {}
thay vì khởi tạo cũ được ngoặc đơn tốt ()
(để giải quyết các vấn đề phân tích rõ nhất trong mã chung).
- Scott Meyers "Thích khai báo bí danh cho typedefs" . Đối với các mẫu, đây là điều bắt buộc và sử dụng nó ở mọi nơi thay vì
typedef
tiết kiệm thời gian và thêm tính nhất quán.
- Tôi sử dụng một
for (auto it = first; it != last; ++it)
mẫu ở một số nơi, để cho phép kiểm tra bất biến vòng lặp cho các phạm vi con đã được sắp xếp. Trong mã sản xuất, việc sử dụng while (first != last)
và một ++first
nơi nào đó bên trong vòng lặp có thể tốt hơn một chút.
Lựa chọn sắp xếp
Lựa chọn sắp xếp không thích ứng với dữ liệu theo bất kỳ cách nào, vì vậy thời gian chạy của nó luôn luônO(N²)
. Tuy nhiên, sắp xếp lựa chọn có đặc tính giảm thiểu số lượng giao dịch hoán đổi . Trong các ứng dụng có chi phí trao đổi vật phẩm cao, việc sắp xếp lựa chọn rất tốt có thể là thuật toán được lựa chọn.
Để triển khai nó bằng Thư viện chuẩn, sử dụng nhiều lần std::min_element
để tìm phần tử tối thiểu còn lại và iter_swap
hoán đổi nó vào vị trí:
template<class FwdIt, class Compare = std::less<>>
void selection_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
for (auto it = first; it != last; ++it) {
auto const selection = std::min_element(it, last, cmp);
std::iter_swap(selection, it);
assert(std::is_sorted(first, std::next(it), cmp));
}
}
Lưu ý rằng selection_sort
phạm vi đã được xử lý được [first, it)
sắp xếp là bất biến vòng lặp của nó. Các yêu cầu tối thiểu là các trình vòng lặp chuyển tiếp , so với std::sort
các trình vòng lặp truy cập ngẫu nhiên.
Chi tiết bỏ qua :
- lựa chọn sắp xếp có thể được tối ưu hóa với một thử nghiệm sớm
if (std::distance(first, last) <= 1) return;
(hoặc cho các vòng lặp chuyển tiếp / hai chiều if (first == last || std::next(first) == last) return;
:).
- đối với các trình lặp hai chiều , phép thử trên có thể được kết hợp với một vòng lặp trong khoảng thời gian
[first, std::prev(last))
, bởi vì phần tử cuối cùng được đảm bảo là phần tử còn lại tối thiểu và không yêu cầu trao đổi.
Sắp xếp chèn
Mặc dù nó là một trong những thuật toán sắp xếp cơ bản với O(N²)
thời gian trong trường hợp xấu nhất, sắp xếp chèn là thuật toán được lựa chọn khi dữ liệu gần như được sắp xếp (vì nó thích nghi ) hoặc khi kích thước sự cố nhỏ (vì nó có chi phí thấp). Vì những lý do này và vì nó cũng ổn định , nên sắp xếp chèn thường được sử dụng làm trường hợp cơ sở đệ quy (khi kích thước bài toán nhỏ) cho các thuật toán sắp xếp phân chia và chinh phục trên cao hơn, chẳng hạn như sắp xếp hợp nhất hoặc sắp xếp nhanh.
Để triển khai insertion_sort
với Thư viện chuẩn, liên tục sử dụng std::upper_bound
để tìm vị trí mà phần tử hiện tại cần đến và sử dụng std::rotate
để dịch chuyển các phần tử còn lại lên trên trong phạm vi đầu vào:
template<class FwdIt, class Compare = std::less<>>
void insertion_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
for (auto it = first; it != last; ++it) {
auto const insertion = std::upper_bound(first, it, *it, cmp);
std::rotate(insertion, it, std::next(it));
assert(std::is_sorted(first, std::next(it), cmp));
}
}
Lưu ý rằng insertion_sort
phạm vi đã được xử lý được [first, it)
sắp xếp là bất biến vòng lặp của nó. Sắp xếp chèn cũng hoạt động với các vòng lặp phía trước.
Chi tiết bỏ qua :
- sắp xếp chèn có thể được tối ưu hóa với thử nghiệm sớm
if (std::distance(first, last) <= 1) return;
(hoặc cho các vòng lặp chuyển tiếp / hai chiều if (first == last || std::next(first) == last) return;
:) và một vòng lặp trong khoảng thời gian [std::next(first), last)
, bởi vì phần tử đầu tiên được đảm bảo đúng vị trí và không yêu cầu xoay.
- đối với các trình vòng lặp hai chiều , tìm kiếm nhị phân để tìm điểm chèn có thể được thay thế bằng tìm kiếm tuyến tính ngược bằng
std::find_if_not
thuật toán của Thư viện chuẩn .
Bốn ví dụ trực tiếp ( C ++ 14 , C ++ 11 , C ++ 98 và Boost , C ++ 98 ) cho đoạn dưới đây:
using RevIt = std::reverse_iterator<BiDirIt>;
auto const insertion = std::find_if_not(RevIt(it), RevIt(first),
[=](auto const& elem){ return cmp(*it, elem); }
).base();
- Đối với các đầu vào ngẫu nhiên, điều này đưa ra
O(N²)
so sánh, nhưng điều này cải thiện để O(N)
so sánh cho các đầu vào gần như được sắp xếp. Tìm kiếm nhị phân luôn sử dụng O(N log N)
so sánh.
- Đối với phạm vi đầu vào nhỏ, địa phương bộ nhớ tốt hơn (bộ đệm, tìm nạp trước) của tìm kiếm tuyến tính cũng có thể chi phối tìm kiếm nhị phân (tất nhiên người ta nên kiểm tra điều này).
Sắp xếp nhanh chóng
Khi được thực hiện cẩn thận, sắp xếp nhanh là mạnh mẽ và có O(N log N)
độ phức tạp dự kiến, nhưng với O(N²)
độ phức tạp trong trường hợp xấu nhất có thể được kích hoạt với dữ liệu đầu vào được chọn bất lợi. Khi không cần một loại ổn định, sắp xếp nhanh là một loại mục đích chung tuyệt vời.
Ngay cả đối với các phiên bản đơn giản nhất, sắp xếp nhanh cũng phức tạp hơn một chút khi thực hiện bằng Thư viện chuẩn so với các thuật toán sắp xếp cổ điển khác. Cách tiếp cận bên dưới sử dụng một vài tiện ích lặp để xác định phần tử ở giữa của phạm vi đầu vào [first, last)
làm trục, sau đó sử dụng hai lệnh gọi std::partition
(để O(N)
) phân vùng ba chiều phạm vi đầu vào thành các phân đoạn của các phần tử nhỏ hơn, bằng, và lớn hơn trục đã chọn, tương ứng. Cuối cùng, hai phân đoạn bên ngoài có các phần tử nhỏ hơn và lớn hơn trục được sắp xếp theo cách đệ quy:
template<class FwdIt, class Compare = std::less<>>
void quick_sort(FwdIt first, FwdIt last, Compare cmp = Compare{})
{
auto const N = std::distance(first, last);
if (N <= 1) return;
auto const pivot = *std::next(first, N / 2);
auto const middle1 = std::partition(first, last, [=](auto const& elem){
return cmp(elem, pivot);
});
auto const middle2 = std::partition(middle1, last, [=](auto const& elem){
return !cmp(pivot, elem);
});
quick_sort(first, middle1, cmp); // assert(std::is_sorted(first, middle1, cmp));
quick_sort(middle2, last, cmp); // assert(std::is_sorted(middle2, last, cmp));
}
Tuy nhiên, sắp xếp nhanh là khá khó để có được chính xác và hiệu quả, vì mỗi bước trên phải được kiểm tra cẩn thận và tối ưu hóa cho mã mức sản xuất. Cụ thể, đối với O(N log N)
độ phức tạp, trục phải dẫn đến một phân vùng cân bằng của dữ liệu đầu vào, không thể được đảm bảo chung cho một O(1)
trục, nhưng có thể được đảm bảo nếu đặt trục làm O(N)
trung vị của phạm vi đầu vào.
Chi tiết bỏ qua :
- việc thực hiện ở trên đặc biệt dễ bị tổn thương đối với các đầu vào đặc biệt, ví dụ: nó có
O(N^2)
độ phức tạp đối với đầu vào " ống nội tạng " 1, 2, 3, ..., N/2, ... 3, 2, 1
(vì phần giữa luôn lớn hơn tất cả các phần tử khác).
- Lựa chọn trục giữa của 3 trong số các yếu tố được chọn ngẫu nhiên từ các bộ bảo vệ phạm vi đầu vào so với các đầu vào gần như được sắp xếp mà độ phức tạp sẽ giảm đi
O(N^2)
.
- Phân vùng 3 chiều (tách các phần tử nhỏ hơn, bằng và lớn hơn trục) như được hiển thị bởi hai lệnh gọi
std::partition
không phải làO(N)
thuật toánhiệu quả nhấtđể đạt được kết quả này.
- đối với các trình vòng lặp truy cập ngẫu nhiên ,
O(N log N)
độ phức tạp được đảm bảo có thể đạt được thông qua lựa chọn trục trung bình bằng cách sử dụng std::nth_element(first, middle, last)
, theo sau là các cuộc gọi đệ quy đến quick_sort(first, middle, cmp)
và quick_sort(middle, last, cmp)
.
- tuy nhiên, sự đảm bảo này phải trả giá, bởi vì hệ số không đổi của
O(N)
độ phức tạp std::nth_element
có thể đắt hơn so với O(1)
độ phức tạp của trục trung bình 3 theo sau là một O(N)
cuộc gọi đến std::partition
(một chuyển tiếp đơn thân thiện với bộ đệm dữ liệu).
Hợp nhất sắp xếp
Nếu việc sử dụng O(N)
thêm không gian không đáng lo ngại, thì sắp xếp hợp nhất là một lựa chọn tuyệt vời: đó là thuật toán sắp xếp ổn định duy nhất O(N log N)
.
Thật đơn giản để thực hiện bằng thuật toán tiêu chuẩn: sử dụng một vài tiện ích lặp để xác định giữa phạm vi đầu vào [first, last)
và kết hợp hai phân đoạn được sắp xếp đệ quy với std::inplace_merge
:
template<class BiDirIt, class Compare = std::less<>>
void merge_sort(BiDirIt first, BiDirIt last, Compare cmp = Compare{})
{
auto const N = std::distance(first, last);
if (N <= 1) return;
auto const middle = std::next(first, N / 2);
merge_sort(first, middle, cmp); // assert(std::is_sorted(first, middle, cmp));
merge_sort(middle, last, cmp); // assert(std::is_sorted(middle, last, cmp));
std::inplace_merge(first, middle, last, cmp); // assert(std::is_sorted(first, last, cmp));
}
Hợp nhất sắp xếp yêu cầu các trình lặp hai chiều, nút cổ chai là std::inplace_merge
. Lưu ý rằng khi sắp xếp danh sách được liên kết, sắp xếp hợp nhất chỉ O(log N)
cần thêm không gian (cho đệ quy). Thuật toán thứ hai được triển khai std::list<T>::sort
trong Thư viện chuẩn.
Sắp xếp đống
Heap sort rất đơn giản để thực hiện, thực hiệnO(N log N)
sắp xếp tại chỗ, nhưng không ổn định.
Vòng lặp đầu tiên, O(N)
giai đoạn "heapify", đưa mảng vào thứ tự heap. Vòng lặp thứ hai, O(N log N
giai đoạn "sắp xếp", liên tục trích xuất tối đa và khôi phục thứ tự heap. Thư viện tiêu chuẩn làm cho điều này cực kỳ đơn giản:
template<class RandomIt, class Compare = std::less<>>
void heap_sort(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
lib::make_heap(first, last, cmp); // assert(std::is_heap(first, last, cmp));
lib::sort_heap(first, last, cmp); // assert(std::is_sorted(first, last, cmp));
}
Trong trường hợp bạn coi đó là "gian lận" để sử dụng std::make_heap
và std::sort_heap
, bạn có thể đi sâu hơn một cấp và tự viết các chức năng đó theo std::push_heap
và std::pop_heap
tương ứng:
namespace lib {
// NOTE: is O(N log N), not O(N) as std::make_heap
template<class RandomIt, class Compare = std::less<>>
void make_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
for (auto it = first; it != last;) {
std::push_heap(first, ++it, cmp);
assert(std::is_heap(first, it, cmp));
}
}
template<class RandomIt, class Compare = std::less<>>
void sort_heap(RandomIt first, RandomIt last, Compare cmp = Compare{})
{
for (auto it = last; it != first;) {
std::pop_heap(first, it--, cmp);
assert(std::is_heap(first, it, cmp));
}
}
} // namespace lib
Thư viện chuẩn chỉ định cả hai push_heap
và pop_heap
độ phức tạp O(log N)
. Tuy nhiên, lưu ý rằng vòng lặp bên ngoài phạm vi [first, last)
dẫn đến O(N log N)
độ phức tạp make_heap
, trong khi std::make_heap
chỉ có O(N)
độ phức tạp. Đối với sự O(N log N)
phức tạp tổng thể của heap_sort
nó không thành vấn đề.
Chi tiết bỏ qua : O(N)
thực hiệnmake_heap
Kiểm tra
Dưới đây là bốn ví dụ trực tiếp ( C ++ 14 , C ++ 11 , C ++ 98 và Boost , C ++ 98 ) kiểm tra tất cả năm thuật toán trên nhiều loại đầu vào (không có nghĩa là toàn diện hoặc nghiêm ngặt). Chỉ cần lưu ý sự khác biệt rất lớn trong LOC: C ++ 11 / C ++ 14 cần khoảng 130 LOC, C ++ 98 và Boost 190 (+ 50%) và C ++ 98 hơn 270 (+ 100%).