Быструю сортировку мы уже разобрали: она выбирает опорный элемент и расставляет остальные вокруг него. У неё есть слабое место — в худшем случае (например, если список уже почти отсортирован, а опорный элемент выбран неудачно) она работает медленно, за O(n2). Сортировка слиянием устроена иначе: она никогда не зависит от того, какие именно числа в списке и в каком порядке они стоят. Она всегда делит список ровно пополам, поэтому всегда укладывается в O(n log n), без исключений. Это цена и выгода одновременно: придётся завести дополнительную память под промежуточные списки, зато скорость предсказуема всегда.
Идея: разделяй и властвуй
Простыми словами. Представьте, что вам дали большую нерасставленную по алфавиту стопку карточек. Расставлять сразу всю стопку неудобно. Но если разделить её на две стопки поменьше, расставить каждую по отдельности, а потом аккуратно слить две уже расставленные стопки в одну — получится быстрее и надёжнее. А если делить дальше, до стопок по одной карточке, расставлять вообще не придётся: одна карточка уже стоит на своём месте сама по себе. Дальше остаётся только правильно сливать стопки обратно, по две.
Официальный термин. Такой приём в программировании называется divide and conquer (разделяй и властвуй): большая задача рекурсивно разбивается на более мелкие копии самой себя, пока они не станут тривиальными, а потом решения мелких задач объединяются в решение исходной. Сортировка, построенная на этом приёме, называется сортировкой слиянием (merge sort). У неё два этапа: деление — список рекурсивно режется пополам, пока не останутся списки из одного элемента (список из одного элемента уже отсортирован по определению); и слияние — отсортированные половины сливаются в один отсортированный список, элемент за элементом.
Проследим на примере списка [38, 27, 43, 3]. Деление: [38, 27, 43, 3] → [38, 27] и [43, 3] → [38], [27], [43], [3]. Слияние с конца: [38] и [27] сливаются в [27, 38], [43] и [3] — в [3, 43]. Последний шаг: [27, 38] и [3, 43] сливаются в [3, 27, 38, 43]. Готово.
Слияние двух отсортированных списков
Простыми словами. Представьте две стопки карточек, каждая уже расставлена по возрастанию, и лежат они лицом вверх, а вы видите только верхнюю карточку каждой стопки. Чтобы слить их в одну, вы всё время сравниваете две верхние карточки и забираете меньшую в общую стопку. Когда одна из стопок закончится, вы просто сгребаете весь остаток второй стопки — сравнивать больше не с чем, там и так уже по порядку.
Официальный термин. Такое слияние называют двухуказательным (two pointers): заводятся два индекса — по одному на каждый список, они показывают, докуда список уже разобран. На каждом шаге сравнивается элемент под одним указателем с элементом под другим, меньший переносится в результат, и указатель у него сдвигается на шаг вперёд.
def merge(left, right):
result = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
Разберём эту функцию по частям.
def merge(left, right):— функция принимает два списка,leftиright, каждый уже отсортирован по возрастанию. Это условие важно: функция не сортирует их с нуля, она только сливает.result = []— сюда будем складывать итоговый отсортированный список.i, j = 0, 0— два указателя.iпоказывает, какой элементleftсравнивается следующим,j— то же самое дляright. Оба начинают с нуля, то есть с первого элемента каждого списка.while i < len(left) and j < len(right):— цикл работает, пока в ОБОИХ списках остались несравненные элементы. Как только один список закончится, условие станет ложным и цикл остановится.if left[i] <= right[j]:— сравниваем текущий элементleftс текущим элементомright. Знак<=, а не просто<, выбран специально: при равенстве элемент изleftидёт первым, это делает сортировку стабильной (одинаковые элементы не меняются местами друг с другом).result.append(left[i])иi += 1— если элементleftменьше или равен, он меньше подходит для текущего места в результате, поэтому забираем его и сдвигаем указательiна следующий элементleft.else: result.append(right[j])иj += 1— иначе меньше элемент изright, забираем его и сдвигаемj.result.extend(left[i:])— когда цикл закончился, один из списков мог остаться неразобранным.left[i:]— срез от текущегоiдо конца списка, то есть всё, что не успели забрать.extendдобавляет все эти элементы в конецresultразом (в отличие отappend, который добавил бы весь список как один элемент).result.extend(right[j:])— то же самое для остаткаright. На практике сработает только одна из этих двух строк: та, чей список ещё не был разобран целиком, у второй срез окажется пустым иextendничего не добавит.return result— возвращаем готовый слитый список.
Проверим на примере: merge([3, 27], [9, 10, 38]). Сначала i=0, j=0: сравниваем 3 и 9, забираем 3, i=1. Сравниваем 27 и 9, забираем 9, j=1. Сравниваем 27 и 10, забираем 10, j=2. Сравниваем 27 и 38, забираем 27, i=2. Список left закончился (i == len(left)), цикл останавливается. left[2:] — пустой срез, right[2:] равен [38], он дописывается в конец. Итог: [3, 9, 10, 27, 38].
Полная сортировка: merge_sort()
Простыми словами. Функция merge() умеет сливать только уже отсортированные списки. Чтобы получить отсортированные половины, с которых можно начать, применим тот же приём деления к каждой половине снова, и снова, пока список не сократится до одного элемента — а список из одного элемента отсортирован всегда, сравнивать там нечего. Это тот самый рекурсивный вызов функции самой себя, с которым мы уже встречались: функция решает большую задачу, вызывая саму себя на меньшей версии этой же задачи.
Официальный термин. Про рекурсию мы уже говорили: у любой рекурсивной функции есть базовый случай — условие, при котором функция отвечает сразу, без нового вызова себя, и рекурсивный случай — вызов функции с меньшим по объёму аргументом. Для сортировки слиянием базовый случай — список длиной 0 или 1, рекурсивный — список делится на left и right, каждая половина сортируется тем же merge_sort(), а затем результаты сливаются функцией merge().
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
if len(arr) <= 1: return arr— базовый случай. Список из нуля или одного элемента уже отсортирован, его незачем ни делить, ни сливать — просто возвращаем как есть. Без этой строки рекурсия делила бы список бесконечно, до ошибки переполнения стека.mid = len(arr) // 2— вычисляем середину списка, целочисленным делением. Например, для списка длиной 7midбудет равен 3.left = merge_sort(arr[:mid])—arr[:mid]— срез, левая половина списка, от начала до середины (не включая её). Функция вызывает саму себя на этой половине — это и есть рекурсивный вызов. Результат, уже отсортированную половину, сохраняем вleft.right = merge_sort(arr[mid:])— то же самое для правой половины, от середины до конца.return merge(left, right)— когда обе половины отсортированы (это гарантирует рекурсия — она не вернёт управление сюда, пока сама себя не отработает до конца), сливаем их функцией из предыдущего раздела и возвращаем результат.
Проследим вызовы для merge_sort([38, 27, 43, 3]). Список делится на [38, 27] и [43, 3]. Каждый из них делится ещё раз: [38, 27] на [38] и [27], [43, 3] на [43] и [3]. Списки из одного элемента — базовый случай, они возвращаются как есть. Дальше идёт слияние, с самого нижнего уровня: merge([38], [27]) даёт [27, 38], merge([43], [3]) даёт [3, 43]. Последний вызов, merge([27, 38], [3, 43]), даёт итоговый [3, 27, 38, 43].
Удобный способ понять рекурсию — не пытаться держать в голове все вызовы сразу, а поверить в то, что merge_sort() честно отсортирует любой список короче текущего. Тогда достаточно проверить: делим на две половины короче исходной, доверяем, что они вернутся отсортированными, и остаётся только слить их.
Сложность и сравнение с быстрой сортировкой
Простыми словами. У быстрой сортировки скорость зависит от того, насколько удачно выбран опорный элемент: не повезёт с данными — будет медленно. У сортировки слиянием такой зависимости нет: она всегда делит список ровно пополам, независимо от того, какие в нём числа и в каком порядке. Поэтому её скорость одинаковая что для случайного списка, что для почти отсортированного, что для отсортированного в обратном порядке.
Официальный термин. Временная сложность сортировки слиянием — O(n log n) и в лучшем, и в среднем, и в худшем случае: она делит список на log n уровней (каждый уровень делит объём пополам), и на каждом уровне сливает суммарно n элементов. У быстрой сортировки средний случай тоже O(n log n), но худший — O(n2). Платить за стабильность приходится памятью: сортировка слиянием создаёт новые списки при каждом слиянии, поэтому требует O(n) дополнительной памяти, тогда как быстрая сортировка перестраивает список на месте, почти без лишней памяти.
| Свойство | Сортировка слиянием | Быстрая сортировка |
|---|---|---|
| Худший случай | O(n log n) | O(n2) |
| Память | O(n) | O(log n) |
| Стабильность | да, при <= |
обычно нет |
Частые ошибки
Забыть базовый случай или написать его неверно.
def merge_sort(arr):
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
Без проверки if len(arr) <= 1: return arr список из одного элемента всё равно попробует поделиться пополам: arr[:0] даст пустой список, а arr[0:] — тот же список без изменений. Рекурсия зациклится и упадёт с ошибкой переполнения стека.
Забыть дописать остаток одного из списков в merge().
while i < len(left) and j < len(right):
...
return result
Если не добавить result.extend(left[i:]) и result.extend(right[j:]) после цикла, хвост более длинного списка потеряется: например, merge([1, 2, 9], [3]) без этих строк вернёт [1, 2, 3] вместо [1, 2, 3, 9] — девятка просто не попадёт в результат.
Передать в merge() списки, которые не отсортированы заранее.
Функция merge() не проверяет и не умеет заново сортировать входные списки — она только правильно сливает уже упорядоченные данные. Если вызвать merge([5, 1], [3, 2]) напрямую, результат будет неверным, потому что оба списка сами по себе не отсортированы. Именно поэтому в merge_sort() к функции merge() обращаются только после рекурсивной сортировки обеих половин.
Что важно запомнить
- Сортировка слиянием работает по принципу «разделяй и властвуй»: делит список пополам, пока не останутся одиночные элементы, затем сливает их обратно по порядку.
- Функция
merge(left, right)сливает два уже отсортированных списка через два указателя, сравнивая текущие элементы и забирая меньший; знак<=делает слияние стабильным. - После цикла в
merge()обязательно дописывается остаток того списка, что не разобрался до конца, черезextend(). - Функция
merge_sort(arr)рекурсивно делит список срезамиarr[:mid]иarr[mid:], базовый случай — список длиной 0 или 1. - Сложность сортировки слиянием — O(n log n) всегда, без худшего случая, в отличие от быстрой сортировки. Плата за это — O(n) дополнительной памяти под промежуточные списки.
Проверьте себя
8 вопросов
Функция слияния merge()
Напишите функцию merge(left, right). Она принимает два списка, left и right, каждый из которых уже отсортирован по возрастанию, и должна вернуть один отсортированный список из всех их элементов.
Например, merge([3, 27], [9, 10, 38]) должна вернуть [3, 9, 10, 27, 38].
Подсказка по приёму: заведите два указателя, i для left и j для right. Пока оба указателя внутри своих списков, сравнивайте текущие элементы и забирайте меньший. Когда один из списков закончится, допишите остаток другого целиком.