Алгоритмические строительные блоки
Мы начнем с сборки алгоритмических строительных блоков из стандартной библиотеки:
#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
- инструменты итератора, такие как non-member
std::begin()
/, std::end()
а также with 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 ++ 14 пока нет. Хорошо это или плохо , но я внимательно слежу за проектом Скотта Мейерса « Эффективный современный С ++» и обновленным GotW Херба Саттера . Я использую следующие рекомендации по стилю:
- Рекомендация Херба Саттера «Почти всегда авто» и рекомендация Скотта Мейерса «Предпочитать авто определенным объявлениям типов» , для которых краткость непревзойденна, хотя ее ясность иногда оспаривается .
- Скотт Мейерс «Различают
()
и {}
при создании объектов» и последовательно выбирает фигурную инициализацию {}
вместо старой доброй инициализации ()
в скобках (чтобы обойти все самые неприятные проблемы синтаксического анализа в общем коде).
- Скотт Мейерс "Предпочитать объявления псевдонимов 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)
.
- Трехстороннее разделение (разделение элементов меньше, равно и больше, чем стержень), как показано двумя вызовами,
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%).