Алгоритм std::sort
(и его кузены std::partial_sort
и std::nth_element
) из стандартной библиотеки С++ находится в большинстве реализаций сложное и гибридное объединение более элементарных алгоритмов сортировки, такие как сортировка выбора, сортировка вставки, быстрая сортировка, сортировка слияния или сортировка кучи.
Здесь много вопросов и на таких сайтах, как https://codereview.stackexchange.com/, связанных с ошибками, сложностью и другими аспектами реализации этих классических алгоритмов сортировки. Большинство предлагаемых реализаций состоят из необработанных циклов, используют манипуляции с индексами и конкретные типы и, как правило, нетривиальны для анализа с точки зрения правильности и эффективности.
Вопрос: как можно реализовать вышеупомянутые классические алгоритмы сортировки с использованием современного С++?
<algorithm>
auto
, псевдонимы шаблонов, прозрачные компараторы и полиморфные лямбды.Примечания:
for
-loop дольше чем состав двух функций с оператором. Таким образом, f(g(x));
или f(x); g(x);
или f(x) + g(x);
не являются необработанными циклами, а также не являются петлями в selection_sort
и insertion_sort
ниже.Начнем с сборки алгоритмических строительных блоков из стандартной библиотеки:
#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()
доступны только с С++ 11 и выше. Для С++ 98 их нужно написать сами. Есть замены из Boost.Range в boost::begin()
/boost::end()
и от Boost.Utility в boost::next()
.std::is_sorted
доступен только для С++ 11 и более поздних версий. Для С++ 98 это может быть реализовано в терминах std::adjacent_find
и рукописного объекта функции. Boost.Algorithm также предоставляет boost::algorithm::is_sorted
в качестве замены.std::is_heap
доступен только для С++ 11 и более поздних версий.С++ 14 предоставляет прозрачные компараторы формы std::less<>
, которые полиморфно действуют на свои аргументы. Это позволяет избежать ввода типа итератора. Это можно использовать в комбинации с С++ 11 аргументы шаблона функции по умолчанию, чтобы создать одиночную перегрузку для сортировки алгоритмы, которые принимают <
как сравнение, и те, которые имеют пользовательский объект функции сравнения.
template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});
В С++ 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{});
В С++ 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>());
}
auto
, которые выводятся как аргументы шаблона функции).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
.boost::bind
и _1
/_2
.std::find_if_not
, тогда как С++ 98 нуждается в std::find_if
с std::not1
вокруг объекта функции.Пока нет общепринятого стиля С++ 14. К лучшему, к худшему, я внимательно слежу за Скоттом Мейерсом черновик Эффективный современный С++ и Herb Sutter обновленный GotW. Я использую следующие рекомендации стиля:
()
и {}
при создании объектов и последовательно выбирать сочетания-инициализации {}
вместо старой старой инициализации в скобках ()
(чтобы устранить все наиболее неприятные-разборные проблемы в общем коде).typedef
экономит время и добавляет согласованность.for (auto it = first; it != last; ++it)
в некоторых местах, чтобы разрешить проверку инвариантности цикла для уже отсортированных поддиапазонов. В производственном коде использование while (first != last)
и a ++first
где-то внутри цикла может быть немного лучше. Сортировка сортировки не адаптируется к данным каким-либо образом, поэтому ее время выполнения всегда O(N^2)
. Однако сортировка выбора имеет свойство , минимизирующее количество свопов. В приложениях, где стоимость подкачки элементов высока, выбор сортировки очень хорошо может быть алгоритмом выбора.
Чтобы реализовать его с использованием стандартной библиотеки, повторно используйте 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^2)
, вставка сортировки алгоритм выбора либо при почти сортировке данных (потому что он адаптивен), либо когда размер проблемы мал (поскольку он имеет низкие накладные расходы). По этим причинам, а также потому, что он также является стабильным, сортировка вставки часто используется как рекурсивный базовый случай (когда размер проблемы мал) для более сложных алгоритмов сортировки с разбивкой и победой, таких как слияние сортировать или быстро сортировать.
Чтобы реализовать 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)
, поскольку первый элемент гарантированно будет на месте и не будет " t требуется поворот.std::find_if_not
.Четыре Live Примеры ( С++ 14, С++ 11, С++ 98 и Boost, С++ 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^2)
, но это улучшает сравнение O(N)
для почти отсортированных входов. В двоичном поиске всегда используются сравнения O(N log N)
.При тщательном применении быстрая сортировка является надежной и имеет O(N log N)
ожидаемую сложность, но с O(N^2)
наихудшая сложность, которая может быть инициирована с использованием смежных входных данных. Когда стабильный вид не нужен, быстрый сортировка - отличный вид общего назначения.
Даже для самых простых версий быстрый сорт довольно сложнее реализовать с использованием стандартной библиотеки, чем другие классические алгоритмы сортировки. Подход ниже использует несколько итератора утилиты, чтобы найти средний элемент диапазона входного [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)
стержень должен приводить к сбалансированному разделению входных данных, который вообще не может быть гарантирован для pivot O(1)
, но который может быть гарантирован, если установить ось в качестве O(N)
медиана входного диапазона.
Детали опущены:
O(N^2)
сложность для ввода < трубки органа "1, 2, 3, ..., N/2, ... 3, 2, 1
(поскольку середина всегда больше всех остальных элементов).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)
дополнительного пространства не вызывает беспокойства, то merge sort - отличный выбор: it является единственным стабильным 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
Вот четыре Live Примеры ( С++ 14, С++ 11, С++ 98 и Boost, С++ 98), проверяя все пять алгоритмов на различных входы (не должны быть исчерпывающими или строгими). Просто обратите внимание на огромные различия в LOC: С++ 11/С++ 14 требуется около 130 LOC, С++ 98 и Boost 190 (+ 50%) и С++ 98 более 270 (+100%).
auto it = first
pattern. Некоторые итераторы копируются нетривиально, и я сомневаюсь, что вы можете положиться на компилятор для оптимизации копии, просто используйте first
итератор, когда это возможно, по этой причине он передается по значению.
auto
(и многие со мной не согласны), мне нравилось видеть, как хорошо используются стандартные библиотечные алгоритмы. Я хотел увидеть несколько примеров такого рода кода после просмотра выступления Шона Родителя. Кроме того, я понятия не имел, что существует std::iter_swap
, хотя мне кажется странным, что он находится в <algorithm>
.
Другой маленький и довольно элегантный изначально найденный при просмотре кода. Я думал, что стоит поделить.
Хотя он довольно специализирован, подсчет сортировки является простым алгоритмом сортировки целых чисел и часто может быть очень быстрым, если значения целых чисел равны сортировка не слишком далеко друг от друга. Это, вероятно, идеально, если вам когда-либо понадобится сортировать коллекцию из миллиона целых чисел, которая, как известно, находится между 0 и 100.
Чтобы реализовать очень простой способ подсчета, который работает как с целыми числами, так и без знака, нужно найти наименьшие и наибольшие элементы в сортировке; их разница будет определять размер массива подсчетов. Затем выполняется второй проход через коллекцию, чтобы подсчитать количество вхождений каждого элемента. Наконец, мы возвращаем требуемое число каждого целого обратно в исходную коллекцию.
template<typename ForwardIterator>
void counting_sort(ForwardIterator first, ForwardIterator last)
{
if (first == last || std::next(first) == last) return;
auto minmax = std::minmax_element(first, last); // avoid if possible.
auto min = *minmax.first;
auto max = *minmax.second;
if (min == max) return;
using difference_type = typename std::iterator_traits<ForwardIterator>::difference_type;
std::vector<difference_type> counts(max - min + 1, 0);
for (auto it = first ; it != last ; ++it) {
++counts[*it - min];
}
for (auto count: counts) {
first = std::fill_n(first, count, min++);
}
}
Хотя это полезно только тогда, когда диапазон целых чисел сортируется, как известно, является небольшим (как правило, не больше размера сортируемой коллекции), делая подсчет более универсальным, сделает его более медленным для своих лучших случаев. Если диапазон известен невелик, другой алгоритм, такой как сортировка radix, ska_sort или spreadsort.
Детали опущены:
Мы могли бы пройти границы диапазона значений, принятых алгоритмом, так как параметры полностью избавились от первого прохождения std::minmax_element
через коллекцию. Это сделает алгоритм еще более быстрым, когда известно, что с помощью небольшого диапазона ограничений известно другое. (Это не должно быть точным: передача константы от 0 до 100 по-прежнему намного лучше, чем дополнительный проход над миллионом элементов, чтобы выяснить, что истинные границы от 1 до 95. Стоило бы даже от 0 до 1000; дополнительные элементы записываются один раз с нулем и читаются один раз).
Растущий counts
"на лету" - еще один способ избежать отдельного первого прохода. Удвоение размера counts
каждый раз, когда оно должно расти, дает амортизированное время O (1) на отсортированный элемент (см. Анализ затрат на вставку таблицы хеш-таблицы для доказательства того, что экспоненциальный рост является ключевым). В конце max
с ростом std::vector::resize
с тегом std::vector::resize
легко добавлять новые обнуленные элементы.
Изменение min
на лету и вставка новых обнуленных элементов на передней панели можно сделать с помощью std::copy_backward
после выращивания вектора. Тогда std::fill
к нулю новые элементы.
Цикл инкремента counts
представляет собой гистограмму. Если данные, вероятно, будут очень повторяющимися, а количество бункеров невелико, может быть целесообразно развернуть несколько массивов, чтобы уменьшить узкое место зацикливания данных в сетях хранения/перезагрузите в тот же лоток. Это означает, что в начале все больше нуля до нуля, а больше - в конце цикла, но для большинства наших процессоров это стоит того, чтобы на нашем примере миллионы от 0 до 100 чисел, особенно если входные данные уже могут быть (частично) отсортированы и имеют длинные прогоны того же числа.
В вышеприведенном алгоритме мы используем проверку min == max
для возврата раньше, когда каждый элемент имеет то же значение (в этом случае сортировка коллекции). Фактически, возможно, можно полностью проверить, отсортирована ли коллекция уже при поиске экстремальных значений коллекции без дополнительного времени, потраченного впустую (если первый проход все еще запоминается в узком месте с дополнительной работой по обновлению min и max). Однако такой алгоритм не существует в стандартной библиотеке, и писать было бы более утомительно, чем писать остальную часть подсчета. Он оставлен как упражнение для читателя.
Поскольку алгоритм работает только с целыми значениями, статические утверждения могут использоваться, чтобы пользователи не допускали очевидных ошибок типа. В некоторых контекстах может быть предпочтительным отказ подстановки с помощью std::enable_if_t
.
В то время как современный С++ классный, будущий С++ может быть еще более крутым: структурированные привязки и некоторые части Диапазоны TS сделали бы алгоритм еще более чистым.
std::minmax_element
который только собирает информацию). Используемым свойством является тот факт, что целые числа могут использоваться в качестве индексов или смещений, и что они могут увеличиваться при сохранении последнего свойства.
counts | ranges::view::filter([](auto c) { return c != 0; })
чтобы вам не приходилось повторно проверять ненулевые значения внутри fill_n
.