Сортировка обменами
Рассмотрим методы сортировок, основными операциями в которых является обмен элементов.
Да, эта та самая операция swap.
Задача сортировки обменами
К нам на вход поступает массив из записей , у каждой записи есть ключ . На ключах задано отношение тотального порядка .
Нужно расположить записи по возрастанию ключей, то есть надо найти такую перестановку , что
Нам нужно переупорядочить записи в том же пространстве памяти
в соответствии с этой перестановкой ,
при этом нам разрешено использовать только операцию swap, которая меняет местами два объекта в памяти.
Также можно использовать любые операции, сводящиеся к swap,
но в итоговом анализе сложности нас будет интересовать именно количество операций swap.
Сортировка пузырьком
Наверное, самый очевидный метод сортировки — сравнивать ключи и , и, если , поменять местами записи и . Такой операцией мы последовательно устраняем инверсии в перестановке. Значит в какой-то момент мы дойдем до перестановки без инверсий. Это и будет наш отсортированный массив.
input mutable array[int] a[n]
int last_swap
int bound = n - 1
bool exchanges_occurred = true
while exchanges_occurred and bound > 0:
exchanges_occurred = false
last_swap = 0
for j = 0; j < bound; j++:
if a[j] > a[j + 1]:
swap a[j], a[j + 1]
exchanges_occurred = true
last_swap = j
bound = last_swapПроцесс сортировки пузырьком
Пусть — перестановка элементов , и — соответствующая ей таблица инверсий.
После очередного прохода при сортировке пузырьком перестановка преобразуется в перестановку . Таблица инверсий получившейся перестановки . Новая таблица инверсий получается из старой путём уменьшения каждого ненулевого элемента на .
Если перед имеется больший элемент, то поменяется местами с наибольшим из предшествующих элементов. Получается, что уменьшится на . А если перед нет большего элемента, то никогда ни с кем не поменяется, и так и останется равным .
Каждый алгоритм сортировки обменами, в том числе и сортировка пузырьком, характеризуется тремя величинами:
Если — таблица инверсий исходной перестановки , то
где — значение перед началом -го прохода.
После проходов сортировки значение переменной bound будет равно
Их этого факта получаем, что
Анализ распределения — количества обменов
Значение равно числу инверсий в перестановке . Распределение числа инверсий мы знаем, его параметры
Анализ распределения — количества проходов
Вероятность того, что нам потребуется не более проходов, равна произведению и числа таблиц инверсий, не содержащих компонент равных или больше.
Значит вероятность того, что нам потребуется ровно проходов равна
Среднее значение вычисляем просто по формуле математического ожидания, выполнив суммирование по частям
Величина подробно анализировалась в разделе «Факториальные суммы» статьи «Асимптотика». Нам достаточно асимптотического поведения .
В итоге получаем, что
Теперь посчитаем дисперсию
Подставляем асимптотическое выражение для и получаем
Теперь можно вычислить стандартное отклонение
Получаем результат анализа распределения — количества проходов при сортировке пузырьком
Анализ распределения — количества сравнений.
Итак, нам нужно проанализировать величину , где . Пусть — число таких таблиц инверсий , для которых при либо , либо .
Тогда вероятность того, что количество сравнений будет не больше выражается через
Значит вероятность того, что количество сравнений будет в точности равна
Для вычисления среднего числа сравнений на шаге посчитаем математическое ожидание
Просуммировав по частям, получаем, что
Тогда, суммируя по , получаем математическое ожидание общего числа сравнений
Теперь можно посчитать дисперсию. Начнем с
Шейкерная сортировка
Сортировка пузырьком, конечно, рабочая, но она относительно медленная. В процессе сортировки пузырьком совершается довольно много лишних сравнений, и элемент не может за один проход переместиться более чем на позицию, поэтому если наименьший элемент будет самым крайним, то нам потребуется максимальное количество сравнений.
Все эти особенности процесса сортировки пузырьком наталкивают на мысли о сортировке, которая будет чередовать направления прохода по массиву. Кеннет Айверсон в 1962 году сделал интересное наблюдение: если две записи и не поменялись местами при двух последовательных проходах в обоих направлениях, то они уже стоят на своих местах и их можно исключить из дальнейшего рассмотрения.
input mutable array[int] a[n]
int last_swap
int left_bound = 0
int right_bound = n - 1
bool exchanges_occurred = true
while exchanges_occurred and left_bound < right_bound:
exchanges_occurred = false
last_swap = left_bound
for j = left_bound; j < right_bound; j++:
if a[j] > a[j + 1]:
swap a[j], a[j + 1]
exchanges_occurred = true
last_swap = j
right_bound = last_swap
if not exchanges_occurred:
break
exchanges_occurred = false
last_swap = right_bound
for j = right_bound; j > left_bound; j--:
if a[j] < a[j - 1]:
swap a[j], a[j - 1]
exchanges_occurred = true
last_swap = j
left_bound = last_swapПусть снова — таблица инверсий исходной перестановки . Считаем величины — количество проходов, — количество обменов и — количество сравнений.
где — значение перед началом -го прохода.
Сортировка Бетчера
Пока у нас получались только сортировки, которые имеют квадратичную временную сложность. Если мы хотим, чтобы наш алгоритм работал быстрее, нам необходимо сравнивать несоседние пары ключей, иначе иначе нам придётся сделать столько сравнений, сколько инверсий в перестановке.
В 1964 году Кеннет Бетчер открыл интересный способ организации сравнений, который позволяет значительно ускорить сортировку и даёт возможность её параллелить.
Алгоритм будет работать для .
Вычислим — наименьшее такое целое число, для которого .
input mutable array[int] records[n]
t = 0
while (1 << t) < N:
t += 1
p = 1 << (t - 1)
while p > 0:
q = 1 << (t - 1)
r = 0
d = p
while q != p:
for int i = 0; i < n - d; i++:
if i & p == r:
if records[i] > records[i + d]:
swap records[i], records[i + d]
d = q - p
q = q // 2
r = p
for int i = 0; i < n - d; i++:
if (i & p) == r:
if records[i][0] > records[i + d][0]:
swap records[i], records[i + d]
p = p // 2Быстрая сортировка
В сортировке Бетчера последовательность сравнений предопределена, то есть мы постоянно сравниваем одни и те же пары ключей на фиксированных позициях вне зависимости от значения этих ключей. А между прочим, уже выполненные операции сравнения могут предоставить нам дополнительную информацию о сортируемой последовательности, и мы можем использовать эту информацию для выполнения более эффективной и быстрой сортировки.
Попробуем отказаться от оптимизаций под параллельные алгоритмы в пользу более быстрой сортировки на обычных процессорах с последовательным выполнением операций. Иными словами, новый метод не будет применим для параллельных вычислений, как сортировка Бетчера, но сможет превзойти её на классической однопоточной архитектуре.
Давайте брать одну из записей и сразу двигать её на то место, которое она должна занять после сортировки. Для поиска номера , под которым должна оказаться выбранная запись , нам нужно распределить остальные записи так, чтобы слева от записи оказались только записи с меньшим ключом, а справа от записи оказались только записи с большим ключом.
После этой операции у нас получатся два подмассива и . При этом, если — ключ записи , и — ключ записи , то
То есть, наш массив записей стал разбит таким образом, что исходная задача сортировки сводится к двум независимым сортировкам пары массивов меньшей длины. Можно применять тот же метод к полученным подмассивам до тех пор, пока мы всё не отсортируем. При этом массивы единичной длины сортировать не надо.
Метод, рассмотренный нами, называется быстрая сортировка, а точнее общая быстрая сортировка.
Понятно, что эффективность алгоритма зависит от стратегии выбора записи ,
по которой будем разделять массив.
Эта запись называется опорным элементом разделения.
Используя разные алгоритмы выбора опорного элемента мы будем получать разные варианты быстрой сортировки,
такие как рандомизированная быстрая сортировка, быстрая сортировка Седгевика и быстрая сортировка Хоара.
Операцию выбора этого элемента назовём select_pivot() и рассмотрим потом отдельно.
Также эффективность алгоритма зависит от способа разбиения подмассива на две части относительно опорного элемента.
Как бы операция выбора опорного элемента является частью операции разбиения,
но для удобства повествования и анализа я разделю эти две операции.
Операция разделения partition(ref array[T] a, int left, int right, int pivot_index) -> int принимает на вход ссылку на исходный массив, границы подмассива для разделения и индекс элемента,
который считается опорным. Возвращает индекс, на который переместился опорный элемент.
При этом записи в подмассиве эта операция перекомпонует так, что
по индексам от left до нового индекса опорного элемента располагаются только записи,
у которых ключи меньше опорного,
а по индексам от нового индекса опорного элемента до right располагаются только записи,
у которых ключи больше опорного.
А пока общий код алгоритма быстрой сортировки
require type T is comparable
function quick_sort(ref array[T] a[n]):
recursive_quick_sort(a, left = 0, right = n - 1)
function recursive_quick_sort(ref array[T] a, int left, int right):
if left >= right:
return
# Выбираем опорный элемент и разделяем массив
int pivot_index = select_pivot(a, left, right)
int s = partition(a, left, right, pivot_index)
# Рекурсивно сортируем обе части
recursive_quick_sort(a, left, s - 1)
recursive_quick_sort(a, s + 1, right)Важно, что сортировка происходит обменами элементами. Конечно, можно было бы создавать новые массивы, а потом их соединять, но такой подход добавляет большое количество лишних операций и линейную сложность по памяти.
Если нам не повезет, и по нашей стратегии мы будем выбирать опорный элемент так, что одна часть будет состоять только из одной записи, а другая часть из всех оставшихся, то время работы нашего алгоритма составит . А если нам повезет, и массив разделится ровно пополам, то время работы нашего алгоритма составит . Получается, что для получения хорошего алгоритма быстрой сортировки нам нужно удачно выбирать опорный элемент.
Аппарат анализа
Для анализа различных стратегий выбора опорного элемента установим общие свойства, верные для любой реализации быстрой сортировки.
Важным свойством быстрой сортировки является сохранение случайности подмассивов после разбиения. Относительное положение записей в подмассивах никак не влияет на алгоритм разделения. Значит, случайность действительно сохраняется, и верна теорема
Сохранение случайности при разбиении
Если исходный массив является случайной перестановкой, то после разбиения относительно опорного элемента оба получившихся подмассива являются случайными перестановками своих элементов.
Пусть также — вероятность того, что опорный элемент разбивает исходный массив на подмассивы длины и .
Будем использовать наше стандартное обозначение для количества сравнений. Понятно, что это величина случайная. Мы предполагаем равномерное распределение всех входных перестановок.
Пусть — производящая функция вероятностей числа сравнений в быстрой сортировке массива из элементов.
Введём величину — число сравнений при разбиении при условии, что опорный элемент занимает позицию . Пусть её производящая функция равна Тогда
Это свойство производящей функции числа сравнений следует из сохранения случайности при разбиении.
Теперь получим формулы для — количество обменов. Это тоже случайная величина.
Пусть — производящая функция вероятностей числа обменов в быстрой сортировке массива из элементов. Здесь опять — вероятность того, что опорный элемент разбивает исходный массив на подмассивы длины и .
Введём также величину — число обменов при разбиении при условии, что опорный элемент занимает позицию . Пусть её производящая функция равна Тогда
Это свойство производящей функции числа обменов следует из сохранения случайности при разбиении и условной независимости числа обменов в подмассивах.
Быстрая сортировка с фиксированным опорным элементом и разделение Ломуто
Рассмотрим простейшую стратегию — выбор элемента по фиксированному индексу. Эта стратегия кажется разумной для случайных данных, но, как мы увидим позже, она оказывается катастрофично неэффективной для упорядоченных и почти упорядоченных последовательностей. Разделение будет происходить по средством одного прямого прохода по массиву.
Данный алгоритм разбиения был предложен Нико Ломуто в 1970-х годах как простой вариант оригинального разбиения Хоара, а позже был популяризован в книгах Бентли (Programming Pearls) и Кормена (Введение в алгоритмы). В оригинале в качестве опорного выбирался строго последний элемент, для еще большего удобства реализации, но я все-таки буду придерживаться принципа максимальной общности и не буду закреплять выбор индекса, просто скажу, что индекс фиксирован.
function select_pivot(ref array[T] a, int left, int right) -> int:
return left # или любой другой фиксированный индекс от left до right
function partition(ref array[T] a, int left, int right, int pivot_index) -> int:
# Переносим опорный элемент в конец для удобства
swap a[pivot_index], a[right]
T pivot = a[right]
# i указывает границу между элементами меньших pivot и больших pivot
int i = left
for int j = left; j <= right - 1; j++:
if a[j] < pivot:
swap a[i], a[j]
i = i + 1
# Возвращаем опорный элемент на правильную позицию
swap a[i], a[right]
return iОценим количество сравнений, выполняемых быстрой сортировкой со схемой Ломуто.
В схеме Ломуто два элемента сравниваются только когда один из них является опорным элементом. При этом каждый элемент становится опорным ровно один раз, после чего занимает свою финальную позицию. То есть любые два различных элемента сравниваются не более одного раза за весь алгоритм сортировки.
Для фиксированного выбора опорного элемента для всех от до . Также при разбиении мы делаем фиксированное число сравнений — , поэтому Подставляем в формулу :
Найти в замкнутом виде выражение для не получится, но это нам и не нужно. Нам нужно просто посчитать, чему равны первая и вторая производная в точке , чтобы посчитать математическое ожидание и дисперсию числа сравнений
Подставляем , и, обозначив , получаем рекуррентное соотношение для :
Решая эту реккуренту получаем
Теперь обозначим , и, посчитав вторую производную, получим рекуррентное соотношение для :
Решая эту рекурренту получаем
Тогда
Максимальное число сравнений достигается тогда, когда опорный элемент оказывается самым минимальным. Для максимального числа сравнений получаем рекурренту с , решением которой является .
Минимальное число сравнений достигается тогда, когда опорный элемент постоянно является медианой, и обрабатываемый массив делится ровно пополам. Понятно, что совсем ровное деление мы получить на произвольном массиве не сможем, потому что размер массива может быть любой. Оценим пока тут немного грубовато. Для минимального числа сравнений получаем рекурренту с , грубым решением которой является .
Получаем все важные характеристики количества сравнений :
Теперь оценим количество обменов.
Для фиксированного выбора опорного элемента , и . Обратите внимание: я учитываю только обмены, которые происходят в цикле в операции разбиения и не учитываю служебные обмены, которые связаны с размещением опорного элемента, потому что количество служебных обменов зависит от реализации. Важно только то, что эти служебные обмены добавляют не более константы к числу обменов на каждом уровне разбиения, что приводит к линейной поправке в итоговой оценке количества обменов. Тогда наша рекуррента превращается в
И снова, найти в замкнутом виде выражение для не получится. А нам и не надо. Нам надо только вычислить значения и .
Начнём с первого момента, . Считаем производную , подставляем и получаем рекурренту
Решаем и получаем, что
Далее, считаем вторую производную , подставляем и получаем рекурренту для :
Решая эту рекурренту, получаем значение второго момента
Тогда
Максимальное число обменов достигается на полностью отсортированном массиве, где на каждом шаге опорный строго больше всех остальных элементов. Тогда .
Минимальное число обменов достигается на отсортированном массиве, ведь мы буквально никого ни с кем не обмениваем. Тогда .
Получаем все важные характеристики количества сравнений :
Как мы поняли, метод неплохой, работает за ожидаемое время , а точнее выполняет в среднем сравнений и обменов. У этого метода есть одна большая проблема: он уязвим к умышленным атакам. Алгоритм детерминированный, так что можно подобрать такой входной массив, что каждый раз будет в качестве опорного элемента выбираться наименьший или наибольший. И тогда нам гарантировано максимальное время работы .
Проблему исправить можно, если выбирать не фиксированный элемент, а случайный.
Рандомизированная быстрая сортировка
Итак, давайте в качестве опорного элемента выбирать случайную запись. То есть опорным элементом может с равной вероятностью оказаться каждая из записей .
function select_pivot(ref array[T] a, int left, int right) -> int:
return uniform random(int, [left ... right])Анализ количества сравнений и количества обменов для рандомизированного выбора опорного элемента полностью совпадает с анализом для фиксированного выбора опорного элемента.
При случайном выборе опорного элемента из всех позиций, вероятность того, что опорный элемент окажется на индексе , и даст подмассивы размеров и , равна . Это точно такое же распределение , как и для фиксированного выбора опорного элемента на случайной перестановке.
Количество сравнений при разбиении тоже равно .
Количество обменов при разбиении тоже равно , ведь для случайной перестановки распределение числа обменов зависит только от позиции опорного элемента, а не от того, как он был выбран.
Получается, что производящие функции числа сравнений и числа обменов в обоих вариантах совпадают, а значит, я могу просто переписать результат анализа.
Для рандомизированной быстрой сортировки
В чём же тогда преимущество такого подхода? В том что этот вариант выбора опорного элемента недетерминированный, и к нему нельзя просто так подобрать плохие входные данные. Да, нам всё еще может не повести с выбором опорного элемента, но вероятность такого невезения крайне мала. А от умышленных атак этот алгоритм защищён, по крайней мере до того момента, пока злоумышленник не узнает алгоритм генерации случайных чисел и его сид. На практике это делает рандомизированную версию значительно устойчивее к враждебным данным
Оригинальная быстрая сортировка Хоара
Рассмотрим оригинальный алгоритм разбиения, предложенный создателем быстрой сортировки Чарльзом Энтони Ричардом Хоаром в 1960 году.
function select_pivot(ref array[T] a, int left, int right) -> int:
return (left + right) // 2 # или любой другой фиксированный индекс от left до right
function hoare_partition(ref array[T] a, int left, int right, int pivot_index) -> int:
pivot = a[pivot_index]
i = left - 1; j = right + 1
forever:
do i++ while a[i] < pivot
do j-- while a[j] > pivot
if i >= j:
return j
swap a[i], a[j]Опорный элемент фиксированный, так что у нас опять .
Проанализируем число сравнений, которые случаются при быстрой сортировке со схемой разделения Хоара.
Каждый индекс посещается соответствующим указателем ровно один раз:
указатель i начинает с левой границы и движется только вправо,
а указатель j начинает с правой границы и движется только влево.
Эти два указателя встречаются где-то посередине и останавливаются.
Значит, каждый индекс будет посещён ровно одним из указателей,
и для него произойдёт ровно одно сравнение ключа с опорным элементом.
Получается, что для всех . Подставляем в рекурренту для :
Считаем производную, подставляем и получаем рекурренту для :
Решая эту реккуренту получаем
Теперь обозначим , и, посчитав вторую производную, получим рекуррентное соотношение для :
Решая эту рекурренту получаем
Тогда
Максимальное число сравнений достигается тогда, когда опорный элемент оказывается самым минимальным. Для максимального числа сравнений получаем рекурренту с , решением которой является .
Минимальное число сравнений достигается тогда, когда опорный элемент постоянно является медианой, и обрабатываемый массив делится ровно пополам. Понятно, что совсем ровное деление мы получить на произвольном массиве не сможем, потому что размер массива может быть любой. Оценим пока тут немного грубовато. Для минимального числа сравнений получаем рекурренту с , грубым решением которой является .
Получаем все важные характеристики количества сравнений :
Проанализируем количество обменов, которое случается при быстрой сортировке со схемой разделения Хоара.
Опорный элемент не участвует в обменах до финального положения. Обмены происходят только между элементами левой и правой частями, при этом каждая пара , где взят из левой части, а взят из правой части, может привести к обмену. Обменяются они тогда и только тогда, когда пара будет составлять инверсию. Получается, что число обменов при быстрой сортировке со схемой Хоара равно числу инверсий между левой и правой частями.
Массив разделён на две части, в одной элементов, а в другой элементов. Производящая функция для числа инверсий между множествами размера и равна гауссовому коэффициенту
Тогда, усредняя по всем равновероятным разбиениям, получаем производящую функцию числа обменов операции разбиения по схеме Хоара
где — число инверсий между множествами и .
Теперь мы можем записать производящую функцию и вычислить оба момента , чтобы найти главные характеристики числа обменов.