Сортировка без магии

В этой главе мы поймём:

Опять автор использует имя функции в качестве английского слова, пришлось заменить; также см. ниже замечание относительно выбора между компаратором и ключом -- FrBrGeorge 2022-10-22 22:17:41

В этой главе мы будем рассматривать алгоритмы, которые решают такую задачу: переставить элементы в массиве размером N так, чтобы они шли в порядке возрастания. Многие более сложные задачи можно упростить и решать более эффективно, если набор данных предварительно отсортировать; временами без сортировки вообще не обойтись. Сортировка очень часто возникает и в повседневных задачах — например, листок со списком сотрудников и их телефонами обычно отсортирован по именам, а список рейсов на табло в аэропорту — по времени вылета.

Если данные в массиве не отсортированы, поиск элемента в наихудшем случае имеет сложность O(N), а если отсортированы — можно применить двоичный поиск, сложность которого в наихудшем случае O(log N).

Обмен элементов в сортировке

Попробуем отсортировать массив A, изображённый на иллюстрации 5-1 сверху. Можно написать числа на клочках бумаги или делать пометки карандашом в прямо книге (только стереть их потом!). Условие: можно использовать только два действия: сравнение двух элементов и обмен местами двух элементов. Какое наименьшее количество обменов потребуется? Заодно стоит посчитать количество сравнений. В примере массив отсортирован посредством пяти обменов — а можно ли было меньше?1

Иллюстрация 5-1. Сортировка некоторого массива A
Иллюстрация 5-1. Сортировка некоторого массива A

← Массив A

↓ Время

обмен 0 и 3

7 сравнений

обмен 5 и 7

6 сравнений

обмен 1 и 6

5 сравнений

проверка 21

4 сравнения

обмен 2 и 4

3 сравнения

проверка 15

2 сравнения

обмен 4 и 5

1 сравнение

Считать количество попарных сравнений элементов не мене важно, чем считать число их обменов. Итак, найти наименьший элемент в A — число 2 — можно за семь сравнений, как это было показано в главе 1. Минимум найден в A[3], так что меняем его местами с A[0], и теперь в начале массива, на своём месте, стоит наименьший элемент. Элементы, которые меняются местами на очередном шаге, выделены на иллюстрации 5-1 серым. Жирной рамкой обведены области массива, элементы которых гарантированно стоят на своих местах и не больше переместятся. Это две непрерывные области с двух концов массива, а всю середину нужно сортировать дальше.

Осталось семь элементов, из которых за шесть сравнений мы находим наибольший, 24 в позиции A[5], и меняем его местами с последним элементом, A[7]. В оставшемся отрезке из шести элементов за пять сравнений мы находим наименьший, 5 в позиции A[6], и меняем его местами с начальным элементом отрезка, A[1]. Теперь снова находим наибольший, 21, оказывается, он уже на своём месте, и обменов не требуется.

Среди четырёх элементов отрезка за три сравнения находим наименьший, 15, и меняем местами с началом отрезка — A[4] меняем c A[2]. Снова поищем минимум, уже за два сравнения, это снова окажется 15, и оно стоит на своём месте. Теперь сравним оставшиеся два элемента, A[4] и A[5] и поменяем их местами. Это последнее действие на иллюстрации 5-1, и можно заметить, что оба элемента при этом оказываются на своих местах, например, 20 не меньше каждого элемента слева от него, и не больше каждого элемента справа. Итак, массив отсортирован за 28 сравнений и 5 обменов.

Никакого конкретного алгоритма в нашей сортировке не наблюдалось — мы искали то минимум, то максимум без особых причин. Тем не менее число сравнений на каждом шаге уменьшалось на единицу, и было не более одного обмена за шаг (что куда меньше числа сравнений). Давайте теперь опишем чёткий алгоритм для массива из N элементов, и оценим его производительность.

Сортировка выбором

Сортировка выбором названа так из-за того, что в ней массив просматривается справа налево, и на каждую очередную позицию выбирается наименьший из оставшихся элементов и меняется местами с тем, что находится в этой позиции сейчас. Для сортировки N элементов массива A сначала надо найти наименьший по всему массиву и поменять его местами с A[0], затем — наименьший среди оставшихся N - 1 элементов, и поменять его местами с A[1]. Останется N - 2 неотсортированных элемента, и так следует повторять, пока не останется один — наибольший.

image Что делать, если на некотором шаге наименьший элемент уже находится на своём месте? В примере ниже это значит, что по окончании цикла по j значение i окажется равным min_index. Да ничего! Программа попытается поменять местами A[i] и A[min_index], и массив не изменится. Можно попробовать проверять, что i и min_index не равны, и только тогда менять местами ячейки, но заметного прироста производительности это не даст.

