Files
hello-algo/ru/chapters/chapter_11.md
2026-03-25 16:54:42 +08:00

103 KiB
Raw Blame History

Сортировка

{width="3.5416655730533684in" height="4.583333333333333in"}

Алгоритмы сортировки

Алгоритмы сортировки используются для упорядочивания набора данных в определенном порядке. Они имеют широкое применение, поскольку упоря- доченные данные обычно можно более эффективно анализировать, обрабаты- вать и выполнять в них поиск.

Типы данных в алгоритмах сортировки могут быть целыми числами, числа- ми с плавающей запятой, символами или строками, как показано на рис. 11.1. Правила сортировки могут быть установлены в зависимости от потребностей, например по величине чисел, порядку ASCII-кодов символов или произволь- ным пользовательским правилам.

Рис. 11.1. Пример типов данных и правил сортировки

  1. Критерии оценки

Эффективность выполнения: ожидается, что временная сложность алгорит- ма сортировки будет как можно ниже, а общее количество операций -- мини- мальным (уменьшение константного множителя во временной сложности). Для больших объемов данных эффективность выполнения особенно важна.

Местность: как следует из названия, сортировка на месте осуществляется путем непосредственной работы с исходным массивом без использования до- полнительных вспомогательных массивов, что позволяет экономить память. Обычно операции перемещения данных при сортировке на месте малочис- ленны, а скорость выполнения выше.

Стабильность: стабильная сортировка сохраняет относительный порядок равных элементов в массиве после завершения сортировки.

Стабильная сортировка является необходимым условием для многоуров- невой сортировки. Предположим, что у нас есть таблица с информацией о студентах, где 1-й и 2-й столбцы -- это имя и возраст соответственно. В этом случае нестабильная сортировка может привести к потере упорядоченности входных данных.

# Входные данные отсортированы по имени. # (name, age)

('A', 19)

('B', 18)

('C', 21)

('D', 19)

('E', 23)

# Предположим, используется нестабильный алгоритм сортировки по возрасту,

# в результате чего изменяется относительное положение ('D', 19) и ('A', 19), # теряется свойство упорядоченности входных данных по имени.

('B', 18)

('D', 19)

('A', 19)

('C', 21)

('E', 23)

Адаптивность: адаптивная сортировка способна использовать имеющуюся информацию о порядке входных данных для уменьшения объема вычислений, достигая более высокой временной эффективности. Лучшая временная слож- ность адаптивных алгоритмов сортировки обычно превосходит среднюю вре- менную сложность.

Основанность на сравнении: сортировка на основе сравнения использует операторы сравнения (<, =, >) для определения относительного порядка эле- ментов, что позволяет отсортировать весь массив. Теоретическая оптималь- ная временная сложность составляет O(n log n). В то время как не основанная на сравнении сортировка не использует операторы сравнения, ее временная сложность может достигать O(n), но ее универсальность относительно ниже.

Идеальный алгоритм сортировки

Быстрый, на месте, стабильный, адаптивный, с хорошей универсально- стью. Очевидно, что до сих пор нет алгоритма сортировки, сочетающего все эти характеристики. Поэтому при выборе алгоритма необходимо учитывать особенности данных и требования задачи.

Далее мы изучим различные алгоритмы сортировки и проанализируем их достоинства и недостатки на основе вышеуказанных критериев оценки.

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

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

Пусть длина массива равна n, алгоритм сортировки выбором заключается в следующем (см. рис. 11.2):

  1. в начальном состоянии все элементы не отсортированы, т. е. неотсорти- рованный (индексный) диапазон равен [0, n -- 1];

  2. выбирается наименьший элемент из диапазона [0, n -- 1] и меняется ме- стами с элементом с индексом 0. После этого первый элемент массива отсортирован;

  3. выбирается наименьший элемент из диапазона [1, n -- 1] и меняется ме- стами с элементом с индексом 1. После этого первые два элемента мас- сива отсортированы;

  4. таким образом, после n -- 1 итераций выбора и обмена первые n -- 1 эле- ментов массива отсортированы;

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

Рис. 11.2. Этапы сортировки выбором. Шаги 1--3

Рис. 11.2. Продолжение. Шаги 4--6

Рис. 11.2. Продолжение. Шаги 7--9

Рис. 11.2. Окончание. Шаги 10--11

В приведенном ниже коде реализации используется переменная k для запи- си индекса наименьшего элемента в неотсортированном диапазоне.

# === File: selection_sort.py === def selection_sort(nums: list[int]):

""" Сортировка выбором.""" n = len(nums)

# Внешний цикл: неотсортированный диапазон [i, n-1]. for i in range(n - 1):

# Внутренний цикл: нахождение наименьшего элемента

№ в неотсортированном диапазоне. k = i

for j in range(i + 1, n): if nums[j] < nums[k]:

k = j # Запись индекса наименьшего элемента.

# Обмен наименьшего элемента с первым элементом неотсортированного диапазона. nums[i], nums[k] = nums[k], nums[i]

Характеристики алгоритма

  • Временная сложность O(n2), неадаптивная сортировка: внешний цикл выполняется n -- 1 раз, длина неотсортированного диапазона на первой итерации равна n, на последней -- 2, т. е. каждый внешний цикл включает n, n -- 1, ..., 3, 2 итераций внутреннего цикла, сумма которых равна (n -- 1)(n + 2)/2.

  • Пространственная сложность O(1), сортировка на месте: указате- ли i и j используют дополнительное пространство постоянного раз- мера.

  • Нестабильная сортировка: как показано на рис. 11.3, элемент nums[i] может быть перемещен вправо от равного ему элемента, что изменяет их относительный порядок.

Рис. 11.3. Пример нестабильности сортировки выбором

Сортировка пузырьком

Сортировка пузырьком реализует сортировку путем последовательного срав- нения и обмена соседних элементов. Этот процесс напоминает подъем пу- зырьков со дна на поверхность, отсюда и такое название.

Процесс поднятия пузырька можно смоделировать операцией обмена элементов: начиная с самого левого конца массива, производится последо- вательное сравнение соседних элементов, и, если левый элемент > правый элемент, они меняются местами, как показано на рис. 11.4. После заверше- ния прохода наибольший элемент будет перемещен в самый правый конец массива.

Рис. 11.4. Моделирование поднятия пузырька с помощью обмена элементов. Шаги 1--4

Алгоритм

Рис. 11.4. Окончание. Шаги 5--7

Пусть дан массив длиной n, тогда сортировка пузырьком выглядит следующим образом (см. рис. 11.5):

  1. сначала выполняется пузырек для n элементов, перемещая наиболь- ший элемент в правильное положение;

  2. затем выполняется пузырек для оставшихся n -- 1 элементов, переме- щая второй по величине элемент в правильное положение;

  3. таким образом, после n -- 1 итераций пузырька первые n -- 1 наиболь- ших элементов перемещены в правильные положения;

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

