std::sort
Алгоритм (и его кузены std::partial_sort
и std::nth_element
) из стандартной библиотеки C ++ в большинстве реализаций сложный и гибридная объединение более элементарных алгоритмов сортировки , таких как выбор сортировки, вставки сортировка, быстрая сортировка, сортировка слиянием, или кучи сортировки.
Здесь и на родственных сайтах, таких как https://codereview.stackexchange.com/, есть много вопросов, связанных с ошибками, сложностью и другими аспектами реализации этих классических алгоритмов сортировки. Большинство предлагаемых реализаций состоят из необработанных циклов, используют манипуляции с индексами и конкретные типы и, как правило, нетривиальны для анализа с точки зрения правильности и эффективности.
Вопрос : как можно реализовать вышеупомянутые классические алгоритмы сортировки с использованием современного C ++?
- нет сырых циклов , но объединяет алгоритмические строительные блоки стандартной библиотеки из
<algorithm>
- интерфейс итератора и использование шаблонов вместо манипулирования индексами и конкретных типов
- Стиль C ++ 14 , включая полную Стандартную библиотеку, а также синтаксические шумоподавители, такие как
auto
псевдонимы шаблонов, прозрачные компараторы и полиморфные лямбды.
Примечания :
- дальнейшие ссылки на реализации алгоритмов сортировки см. в Википедии , Rosetta Code или 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. Где уместно, я обозначаю синтаксические и библиотечные различия, где разные языковые версии различаются.
Ответы:
Алгоритмические строительные блоки
Мы начнем с сборки алгоритмических строительных блоков из стандартной библиотеки:
std::begin()
/,std::end()
а также withstd::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 для создания единой перегрузки для алгоритмов сортировки, которые принимают в<
качестве сравнения, и алгоритмов, которые имеют определенный пользователем объект функции сравнения.В C ++ 11 можно определить многократно используемый псевдоним шаблона для извлечения типа значения итератора, который добавляет незначительный беспорядок в сигнатуры алгоритмов сортировки:
В C ++ 98 нужно написать две перегрузки и использовать подробный
typename xxx<yyy>::type
синтаксисauto
параметрами, которые выводятся как аргументы шаблона функции).value_type_t
.std::bind1st
/std::bind2nd
/std::not1
типу синтаксиса.boost::bind
и_1
/_2
placeholder.std::find_if_not
, в то время как C ++ 98 нуждаетсяstd::find_if
вstd::not1
функциональном объекте.Стиль С ++
Общепринятого стиля C ++ 14 пока нет. Хорошо это или плохо , но я внимательно слежу за проектом Скотта Мейерса « Эффективный современный С ++» и обновленным GotW Херба Саттера . Я использую следующие рекомендации по стилю:
()
и{}
при создании объектов» и последовательно выбирает фигурную инициализацию{}
вместо старой доброй инициализации()
в скобках (чтобы обойти все самые неприятные проблемы синтаксического анализа в общем коде).typedef
экономии времени и повышения согласованности.for (auto it = first; it != last; ++it)
В некоторых местах я использую шаблон, чтобы разрешить проверку инварианта цикла для уже отсортированных поддиапазонов. В рабочем коде использованиеwhile (first != last)
и++first
где-то внутри цикла может быть немного лучше.Сортировка выбора
Сортировка выбора никак не адаптируется к данным, поэтому время выполнения всегда
O(N²)
. Однако сортировка выбора обладает свойством минимизации количества перестановок . В приложениях, где стоимость обмена предметов высока, алгоритм выбора может быть очень хорошим выбором.Чтобы реализовать это с помощью стандартной библиотеки, несколько раз используйте,
std::min_element
чтобы найти оставшийся минимальный элемент иiter_swap
заменить его на место:Обратите внимание, что
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
чтобы сместить оставшиеся элементы вверх во входном диапазоне:Обратите внимание, что
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 ) для фрагмента ниже:
O(N²)
сравнения, но это улучшаетO(N)
сравнения для почти отсортированных входных данных. Бинарный поиск всегда используетO(N log N)
сравнения.Быстрая сортировка
При тщательной реализации быстрая сортировка является надежной и имеет
O(N log N)
ожидаемую сложность, но сO(N²)
наихудшей сложностью, которая может быть вызвана выбранными пользователем входными данными. Когда стабильная сортировка не требуется, быстрая сортировка является превосходной универсальной сортировкой.Даже для самых простых версий быструю сортировку немного сложнее реализовать с помощью стандартной библиотеки, чем другие классические алгоритмы сортировки. Приведенный ниже подход использует несколько утилит-итераторов для определения среднего элемента входного диапазона в
[first, last)
качестве точки поворота, а затем использует два вызоваstd::partition
(которые являютсяO(N)
) для трехстороннего разделения входного диапазона на сегменты элементов, которые меньше, равны, и больше, чем выбранный стержень, соответственно. Наконец, два внешних сегмента с элементами меньше и больше, чем стержень, рекурсивно сортируются:Однако быструю сортировку довольно сложно получить правильно и эффективно, поскольку каждый из вышеперечисленных шагов должен быть тщательно проверен и оптимизирован для кода производственного уровня. В частности, для
O(N log N)
сложности, сводка должна приводить к сбалансированному разделу входных данных, что в общем случае не может быть гарантировано для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)
дополнительного пространства не имеет значения, сортировка по слиянию - отличный выбор: это единственный стабильныйO(N log N)
алгоритм сортировки.Это легко реализовать с использованием стандартных алгоритмов: используйте несколько утилит итераторов, чтобы найти середину входного диапазона,
[first, last)
и объедините два рекурсивно отсортированных сегмента с помощьюstd::inplace_merge
:Для сортировки слиянием требуются двунаправленные итераторы, узким местом которых является
std::inplace_merge
. Обратите внимание, что при сортировке связанных списков сортировка слиянием требует толькоO(log N)
дополнительного пространства (для рекурсии). Последний алгоритм реализованstd::list<T>::sort
в стандартной библиотеке.Сортировка кучи
Сортировка кучи проста в реализации, выполняет
O(N log N)
сортировку на месте, но не стабильна.Первый цикл,
O(N)
фаза «heapify», помещает массив в порядок кучи. Второй цикл,O(N log N
фаза «сортировки», многократно извлекает максимум и восстанавливает порядок кучи. Стандартная библиотека делает это чрезвычайно простым:В случае , если вы считаете это «обман» , чтобы использовать
std::make_heap
иstd::sort_heap
, вы можете пойти на один уровень глубже и писать эти функции самостоятельно с точки зренияstd::push_heap
иstd::pop_heap
, соответственно:Стандартная библиотека определяет как
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%).
источник
auto
(и многие люди не согласны со мной), мне нравилось видеть, как хорошо используются стандартные библиотечные алгоритмы. Я хотел увидеть несколько примеров такого рода кода после просмотра выступления Шона Родителя. Кроме того, я понятия не имелstd::iter_swap
, хотя мне кажется странным, что он существует<algorithm>
.if (first == last || std::next(first) == last)
. Я мог бы обновить это позже. Реализация материала в разделах «пропущенные детали» выходит за рамки вопроса, IMO, потому что они содержат ссылки на все вопросы и ответы. Реализовать процедуры сортировки реальных слов сложно!nth_element
по моему мнению.nth_element
выполняет половину быстрой сортировки (включая шаг разбиения и рекурсию на половину, которая включает в себя интересующий вас n-й элемент).Еще один маленький и довольно элегантный, изначально найденный в обзоре кода . Я думал, что это стоит поделиться.
Подсчет сортировки
Хотя сортировка подсчета является довольно специализированной, это простой алгоритм целочисленной сортировки, который часто может быть очень быстрым, если значения сортируемых целых чисел не слишком далеко друг от друга. Это, вероятно, идеально, если когда-нибудь понадобится отсортировать коллекцию из миллиона целых чисел, например, между 0 и 100.
Чтобы реализовать очень простую подсчетную сортировку, которая работает как со знаковыми, так и без знака целыми числами, нужно найти самые маленькие и самые большие элементы в коллекции для сортировки; их различие скажет размер массива, который нужно выделить. Затем выполняется второй проход по коллекции для подсчета количества вхождений каждого элемента. Наконец, мы записываем необходимое число каждого целого обратно в исходную коллекцию.
Хотя это полезно только в том случае, если известно, что диапазон целых чисел для сортировки мал (обычно не больше, чем размер коллекции для сортировки), если сделать сортировку подсчета более универсальной, это замедлит работу в ее лучших случаях. Если неизвестно, что диапазон мал, можно использовать другой алгоритм, такой как сортировка по основанию , 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
обнулить новые элементы.Цикл
counts
приращения является гистограммой. Если данные, скорее всего, будут сильно повторяющимися, а количество бинов невелико, возможно, стоит развернуть несколько массивов, чтобы уменьшить узкое место зависимости данных при сериализации при хранении / перезагрузке в одном и том же бине. Это означает, что большее число обнуляется в начале и больше зацикливается в конце, но оно должно стоить этого на большинстве процессоров для нашего примера миллионов чисел от 0 до 100, особенно если входные данные уже (частично) отсортированы и есть длинные пробеги одного и того же числа.В приведенном выше алгоритме мы используем
min == max
проверку для раннего возврата, когда каждый элемент имеет одинаковое значение (в этом случае коллекция сортируется). На самом деле можно вместо этого полностью проверить, отсортирована ли уже коллекция, находя экстремальные значения коллекции без лишних затрат времени (если первый проход все еще является узким местом в памяти с дополнительной работой обновления min и max). Однако такого алгоритма нет в стандартной библиотеке, и написание его было бы более утомительным, чем написание остальной части самой сортировки подсчета. Это оставлено как упражнение для читателя.Поскольку алгоритм работает только с целочисленными значениями, можно использовать статические утверждения, чтобы пользователи не допускали очевидных ошибок типов. В некоторых случаях сбой замещения с
std::enable_if_t
может быть предпочтительным.Хотя современный C ++ классный, будущий C ++ может быть еще круче: структурированные привязки и некоторые части TS Ranges сделают алгоритм еще чище.
источник
std::minmax_element
только для сбора информации). Используемым свойством является тот факт, что целые числа могут использоваться в качестве индексов или смещений, и что они могут увеличиваться при сохранении последнего свойства.counts | ranges::view::filter([](auto c) { return c != 0; })
так что вам не придется повторно проверять ненулевые значения внутриfill_n
.small
rather
appart
counts[]
на лету было бы победой по сравнению с обходом вводаminmax_element
до гистограммы. Специально для случая использования, где это идеально, очень большой ввод со многими повторениями в небольшом диапазоне, потому что вы быстро увеличитесьcounts
до его полного размера, с небольшим количеством ошибочных прогнозов ветвлений или удвоением размера. (Конечно, знание достаточно маленькой границы диапазона позволит вам избежатьminmax_element
сканирования и избежать проверки границ внутри цикла гистограммы.)