В примере 5-1 внешний цикл по i перебирает почти все позиции в массиве, от 0 до N - 2. Внутренний цикл по j перебирает позиции оставшихся неотсортированными элементов, от i + 1 до N - 1 — среди них нужно найти наименьшее. В конце цикла по i элемент массива в позиции i меняется местами с найденным наименьшим элементом в позиции min_index.

   1 def selection_sort(A):
   2   N = len(A)
   3   for i in range(N-1):                          # 1
   4     min_index = i                               # 2
   5     for j in range(i+1, N):
   6       if A[j] < A[min_index]:                   # 3
   7         min_index = j
   8       A[i], A[min_index] = A[min_index], A[i]   # 4
  1. На каждом шаге цикла по i мы знаем, что A[0 … i-1] уже отсортированы.

  2. Индекс наименьшего элемента среди A[i … N-1] будет занесён в min_index. /* min_index is the index location of the smallest value in A[i .. N-1].

  3. Если какой-то A[j] окажется меньше A[min_index], надо обновить min_index — запомнить позицию наименьшего на данный момент элемента.

  4. После обмена A[i] и A[min_index] отсортированы будут уже A[0 … i].

Внешний цикл сортировки выбором работает с набором данных размера N (всем массивом), а затем размер задействованного набора уменьшается на 1 с каждым оборотом цикла — сначала до N - 1, затем — до N - 2 и так до конца сортировки. Иллюстрация 5-2 показывает, что для этого требуется ровно N - 1 обмен.

После N - 1 обмена все элементы оказываются на своих местах, включая последний, A[N - 1] — он не участвовал в поиске минимума, но остался один, и значит, он наибольший. Теперь попробуем посчитать количество сравнений — это чуть-чуть сложнее. На иллюстрации 5-2 сравнений всего 28, что равно сумме чисел от 1 до 7.

Сумму значений от 1 до K можно вычислить как K · (K + 1) / 2. Такие числа в математике называются треугольными, потому что K · (K + 1) / 2 предмета можно расположить в виде треугольника. На иллюстрации 5-3 наглядно представлено само треугольное число 28, и идея, на которой основана формула.

Расположим предметы в виде треугольника из K строк и K элементов в самой длинной строке. Продублируем этот треугольник, перевернём вверх ногами, и приложим к исходному. Получим прямоугольник размером K на K + 1. Выходит, что количество предметов в треугольнике — это ровно половина количества предметов в прямоугольнике. Для треугольника из семи строк «двойной» прямоугольник будет состоять из 7 · 8 = 56 предметов, значит, в треугольнике их 28. В сортировке K = N - 1, это количество сравнений на первом шаге, необходимое для поиска минимума. Общее число сравнений равно (N - 1) · N / 2.

Мало того, что это материал четвёртого класса средней школы, автор умудрился нарушить логическую последовательность рассуждений и привести потенциально некорректную формулу ½N2-½N. Исправил как мог. -- FrBrGeorge 2022-09-21 09:17:53

Иллюстрация 5-2. Сортировка произвольного массива выбором
Иллюстрация 5-2. Сортировка произвольного массива выбором

← Массив A

↓Время

обмен 0 и 3

7 сравнений

обмен 1 и 6

6 сравнений

обмен 2 и 3

5 сравнений

обмен 3 и 4

4 сравнения

обмен 4 и 7

3 сравнения

обмен 5 и 7

2 сравнения

обмен 6 и 6

1 сравнение

Иллюстрация 5-3. Наглядное представление формулы треугольного числа: 28 как сумма от 1 до 7
Иллюстрация 5-3. Наглядное представление формулы треугольного числа: 28 как сумма от 1 до 7

Структура квадратичных алгоритмов сортировки

Доминирующая составляющая в оценке сложности сортировки выбором — это количество сравнений, их порядка , значит, и быстродействие окажется ей под стать — O(N²). В самом деле, в алгоритме сортировки N элементов ровно N - 1 шаг. На первом шаге для поиска минимума необходимо N - 1 сравнение, и только один элемент встаёт на своё место. На оставшихся N - 2 шагах количество сравнений уменьшается, но так медленно, что только под самый конец в них отпадает необходимость. При таком подходе число сравнений будет строго соответствовать формуле, то есть иметь квадратичный порядок. Можно ли как-то уменьшить это число?

Сортировка вставками — это другой подход, в котором для упорядочивания массива слева направо тоже нужно проделать N - 1 отдельных шагов. Начнём с того, что массив из одного элемента всегда упорядочен; а вдруг A[0] — и вправду наименьший, ведь и такое случается?. Если на первом шаге окажется, что A[1] меньше A[0], A[1] надо поставить в начало — в данном случае, поменять местами с A[0]. На втором шаге мы рассматриваем A[2] и пробуем вставить его на своё место, считая, что массив пока состоит только из трёх элементов. Возможны три варианта: A[2] и так на своём месте; его надо вставить между A[0] и A[1]; его надо вставить в начало, перед A[0]. Однако нельзя просто так вставить в классический массив один элемент между другими — можно только «сдвинуть» все ячейки поэлементно. Для этого надо запомнить исходный элемент, поставить соседа на его место, поставить следующего соседа на место соседа и т. д. до нужной позиции, но которую поставить исходный элемент. Другой вариант — менять местами элементы попарно до тех пор, пока исходный не встанет на своё место2.

Как показано на иллюстрации 5-4, каждый шаг завершается последовательным обменом соседних ячеек, пока те не упорядочены.

Иллюстрация 5-4. Сортировка вставками в произвольный массив
Иллюстрация 5-4. Сортировка вставками в произвольный массив

←Массив A

↓Время

Вставка: 21

1 сравнение

Вставка: 20

2 сравнения

обмен 2 и 1

Вставка: 2

3 сравнения

обмены: 3 и 2, 2 и 1, 1 и 0

Вставка: 15

3 сравнения

обмены: 4 и 3, 3 и 2

Вставка: 24

1 сравнение

Вставка: 5

6 сравнений

обмены: 6 и 5, 5 и 4, 4 и 3, 3 и 2, 2 и 1

Вставка: 19

4 сравнения

обмены: 7 и 6, 6 и 5, 5 и 4

Все элементы, которые пришлось менять местами, выделены серым, а отсортированная область массива обведена жирной чертой. На иллюстрации видно, что элементы в отсортированной области время от времени тоже переставляются (в сортировке выбором было не так). Количество последовательных обменов, приводящих к вставке элемента на своё место, иногда довольно велико (как, например, при вставке 5), а иногда (как, например, при вставке 21 и 24), обменов вовсе не требуется — очередное значение и так больше остальных, и порядок сохраняется. В нашем примере 20 сравнений и 14 обменов. При сортировке вставками число сравнений всегда больше числа обменов, но по сравнению с сортировкой выбором обменов больше, а сравнений меньше. Неожиданно простая реализация этого алгоритма приведена в примере 5-2.

   1 def insertion_sort(A):
   2   for i in range(1, len(A)):            # 1
   3     for j in range(i, 0, -1):           # 2
   4       if A[j-1] <= A[j]:                # 3
   5         break
   6       A[j], A[j-1] = A[j-1], A[j]       # 4

  1. На каждом шаге цикла по i мы знаем, что A[0 … i-1] уже отсортированы.

  2. Индекс j уменьшается от i до 1 включительно — это возможные положения элемента, с которым нужно менять местами вставляемый.

  3. Если A[j-1] не больше A[j], место для вставки найдено, и цикл останавливается.

  4. В противном случае меняем местами два элемента: они нарушали порядок

Больше всего работы достаётся сортировке вставками, когда очередное добавляемое значение меньше всех уже отсортированных. Наихудший случай для сортировки вставками — значения, упорядоченные по убыванию. При этом на каждом шаге и количество сравнений, и количество обменов увеличивается на 1, так что обе характеристики оказываются уже знакомыми нам треугольными числами.

Оценка производительности сортировки выбором и сортировки вставками

Сортировка N значений выбором всегда делает N · (N - 1) сравнение и N - 1 обмен. В случае сортировки вставками посчитать количество действий труднее, потому что оно зависит от порядка самих значений в массиве. Сортировка выбором в среднем менее эффективна, но наихудший случай для сортировки вставками — значения, упорядоченные по убыванию — даёт N · (N - 1) сравнений и столько же обменов. Как бы мы ни улучшали эти алгоритмы, порядок сравнений в них — ; на иллюстрации 5-5 приведены графики быстродействия. Чем это нас не устраивает? Как обычно, сравним, насколько понижается быстродействие, когда входной набор данных увеличивается. Набор из 524288 элементов в 512 раз больше набора из 1024 элементов, а оба алгоритма сортировки работают на нём почти в 275000 раз медленнее.3 Сортировка 524288 значений вставками заняла в эксперименте около двух часов, а сортировка выбором — почти четыре. Если объёмы данных будут больше, очень скоро счёт пойдёт на дни и недели. Это никуда не годится, но квадратичные алгоритмы (сложность которых O(N²)) на лучшее не способны.

Иллюстрация 5-5. Время работы сортировки вставками и сортировки выбором
Иллюстрация 5-5. Время работы сортировки вставками и сортировки выбором

Производительность сортировки вставками и сортировки выбором

Время в секундах

1 день

Сортировка выбором

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

1 час

N — объём входных данных

Что если мы хотим отсортировать массив в каком-нибудь другом порядке? Хоть бы и по убыванию? В общем случае у нас даже может не быть операции сравнения самих элементов массива, а порядок их важен, что делать тогда? Простой подход состоит в том, чтобы вместо арифметической операции «меньше, чем» использовать заранее заданную функцию-компаратор, которая определит, не нарушается ли порядок в паре значений. Функция эта передаётся в качестве дополнительного параметра алгоритму сортировки. Все алгоритмы в этой главе можно модифицировать таким образом, так что впредь будем для простоты рассматривать только сортировку по возрастанию. В примере 5-3 представлена сортировка вставками с компаратором.

   1 def less_than(one, two):
   2   return  one <= two
   3 
   4 def insertion_sort_cmp(A, less=less_than):
   5   N = len(A)
   6   for i in range(1, N):
   7     for j in range(i, 0, -1):
   8       if less(A[j-1], A[j]):            # 1
   9         break
  10       A[j], A[j-1] = A[j-1], A[j]
  1. Порядок сортировки определяется возвращаемым значением функции-компаратора, less(). Если less(A[j-1], A[j]) истинно, A[x] должно предшествовать A[y].

image Компаратор — не просто функция, возвращающая True или False. Он должен обладать свойством сохранения порядка (на математическом языке — транзитивностью), при котором из того, что A предшествует B, а B предшествует C, с необходимостью следует, что A предшествует также и C. Типичный пример нетранзитивного компаратора — определение победителя в игре «камень, ножницы, бумага», в которой ножницы побеждают бумагу, камень побеждает ножницы, но бумага побеждает камень. Задание нетранзитивного компаратора может привести к вечному зацикливанию алгоритма или неоднозначности результата. Для большей безопасности используют т. н. сравнение по ключу, когда переданная алгоритму сортировки функция вычисляет из элемента массива некоторое значение (обычно числовое или строковое) — ключ — а для определения порядка ключи элементов сравниваются заведомо транзитивной операцией «меньше, чем». Именно так устроена стандартная функция sort() в Python.

И сортировка выбором, и сортировка вставками упорядочивают массив за N - 1 шаг, на каждом из которых объём входных данных уменьшается на 1. Совершенно другой подход, который можно назвать «разделяй и властвуй», состоим в том, чтобы разбить задачу на две подзадачи, и решать их по отдельности.

Рекурсия: разделяй и властвуй!

Идея рекурсии возникла в математике сотни лет назад. В программировании рекурсия появляется, когда функция вызывает саму себя.

image Последовательность Фибоначчи начинается с двух целых чисел, 0 и 1. Каждый следующий член этой последовательности — сумма двух предыдущих. Первые несколько чисел последовательности Фибоначчи: 1, 2, 3, 5, 8, 13, и так далее. Можно записать рекуррентное соотношение для n-го члена последовательности Фибоначчи: Fn=Fn-1+Fn-2. Если запрограммировать это вычисление рекурсивно, функция F(n) будет дважды вызывать саму себя.

Факториал натурального числа N — это произведение всех натуральных числе, не превосходящих N. Обозначается он N!, так что 5! = 5 · 4 · 3 · 2 · 1 = 120. Можно записать и рекуррентное соотношение: N! = N · (N - 1)!. Действительно, 120 = 5! = 5 * 4!, где 4! = 24. Пользуясь этим соотношением можно реализовать вычисление факториала рекурсивно.

   1 def fact(N):
   2   if N <= 1                     # 1
   3     return 1
   4   return N * fact(N - 1)        # 2
  1. Основание рекурсии: если N ⩽ 1, возвращается 1.

  2. Рекурсивный вызов: вычислим (N-1)! и домножим его на N.

Функция fact() вызывает сама себя. Это подозрительно! А вдруг она никогда не закончится? Чтобы этого не произошло, в каждую рекурсивную функцию добавляют основание рекурсии — условие, при котором вызова не происходит, и действия не становятся бесконечными. Основание для факториала — N = 1. В этом случае функция сразу возвращает 1 и не выполняет рекурсивного вызова.4. Рекурсивный вызов в fact(N) — это fact(N - 1), результат домножается на N, и это и есть факториал.

На иллюстрации 5-6 показано, как Python интерпретирует команду y = fact(3) (каждое следующее по времени состояние интерпретатора отображается под предыдущим). Прямоугольниками обозначены контексты вызова функции fact() с соответствующими параметрами (3, 2 и 1). Вызов fact(3) приводит к рекурсивному вызову fact(2). В это время вычисление fact(3) «приостанавливается» (на иллюстрации выделено серым) до тех пор, пока не станет известно значение fact(2). Вызову fact(2) в свою очередь требуется рекурсивно вызвать fact(1), так что он тоже приостанавливается (и мы его отмечаем серым) до вычисления fact(1). Тут мы достигаем основания рекурсии, и fact(1) возвращает 1. Контекст вызова fact(1) уничтожается, остаётся только возвращаемое значение (оно выделено пунктирной окружностью), которое передаётся в контекст вызова fact(2), и вычисления в нём возобновляются: возвращается 2 · 1 = 2. Остаётся только исходный контекст вызова fact(3) со значением 2, теперь возобновляется он, вычисляет и позвращает 3 · 2 = 6, и в результате y становится равным 6.

Итак, рекурсивный вызов fact(N) порождает цепочку контекстов, приостановленных до тех пор, пока в самом последнем не будет достигнуто основание рекурсии. Затем Python начинает «отматывать» эту цепочку обратно, по одному вызову, до тех пор, пока не завершится самый первый.

Иллюстрация 5-6. Рекурсивный вызов fact(3)
Иллюстрация 5-6. Рекурсивный вызов fact(3)

Следующий текст — мой, это антитеза авторского «ограничение в 1000 введено "по техническим причинам"», что в контексте данной книги просто неверно. -- FrBrGeorge 2022-09-25 10:10:25

Рекурсивное вычисление факториала — самый популярный, но, к сожалению, неправильный пример использования рекурсии. Из иллюстрации 5-6 понятно, что на реализацию каждого шага рекурсии затрачивается ощутимое количество памяти. Кроме того, язык программирования вынужден выполнять довольно много работы по обслуживанию самого механизма рекурсии: например, Python на каждом шаге создаёт отдельное пространство имён и заводит там локальные переменные; при выходе из рекурсивного вызова всё это надо ещё удалить. Реализация того же алгоритма обычным циклом будет работать в несколько раз быстрее, и, что ещё важнее, будет иметь более низкий порядок сложности по памяти (в нашем случае — O(1), а не O(N)).

Поэтому стоит запомнить одно простое правило. Если в рекурсивно организованном алгоритме, который обрабатывает входные данные размера N, глубина рекурсии (сколько раз максимум функция вложенно вызвала сама себя,) пропорциональна N, то вы, скорее всего реализовали обычный цикл, и так делать не надо. Как мы уже знаем, для этого достаточно увидеть, что объём входных данных при каждом вызове уменьшается на константу (чаще всего, на 1). А вот если объём входных данных при каждом вложенном вызове уменьшается в несколько раз (например, вдвое), глубина рекурсии окажется логарифмической — O(log N) — и это хорошее, годное её применение.

По этим двум причинам (относительная «дороговизна» рекурсивного вызова и бессмысленность замены линейного цикла рекурсией) максимальное количество вложенных вызовов функций друг другом ограничено в Python тысячей. С одной стороны, тысяча контекстов вызова — всё ещё не слишком большое потребление памяти, с другой — очевидно, что даже для двоичного логарифма логарифмическая глубина рекурсии в 1000 актуально недостижима (размер входных данных не может быть 2¹⁰⁰⁰, то есть примерно 10³⁰²).

Наш алгоритм этому логарифмическому критерию не соответствует: в нём объём входных данных N на каждом шаге уменьшается только на единицу. Иллюстрация 5-6 наглядно показывает, что числа от N до 1 — это именно входные данные: все они одновременно хранятся в контекстах вызова и занимают память. Вот если бы было можно разбить входные данные на две примерно равные N / 2 части, и решать две получившихся подзадачи раздельно! Две подзадачи с наборами размера N / 2 в свою очередь разделятся на четыре с размером N / 4, и так далее. Будет ли в этом случае останавливаться рекурсия? Да, потому что последовательное деление конечного N пополам непременно выведет это значение за пределы основания рекурсии, и вычисление каждой подзадачи остановится.

Давайте вспомним знакомую нам задачу поиска макcимума в неупорядоченном массиве из N элементов. Напишем вспомогательную функцию rmax(lo, hi), которая ищет наибольшее значение на отрезке массива A[lo … hi]; она-то и будет вызываться рекурсивно.5 В примере 5-5 вызов find_max(A) превращается в вызов rmax(0, len(A)–1), в котором задаются исходные границы поиска — lo = 0 и hi = N - 1 (N — это размер массива A). Основанием для rmax() будет единичный размер отрезка — равенство lo и hi, потому что в наборе из одного элемента максимум не нужно искать. Результатом rmax(), наибольшем числом в объединении отрезков, будет наибольший из максимумов в левом и правом отрезке.

   1 def find_max(A):
   2 
   3   def rmax(lo, hi):
   4     if lo == hi:                # 2
   5         return A[lo]
   6 
   7     mid = (lo+hi) // 2          # 3
   8     L = rmax(lo, mid)           # 4
   9     R = rmax(mid+1, hi)         # 5
  10     return max(L, R)            # 6
  11 
  12   return rmax(0, len(A)-1)      # 1

Функция  rmax(lo, hi) ищет максимум так: делит отрезок исходного массива от lo до hi включительно на два отрезка половинной длины, ищет максимумы в них и сравнивает результаты. На иллюстрации 5-7 показано, как работает rmax(0, 3) на массиве A длиной 4. Для этого решаются две подзадачи — поиск максимума в девой половине массива, rmax(0, 1), и в правой, rmax(2, 3). Поскольку rmax() дважды вызывает рекурсивно сама себя, важно знать в каком именно месте функции вычисление приостановилось. Понадобится новая раскраска: серым фоном, как и прежде, мы будем обозначать контекст функции, чьё выполнение сейчас приостановлено, и там же вдобавок чёрным фоном будем отмечать строки, которые ещё предстоит выполнить, когда вычисления возобновятся.

Места на иллюстрации 5-7 хватило только на три первых рекурсивных вызова, в результате которых был найден максимум левой части массива, 21. Мы видим, что две последние строки в соответствующем контексте всё ещё помечены чёрным, напоминая про оставшуюся часть вычислений: продолжением будет очередной рекурсивный вызов, rmax(2,3). Он приведёт ещё к трём похожим рекурсивным вызовам, а уже после них наконец можно будет вычислить и исходное rmax(0, 3): возвращаемое значение вычислится из max(21, 20).

Иллюстрация 5-7. Протокол рекурсивного вызова rmax(0, 3) для массива A = [15, 21, 20, 2]
Иллюстрация 5-7. Протокол рекурсивного вызова rmax(0, 3) для массива A = [15, 21, 20, 2]

↓ Время

Массив A

На иллюстрации 5-8 представлен полный протокол работы rmax(0, 7). Здесь так же, как и в разборе fact(), серым обозначена приостановка вычислений: например, во время того, как rmax(0, 3) дожидается ответа от рекурсивного вызова rmax(0, 1). Исходный массив делится на всё меньшие отрезки до тех пор, пока в очередном вызове оба параметра rmax() не окажутся равными. На иллюстрации мы видим восемь таких случаев, потому что в массиве N = 8 элементов. Во всех восьми случаях мы имеем дело с основанием рекурсии, и, следовательно, дальнейших вызовов не происходит. Максимум массива A, очевидно, 24, и на иллюстрации отдельно отмечены рекурсивные вызовы, которые возвращают этот максимум.

Иллюстрация 5-8. Полный протокол рекурсивного вызова rmax(), 7)
Иллюстрация 5-8. Полный протокол рекурсивного вызова rmax(), 7)

↓ Время

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

Смотрим мы на наши примеры, и возникает вопрос: а нельзя ли и для сортировки применить методику «разделяй и властвуй»? Идея в том, чтобы, как это показано в примере 5-6, рекурсивно отсортировать левую половину массива, затем рекурсивно отсортировать правую, а затем как-нибудь быстренько объединить уже отсортированные половины. Тогда и весь массив окажется упорядоченным.

   1 def sort(A):
   2 
   3   def rsort(lo, hi):            # 1
   4     if hi > lo:                 # 2
   5       mid = (lo+hi) // 2
   6       rsort(lo, mid)            # 3
   7       rsort(mid+1, hi)
   8       merge(lo, mid, hi)        # 4
   9 
  10   rsort(0, len(A)-1)
  1. Вспомогательная функция, которая сортирует отрезок A[lo … hi].

  2. Пустой или единичный отрезок сортировать не надо, это основание рекурсии. В противном случае надо вычислять дальше.

  3. Рекурсивные вызовы: сортировка левого и правого отрезков массива.

  4. Слияние двух упорядоченных отрезков и замена их на результат слияния.

Здесь мы воспользовались схемой, которую придумали для функции find_max() и показали в примере 5-5. Если реализация вспомогательной функции merge() окажется достаточно простой, у нас получится настоящая сортировка слиянием — рекурсивный алгоритм, который использует дополнительную память, но в результате сортирует исходный массив за O(N log N) действий — то есть мы наконец-то преодолеем квадратичный порог сложности!

Главное в сортировке слиянием — это функция merge(), слияние двух отрезков массива «по месту», когда отсортированная правая и отсортированная левая половины массива заменяются на результат из слияния. Принцип работы merge() знаком всем, кто когда-либо превращал две упорядоченные колоды карт в одну, тоже упорядоченную. Наглядно этот принцип показан на иллюстрации 5-9. «Карты» на этой иллюстрации — это просто карточки с номерами.

Иллюстрация 5-9. Слияние двух колод в одну
Иллюстрация 5-9. Слияние двух колод в одну

Упорядоченная левая колода

Упорядоченная правая колода

Упорядоченная полная колода

Л П

Л П

Л П

Л П

Л П

Л П

Л П

Л П

Шаг 1

Шаг 2

Шаг 3

Шаг 4

Шаг 5

Шаг 6

Шаг 7

Шаг 8

Если у нас есть две упорядоченных колоды, сделать из них одну с сохранением порядка можно так. Сравним две карты на вершинах колоды, выберем наименьшую из них и положим на место, где будет формироваться полная колода. Все остальные карты мы будем подсовывать под полную колоду, чтобы сохранить тот же порядок (в нашем случае — возрастания снизу вверх). В примере на первых двух шагах сначала подсовываем 2 из левой колоды, а затем — 5 из правой. Теперь в обеих колодах значения одинаковы — 15; возьмём сначала из левой, затем из правой. Продолжим повторять эти действия до тех пор, пока какая-нибудь из колод не опустеет — это будет предпоследний шаг слияния. На последнем шаге просто заберём все карты из оставшейся непустой колоды — они и так не меньше всех остальных, и уже отсортированы.

Обратите внимание на то, что нам понадобилось «место, где будет формироваться полная колода». Это означает, что при программировании такого алгоритма понадобится дополнительное хранилище данных. Самым эффективным для сортировки слиянием оказался самый простой способ: завести ещё один массив размером с исходный, и хранить там подлежащие сортировки отрезки, выполняя слияние обратно в исходный массив. Вариант функции merge() приведён в примере 5-7.

   1 def merge_sort(A):
   2     aux = [None] * len(A)                                               # 1
   3 
   4     def rsort(lo, hi):
   5         if hi > lo:                                                     # 2
   6           mid = (lo+hi) // 2
   7           rsort(lo, mid)                                                # 3
   8           rsort(mid+1, hi)
   9           merge(lo, mid, hi)
  10 
  11     def merge(lo, mid, hi):
  12         aux[lo:hi+1] = A[lo:hi+1]                                       # 4
  13         left, right = lo, mid+1                                         # 5
  14         for i in range(lo, hi+1):
  15             if left > mid or right <= hi and aux[right] < aux[left]:    # 6
  16                 A[i] = aux[right]
  17                 right += 1                                              # 7
  18             else:                                                       # 8
  19                 A[i] = aux[left]
  20                 left += 1
  21 
  22     rsort(0, len(A)-1)                                                  # 9
  1. Заводим дополнительное хранилище размером с исходный массив.

  2. Основание рекурсии — отрезок не более чем единичной длины; в противном случае вычисления продолжаются.

  3. Два рекурсивных вызова: сортировка левой и правой половин отрезка, а затем их слияние.

  4. Копируем элементы двух отсортированных отрезков в хранилище: их предстоит объединить.

  5. Задаём начало левого отрезка, left, и правого — right.

  6. Элемент правого отрезка мы берём в двух случаях:
    • левый отрезок уже пуст,
    • оба отрезка непусты, и элемент в начале правого отрезка меньше.

  7. Сдвигаем начало правого отрезка на следующий его элемент.
  8. В двух других случаях — правый отрезок пуст или оба непусты и левый элемент меньше — берём элемент и сдвигаем начало левого отрезка.

  9. Исходный вызов рекурсивной функции.

image В условном операторе внутри цикла функции merge() проявляется свойство ленивых вычислений Python: правый операнд, B, логического выражения «A or B» вычисляется только если значение A ложно, а выражения «A and B» — только если A истинно. В нашем случае нельзя проверять right <= hi и aux[right] < aux[left] одновременно: если первое сравнение ложно, второе, скорее всего, просто ошибочно, и его не нужно даже пытаться интерпретировать. Именно так и работает операция «and»! То же самое и со сравнением left > mid: если оно истинно, не стоит пытаться выполнить остальные сравнения, но именно так (с учётом приоритета and над or) и работает операция or.

На иллюстрации 5-10 по шагам показано, как работает merge(). На первом шаге вызова merge(lo, mid, hi) элементы массива A[lo … hi] копируются в aux[lo … hi] — потому что именно этот отрезок и надо отсортировать.

Цикл по i выполнится восемь раз, потому что всего в массиве восемь элементов, из которых состоят два объединяемых отрезка. Начиная с третьего ряда иллюстрации 5-10 мы можем обращаться к переменным left, right и i, которые хранят интересующие нас индекcы:

Иллюстрация 5-10. Слияние отрезков массива по 4 элемента в каждом
Иллюстрация 5-10. Слияние отрезков массива по 4 элемента в каждом

↓ Время

left и right не переводятся -- FrBrGeorge 2022-09-30 13:52:51

В цикле for при необходимости сравниваются два значения из aux (выделены серым на иллюстрации 5-10), выбирается меньшее, и оно записывается в A[i]. Переменная i увеличивается на каждом шаге, а left и right — только когда соответствующее значение, aux[left] или aux[right], оказывается очередным наименьшим и переносится в A. Время работы merge() прямо пропорционально сумме длин обоих отрезков, то есть hi - lo + 1.

Сортировка слиянием — отличный пример алгоритма в стиле «разделяй и властвуй», быстродействие которого оценивается как O(N log N). В целом произвольную задачу можно решить за O(N log N) действий, если в ней соблюдаются такие условия:

  1. Исходный набор данных можно поделить пополам, и независимо решить две получившиеся подзадачи. Допустимо делить не строго пополам, различие в длина на небольшую константу погоды не делает.

  2. Если в основании рекурсии лежит фиксированное количество действий, или не лежит никаких действий, как в сортировке слиянием.

  3. Если на каждом шаге рекурсии — и до, и после разделения данных и рекурсивного вызова — количество выполняемых действий прямо пропорционально объёму обрабатываемых данных. Например, в функции merge(), которая выполняется после рекурсивного вызова, количество итераций цикла for равно размеру сортируемого отрезка.

Стоит вспомнить, что время работы программы зависит от конкретной реализации, и его нужно измерять. Таким образом, время работы не может являться свойством задачи. А вот количество действий в алгоритме — может. Если адекватно оценить количество действий, можно предположить, что быстродействие будет удовлетворять той же оценке.

Автор сразу вёл речь о быстродействии как о свойстве задачи, что, по-моему, неверно. -- FrBrGeorge 2022-09-30 13:52:51

Быстрая сортировка

Быстрая сортировка — ещё один весьма известный и очень эффективный алгоритм в стиле «разделяй и властвуй»6. Идея в том, чтобы выбрать в массиве некоторый элемент p (назовём его опорным), и поставить его на своё место, туда, где он окажется в отсортированном массиве. Для этого предлагается переставить местами элементы массива так, чтобы слева от p оказались только не большие p элементы, а справа — только не меньшие. Затем надо рекурсивно отсортировать два отрезка массива слева и справа от позиции p. На иллюстрации 5-11 показан такой массив; можно проверить, что он и правда «разгорожен» элементом p.

Иллюстрация 5-11. Результат работы partition(A, 0, 7 ,0) с опорным элементом A[0]
Иллюстрация 5-11. Результат работы partition(A, 0, 7 ,0) с опорным элементом A[0]

Исходный массив

Разгороженный массив

Это кажется каким-то трюком на грани мошенничества: как можно знать заранее, куда попадёт p в отсортированном массиве? Оказывается, массив A для этого не нужно сортировать — достаточно переставить местами некоторые элементы по отношению к p. В действительности мы уже видели алгоритм, который умеет это делать: функция partition() встречалась в тренировочных заданиях к главе 1. Именно она и превратила массив на иллюстрации 5-11 в два отрезка: левый состоит из двух элементов, а правый — из семи. Теперь достаточно рекурсивно применить к этим отрезкам быструю сортировку, как в примере 5-8, и задача будет решена.

   1 def quick_sort(A):
   2 
   3   def qsort(lo, hi):
   4     if hi > lo:                                   # 1
   5       pivot_idx = lo                              # 2
   6       location = partition(A, lo, hi, pivot_idx)  # 3
   7       qsort(lo, location-1)                       # 4
   8       qsort(location+1, hi)
   9 
  10   qsort(0, len(A)-1)                              # 5
  1. Основание рекурсии: если отрезок не длиннее одного элемента, сортировать его не надо, иначе работу следует продолжить.

  2. Опорным элементом p массива выберем A[lo].

  3. Переставим элементы A и вернём позицию p в нём:

    • location — это позиция p

    • Все элементы левого отрезка A[lo … location–1] не больше p.

    • Все элементы правого отрезка A[location+1 … hi] не меньше p.

  4. Рекурсивный вызов: отсортируем элементы обоих отрезков по месту, а p и так уже находится так, где следует — в A[location].

  5. Исходный вызов рекурсивной функции

Быстрая сортировка — красивое рекурсивное решения задачи, но её эффективность каждого её шага зависит от того, где в отсортированном массиве располагается опорный элемент. Например, если так случится, что функция partition() выберет в качестве опорного наименьший элемент отрезка A[lo … hi] длины N элементов, результатом будет пустой левый отрезок и правый отрезок длины N-1. Если на каждом шагу объём данных уменьшается только на 1, как в сортировке вставками или сортировке выбором, сложность алгоритма станет такой же неэффективной — O(N²). Верхняя часть иллюстрации 5-12 отображает основные шаги быстрой сортировки массива с иллюстрации 5-11. В нижней части перечислены все рекурсивные вызовы. В этой части справа показан сам массив A, и можно пронаблюдать, как меняются его значения при каждом вызове. Здесь опорной точкой отрезка A[lo … hi] всегда выбирается A[lo], так что функция partition() всегда вызывается с параметрами lo, hi, lo. Стечением времени — сверху вниз — становится понятно, что каждый вызов partition() сопровождается одним или двумя рекурсивными вызовами qsort(). Например, partition(0, 7, 0) ставит нас своё место в массиве A число 15 (это место выделено серым), после чего дважды вызывается рекурсивно сначала qsort(0, 1) на левом отрезке, а затем — qsort(3, 7) на правом. Понятно, что qsort(3, 7) запустится только после того, как завершит работу qsort(0, 1).

Каждый вызов partition() приводит к тому, что некоторый элемент массива оказывается на своём месте — на иллюстрации он подкрашивается серым. Если при вызове qsort(lo, hi) отрезок оказывается единичной длины, то есть lo == hi, соответствующий элемент уже на своём месте, и тоже подкрашивается серым.

Если вызов partition(lo, hi, lo) привёл только к единственному вызову qsort(), значит, опорный элемент оказался или в позиции A[lo], или в позиции A[hi] — а это означает, что длина сортируемого отрезка уменьшилась только на 1. Таким образом реализация быстрой сортировки из примера 5-8 покажет рекордно низкую — O(N²) — производительность на… уже отсортированном массиве! Поскольку уже отсортированные данные встречаются часто, быструю сортировку пытаются улучшить, выбирая опорный элемент случайно из диапазона A[lo … hi]. В примере для этого достаточно заменить pivot_idx = lo на pivot_idx = random.randint(lo, hi). Десятилетия исследований показали, что теоретическая вероятность наихудшего случая, на котором быстрая сортировка показывает производительность всего O(N²), есть всегда7. Несмотря на такой недостаток, именно этот алгоритм часто используют на практике — потому, например, что он не требует дополнительной памяти (как сортировка слиянием). Так-то быстрая сортировка отвечает всем трём требованиям к алгоритму со сложностью O(N log N).

Иллюстрация 5-12. Полный протокол рекурсивного вызова функции quicksort()
Иллюстрация 5-12. Полный протокол рекурсивного вызова функции quicksort()

↓Время

Больше ничего переводить не надо -- FrBrGeorge 2022-10-07 15:31:29

Есть и другой способ добиться производительности O(N log N): выполнить N шагов, каждый из которых будет не сложнее O(log N). В пирамидальной сортировке используется уже знакомая нам структура данных — куча, и можно ожидать, что N операций добавления произвольных элементов в кучу и N операций снятия (уже упорядоченных элементов), при сложности этих операций не более log N, и дадут нам искомое O(N log N).

Пирамидальная сортировка

Давайте разберёмся, как с помощью кучи отсортировать массив. На иллюстрации 5-13 показан массив с иллюстрации 4-17, то есть обладающий свойствами кучи. Наибольший его элемент находится в A[1]. Когда мы снимаем наибольший элемент с кучи, содержимое массива перестраивается, при этом в куче становится одним элементом меньше. И тут оказывается, что последний, теперь неиспользуемый, индекс в массиве — A[18] — это то самое место, куда надо поместить только что снятый наибольший элемент, если мы собираемся сортировать массив по возрастанию. Ещё раз снимем элемент — это будет второе максимальное значение из кучи — и поместим его на освободившуюся позицию A[17].

Иллюстрация 5-13. Схема использования двоичной кучи с частичным убыванием для сортировки
Иллюстрация 5-13. Схема использования двоичной кучи с частичным убыванием для сортировки

уровень 0

уровень 1

уровень 2

уровень 3

уровень 4

↓ После снятия наибольшего элемента с кучи массив обновляется, освобождая последнюю ячейку в нём.

↓ Снимем следующий наибольший элемент. Массив снова обновится и свободной окажется предпоследняя ячейка.

Чтобы эта красота заработала, надо разобраться с двумя нюансами:

Давайте разберёмся, как вычислять индексы в сортируемом массиве. Куча из 18 элементов (которые показаны на иллюстрации 5-13) использовала массив из 19 элементов. Можно было считать, что индексация массива начинаетс с 1, то есть A[1] — это начальный элемент кучи, а A[N] — последний. В примере 5-9 мы переопределили less(i, j) и swap(i, j): когда i и j используются как индексы массива A, из них вычитается по единице. Таким образом в самом алгоритме индексация у нас идёт с единицы, а для массива A она превращается в индексацию с нуля. Наибольшее значение кучи находится в A[0]; вызов swap(1, N) из метода sort() на самом деле меняет местами A[0] и A[N-1]; так же преобразует индексы и less(). Это небольшое изменение позволяет нам не менять метод sink(), а метод swim() пирамидальная сортировка не использует.

   1 class HeapSort:
   2   def __init__(self, A):
   3     self.A = A
   4     self.N = len(A)
   5 
   6       for k in range(self.N//2, 0, -1):                 # 2
   7         self.sink(k)
   8 
   9   def sort(self):
  10     while self.N > 1:                                   # 3
  11       self.swap(1, self.N)                              # 4
  12       self.N -= 1                                       # 5
  13       self.sink(1)                                      # 6
  14 
  15   def less(self, i, j):
  16     return self.A[i-1] < self.A[j-1]                    # 1
  17 
  18   def swap(self, i, j):
  19     self.A[i-1], self.A[j-1] = self.A[j-1], self.A[i-1]
  1. Обе функции — less() и swap() — вычитают по единице из индексов, при этом можно считать, что индексация в них идёт с 1. и i // 2 — это индекс радетеля для i-й ячейки.

  2. Все элементы, у которых есть хотя бы один потомок, могут нарушать частичный порядок, поэтому их следует «утопить», начиная с последнего из них (в позиции N//2) и заканчивая первым. Наш сортируемый массив A при этом превращается в двоичную кучу с убыванием.

  3. Цикл while заканчивается с опустошением кучи, в которой лежат сортируемые элементы.

  4. Обменяем местами наибольшее и последнее значение в куче.

  5. Тем самым мы сняли наибольшее значение, теперь нужно уменьшить размер кучи (иначе sink() может работать неправильно).

  6. На вершине кучи лежит только что добавленное значение. Его следует «утопить» на место, дабы восстановить частичный порядок.

Самое важное происходит в начале пирамидальной сортировки: нужно превратить сортируемый массив в двоичную кучу с убыванием. Для этого в конструкторе HeapSort есть цикл for — его работа показана на иллюстрации 5-14. На превращение конкретного массива в кучу ушло всего 23 сравнения и 5 обменов. Цикл обрабатывает все элементы, у которых есть хотя бы один потомок, начиная с позиции N//2 вплоть до вершины кучи. Индекс k перебирает элементы в обратном порядке, и для каждого вызывается sink() — таким образом в куче в конце концов восстановится частичный порядок. Элемент в позиции k отмечен на иллюстрации 5-14 полужирной рамкой.

Часть элементов не нуждается в погружении — у них нет потомков. Половина из оставшихся погружаются не более, чем на один уровень, ещё в два раза меньше — не более, чем на два и т. д., вплоть до вершины, которая может погрузиться максимум на log₂ N уровней, а на каждый уровень погружения тратится не более двух сравнений. Аккуратный математический анализ показывает, что преобразование произвольного массива в кучу неожиданно выполняет не больше 2N сравнений в худшем случае. На иллюстрации 5-14 хорошо видно, как медленно (хотя и неуклонно) увеличивается количество сравнений. Элементы, находящиеся на одном и том же уровне кучи, как и в прошлом примере, отмечены либо серым, либо белым — тогда становится видно, как при обмене они переходят с уровня на уровень.

Иллюстрация 5-15. Преобразование массива в двоичную кучу с убыванием
Иллюстрация 5-15. Преобразование массива в двоичную кучу с убыванием

← Сортируемый массив

Общее количество сравнений

уровень 0

уровень 1

уровень 2

уровень 3

уровень 4

Последняя строка — это уже настоящая двоичная куча (на самом деле — в точности такая же, что и на иллюстрации 4-16, только индексирование в ней идёт с нуля, а не с единицы, чтобы использовать все N ячеек). Теперь метод sort() из примера 5-9 может снимать наибольшее значение с кучи и помещать его в её конец (пользуясь уловкой с иллюстрации 5-13). При этом конец постоянно уменьшающейся кучи — это и есть то место, где очередной элемент будет стоять в отсортированном массиве. Размер кучи уменьшает сам sort(), и он же «топит» элемент, некогда стоявший в её конце — так восстанавливается её частичный порядок. В главе 4 мы оценили сложность такой процедуры как O(log N).

Сравнение быстродействия алгоритмов со сложностью O(N log N)

Итак, у нас есть уже несколько алгоритмов сортировка, сложность которых оценивается как O(N log N). Хорошо бы теперь сравнить их быстродействие друг с другом. В таблице 5-1 приведены результаты экспериментов с различными реализациями сортировки. Размер сортируемого массива указан в первой колонке — он увеличивается в двое с каждой новой строкой, а время работы — в колонках, соответствующих алгоритмам. Можно заметить, что при удвоении входных данных затраченное время растёт чуть больше, чем вдвое — это и есть, грубо говоря, признак того, что алгоритм имеет сложность O(N log N).

N

Слиянием

Быстрая

Пирамидальная

Тима

Python

1024

0.002

0.002

0.006

0.002

0.000

2048

0.004

0.004

0.014

0.005

0.000

4096

0.009

0.008

0.032

0.011

0.000

8192

0.020

0.017

0.073

0.023

0.001

16384

0.042

0.037

0.160

0.049

0.002

32768

0.090

0.080

0.344

0.103

0.004

65536

0.190

0.166

0.751

0.219

0.008

131072

0.402

0.358

1.624

0.458

0.017

262144

0.854

0.746

3.486

0.970

0.039

524288

1.864

1.659

8.144

2.105

0.096

1048576

3.920

3.330

16.121

4.564

0.243

Время работы разных алгоритмов на одном и том же наборе данных в каждой строке различно. В главе 2 мы рассматривали понятие мультипликативной постоянной, которая может отличать быстродействие алгоритмов одного класса. Подтверждение этому — последние строки таблицы: начиная с некоторого достаточного размера массива, быстрая сортировка на 15% превосходит сортировку слиянием, а пирамидальная сортировка медленнее её вчетверо.

Две последние колонки таблицы 5-1 — это тест производительности ещё двух алгоритмов. Предпоследняя колонка — это сортировка Тима: в 2002 году Тим Петерс реализовал этот метод сортировки в качестве стандартного для Python. Метод пришёлся по душе многим разработчикам языков программирования, и теперь он используется в большинстве современных языков — в самом Python, в Java, Swift, Rust и т. д., по сути превратясь в стандарт. В таблице рассматривается упрощённый вариант сортировки Тима — по этой причине он слегка уступает в производительности быстрой сортировке — тем не менее он тоже показывает быстродействие O(N log N). Последняя колонка — это работа встроенной в Python функции sort() на типе данных list. Функция sort() также реализует сортировку Тима, но в полном варианте, допускающем массу оптимизаций. К тому же она встроенная, и с такими простыми объектами, как list, работает непосредственно — в результат чего она раз пятнадцать быстрее быстрой сортировки. Саму сортировку Тима имеет смысл рассмотреть отдельно: в ней объединены достоинства сразу двух алгоритмов, что делает её весьма эффективной.

Сортиорвка Тима

В сортировке Тима используется подход сортировки вставками с применением функции merge() из сортировки слиянием, но в несколько иных условиях. На повседневных данных получившийся алгоритм обгоняет все уже известные нам алгоритмы сортировки! В частности, сортировка Тима пользуется тем, что некоторые отрезки массива уже отсортированы — это позволяет основательно поднять производительность.

Когда сортируешь относительно небольшой отрезок, разница между алгоритмом O(N²) и O(N log N) может оказаться не в пользу последнего! В самом деле, допустим, что точная (с учётом мультипликативных коэффициентов) оценка сложности алгоритма A — это 2 N² действий, а алгоритма B — это 50 N log₂ N действий. Получится, что отрезки длиной менее 190 элементов выгоднее сортировать алгоритмом A, а не алгоритмом B, потому что 2 · 189² = 71442 < 71841 ≈ 50 · 189 · log₂ 189. Как видно из примера 5-10, в сортировке Тима так и происходит. Сначала сортируются вставками все N/size отрезков длиной size, при этом size заранее вычисляется функцией compute_min_run(). Поскольку обычно size — это число в диапазоне от 32 до 64, можно считать его константой, не зависящей от N8. В результате мы получаем массив с упорядоченными отрезками, достаточно длинными для того,чтобы на них эффективно работала вспомогательная функция merge() из сортировки слиянием, которая из два отсортированных отрезка сливает в один.

   1 def tim_sort(A):
   2   N = len(A)
   3   if N < 64:                                    # 1
   4     insertion_sort(A, 0, N-1)
   5     return
   6 
   7   size = compute_min_run(N)                     # 2
   8   for lo in range(0, N, size):                  # 3
   9     insertion_sort(A, lo, min(lo+size-1, N-1))
  10 
  11   aux = [None]*N                                # 4
  12   while size < N:
  13     for lo in range(0, N, 2*size):
  14       mid = min(lo + size - 1, N-1)             # 5
  15       hi = min(lo + 2*size - 1, N-1)
  16       merge(A, lo, mid, hi, aux)                # 6
  17     size = 2 * size                             # 7
  1. Небольшие массивы можно сразу сортировать вставками.

  2. Вычислим size — размер отрезков, которые мы будем сортировать вставками (обычно это целое в диапазоне от 32 до 64).

  3. Отсортируем вставками все отрезки A[lo … lo+size-1] (не забудем, что последний из них может быть короче size).

  4. Для сортировки слиянием требуется дополнительная память размером с исходный массив.

  5. Вычислим границы отрезков, которые на надо объединить, A[lo … mid] и A[mid+1 … hi]; не забудем о том, что отрезок может оказаться меньшей длины.

  6. Объединим два отрезка и получим отсортированный A[lo … hi]; в качестве дополнительной памяти используем aux.

  7. На следующем проходе цикла while, после того, как все отрезки длиной size попарно объединены, начнём объединять отрезки вдвое большего размера.

Дополнительный массив aux создаётся единожды, а затем используется в каждом вызове merge(). Полная реализация сортировки Тима намного сложнее, чем наша. В ней сразу определяются отрезки, уже упорядоченные по возрастанию или убыванию, и функция merge() работает не с изолированными элементами, как у нас, а сразу с отрезками. На иллюстрации 5-15 показаны основные преобразования сортируемого массива. Сортировку изучали так долго и так основательно, так часто применяли — тем удивительнее то, что самым эффективным для повседневных задач оказался новый алгоритм, изобретённый уже в нашем веке.

Иллюстрация 5-15. Как меняется массив во время сортировки Тима с начальным размером отрезка 4
Иллюстрация 5-15. Как меняется массив во время сортировки Тима с начальным размером отрезка 4

↓ время

← Сортируемый массив

size не переводится, это переменная -- FrBrGeorge 2022-10-19 17:34:29

Размер min_run на иллюстрации 5-15 равен всего четырём — так проще рассмотреть работу сортировки Тима. На первом этапе четыре отрезка длиной 4 сортируются вставками (последний отрезок меньше, он содержит только два элемента — 2 и 8). Отсортированные отрезки помечены на иллюстрации белым и серым фоном поочерёдно. Количество таких отрезков — N/size или на один больше, если N не делится на size нацело. Мы помним что сортировка size элементов вставками требует size · (size - 1) / 2 действий, а поскольку самих отрезков у нас N/size штук, время работы первого этапа будет прямо пропорционально N · (size - 1) / 2. Значение size в алгоритме — константа, так что сложность первого этапа можно оценить как O(N).

На втором этапе мы попарно объединяем соседние отрезки. Разбирая работу сортировки слияниями, мы нашли, что общее время работы всех вызовов merge() на одном шаге пропорционально N. После первого прохода цикла while размер отсортированных отрезков удвоился до 8 — это показано на иллюстрации 5-15 соответствующим фоном. В нашем примере за три прохода size вырастает с 4 до 32, а это уже больше, чем N. В общем случае если исходный размер равен size, и вся сортировка выполняется за k проходов цикла while, это цикл заканчивается, когда size·2k превысит N, то есть когда N/size<2k.

Чтобы оценить k, возьмём логарифм обеих частей неравенства, и получим k > log₂(N/size). Логарифм частного — это разность логарифмов, так что k > log₂ N - log₂ size. Вспомним, что size — это константа, и получим, что k — это наименьшее целое число, которое не меньше log₂N за вычетом небольшой константы.

Подытожим: первый этап — с сортировкой вставками — мы оценили как O(N); второй — со слияниями — как O(N · k), где k не превосходит log₂N. Вторая оценка доминирует над первой, так что общая оценка оказывается O(N log N).

Заключение

Сортировка — одна из основных задач в программировании, изучению которой посвящено немало времени и сил. Большинство встроенных типов данных в Python предусматривают сравнение элементов. Массив таких элементов можно отсортировать напрямую. Более сложные структуры данных могут не иметь функции сравнения, либо алгоритм сравнения по умолчанию может быть неподходящим — например, довольно бессмысленно сравнивать координаты точек лексикографически, то есть сначала сравнивать их абсциссы, а если те равны — то ординаты. В этом случае к каждому элементу можно применить функцию-ключ, которая вернёт значение простого типа, после чего сортировать исходный массив, сравнивая ключи элементов. Например, ключом для точки может быть расстояние от центра координат — тогда сортировка будет по степени удаления точки от центра.

Здесь у автора две фактические ошибки и одно спорное утверждение в одном предложении, это неремонтопригодно. Мало того, автор в начале пообещал рассказать, как сортировать точки — и не рассказал! -- FrBrGeorge 2022-10-22 22:17:41

Вот что в этой главе мы узнали про сортировку:

Тренировочные задания

  1. Напишите рекурсивную функцию count(A, t), которая возвращает количество вхождений элемента t в массив A. Рекурсивный вызов должен быть устроен так же, как в find_max(A).

  2. Напишите функцию num_swaps(A), на вход которой подаётся массив размера N, содержащий набор различных целых чисел от 0 до N - 1 в произвольном порядке. Функция должна возвращать наименьшее количество обменов двух элементов массива, которое необходимо для его сортировки. Сам массив сортировать не надо, нужно лишь количество обменов.

    Дополните алгоритм так, чтобы можно было обрабатывать массив из N различных произвольных объектов, используя для этого хеш-таблицу из главы 3. Проверьте, что для сортировки массива с иллюстрации 5-1 требуется пять обменов.

  3. Сколько всего сравнений требуется для вычисления наибольшего значения в неупорядоченном массиве из N элементов с помощью рекурсивной функции find_max(A)? Функция largest(A) из главы 1 требует больше сравнений или меньше?

  4. Функция merge() в сортировке слиянием в какой-то момент оказывается в ситуации, когда один из объединяемых отрезков закончился, а другой ещё нет. Наша функция продолжает обрабатывать оставшийся отрезок поэлементно. Исправьте это поведение: пускай в этот момент функция сразу заменяет один сегмент массива на другой — так, как мы это делали в aux[lo:hi+1] = A[lo:hi+1]. Проведите серию испытаний и определите, приводит ли это к повышению быстродействия, и если да, то насколько.

  5. Напишите рекурсивную функцию recursive_two(A), которая возвращает два значения — наибольший элемент массива A и второй наибольший его элемент. Сравните её быстродействие с другими вариантами поиска двух наибольших, которые мы видели в главе 1. Также сравните количество задействованных операций «меньше, чем».

  6. Числа Фибоначчи — это последовательность, которая вычисляется по рекуррентному соотношению FN=FN-1+FN-2, где F0=0, а F1=1. Числа Люка задаются примерно так же: LN=LN-1+LN-2, где L0=2, а L1=1. Пользуясь стандартным рекурсивным подходом, напишите две функции — fibonacci(n) и lucas(n), которые будут вычислять n-е число Фибоначчи и Люка соответственно. Измерьте время, которое требуется для вычисления FN и LN вплоть до N=40. Если ваш компьютер достаточно быстрый, N стоит увеличить для повышения точности, а если слишком медленный — уменьшить, чтобы измерения заканчивались за приемлемое время. Затем напишите функцию fib_with_lucas(n) и вспомогательную к ней, lucas_with_fib(n), пользуясь следующими свойствами этих последовательностей:

    • fib_with_lucas(n): Если поделить n нацело пополам и взять i = n//2, а j = n-i, то Fn = Fi+j = (Fi+Lj)·(Fj+Li)//2

    • lucas_with_fib(n): В свою очередь, LN = FN-1 + FN+1

    Сравните быстродействие fibonacci() и fib_with_lucas().

  1. Нельзя. См. пример в конце тренировочных заданий (прим. автора). (1)

  2. В результате автор избрал второй вариант, который не является сортировкой вставками в точном смысле — это скорее гибрид между ней и сортировкой пузырьком. (2)

  3. 275000 примерно равно квадрату 512 (прим. автора) (3)

  4. Если проверять только равенство, интерпретатор Python всё равно может войти в бесконечную рекурсию и аварийно завершиться (если N отрицательное или не целое). Поэтому мы считаем основанием все N, не превосходящие 1 (прим. автора). (4)

  5. Название rmax происходит от recursive max (прим. автора). (5)

  6. Изобрёл её Тони Хоар в далёком 1959 году, так что этому алгоритму уже за 60! (прим. автора) (6)

  7. Ох уж эти столь любимые автором «десятилетия исследований»! В действительности всё куда проще. Каким бы ни был алгоритм выбора опорного элемента, всегда есть соответствующий ему порядок элементов массива, на котором в качестве опорного всегда будет выбираться крайний элемент. Но вот вероятность появления такого набора данных (особенно в случае случайного выбора опорного элемента) крайне низка. (7)

  8. Одна из оптимизаций состоит, например, в том, что видя N равным 80, compute_min_run() вернёт size == 40 — это уменьшит суммарное время сортировки вставками. (8)

FrBrGeorge/Books/LearningAlgorythms/05_Sortiing_Without (последним исправлял пользователь FrBrGeorge 2022-10-30 11:35:54)