Алгоритмічні будівельні блоки
Почнемо зі складання алгоритмічних будівельних блоків зі стандартної бібліотеки:
#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
- інструменти ітератора, такі як не члени
std::begin()
/ std::end()
а також з std::next()
, доступні лише для C ++ 11 і вище. Для C ++ 98 потрібно написати їх сам. Є замінники Boost.Range в boost::begin()
/ boost::end()
і від Boost.Utility в boost::next()
.
std::is_sorted
алгоритм доступний тільки для C ++ 11 і за її межами. Для C ++ 98 це може бути реалізовано в термінах std::adjacent_find
і вручну написаним об'єктом функції. Boost.Algorithm також надає boost::algorithm::is_sorted
як заміну.
std::is_heap
алгоритм доступний тільки для C ++ 11 і за її межами.
Синтаксичні смаколики
C ++ 14 забезпечує прозорі компаратори форми, std::less<>
які діють поліморфно на їх аргументи. Це дозволяє уникнути надання типу ітератора. Це можна використовувати в поєднанні з аргументами шаблону функцій за замовчуванням C ++ 11 для створення єдиного перевантаження для сортування алгоритмів, які приймаються <
за порівняння, та тих, які мають визначений користувачем об'єкт функції порівняння.
template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
У C ++ 11 можна визначити псевдонім шаблону для багаторазового використання для виведення типу значення ітератора, який додає незначне захаращення підписів алгоритмів сортування:
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{});
У C ++ 98 потрібно написати два перевантаження та використати багатослівний typename xxx<yyy>::type
синтаксис
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>());
}
- Ще одна синтаксична принадність полягає в тому, що C ++ 14 полегшує перенесення визначених користувачем компараторів через поліморфні лямбда (з
auto
параметрами, що виводяться як аргументи шаблону функції).
- C ++ 11 має лише мономорфні лямбда, які вимагають використання вищезгаданого псевдоніма шаблону
value_type_t
.
- У C ++ 98 потрібно або написати окремий об'єкт функції або вдатися до синтаксису багатослівного
std::bind1st
/ std::bind2nd
/ std::not1
типу.
- Boost.Bind покращує це за допомогою синтаксису
boost::bind
та _1
/ _2
placeholder.
- C ++ 11 і за її межами також
std::find_if_not
, в той час як C ++ 98 Потреби std::find_if
з А std::not1
навколо об'єкта функції.
C ++ Стиль
Загально прийнятного стилю C ++ 14 поки що немає. На краще або на гірше, я уважно стежу за проектом «Ефективний сучасний C ++» Скотта Майєрса та оновленим GotW Herb Sutter . Я використовую такі стильові рекомендації:
- Рекомендація «Майже завжди авто» Герба Саттера та рекомендація Скотта Майєрса «Віддавайте перевагу деклараціям конкретного типу» , для яких стислість неперевершена, хоча її чіткість іноді суперечить .
- Скотт Майєрс "Розрізняє
()
і {}
при створенні об'єктів" і послідовно вибирає braced-ініціалізацію {}
замість старої хорошої ініціалізації ()
в скобках (для того, щоб узагальнити всі найпокусніші проблеми розбору в загальному коді).
- Скотт Майєрс "Віддавайте перевагу деклараціям псевдоніму типу typedefs" . Для шаблонів це все-таки обов’язково, а використання його скрізь замість цього
typedef
економить час і додає послідовності.
- Я використовую
for (auto it = first; it != last; ++it)
шаблон в деяких місцях, щоб дозволити інваріантну перевірку циклу для вже відсортованих піддіапазонів. У виробничому коді використання while (first != last)
та ++first
десь усередині циклу може бути дещо кращим.
Вибір сортування
Сортування вибору жодним чином не адаптується до даних, тому час його виконання завжди єO(N²)
. Однак сортування селекції має властивість мінімізувати кількість свопів . У додатках, де вартість заміни товарів висока, сортування вибору може бути найкращим алгоритмом вибору.
Щоб реалізувати його за допомогою Стандартної бібліотеки, кілька разів використовуйте std::min_element
для пошуку мінімального елемента, що залишився, і iter_swap
поміняйте його на місце:
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));
}
}
Зауважимо, що selection_sort
вже оброблений діапазон [first, it)
відсортований як його інваріантний цикл. Мінімальні вимоги є ітераторами переадресації , порівняно з std::sort
ітераторами випадкового доступу.
Деталі пропущені :
- Сортування вибору може бути оптимізовано за допомогою раннього тестування
if (std::distance(first, last) <= 1) return;
(або для ітераторів вперед / двонаправлення:) if (first == last || std::next(first) == last) return;
.
- для двонаправлених ітераторів вищевказаний тест може поєднуватися з циклом протягом інтервалу
[first, std::prev(last))
, оскільки останній елемент гарантовано є мінімальним залишковим елементом і не потребує заміни.
Сортування вставки
Хоча це один із елементарних алгоритмів сортування з O(N²)
найгіршим часом, сортування вставки - це алгоритм вибору або тоді, коли дані майже відсортовані (оскільки вони адаптивні ), або коли розмір проблеми невеликий (тому що він має низькі накладні витрати). З цих причин і через те, що він також стабільний , сортування вставки часто використовується як рекурсивний базовий випадок (коли розмір проблеми невеликий) для більш високих накладних алгоритмів сортування розділення та підкорення, таких як сортування об'єднань або швидке сортування.
Для реалізації insertion_sort
з Стандартною бібліотекою кілька разів використовуйте std::upper_bound
для пошуку місця, куди потрібно перейти поточний елемент, і використовуйте std::rotate
для переміщення решти елементів вгору у діапазоні введення:
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));
}
}
Зауважимо, що insertion_sort
вже оброблений діапазон [first, it)
відсортований як його інваріантний цикл. Сортування вставки також працює з ітераторами вперед.
Деталі пропущені :
- Сортування вставки можна оптимізувати за допомогою раннього тестування
if (std::distance(first, last) <= 1) return;
(або для ітераторів вперед / бік:) if (first == last || std::next(first) == last) return;
та циклу через інтервал [std::next(first), last)
, оскільки перший елемент гарантовано є на місці і не потребує повороту.
- для двонаправлених ітераторів дворядний пошук для пошуку точки вставки може бути замінений зворотним лінійним пошуком за допомогою
std::find_if_not
алгоритму Стандартної бібліотеки .
Чотири живі приклади ( C ++ 14 , C ++ 11 , C ++ 98 та Boost , C ++ 98 ) для фрагменту нижче:
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();
- Для випадкових входів це дає
O(N²)
порівняння, але це покращує O(N)
порівняння майже впорядкованих даних. Двійковий пошук завжди використовує O(N log N)
порівняння.
- Для невеликих вхідних діапазонів краща локальність пам'яті (кеш, попереднє завантаження) лінійного пошуку також може домінувати над бінарним пошуком (це, звичайно, слід перевірити).
Швидкий сорт
При ретельному впровадженні швидке сортування є надійним і O(N log N)
очікує складності, але з O(N²)
найгіршим складною можливістю, яке може бути спричинене суперечливо вибраними вхідними даними. Коли стабільний сорт не потрібен, швидкий сорт - це чудовий сорт загального призначення.
Навіть для найпростіших версій швидке сортування є дещо складнішим у здійсненні за допомогою Стандартної бібліотеки, ніж інші класичні алгоритми сортування. У нижченаведеному підході використовується декілька утиліт ітератора, щоб знайти середній елемент вхідного діапазону [first, last)
як стрижневий, а потім використати два виклики для std::partition
(тривалих O(N)
) розділів діапазону вводу на сегменти елементів, менших за, рівних, і більше, ніж вибраний шарнір відповідно. Нарешті, два зовнішні сегменти з елементами, меншими та більшими за шарнір, рекурсивно сортуються:
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));
}
Однак швидке сортування є досить складним, щоб отримати правильний та ефективний, оскільки кожен з перерахованих вище кроків повинен бути ретельно перевірений та оптимізований для коду рівня виробництва. Зокрема, для O(N log N)
складності поворот повинен призвести до врівноваженого розподілу вхідних даних, що взагалі не може бути гарантовано для O(1)
стрижня, але який можна гарантувати, якщо встановити стрижень як O(N)
медіану вхідного діапазону.
Деталі пропущені :
- вищезазначена реалізація особливо вразлива для спеціальних входів, наприклад, вона має
O(N^2)
складність для вводу " органної труби " 1, 2, 3, ..., N/2, ... 3, 2, 1
(тому що середина завжди більша, ніж усі інші елементи).
- середній вибір з 3 стрижнів від випадково вибраних елементів із захисних діапазонів вхідних сигналів проти майже відсортованих входів, для яких складність інакше погіршиться до
O(N^2)
.
- 3-х напрямне розділення (розділення елементів менших, рівних та більших за зріз), як показано двома дзвінками
std::partition
, не є найбільш ефективнимO(N)
алгоритмом для досягнення цього результату.
- для ітераторів довільного доступу гарантована
O(N log N)
складність може бути досягнута за допомогою медіанного вибору зсуву з std::nth_element(first, middle, last)
подальшими рекурсивними викликами до quick_sort(first, middle, cmp)
та quick_sort(middle, last, cmp)
.
- ця гарантія, однак, коштує, оскільки постійний коефіцієнт
O(N)
складності std::nth_element
може бути дорожчим, ніж O(1)
складність опорної межі 3 з подальшим O(N)
викликом std::partition
(що є зручним для кешу єдиним переходом вперед дані).
Злиття сортування
Якщо використання O(N)
додаткового простору не викликає занепокоєнь, то сортування злиття є відмінним вибором: це єдиний стабільний O(N log N)
алгоритм сортування.
Це легко здійснити за допомогою стандартних алгоритмів: використовуйте кілька утиліт ітератора, щоб знайти середину вхідного діапазону [first, last)
та комбінувати два рекурсивно відсортовані сегменти із 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));
}
Сортування сортування вимагає двонаправлених ітераторів, вузьким місцем яких є std::inplace_merge
. Зауважте, що при сортуванні пов'язаних списків сортування злиття потребує лише O(log N)
додаткового місця (для рекурсії). Останній алгоритм реалізований std::list<T>::sort
у Стандартній бібліотеці.
Купи сорту
Сортування купи просто провести у виконанні, виконує наO(N log N)
місці сортування, але не є стабільним.
Перший цикл, O(N)
фаза "heapify", наводить масив у порядок купи. Другий цикл, O(N log 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));
}
У разі , якщо ви вважаєте це «обман» , щоб використовувати std::make_heap
і std::sort_heap
, ви можете піти на один рівень глибше і писати ці функції самостійно з точки зору std::push_heap
і std::pop_heap
, відповідно:
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
Стандартна бібліотека визначає як складність, так push_heap
і pop_heap
складність O(log N)
. Однак зауважимо, що зовнішня петля за діапазоном [first, last)
призводить до O(N log N)
складності для make_heap
, тоді як std::make_heap
має лише O(N)
складність. Для загальної O(N log N)
складності heap_sort
це не має значення.
Деталі опущені : O(N)
реалізаціяmake_heap
Тестування
Ось чотири живі приклади ( C ++ 14 , C ++ 11 , C ++ 98 та Boost , C ++ 98 ), які перевіряють усі п'ять алгоритмів на різноманітних входах (не маючи на увазі вичерпних чи суворих). Зауважте лише великі відмінності у LOC: C ++ 11 / C ++ 14 потребує приблизно 130 LOC, C ++ 98 та Boost 190 (+ 50%) та C ++ 98 понад 270 (+ 100%).