Рис. 11.5. Процесс сортировки пузырьком

Ниже приведен пример кода.

# === File: bubble_sort.py === def bubble_sort(nums: list[int]):

""" Сортировка пузырьком.""" n = len(nums)

# Внешний цикл: неотсортированный диапазон [0, i]. for i in range(n - 1, 0, -1):

# Внутренний цикл: перемещение наибольшего элемента в неотсортированном

# диапазоне [0, i] в его правый конец. for j in range(i):

if nums[j] > nums[j + 1]:

# Обмен nums[j] и nums[j + 1].

nums[j], nums[j + 1] = nums[j + 1], nums[j]

Оптимизация эффективности

Если в какой-либо итерации пузырька не выполняется ни одной операции об- мена, это означает, что массив уже отсортирован, и можно сразу вернуть ре- зультат. Поэтому можно добавить флаг flag для отслеживания этой ситуации, и как только она возникнет, немедленно выйти из цикла.

После оптимизации наихудшая и средняя временные сложности сортиров- ки пузырьком остаются O(n2); однако, если входной массив полностью отсо- ртирован, можно достичь лучшей временной сложности O(n).

# === File: bubble_sort.py ===

def bubble_sort_with_flag(nums: list[int]):

""" Сортировка пузырьком (оптимизация с флагом).""" n = len(nums)

# Внешний цикл: неотсортированный диапазон [0, i]. for i in range(n - 1, 0, -1):

flag = False # Инициализация флага.

# Внутренний цикл: перемещение наибольшего элемента в неотсортированном # диапазоне [0, i] в его правый конец.

for j in range(i):

if nums[j] > nums[j + 1]:

# Обмен nums[j] и nums[j + 1]

nums[j], nums[j + 1] = nums[j + 1], nums[j] flag = True # Запись обмена элементов

if not flag:

break # В этой итерации "пузырька" не было обмена, выход из цикла.

Характеристики алгоритма

  • Временная сложность O(n2), адаптивная сортировка: длина масси- ва, проходящего каждую итерацию пузырька, последовательно равна n -- 1, n -- 2, ..., 2, 1. Сумма этих значений равна (n -- 1)n/2. После вве- дения оптимизации с флагом лучшая временная сложность может до- стигать O(n).

  • Пространственная сложность O(1), сортировка на месте: указатели

i и j используют дополнительную память постоянного размера.

  • Стабильная сортировка: поскольку при сортировке пузырьком равные элементы не меняются местами.

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

Сортировка вставками -- это простой алгоритм сортировки, работа которого схожа с процессом ручной сортировки карт в колоде.

Более конкретно: в неотсортированном сегменте выбирается опорный эле- мент, который сравнивается по величине с элементами в отсортированном сегменте слева и вставляется на правильное место.

На рис. 11.6 иллюстрируется процесс вставки элемента в массив. Пусть опорный элемент обозначен как base, необходимо сдвинуть все элементы от целевого индекса до base вправо на одну позицию, затем присвоить base целе- вому индексу.

  1. Сортировка вставками ❖ 299

Рис. 11.6. Операция одиночной вставки

Алгоритм

Процесс сортировки вставками выглядит следующим образом (см. рис. 11.7):

  1. в начальном состоянии первый элемент массива уже отсортирован;

  2. выбирается второй элемент массива в качестве base, после его вставки на правильное место первые два элемента массива отсортированы;

  3. выбирается третий элемент в качестве base, после его вставки на пра- вильное место первые три элемента массива отсортированы;

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

Отсортированный

Рис. 11.7. Процесс сортировки вставками

Ниже приведен пример кода:

# === File: insertion_sort.py === def insertion_sort(nums: list[int]):

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

# Внешний цикл: отсортированный сегмент [0, i-1]. for i in range(1, len(nums)):

base = nums[i] j = i - 1

# Внутренний цикл: вставка base в правильное место в отсортированном

# сегменте [0, i-1].

while j >= 0 and nums[j] > base:

nums[j + 1] = nums[j] # Сдвиг nums[j] вправо на одну позицию. j -= 1

nums[j + 1] = base # Присвоение base правильному месту.

Характеристики алгоритма

  • Временная сложность O(n2), адаптивная сортировка: в худшем слу- чае каждая операция вставки требует n 1, n 2, ..., 2, 1 циклов. Сумма этих чисел составляет (n 1)n/2, поэтому временная сложность равна O(n2). При наличии упорядоченных данных операция вставки заверша- ется досрочно. Когда входной массив полностью упорядочен, сортировка вставками достигает лучшей временной сложности O(n).

  • Пространственная сложность O(1), сортировка на месте: указатели

i и j используют дополнительную память постоянного размера.

  • Стабильная сортировка: в процессе вставки элементы вставляются справа от равных элементов, не изменяя их порядок.

Преимущества сортировки вставками

Временная сложность сортировки вставками составляет O(n2), тогда как вре- менная сложность быстрой сортировки, которую мы скоро изучим, равна O(n log n). Несмотря на более высокую временную сложность, сортировка вставками обычно быстрее при небольших объемах данных.

Этот вывод аналогичен применению линейного поиска и двоичного по- иска. Алгоритмы сортировки, такие как быстрая сортировка с временной сложностью O(n log n), основаны на стратегии «разделяй и властвуй» и часто содержат больше элементарных вычислительных операций. Однако при не- больших объемах данных значения n2 и n log n близки, и сложность не явля- ется доминирующей, а количество элементарных операций в каждом раунде играет решающую роль.

Фактически многие языки программирования (например, Java) использу- ют встроенные функции сортировки, которые применяют сортировку встав- ками. Основная идея заключается в следующем: для длинных массивов ис-

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

Хотя временная сложность сортировки пузырьком, сортировки выбором и сортировки вставками одинакова и равна O(n2), на практике сортировка вставками используется значительно чаще по следующим причинам.

  • Сортировка пузырьком основана на обмене элементов, требует исполь- зования временной переменной и включает три элементарные опера- ции. Сортировка вставками основана на присвоении элементов и тре- бует только одну элементарную операцию. Поэтому вычислительные затраты сортировки пузырьком обычно выше, чем у сортировки вставками.

  • В любом случае временная сложность сортировки выбором равна O(n2). Если задана частично упорядоченная группа данных, сортировка вставками обычно эффективнее сортировки выбором.

  • Сортировка выбором нестабильна и не может быть применена для мно- гоуровневой сортировки.

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

Быстрая сортировка -- это алгоритм сортировки, основанный на стратегии

«разделяй и властвуй». Он отличается высокой эффективностью и широким применением.

