
Два распространенных алгоритма могут ускользать от понимания. В чем отличие разбиения в быстрой сортировке и похожих «магических» движений в сортировке слиянием? Меня это долго сбивало с толку. Разберемся же с ними наконец!
Быстрая сортировка
В основе работы этого алгоритма лежит принцип «разделяй и властвуй». Опорный элемент перемещается на такую позицию, что все элементы слева от него меньше или равны ему, а все элементы справа — больше или равны. Затем соседние части рассматриваются как отдельные массивы и определение опорного элемента повторяется в каждом из них. Так продолжается до тех пор, пока все элементы исходного массива не окажутся упорядоченными. Ничто не объясняет процесс лучше, чем видео, созданное в румынском Университете Сапиентия, Тыргу-Муреш (Марошвашархей).
Для выбора опорного элемента Существует несколько стратегий. Можно начинать с первой или последней ячейки, брать центральную или же вообще случайную.
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) — активный процесс. Основная работа происходит до рекурсивных вызовов для подмассивов.
Сортировка слиянием: разбивка проще — массив просто делится пополам. Основная работа происходят на этапе слияния после того, как рекурсивные вызовы уже отсортировали подмассивы.