
Два распространенных алгоритма могут ускользать от понимания. В чем отличие разбиения в быстрой сортировке и похожих «магических» движений в сортировке слиянием? Меня это долго сбивало с толку. Разберемся же с ними наконец!
Быстрая сортировка
В основе работы этого алгоритма лежит принцип «разделяй и властвуй». Опорный элемент перемещается на такую позицию, что все элементы слева от него меньше или равны ему, а все элементы справа — больше или равны. Затем соседние части рассматриваются как отдельные массивы и определение опорного элемента повторяется в каждом из них. Так продолжается до тех пор, пока все элементы исходного массива не окажутся упорядоченными. Ничто не объясняет процесс лучше, чем видео, созданное в румынском Университете Сапиентия, Тыргу-Муреш (Марошвашархей).
Для выбора опорного элемента Существует несколько стратегий. Можно начинать с первой или последней ячейки, брать центральную или же вообще случайную.
def partition(arr, low, high):
# В качестве опорного элемента выбираем последний
pivot = arr[high]
# Указатель на больший элемент
i = low - 1
# Проходимся по всем ячейкам, сравнивая их с опорным элементом
for j in range(low, high):
if arr[j] < pivot:
# Если значение ячейки оказалось меньше опорного элемента,
# меняем ячейки местами
i += 1
arr[i], arr[j] = arr[j], arr[i]
# Меняем местами ячейки с опорным элементом и с первым бо́льшим его элементом
arr[i + 1], arr[high] = arr[high], arr[i + 1]
# Возвращаем указатель на разделяющую ячейку
return i + 1
def quick_sort(arr, low, high):
if low < high:
# Находим элемент опоры таким образом, чтобы элементы, которые меньше его,
# располагались слева, а те, что больше — справа
pivot_index = partition(arr, low, high)
# Рекурсивно применяем этот алгоритм к подмассивам слева и справа
# от опорного элемента
quick_sort(arr, low, pivot_index - 1)
quick_sort(arr, pivot_index + 1, high)
# Пример использования
arr = [10, 7, 8, 9, 1, 5]
quick_sort(arr, 0, len(arr) - 1)
# Выводим отсортированный массив
print("Sorted array:", arr)
Вывод:
Sorted array: [1, 5, 7, 8, 9, 10]
Разберем пример подробнее
1. Отправляем весь массив в
quick_sort с параметрами low = 0 и high = 5.2. Проверяем, что
low < high. Если так, вызывается функция partition, которая вычисляет опорный элемент (pivot) и по его положению разбивает исходный массив. При выделении двух подмассивов мы подразумеваем, что все элементы больше опорного располагаются справа, а меньшие — слева.3. Итак… partition вызывается со всем массивом,
low = 0 и high = 5.Начальный массив:
[10, 7, 8, 9, 1, 5], где low = 0, high = 5.Опорная точка:
arr[high] = arr[5] = 5.Индекс
i = low − 1 = 0 – 1 = –1.Теперь цикл идет от
low к high−1, то есть от 0 до 4.- j = 0,
arr[j] = 10, что не меньше pivot (5), поэтому ничего не делаем. - j = 1,
arr[j] = 7, что так же не менее pivot, ничего не делаем. - j = 2,
arr[j] = 8, аналогично — ничего. - j = 3,
arr[j] = 9, то же самое. - j = 4,
arr[j] = 1— теперь значение меньшеpivot(5), поэтому мы увеличиваемiна 1 (становится 0) и меняем местамиarr[i]иarr[j]. Теперьi = 0, аarr = [1, 7, 8, 9, 10, 5]. Это важный шаг, который надо понять до конца.
После цикла
i = 0 и arr = [1, 7, 8, 9, 10, 5].Мы меняем местами
i+1 и high, поэтому arr становится [1, 5, 8, 9, 10, 7].Значение
i+1 равно 1. Теперь обратите внимание, что все числа, которые меньше 5, находятся слева, а все числа, которые больше 5, — справа. Значение опорного элемента — 5, а его индекс — 1.4. Итак, ячейка 1 хранит опорный элемент со значением 5. Массив:
[1, 5, 8, 9, 10, 7]. Теперь мы вызываем quick_sort для левого и правого подмассивов. Это следующий важный шаг.5. Продолжаем рекурсивно вызывать
quick_sort для левого и правого подмассивов, пока не достигнем базового случая, где low >= high.Временна́я сложность:
- лучшее значение: T(N) = 2T(N/2) + O(N) ⇒ O(N log N),
- худшее: T(N) = T(N−1) + O(N) ⇒ O(N²),
- среднее: O(N log N).
Пространственная сложность:
- среднее значение: O (log N) — из-за стека рекурсии,
- худшее: O (N).