Основной операцией быстрой сортировки является разделение с помощью стража, цель которого заключается в следующем: выбрать один из элементов массива в качестве опорного и переместить все элементы, меньшие опорно- го, влево от него, а элементы, большие опорного, вправо. Процесс разделения с помощью стража выглядит следующим образом (см. рис. 11.8):

  1. выбрать элемент на крайнем левом конце массива в качестве опор- ного, инициализировать два указателя i и j, указывающих на концы массива;

  2. установить цикл, в каждой итерации которого i (j) ищет первый элемент, больший (меньший) опорного, после чего эти два элемента меняются местами;

  3. продолжать выполнение шага 2 до тех пор, пока i и j не встретятся, затем переместить опорный элемент на границу между двумя под- массивами.

Рис. 11.8. Этапы разделения с помощью стража. Шаги 1--2

Рис. 11.8. Продолжение. Шаги 3--4

Рис. 11.8. Продолжение. Шаги 5--6

Рис. 11.8. Продолжение. Шаги 7--8

Рис. 11.8. Окончание. Шаг 9

После завершения разделения с помощью стража исходный массив делится на три части: левый подмассив, опорный элемент и правый подмассив. При этом выполняется условие: любой элемент левого подмассива ≤ опорный эле- мент ≤ любой элемент правого подмассива. Следовательно, далее необходимо отсортировать только эти два подмассива.

# === File: quick_sort.py ===

def partition(self, nums: list[int], left: int, right: int) -> int: """ Разделение с помощью стража."""

# Опорный элемент -- nums[left]. i, j = left, right

while i < j:

while i < j and nums[j] >= nums[left]:

j -= 1 # Поиск справа налево первого элемента, меньшего опорного. while i < j and nums[i] <= nums[left]:

i += 1 # Поиск слева направо первого элемента, большего опорного. # Обмен элементов.

nums[i], nums[j] = nums[j], nums[i]

# Перемещение опорного элемента на границу между двумя подмассивами. nums[i], nums[left] = nums[left], nums[i]

return i # Возврат индекса опорного элемента.

Алгоритм

Процесс быстрой сортировки выглядит следующим образом (см. рис. 11.9):

  1. сначала выполнить одно разделение с помощью стража для исходного массива, получив неотсортированные левый и правый подмассивы;

  2. затем рекурсивно выполнить разделение с помощью стража для левого и правого подмассивов;

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

Рис. 11.9. Процесс быстрой сортировки

# === File: quick_sort.py ===

def quick_sort(self, nums: list[int], left: int, right: int): """ Быстрая сортировка."""

# Прекращение рекурсии, если длина подмассива равна 1. if left >= right:

return

# Разделение с помощью стража.

pivot = self.partition(nums, left, right)

# Рекурсия для левого и правого подмассивов. self.quick_sort(nums, left, pivot - 1) self.quick_sort(nums, pivot + 1, right)

Характеристики алгоритма

  • Временная сложность O(n log n), неадаптивная сортировка: в сред- нем случае количество рекурсивных уровней разделения с помощью стража равно log n, общее количество циклов на каждом уровне равно n,

что соответствует времени O(n log n). В худшем случае каждая операция разделения с помощью стража делит массив длиной n на два подмассива длиной 0 и n -- 1, в этом случае количество рекурсивных уровней дости- гает n, количество циклов на каждом уровне равно n, что соответствует времени O(n2).

  • Пространственная сложность O(n), сортировка на месте: в случае пол- ностью обратным порядком входного массива достигается худшая рекур- сивная глубина n, используется O(n) кадров стека. Сортировка выполня- ется на исходном массиве без использования дополнительных массивов.

  • Нестабильная сортировка: на последнем шаге разделения с помощью стража опорный элемент может быть перемещен вправо от равных ему элементов.

Почему быстрая сортировка быстрая

Уже из названия понятно, что быстрая сортировка должна иметь определен- ные преимущества в плане эффективности. Хотя средняя временная слож- ность быстрой сортировки такая же, как у сортировки слиянием и пирами- дальной сортировки, обычно быстрая сортировка более эффективна, по сле- дующим причинам.

  • Вероятность возникновения худшего случая очень низка: хотя худ- шая временная сложность быстрой сортировки составляет O(n2), что не так стабильно, как у сортировки слиянием, в подавляющем большин- стве случаев быстрая сортировка работает со сложностью O(n log n).

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

  • Низкий коэффициент постоянной сложности: среди трех упомяну- тых алгоритмов общее количество операций сравнения, присваивания и обмена в быстрой сортировке минимально. Это похоже на причину, по которой сортировка вставками быстрее пузырьковой сортировки.

Оптимизация выбора опорного элемента

Быстрая сортировка может демонстрировать снижение эффективности на некоторых входных данных. Например, в случае, когда входной массив полностью отсортирован в обратном порядке, если выбирать самый левый элемент в качестве опорного, то после завершения разделения по методу стра- жей опорный элемент перемещается в самый правый конец массива. В этом случае левый подмассив будет длиной n -- 1, а правый -- длиной 0. При таком рекурсивном подходе после каждого разделения один из подмассивов оказы- вается длиной 0, стратегия «разделяй и властвуй» не работает, и быстрая со- ртировка вырождается в форму, близкую к сортировке пузырьком.

Чтобы минимизировать вероятность возникновения такой ситуации, мож- но оптимизировать стратегию выбора опорного элемента в методе раз-

деления. Например, можно выбрать опорный элемент случайным образом. Однако, если вам не повезет и каждый раз будет выбран неудачный опорный элемент, эффективность все равно будет неудовлетворительной.

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

Для дальнейшего улучшения можно выбирать трех кандидатов из массива (обычно это первый, последний и средний элементы массива) и использо- вать медиану этих трех кандидатов в качестве опорного элемента. Таким об- разом, вероятность того, что опорный элемент будет ни слишком маленьким, ни слишком большим, значительно возрастает. Конечно, можно выбрать больше кандидатов, чтобы еще больше повысить устойчивость алгоритма. Применение этого метода значительно снижает вероятность ухудшения вре- менной сложности до O(n2).

Ниже приведен пример кода.

# === File: quick_sort.py ===

def median_three(self, nums: list[int], left: int, mid: int, right: int) ->

int:

""" Выбор медианы из трех кандидатов """

l, m, r = nums[left], nums[mid], nums[right] if (l <= m <= r) or (r <= m <= l):

return mid # m находится между l и r if (m <= l <= r) or (r <= l <= m):

return left # l находится между m и r return right

def partition(self, nums: list[int], left: int, right: int) -> int: """ Разделение по методу стражей (медиана из трех) """

# Использование nums[left] в качестве опорного элемента

