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


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

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

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

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

Примечания:

  • для дальнейших ссылок на реализации алгоритмы сортировки см. Википедия,Розетта Код или http://www.sorting-algorithms.com/
  • по данным конвенции Шона родителя (слайд 39), необработанный цикл-это for-цикл длиннее, чем композиция из двух функций с оператором. Так что f(g(x)); или f(x); g(x); или f(x) + g(x); не являются необработанными петлями, и ни петли в selection_sort и insertion_sort ниже.
  • я следую за Скоттом Мейерсом терминология для обозначения текущего C++1y уже как C++14, и для обозначения C++98 и C++03 Как C++98, так что не пламя меня за это.
  • как было предложено в комментариях @Mehrdad, я предоставляю четыре реализации в качестве живого примера в конце ответа: C++14, C++11, C++98 и Boost и C++98.
  • сам ответ представлен только в терминах C++14. Где это уместно, я обозначаю синтаксические и библиотечные различия, где различные языковые версии отличаться.
2 297

2 ответа:

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

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

#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() а также с std::next() доступны только с C++11 и выше. Для C++98 это нужно написать самому. Есть заменители от Boost.Диапазон в boost::begin()/boost::end(), и от наддува.Утилита в boost::next().
  • the std::is_sorted алгоритм доступен только для C++11 и выше. Для C++98 это может быть реализовано в терминах std::adjacent_find и рукописный объект функции. Повышение.Алгоритм также обеспечивает boost::algorithm::is_sorted в качестве замены.
  • the 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 нужно написать две перегрузки и использовать verbose 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 имеет только мономорфный лямбды, которые требуют использования вышеуказанного шаблона alias value_type_t.
  • в C++98 нужно либо написать автономный объект функции, либо прибегнуть к подробному std::bind1st/std::bind2nd/std::not1 тип синтаксиса.
  • импульс.Bind улучшает это с помощью boost::bind и _1/_2 синтаксис заполнителя.
  • C++11 и за его пределами тоже есть std::find_if_not, в то время как C++98 нуждается std::find_if С std::not1 вокруг функция объект.

Стиль C++

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

сортировка выбор

сортировка выбор никак не адаптируется к данным, поэтому его время выполнения всегда 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), потому что первый элемент гарантированно будет на месте и не требует поворота.
  • на двунаправленные итераторы, двоичный поиск для поиска точки вставки может быть заменен на обратный линейный поиск использование стандартной библиотеки .

четыре Живые Примеры (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) сложность, pivot должен привести к сбалансированному разделу входных данных, который не может быть гарантирован в целом для O(1) pivot, но который может быть гарантирован, если установить pivot как O(N) медиана входного диапазона.

подробности опущу:

  • вышеуказанная реализация особенно уязвима для специальных входов, например, она имеет O(N^2) сложность для "орган" ввод 1, 2, 3, ..., N/2, ... 3, 2, 1 (потому что середина-это всегда больше, чем все остальные элементы).
  • медиана-3 pivot выбор из случайно выбранных элементов от входного диапазона защищает от почти отсортированных входов, для которых сложность в противном случае ухудшилась бы до O(N^2).
  • 3-полосная разметка (разделительные элементы меньше, равны и больше, чем pivot), как показано на двух вызовах std::partition не самый эффективный для достижения этого результата.
  • для итераторы произвольного доступа гарантия 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 pivot с последующим O(N) вызов std::partition (который является кэш-дружественный одиночный прямой проход над данными).

сортировка слиянием

при использовании O(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) фаза "sortdown", многократно извлекает максимум и восстанавливает порядок кучи. Стандартная библиотека делает это чрезвычайно простым:

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%).

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

подсчет вроде

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

подробности опущено:

  • мы могли бы перейти границы диапазона значений, принятых алгоритмом в качестве параметров, чтобы полностью избавиться от первого std::minmax_element пройти через коллекцию. Это сделает алгоритм еще быстрее, когда полезно-малый Предел Диапазона известен другими средствами. (Это не обязательно должно быть точным; передача константы от 0 до 100 по-прежнему много лучше, чем дополнительный проход над миллионом элементов, чтобы узнать, что истинные границы От 1 до 95. Даже от 0 до 1000 будет стоить того; дополнительные элементы записываются один раз с нулем и читаются один раз).

  • растет counts на лету-это еще один способ избежать отдельной первого прохода. Удвоение counts размер каждый раз, когда он должен расти, дает амортизированное время O(1) на сортированный элемент (см. анализ затрат на вставку хэш-таблицы для доказательства того, что экспоненциальный рост является ключевым). Растет в конце для нового max С std::vector::resize для добавления новых обнуленных элементов. Изменение min на лету и вставляя новые обнуленные элементы на фронте можно сделать с std::copy_backward после роста вектора. Тогда std::fill обнулить новые элементы.

  • The counts цикл инкремента представляет собой гистограмму. Если данные, вероятно, будут сильно повторяющимися, а количество бункеров невелико, это может стоить развертывание по нескольким массивам чтобы уменьшить узкое место зависимости сериализации данных от хранилища / перезагрузки в ту же ячейку. Это значит отсчеты к нулю в начале и больше, чтобы зациклиться в конце, но это должно стоить на большинстве процессоров для нашего примера миллионов чисел от 0 до 100, особенно если вход уже может быть (частично) отсортирован и имеет длинные пробеги одного и того же числа.

  • в алгоритме выше, мы используем min == max проверьте, чтобы вернуться раньше, когда каждый элемент имеет то же значение (в этом случае коллекция сортируется). На самом деле можно вместо этого полностью проверить, является ли коллекция уже сортируется при поиске экстремальных значений коллекции без дополнительной потери времени (если первый проход все еще является узким местом памяти с дополнительной работой по обновлению min и max). Однако такой алгоритм не существует в стандартной библиотеке, и писать его было бы более утомительно, чем писать остальную часть подсчета сортировки. Это оставлено как упражнение для читателя.

  • поскольку алгоритм работает только с целочисленными значениями, статические утверждения могут быть используется для предотвращения очевидных ошибок типа пользователей. В некоторых контекстах ошибка подстановки с std::enable_if_t может быть предпочтительным.

  • в то время как современный C++ - это круто, будущий C++ может быть еще круче: структурированные привязки и некоторые части диапазоны TS сделает алгоритм еще чище.