Как реализовать классические алгоритмы сортировки в современном С++?

Алгоритм std::sort (и его кузены std::partial_sort и std::nth_element) из стандартной библиотеки С++ находится в большинстве реализаций сложное и гибридное объединение более элементарных алгоритмов сортировки, такие как сортировка выбора, сортировка вставки, быстрая сортировка, сортировка слияния или сортировка кучи.

Здесь много вопросов и на таких сайтах, как https://codereview.stackexchange.com/, связанных с ошибками, сложностью и другими аспектами реализации этих классических алгоритмов сортировки. Большинство предлагаемых реализаций состоят из необработанных циклов, используют манипуляции с индексами и конкретные типы и, как правило, нетривиальны для анализа с точки зрения правильности и эффективности.

Вопрос: как можно реализовать вышеупомянутые классические алгоритмы сортировки с использованием современного С++?

  • нет сырых циклов, но объединение стандартных алгоритмических блоков стандартной библиотеки из <algorithm>
  • интерфейс итератора и использование шаблонов вместо манипуляции индексами и конкретных типов
  • стиль С++ 14, включая полную стандартную библиотеку, а также синтаксические шумоподавители, такие как auto, псевдонимы шаблонов, прозрачные компараторы и полиморфные лямбды.

Примечания:

  • для дальнейших ссылок на реализации алгоритмов сортировки см. Wikipedia, Rosetta Code или http://www.sorting-algorithms.com/
  • согласно Соглашения Sean Parent (слайд 39), необработанный цикл - это for -loop дольше чем состав двух функций с оператором. Таким образом, f(g(x)); или f(x); g(x); или f(x) + g(x); не являются необработанными циклами, а также не являются петлями в selection_sort и insertion_sort ниже.
  • Я следую терминологии Scott Meyers, чтобы обозначить текущий С++ 1y уже как С++ 14 и обозначить С++ 98 и С++ 03 как С++ 98, поэтому не плачьте меня за это.
  • Как было предложено в комментариях @Mehrdad, в конце ответа я предоставляю четыре реализации в виде Живого примера: С++ 14, С++ 11, С++ 98 и Boost и С++ 98.
  • Сам ответ представлен только в С++ 14. Там, где это уместно, я обозначаю различия в синтаксисе и библиотеке, где разные версии языка отличаются.

Ответ 1

Алгоритмические строительные блоки

Начнем с сборки алгоритмических строительных блоков из стандартной библиотеки:

#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>());
}
  • Другая синтаксическая тонкость заключается в том, что С++ 14 облегчает перенос пользовательских компараторов с помощью полиморфных lambdas (с параметрами auto, которые выводятся как аргументы шаблона функции).
  • С++ 11 имеет только мономорфные лямбды, для которых требуется использование вышеупомянутого псевдонима шаблона value_type_t.
  • В С++ 98 нужно либо написать отдельный объект функции, либо обратиться к подробному типу синтаксиса std::bind1st/std::bind2nd/std::not1.
  • Boost.Bind улучшает это с помощью синтаксиса boost::bind и _1/_2.
  • С++ 11 и далее также имеют std::find_if_not, тогда как С++ 98 нуждается в std::find_if с std::not1 вокруг объекта функции.

Стиль С++

Пока нет общепринятого стиля С++ 14. К лучшему, к худшему, я внимательно слежу за Скоттом Мейерсом черновик Эффективный современный С++ и Herb Sutter обновленный GotW. Я использую следующие рекомендации стиля:

  • Herb Sutter "Почти всегда Авто" и Скотт Мейерс "Предпочитает автоматическую декларацию определенного типа" , для которой краткость является непревзойденной, хотя ее ясность иногда оспаривал.
  • Скотт Мейерс " Различать () и {} при создании объектов и последовательно выбирать сочетания-инициализации {} вместо старой старой инициализации в скобках () (чтобы устранить все наиболее неприятные-разборные проблемы в общем коде).
  • Скотт Мейерс " Предпочитайте псевдонимы для typedefs. Для шаблонов это необходимо в любом случае, и использование его везде вместо 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 (поскольку середина всегда больше всех остальных элементов).
  • медианный из 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) дополнительного пространства не вызывает беспокойства, то 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%).

Ответ 2

Другой маленький и довольно элегантный изначально найденный при просмотре кода. Я думал, что стоит поделить.

Сортировка сортировки

Хотя он довольно специализирован, подсчет сортировки является простым алгоритмом сортировки целых чисел и часто может быть очень быстрым, если значения целых чисел равны сортировка не слишком далеко друг от друга. Это, вероятно, идеально, если вам когда-либо понадобится сортировать коллекцию из миллиона целых чисел, которая, как известно, находится между 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 сделали бы алгоритм еще более чистым.