med = self.median_three(nums, left, (left + right) // 2, right) # Перемещение медианы в начало массива

nums[left], nums[med] = nums[med], nums[left]

# Использование nums[left] в качестве опорного элемента i, j = left, right

while i < j:

while i < j and nums[j] >= nums[left]:

j -= 1 # Поиск элемента, меньшего опорного, справа налево while i < j and nums[i] <= nums[left]:

i += 1 # Поиск элемента, большего опорного, слева направо # Обмен элементов

nums[i], nums[j] = nums[j], nums[i]

# Перемещение опорного элемента на границу подмассивов nums[i], nums[left] = nums[left], nums[i]

return i # Возврат индекса опорного элемента

Оптимизация хвостовой рекурсии

На некоторых входных данных быстрая сортировка может потреблять много памяти. Например, в случае полностью отсортированного массива если длина подмассива в рекурсии равна m, то после каждого разделения по методу стражей образуется левый подмассив длиной 0 и правый под- массив длиной m -- 1. Это означает, что уменьшение размера задачи на каж- дом уровне рекурсии очень незначительно (уменьшается только на один элемент), и высота рекурсивного дерева достигает n -- 1, что требует O(n) памяти для стека.

Чтобы предотвратить накопление памяти стека, можно после каждого разделения по методу стражей сравнивать длины двух подмассивов и вы- полнять рекурсию только для более короткого из них. Поскольку длина более короткого подмассива не превышает n/2, этот метод гарантирует, что глубина рекурсии не превысит log n, тем самым оптимизируя наихудшую пространственную сложность до O(log n). Пример кода приведен ниже.

# === File: quick_sort.py ===

def quick_sort(self, nums: list[int], left: int, right: int): """ Быстрая сортировка (оптимизация хвостовой рекурсии) """ # Завершение при длине подмассива 1

while left < right:

# Разделение по методу стражей

pivot = self.partition(nums, left, right)

# Рекурсивная сортировка более короткого подмассива if pivot - left < right - pivot:

self.quick_sort(nums, left, pivot - 1) # Рекурсивная сортировка

# левого подмассива

left = pivot + 1 # Неотсортированный диапазон [pivot + 1, right]

else:

self.quick_sort(nums, pivot + 1, right) # Рекурсивная сортировка

# правого подмассива right = pivot - 1 # Неотсортированный диапазон [left, pivot - 1]

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

Сортировка слиянием -- это алгоритм сортировки, основанный на стратегии

«разделяй и властвуй», включающий этапы разделения и слияния, как пока- зано на рис. 11.10.

  1. Этап разделения: массив рекурсивно делится пополам, превращая задачу сортировки длинного массива в задачу сортировки коротких массивов.

  2. Этап слияния: когда длина подмассива достигает 1, разделение пре- кращается и начинается слияние, при котором два более коротких упо- рядоченных массива объединяются в один более длинный упорядочен- ный массив.

Рис. 11.10. Этапы разделения и слияния в сортировке слиянием

Алгоритм

Этап разделения рекурсивно делит массив на два подмассива от вершины до основания, как показано на рис. 11.11.

  1. Вычисление средней точки массива mid, рекурсивное разделение левого подмассива (интервал [left, mid]) и правого подмассива (интервал [mid

+ 1, right]).

  1. Рекурсивное выполнение шага 1 до тех пор, пока длина интервала под- массива не станет равной 1.

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

Рис. 11.11. Этапы сортировки слиянием. Шаг 1

Рис. 11.11. Продолжение. Шаги 2--4

Рис. 11.11. Продолжение. Шаги 5--7

Рис. 11.11. Окончание. Шаги 8--10

Можно заметить, что порядок рекурсии в сортировке слиянием совпадает с порядком обхода в глубину двоичного дерева.

  • Обход в глубину: сначала рекурсивный обход левого поддерева, затем правого поддерева и в конце обработка корневого узла.

  • Сортировка слиянием: сначала рекурсивное разделение левого под- массива, затем правого подмассива и в конце обработка слияния.

Ниже приведен код реализации сортировки слиянием. Обратите внимание, что интервал для слияния в массиве nums -- это [left, right], а соответствующий интервал в tmp -- это [0, right - left].

# === File: merge_sort.py ===

def merge(nums: list[int], left: int, mid: int, right: int): """ Слияние левого и правого подмассивов."""

# Левый подмассив: [left, mid], правый подмассив: [mid+1, right]. # Создание временного массива tmp для хранения результата слияния. tmp = [0] * (right - left + 1)

# Инициализация начальных индексов для левого и правого подмассивов. i, j, k = left, mid + 1, 0

# Пока в обоих подмассивах есть элементы, сравнивать и копировать меньший

# элемент во временный массив. while i <= mid and j <= right: if nums[i] <= nums[j]:

tmp[k] = nums[i] i += 1

else:

tmp[k] = nums[j] j += 1

k += 1

# Копирование оставшихся элементов из левого и правого подмассивов # во временный массив.

while i <= mid: tmp[k] = nums[i] i += 1

k += 1

while j <= right: tmp[k] = nums[j] j += 1

k += 1

# Копирование элементов из временного массива tmp обратно в соответствующий #- интервал оригинального массива nums.

for k in range(0, len(tmp)): nums[left + k] = tmp[k]

def merge_sort(nums: list[int], left: int, right: int): """ Сортировка слиянием."""

# Условие остановки. if left >= right:

return # Завершение рекурсии, когда длина подмассива равна 1. # Этап разделения.

mid = (left + right) // 2 # Вычисление средней точки.

merge_sort(nums, left, mid) # Рекурсивное разделение левого подмассива. merge_sort(nums, mid + 1, right) # Рекурсивное разделение правого подмассива. # Этап слияния.

merge(nums, left, mid, right)

Характеристики алгоритма

  • Временная сложность O(n log n), неадаптивная сортировка: разделе- ние создает рекурсивное дерево высотой log n, общее количество опера- ций слияния на каждом уровне составляет n, поэтому общая временная сложность равна O(n log n).

  • Пространственная сложность O(n), не на месте: глубина рекурсии равна log n. Используется кадр стека размером O(log n). Операция слия- ния требует использования вспомогательного массива, что занимает до- полнительное пространство O(n).

  • Стабильная сортировка: в процессе слияния порядок равных элемен- тов сохраняется.

Сортировка связного списка

Для связного списка сортировка слиянием имеет значительное преимущество перед другими алгоритмами, позволяя оптимизировать пространствен- ную сложность задачи сортировки связного списка до O(1).

  • Этап разделения: для выполнения разделения связного списка можно использовать итерацию вместо рекурсии, что позволяет избежать ис- пользования стекового кадра рекурсии.

  • Этап слияния: в связном списке операции добавления и удаления уз- лов требуют лишь изменения ссылок (указателей), поэтому на этапе слияния (объединение двух коротких упорядоченных списков в один длинный упорядоченный список) нет необходимости создавать допол- нительный список.

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

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

Пирамидальная сортировка -- это эффективный алгоритм сортировки, осно- ванный на структуре данных «куча». Для реализации пирамидальной сорти- ровки можно использовать уже изученные операции построения кучи и из- влечения элемента из кучи.

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

  2. Постоянное выполнение операции извлечения из кучи. Последователь- ная запись извлеченных элементов позволяет получить последователь- ность, отсортированную по возрастанию.

Хотя этот метод и работает, он требует использования дополнительного массива для хранения извлеченных элементов, что неэффективно с точки зрения использования пространства. На практике обычно используется более элегантный способ реализации.

Алгоритм

Пусть дан массив длины n, процесс пирамидальной сортировки выглядит сле- дующим образом (см. рис. 11.12):

  1. ввод массива и построение максимальной кучи. После завершения мак- симальный элемент находится на вершине кучи;

  2. обмен вершины кучи (первого элемента) с элементом в основании кучи (последним элементом). После завершения обмена длина кучи уменьшается на 1, а количество отсортированных элементов увеличи- вается на 1;

  3. начать с вершины кучи и выполнить операцию упорядочивания сверху вниз. После завершения упорядочивания свойства кучи восстанавли- ваются;

  4. циклическое выполнение шагов 2 и 3. После n -- 1 итераций сортировка массива будет завершена.

Рис. 11.12. Этапы пирамидальной сортировки. Шаг 1

Рис. 11.12. Продолжение. Шаги 2--3

Рис. 11.12. Продолжение. Шаг 4--5

Рис. 11.12. Продолжение. Шаг 6--7

Рис. 11.12. Продолжение. Шаг 8--9

Рис. 11.12. Продолжение. Шаг 10--11

Рис. 11.12. Окончание. Шаг 12

В коде для выполнения упорядочивания сверху вниз используется функция sift_down(), аналогичная той, что была в разделе «Куча». Следует отметить, что длина кучи уменьшается по мере извлечения максимальных элементов, по- этому необходимо добавить в функцию sift_down() параметр длины n, чтобы указать текущую действительную длину кучи. Ниже приведен код реализации.

# === File: heap_sort.py ===

def sift_down(nums: list[int], n: int, i: int):

""" Длина кучи равна n, упорядочивание сверху вниз, начиная с узла i.""" while True:

# Определение узла с максимальным значением среди узлов i, l, r,

# обозначенного как ma. l = 2 * i + 1

r = 2 * i + 2 ma = i

if l < n and nums[l] > nums[ma]: ma = l

if r < n and nums[r] > nums[ma]: ma = r

# Если узел i максимальный или индексы l, r выходят за пределы,

# упорядочивание не требуется, выход. if ma == i:

break

# Обмен двух узлов.

nums[i], nums[ma] = nums[ma], nums[i] # Циклическое упорядочивание вниз.

i = ma

def heap_sort(nums: list[int]): """ Сортировка кучей."""

# Операция построения кучи: упорядочивание всех узлов, кроме листьев. for i in range(len(nums) // 2 - 1, -1, -1):

sift_down(nums, len(nums), i)

# Извлечение максимального элемента из кучи, цикл из n-1 итераций. for i in range(len(nums) - 1, 0, -1):

# Обмен корневого узла и самого правого листа (обмен первого

# и последнего элементов).

nums[0], nums[i] = nums[i], nums[0]

# Упорядочивание сверху вниз, начиная с корневого узла. sift_down(nums, i, 0)

Характеристики алгоритма

  • Временная сложность O(n log n), неадаптивная сортировка: опера- ция построения кучи занимает время O(n). Временная сложность извле- чения максимального элемента из кучи составляет O(log n), всего n -- 1 итераций.

  • Пространственная сложность O(1), сортировка на месте: несколько указателей используют пространство O(1). Обмен элементов и операции упорядочивания выполняются на исходном массиве.

  • Нестабильная сортировка: при обмене элементов на вершине и внизу кучи относительное положение равных элементов может измениться.

Блочная сортировка

Ранее рассмотренные алгоритмы сортировки относятся к алгоритмам сорти- ровки на основе сравнения, которые осуществляют сортировку путем срав- нения величин элементов. Временная сложность таких алгоритмов не может превысить O(n log n). Далее рассмотрим алгоритмы сортировки без сравнения, временная сложность которых может достигать линейного порядка.

Блочная сортировка является типичным применением стратегии «разделяй и властвуй». Она создает набор упорядоченных по величине блоков, где каж- дый блок соответствует определенному диапазону данных, и равномерно рас- пределяет элементы по этим блокам. Затем сортировка выполняется отдель- но внутри каждого блока, после чего отсортированные данные объединяются в соответствии с порядком блоков.

Алгоритм

Пусть дан массив длиной n, элементы которого являются числами с плаваю- щей запятой в диапазоне [0, 1). Процесс блочной сортировки выглядит следу- ющим образом (см. рис. 11.13):

  1. инициализация k блоков, распределение n элементов по k блокам;

  2. выполнение сортировки отдельно для каждого блока (здесь использует- ся встроенная функция сортировки языка программирования);

  3. объединение результатов в порядке от меньшего блока к большему.

  1. Блочная сортировка ❖ 325

Массив для сортировки nums

Блоки

Buckets

Обход массива, распределение чисел по блокам

Сортировка отдельно каждого блока

Диапазон чисел

Результирующий массив nums

Объединение блоков в конечный результат

Рис. 11.13. Процесс выполнения алгоритма блочной сортировки

Ниже представлен код реализации.

# === File: bucket_sort.py ===

def bucket_sort(nums: list[float]): """ Блочная сортировка."""

# Инициализация k = n/2 блоков, предполагается распределение 2 элементов

# на каждый блок. k = len(nums) // 2

buckets = [[] for _ in range(k)]

# 1. Распределение элементов массива по блокам. for num in nums:

# Диапазон входных данных [0, 1), использование num * k для отображения

# в индексный диапазон [0, k-1]. i = int(num * k)

# Добавление num в блок i. buckets[i].append(num)

# 2. Выполнение сортировки для каждого блока. for bucket in buckets:

# Использование встроенной функции сортировки, можно заменить на другой

# алгоритм сортировки. bucket.sort()

# 3. Обход блоков и объединение результатов. i = 0

for bucket in buckets: for num in bucket:

nums[i] = num i += 1

Характеристики алгоритма

Блочная сортировка подходит для обработки очень больших объемов дан- ных. Например, если входные данные содержат 1 млн элементов и из-за

ограничений по памяти система не может загрузить все данные сразу, то можно разделить данные на 1000 блоков, затем отсортировать отдельно каждый блок и в конце объединить результаты.

  • Временная сложность O(n + k): при условии равномерного распределе- ния элементов по блокам количество элементов в каждом блоке равно n/k. Если сортировка одного блока занимает время O(n/k log n/k), то сорти- ровка всех блоков занимает время O(n log n/k). Когда количество блоков k достаточно велико, временная сложность стремится к O(n). При объеди- нении результатов необходимо обойти все блоки и элементы, что зани- мает время O(n + k). В худшем случае все данные распределяются в один блок, и сортировка этого блока занимает время O(n2).

  • Пространственная сложность O(n + k), не на месте: требуется допол- нительное пространство для k блоков и всех n элементов.

  • Стабильность блочной сортировки зависит от стабильности алгоритма сортировки элементов внутри блоков.

Реализация равномерного распределения

Время выполнения блочной сортировки теоретически может достигать O(n). Ключевым моментом здесь является равномерное распределение эле- ментов по блокам, так как в реальных данных распределение часто нерав- номерное. Например, мы хотим распределить все товары на маркетплейсе по ценовым диапазонам в 10 блоков, но цены товаров распределены неравно- мерно: очень много товаров дешевле 100 руб. и очень мало дороже 1000 руб. Если разделить ценовой диапазон на 10 равных частей, количество товаров в каждом блоке будет значительно различаться.

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

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

Если заранее известна вероятность распределения цен товаров, можно установить границы цен для каждого блока на основе этого распреде- ления. Стоит отметить, что распределение данных не обязательно подсчиты- вать точно, можно использовать вероятностную модель для приближенного определения.

Предположим, что цены товаров подчиняются нормальному распределе- нию, таким образом, можно разумно установить ценовые диапазоны и равно- мерно распределить товары по блокам, как показано на рис. 11.15.

Рис. 11.14. Рекурсивное деление блоков

Рис. 11.15. Деление блоков на основе вероятностного распределения

Сортировка подсчетом

Сортировка подсчетом реализует сортировку путем подсчета количества эле- ментов и обычно применяется к массивам целых чисел.

Простая реализация

Рассмотрим простой пример. Пусть дан массив nums длиной n, элементы кото- рого -- неотрицательные целые числа. Процесс сортировки подсчетом выгля- дит следующим образом (см. рис. 11.16):

  1. обойти массив, найти максимальное число, обозначить его как m, затем создать вспомогательный массив counter длиной m + 1;

  2. с помощью counter подсчитать количество вхождений каждого числа в nums, где counter[num] соответствует количеству вхождений числа num. Метод подсчета прост: нужно обойти nums (пусть текущее число -- num), и на каждой итерации увеличивать counter[num] на 1;

  3. так как индексы в counter естественно упорядочены, значит все чис- ла уже отсортированы. Далее обходим counter и заполняем nums в по- рядке возрастания количества вхождений каждого числа.

Массив для сортировки nums

Индекс (число)

Массив счетчиков

counter

Результирующий массив nums

Обход counter, заполнение nums

[в]{.smallcaps} соответствии с количеством вхождений. Элемент counter[num] содержит количество вхождений num

Обход nums и подсчет количества вхождений каждого числа.

Элемент counter[num] содержит количество вхождений num

Рис. 11.16. Процесс сортировки подсчетом

Ниже приведен код реализации.

# === File: counting_sort.py ===

def counting_sort_naive(nums: list[int]): """ Сортировка подсчетом."""

# Простая реализация, не подходит для сортировки объектов. # 1. Статистика максимального элемента массива m.

m = 0

for num in nums:

m = max(m, num)

# 2. Подсчет количества вхождений каждого числа.

# counter[num] представляет количество вхождений num. counter = [0] * (m + 1)

for num in nums: counter[num] += 1

# 3. Обход counter, заполнение исходного массива nums.

i = 0

for num in range(m + 1):

for _ in range(counter[num]): nums[i] = num

i += 1

Полная реализация

Внимательный читатель мог заметить, что если входные данные -- объекты, то шаг 3 в алгоритме выше не будет работать. Предположим, что входные данные -- объекты товаров, и мы хотим отсортировать их по цене (члену класса), но приведенный алгоритм может сортировать только цены отдельно.

Как же получить результат сортировки исходных данных? Сначала необхо- димо вычислить префиксную сумму counter. Как следует из названия, префикс- ная сумма в позиции i, т. е. prefix[i], равна сумме первых i элементов массива:

i

prefix ℘.λiλϑ = Ιcounter ℘.λ jλϑ.

j =0

Префиксная сумма имеет четкий смысл: prefix[num] - 1 представляет ин- декс последнего вхождения элемента num в результирующем массиве res. Эта информация очень важна, так как она указывает, где каждый элемент дол- жен находиться в результирующем массиве. Далее, обходим исходный массив nums в обратном порядке, и на каждой итерации выполняем следующие два шага:

  1. вставить num в массив res на позицию prefix[num] - 1;

  2. уменьшить префиксную сумму prefix[num] на 1, чтобы получить индекс для следующего размещения num.

После завершения обхода массив res будет содержать отсортированные дан- ные, и в завершение можно использовать res для замены исходного массива nums. На рис. 11.17 демонстрируется полный процесс сортировки подсчетом.

Рис. 11.17. Этапы сортировки подсчетом. Шаг 1

Рис. 11.17. Продолжение. Шаги 2--3

Рис. 11.17. Продолжение. Шаги 4--5

Рис. 11.17. Продолжение. Шаги 6--7

Рис. 11.17. Окончание. Шаг 8 Ниже приведена реализация сортировки подсчетом. # === File: counting_sort.py ===

def counting_sort(nums: list[int]): """ Сортировка подсчетом."""

# Полная реализация, сортируемые объекты, стабильная сортировка. # 1. Определение максимального элемента массива m.

m = max(nums)

# 2. Подсчет количества вхождений каждого числа.

# counter[num] представляет количество вхождений num. counter = [0] * (m + 1)

for num in nums: counter[num] += 1

# 3. Вычисление префиксной суммы counter, преобразование "количества

# вхождений" в "конечный индекс".

# То есть counter[num]-1 -- это индекс последнего вхождения num в res. for i in range(m):

counter[i + 1] += counter[i]

# 4. Обратный обход nums, заполнение элементов в результирующий массив res. # Инициализация массива res для записи результата.

n = len(nums) res = [0] * n

for i in range(n - 1, -1, -1): num = nums[i]

res[counter[num] - 1] = num # Размещение num на соответствующем индексе. counter[num] -= 1 # Уменьшение префиксной суммы на 1, получение

# индекса для следующего размещения num.

# Использование результирующего массива res для замены исходного массива nums. for i in range(n):

nums[i] = res[i]

Характеристики алгоритма

  • Временная сложность O(n + m), неадаптивная сортировка: включает обход nums и counter, оба обхода выполняются за линейное время. Обычно nm, временная сложность стремится к O(n).

  • Пространственная сложность O(n + m), не на месте: используются вспомогательные массивы res и counter длиной n и m соответственно.

  • Стабильная сортировка: поскольку элементы добавляются в res от конца к началу, обратный обход nums позволяет избежать изменения от- носительного положения равных элементов, обеспечивая стабильность сортировки. На самом деле прямой обход nums также дает правильный результат, но он уже не будет стабильным.

Ограничения

На этом этапе может показаться, что сортировка подсчетом весьма изящна, так как позволяет эффективно сортировать, просто подсчитывая количество. Однако условия для применения сортировки подсчетом довольно строгие.

Сортировка подсчетом применима только к неотрицательным целым числам. Если требуется использовать ее для других типов данных, необходи- мо убедиться, что их можно преобразовать в неотрицательные целые числа, не изменяя относительное положение элементов. Например, для массива це- лых чисел с отрицательными значениями можно сначала добавить ко всем числам константу, чтобы все числа стали положительными, а после сортиров- ки вернуть их к первоначальным значениям.

Сортировка подсчетом подходит для случаев, когда объем данных велик**, а диапазон данных мал. Например, в приведенном выше примере m не должно быть слишком велико, иначе потребуется слишком много памяти. А когда nm, сортировка подсчетом использует время O(m), что может быть медленнее, чем алгоритмы сортировки с временной сложностью O(n log n).

поразрядная сортировка

В предыдущем разделе была рассмотрена сортировка подсчетом, которая хо- рошо подходит для случаев, когда объем данных n велик, а диапазон данных m мал. Предположим, необходимо отсортировать n = 106 номеров студентов, где номер -- это восьмизначное число, т. е. диапазон данных m = 108 очень велик. Использование сортировки подсчетом потребует выделения большого объема памяти, тогда как поразрядная сортировка позволяет избежать этой проблемы. Поразрядная сортировка основывается на той же идее, что и сортировка подсчетом, и также реализуется путем подсчета количества. Но поразрядная сортировка использует прогрессивные отношения между разрядами чисел,

выполняя сортировку по каждому разряду.

Алгоритм

Возьмем в качестве примера данные о номерах студентов. Предположим, что наименьший разряд -- это 1-й разряд, а наибольший -- 8-й разряд. Процесс по- разрядной сортировки выглядит следующим образом (см. рис. 11.18):

  1. Поразрядная сортировка ❖ 335

    1. инициализация разряда k = 1;

    2. выполнение сортировки подсчетом по k-му разряду номеров студентов. После завершения данные будут отсортированы по k-му разряду в по- рядке возрастания;

    3. увеличение k на 1 и возврат на шаг 2. Продолжение итераций до завер- шения сортировки по всем разрядам.

Рис. 11.18. Процесс алгоритма сортировки по разрядам

Теперь проанализируем код реализации. Пусть дано число x в d-ричной си- стеме исчисления. Чтобы получить его k-й разряд xk, можно использовать сле- дующую формулу:

Ι 1

k Λ d k 1 Υ

где ⌊a⌋ обозначает округление числа a вниз, а mod d обозначает взятие остатка от деления на d. Для нашей задачи о номерах студентов d = 10 и k ∈ [1, 8].

Кроме того, необходимо немного изменить код сортировки подсчетом, что- бы он мог сортировать по k-му разряду числа.

# === File: radix_sort.py ===

def digit(num: int, exp: int) -> int:

""" Получение k-го разряда элемента num, где exp = 10^(k-1)."""

# Передача exp вместо k позволяет избежать повторного выполнения дорогого # вычисления степени.

return (num // exp) % 10

def counting_sort_digit(nums: list[int], exp: int):

""" Сортировка подсчетом (по k-му разряду nums)."""

# Десятичный диапазон цифр составляет от 0 до 9, поэтому требуется массив # корзин длиной 10.

counter = [0] * 10. n = len(nums)

# Подсчет количества вхождений каждой цифры от 0 до 9. for i in range(n):

d = digit(nums[i], exp) # Получение k-й цифры числа nums[i],

#обозначенной как d.

counter[d] += 1 # Подсчет количества вхождений цифры d.

# Вычисление префиксной суммы для преобразования "количества вхождений" # в "индексы массива".

for i in range(1, 10): counter[i] += counter[i - 1]

# Обратный обход, заполнение элементов в res на основе результатов подсчета

# в корзинах. res = [0] * n

for i in range(n - 1, -1, -1): d = digit(nums[i], exp)

j = counter[d] - 1 # Получение индекса j для d в массиве. res[j] = nums[i] # Заполнение текущего элемента в индекс j. counter[d] -= 1 # Уменьшение количества d на 1.

# Перезапись исходного массива nums результатами сортировки. for i in range(n):

nums[i] = res[i]

def radix_sort(nums: list[int]): """Базовая сортировка."""

# Получение максимального элемента массива для определения

# максимальной разрядности. m = max(nums)

# Обход от младшего разряда к старшему. exp = 1

while exp <= m:

# Выполнение сортировки подсчетом для k-й цифры элементов массива. # k = 1 -> exp = 1

# k = 2 -> exp = 10

# То есть exp = 10^(k-1). counting_sort_digit(nums, exp) exp *= 10

Характеристики алгоритма

  1. Резюме ❖ 337

По сравнению с сортировкой подсчетом поразрядная сортировка подходит для случаев с большим диапазоном чисел, но при условии, что данные мож- но представить в формате фиксированной разрядности, и разрядность не должна быть слишком большой. Например, числа с плавающей запятой не подходят для поразрядной сортировки, поскольку их разрядность k слиш- ком велика, что может привести к временной сложности O(nk) ≫ O(n2).

  • Временная сложность O(nk), неадаптивная сортировка: пусть объ- ем данных равен n, данные имеют d-ричную систему счисления, макси- мальная разрядность равна k. Тогда выполнение сортировки подсчетом для одной цифры требует времени O(n + d), сортировка всех k цифр тре- бует времени O((n + d)k). Обычно d и k относительно малы, и временная сложность стремится к O(n).

  • Пространственная сложность O(n + d), не на месте: как и сортировка подсчетом, поразрядная сортировка требует использования массивов res и counter длиной n и d.

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

11.11. резюме

Ключевые моменты
  • Сортировка пузырьком реализует сортировку путем обмена соседних элементов. Добавив флаг для досрочного выхода из цикла, можно оп- тимизировать лучшую временную сложность пузырьковой сортиров- ки до O(n).

  • Сортировка вставками в каждом раунде вставляет элемент из неот- сортированного диапазона в правильное место в отсортированном диапазоне. Хотя временная сложность этой сортировки составля- ет O(n2), благодаря относительно малому количеству элементарных операций она хорошо подходит для задач сортировки небольших объемов данных.

  • Быстрая сортировка основана на операции разделения с использова- нием опорного элемента. При разделении возможна ситуация, когда каждый раз выбирается наихудший опорный элемент, что приводит к ухудшению временной сложности до O(n2). Введение медианного или случайного опорного элемента может снизить вероятность такого ухудшения. Метод хвостовой рекурсии может эффективно уменьшить глубину рекурсии и оптимизировать пространственную сложность до O(log n).

  • Сортировка слиянием включает два этапа -- разделение и слияние -- и является типичным представителем стратегии «разделяй и властвуй». В сортировке слиянием для сортировки массива требуется создание

вспомогательного массива, поэтому пространственная сложность со- ставляет O(n). Однако для сортировки связного списка пространствен- ную сложность можно оптимизировать до O(1).

  • Блочная сортировка включает три этапа: распределение данных по блокам, сортировку внутри блоков и объединение результатов. Она также демонстрирует стратегию «разделяй и властвуй» и подходит для случаев с большими объемами данных. Ключ к эффективной блоч- ной сортировке заключается в равномерном распределении данных по блокам.

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

  • Поразрядная сортировка реализует сортировку данных путем последо- вательной сортировки по разрядам. Для этого требуется, чтобы данные можно было представить в виде чисел фиксированной разрядности.

  • В целом мы стремимся найти алгоритм сортировки, обладающий такими преимуществами, как высокая эффективность, стабильность, выполне- ние на месте и адаптивность. Однако, как и в случае с другими структу- рами данных и алгоритмами, не существует алгоритма сортировки, ко- торый одновременно удовлетворял бы всем этим условиям. На практике необходимо выбирать подходящий алгоритм сортировки в зависимости от характеристик данных.

  • На рис. 11.19 приведено сравнение таких характеристик основных ал- горитмов сортировки, как эффективность, стабильность, выполнение на месте и адаптивность.

+-------------------------------------+---------------------------+-------------------------------------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Алгоритм сортировки | > Временная сложность | > Простран- ственная сложность | > Стабиль- ность | > Местность | > Адаптив- ность | > Основан- ность на сравнении | | | +--------------+---------------+------------+ | | | | | | | | > Лучшая | > Средняя | Худшая | | | | | | +=====================================+===========================+:============:+===============+===========:+:==================================:+======================+:===============:+======================+:=================================:+ | > Сортировка обходом O(n2) | > Выбором | > O(n2) | > O(n2) | > O(n2) | > O(1) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | | +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Пузырьком | > O(n) | > O(n2) | > O(n2) | > O(1) | > Стабильный | > На месте | > Адаптив- ный | > Сравнение | | +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Вставками | > O(n) | > O(n2) | > O(n2) | > O(1) | > Стабильный | > На месте | > Адаптив- ный | > Сравнение | +-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | > Сортировка разделением O(n log n) | > Быстрая | > O(n log n) | > O(n log n) | > O(n2) | > O(log n) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | | +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Слиянием | > O(n log n) | > O(n log n) | O(n log n) | > O(n) | > Стабильный | > Не на месте | > Неадаптив- ный | > Сравнение | | +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Пирами- дальная | > O(n log n) | > O(n log n) | O(n log n) | > O(1) | > Нестабиль- ный | > На месте | > Неадаптив- ный | > Сравнение | +-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | > Линейная со- ртировка O(n) | > Блочная | > O(n + k) | > O(n + k) | > O(n2) | > O(n + k) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | | +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Подсчетом | > O(n + m) | > O(n + m) | O(n + m) | > O(n + m) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | | +---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+ | | > Поразряд- ная | > O(n k) | > O(n k) | > O(n k) | > O(n + b) | > Стабильный | > Не на месте | > Неадаптив- ный | > Не сравне- ние | +-------------------------------------+---------------------------+--------------+---------------+------------+------------------------------------+----------------------+-----------------+----------------------+-----------------------------------+

11.11. Резюме ❖ 339

Плохая Средняя Хорошая


  • n
    -- размер данных

  • В блочной сортировке k -- количество блоков

  • В сортировке подсчетом m -- диапазон данных

  • В поразрядной сортировке k -- максимальное количество разрядов, b -- основание системы счисления данных

Рис. 11.19. Сравнение алгоритмов сортировки

Вопросы и ответы

Вопрос. В каких случаях необходима стабильность алгоритма сортировки?

Ответ. В реальной жизни может возникнуть необходимость сортировки объектов по какому-либо атрибуту. Например, у студентов есть два атрибута: имя и рост. Мы хотим осуществить многоуровневую сортировку: сначала по имени, получив (A, 180) (B, 185) (C, 170) (D, 170), затем по росту. Если алго- ритм сортировки нестабилен, возможно получение такого результата: (D, 170) (C, 170) (A, 180) (B, 185).

Можно заметить, что позиции студентов D и C поменялись и порядок по имени был нарушен, что является нежелательным результатом.

Вопрос. Можно ли поменять порядок выполнения операций поиска справа налево и поиска слева направо в методе разделения с использованием стража? Ответ. Нет, если в качестве опорного элемента выбран самый левый эле- мент, необходимо сначала искать справа налево, а затем искать слева направо.

Этот вывод может показаться неочевидным, разберем его причины.

Последний шаг метода разделения partition() заключается в обмене nums[left] и nums[i]. После обмена элементы слева от опорного элемента долж- ны быть <= опорного элемента, что требует выполнения условия nums[left]

>= nums[i] перед обменом. Если сначала искать слева направо, то в случае, если не удастся найти элемент больше опорного, цикл завершится при i == j, и возможна ситуация nums[j] == nums[i] > nums[left]. То есть на последнем шаге обмена элемент, больший опорного, будет перемещен в начало массива, что приведет к неудаче разделения с использованием стража.

Например, если для массива [0, 0, 0, 0, 1] искать слева направо, после

разделения с использованием стража получится [1, 0, 0, 0, 0], что является неправильным результатом.

Если выбрать nums[right] в качестве опорного элемента, то порядок будет об- ратным, и необходимо сначала искать слева направо.

Вопрос. Почему при оптимизации хвостовой рекурсии выбор короткого массива гарантирует, что глубина рекурсии не превысит log n?

Ответ. Глубина рекурсии -- это количество текущих невозвращенных ре- курсивных вызовов. На каждом этапе разделения с использованием стража исходный массив делится на два подмассива. После оптимизации хвостовой рекурсии длина подмассива, в который продолжается рекурсия, не превышает половины длины исходного массива. В худшем случае, если длина всегда будет составлять половину, окончательная глубина рекурсии составит log n.

В оригинальном алгоритме быстрой сортировки возможно последователь- ное рекурсивное обращение к более длинным массивам, в худшем случае -- n, n 1, ..., 2, 1, что приводит к глубине рекурсии n. Оптимизация хвостовой ре- курсии позволяет избежать такой ситуации.

Вопрос. Если все элементы массива равны, является ли временная слож- ность быстрой сортировки O(n2)? Как справиться с таким вырождением?

Ответ. Да. В этом случае можно рассмотреть возможность разделения массива на три части с использованием стража: меньше, равно и больше опорного элемента. Рекурсия продолжается только для частей, меньших и больших опорного элемента. При таком подходе массив с одинаковыми

11.11. Резюме ❖ 341

элементами будет отсортирован за одну итерацию разделения с использо- ванием стража.

Вопрос. Почему временная сложность сортировки подсчетом в худшем слу- чае составляет O(n2)?

Ответ. В худшем случае все элементы попадут в одну корзину. Если для со- ртировки этих элементов используется алгоритм с временной сложностью O(n2), то общая временная сложность составит O(n2).

Глава 12