Сортировка слиянием
И этот алгоритм придерживается принципа «разделяй и властвуй». Мы так же делим массив на две половины, сортируем их рекурсивно, а затем объединяем. Еще одно превосходное видео, которое, как я считаю, хорошо объясняет последовательность действий:
В отличие от предыдущего случая, никакой опорной точки нет. Мы просто делим массив на две половины, рекурсивно вызываем сортировку слиянием, а позже объединяем все части.
# Использование словаря levels для отслеживания массивов — интересный
# способ визуализации, но не более. Он не является частью самого алгоритма.
levels = {}
def merge_sort(arr, level):
"""
Рекурсивно разбиваем массив на половинки и отслеживаем уровни.
Отсортированные половинки объединяем вместе.
"""
# Сохраним массив текущего уровня
if level not in levels:
levels[level] = []
levels[level].append(arr.copy()) # Сохраняем копию
# Базовый случай: если массив пустой или есть только один элемент, то сортировать нечего.
if len(arr) > 1:
mid = len(arr) // 2 # Находим середину
# Разбиваем массив на две половинки.
left_half = arr[:mid]
right_half = arr[mid:]
# Рекрурсивно применяем алгоритм к каждой из половинок.
merge_sort(left_half, level + 1)
merge_sort(right_half, level + 1)
# Соединяем отсортированные половинки.
merge(arr, left_half, right_half)
def merge(arr, left_half, right_half):
"""
Объединяем два осортированных подмассива (left_half и right_half) в единый массив.
"""
i = j = k = 0 # Инициализируем индексы для left_half, right_half и единого массива.
# Сравниваем значения двух половинок и соединяем упорядоченно.
while i < len(left_half) and j < len(right_half):
if left_half[i] < right_half[j]:
arr[k] = left_half[i]
i += 1
else:
arr[k] = right_half[j]
j += 1
k += 1
# Копируем оставшиеся элементы в left_half
while i < len(left_half):
arr[k] = left_half[i]
i += 1
k += 1
# Также поступаем с оставшимися ячейками в right_half
while j < len(right_half):
arr[k] = right_half[j]
j += 1
k += 1
# Пример
arr = [10, 7, 8, 9, 1, 5]
# Выпоняем сортировку, отслеживаем уровни.
merge_sort(arr, 0)
# Смотрим, как массив разделяется на каждом уровне.
for level in levels:
print("Level:", level, ", Arrays:", levels[level])
# Отсортированный массив.
print("Final Sorted Array:", arr)
Вывод:
Level: 0 , Arrays: [[10, 7, 8, 9, 1, 5]] Level: 1 , Arrays: [[7, 8, 10], [1, 5, 9]] Level: 2 , Arrays: [[10], [7, 8], [9], [1, 5]] Level: 3 , Arrays: [[7], [8], [1], [5]] Final Sorted Array: [1, 5, 7, 8, 9, 10]
Подробнее
1. Отправляем весь массив в
merge_sort с уровнем равным 0.2. Обратите внимание, выводим массив до проверки длины — так мы увидим его состояние на каждом уровне.
3. Состояние массива на каждом уровне сохраняем в словаре, который называется levels.
4. Проверяем, не является ли массив базовым случаем с одним элементом (или вообще пустым). Так есть смысл делить его пополам.
5. Рекурсивно вызываем
merge_sort для левой и правой половин, увеличивая уровень на 1.Это самый важный шаг: мы продолжаем рекурсивно вызывать
merge_sort для левой и правой половин, пока не достигнем базового случая с единичной длиной. После того, как рекурсивные вызовы отработали, можно производить слияние.Но где же происходит сортировка?.. В функции слияния!
6. Наконец объединяем две половины с помощью функции слияния.
Сначала рассмотрим базовый случай — например, [7] и [8], которыми на верхнем уровне оперировала функция
merge_sort на левой и правой половинках. Здесь все просто: когда выполняется слияние, элементы просто сравниваются и получают позиции в правильном порядке.Другой вариант — слияние половинок из нескольких элементов. Для примера можно взять половинки
[7,8,10] и [1,5,9], которые обрабатывались merge_sort на верхнем уровне. При слиянии сначала сравнивается 7 и 1 — первой размещается 1. Затем сравнивается 7 и 5 — после 1 размещается 5. Доходит очередь до сравнения 7 и 9 — на этот раз 7 становится после 5.Временна́я сложность:
- Лучшее, худшее и среднее значение: T(N) = 2 · T(N/2) + O(N) ⇒ O(N log N).
Пространственная сложность:
- O (N) — из‑за необходимости дополнительных массивов.
Сравнение быстрой сортировки и сортировки слиянием
И быстрая сортировка, и сортировка слиянием — эффективные алгоритмы, но у них разные характеристики и предпочтительные варианты использования. Вот небольшая таблица для лучшего понимания:

Быстрая сортировка: разбивка (partitioning) — активный процесс. Основная работа происходит до рекурсивных вызовов для подмассивов.
Сортировка слиянием: разбивка проще — массив просто делится пополам. Основная работа происходят на этапе слияния после того, как рекурсивные вызовы уже отсортировали подмассивы.