Семинар. Разделяй и властвуй: merge sort и quick sort
Семинар 5. Разделяй и властвуй: merge sort и quick sort
Лекция 5 дала слияние, сортировку слиянием, разбиения Ломуто и Хоара, худший случай и случайный опорный. Здесь вы пишете это руками: сначала слияние, потом сортировку слиянием, потом разбиение и быструю сортировку.
Встроенную сортировку сегодня не используем как решение. sorted() и a.sort() допустимы только как эталон проверки: с ними сравниваем свой результат. Это условие пары, а не ограничение тестера.
Ноутбук — тетрадь пары. Его семинаристу не отправляйте. Зачёт пары — прорешанность задач контеста на algo.ai.msu.ru.
Заглушки ничего не сортируют и возвращают неверный ответ — так и задумано, их надо переписать.
Как устроен ноутбук
Как на семинарах 1–4: ячейки Markdown и Code; командный режим — Esc, правка — Enter; Shift+Enter выполняет ячейку.
Скачайте с сайта курса этот файл .ipynb. Отдельного файла тестов на этой неделе нет: проверки встроены в ноутбук, зачёт — контест на платформе.
Ячейки с комментарием «Ваш эксперимент» никто автоматически не проверяет.
Порядок работы
Номера в заголовках разделов соответствуют этому списку:
- инструменты проверки:
is_sorted,check_sort,check_pairs_sort,check_partition; - 1. слияние двух упорядоченных массивов;
- 2. сортировка слиянием и устойчивость по ключу;
- 3. разбиение Ломуто и проверка его контракта;
- 4. быстрая сортировка: границы рекурсии, глубина на плохом входе, случайный опорный;
- 5. почему quick sort неустойчив — и чем контракт Хоара отличается от Ломуто;
- 6. трёхчастное разбиение (нужно для задачи № 6);
quickselectи подсчёт инверсий — по желанию; - контест на algo.ai.msu.ru.
Разделы 1–5 — основная часть пары. В разделе 6 трёхчастное разбиение нужно для обязательной задачи № 6, остальное — по желанию.
Инструменты проверки
Эти функции уже написаны, их менять не нужно. check_sort прогоняет вашу сортировку на случайных массивах и печатает первый вход, на котором она ошиблась.
Здесь sorted() стоит внутри проверки, а не внутри решения: сравнивать свой ответ с эталоном — нормальная практика, сдавать эталон вместо решения — нет.
import random
def is_sorted(a):
"""Массив неубывает?"""
return all(a[i] <= a[i + 1] for i in range(len(a) - 1))
def check_sort(sort_fn, in_place=False, trials=200, seed=0):
"""Проверяет сортировку на случайных массивах; sorted() — только эталон.
seed фиксирован, поэтому неудача воспроизводится: набор входов один и тот же.
"""
rng = random.Random(seed)
for _ in range(trials):
a = [rng.randint(-5, 5) for _ in range(rng.randint(0, 12))]
expected = sorted(a)
if in_place:
got = a[:]
sort_fn(got)
else:
got = sort_fn(a[:])
if got != expected:
print("не сходится на", a)
print(" получено: ", got)
print(" ожидается:", expected)
return False
print(f"ok: {trials} случайных массивов отсортированы верно")
return True
def check_pairs_sort(sort_pairs_fn, trials=200, seed=0):
"""Сортировка пар по ключу и её устойчивость.
Эталон — sorted с ключом x[0]: встроенная сортировка устойчива, поэтому
пары с равными ключами обязаны остаться в порядке входа.
"""
rng = random.Random(seed)
for _ in range(trials):
keys = [rng.randint(0, 3) for _ in range(rng.randint(0, 10))]
payload = list(range(len(keys)))
rng.shuffle(payload)
pairs = list(zip(keys, payload))
expected = sorted(pairs, key=lambda x: x[0])
got = sort_pairs_fn(pairs[:])
if got != expected:
print("не сходится на", pairs)
print(" получено: ", got)
print(" ожидается:", expected)
print(" подсказка: сравнивайте ключ x[0], а не пару целиком")
return False
print(f"ok: {trials} наборов пар отсортированы устойчиво по ключу")
return True
print("инструменты готовы")
1. Слияние двух упорядоченных массивов
Два уже неубывающих массива left и right. Держим два указателя на первые ещё не взятые элементы. Меньшую голову переносим в результат и сдвигаем её указатель. Когда один массив кончился, дописываем остаток другого.
Инвариант. Результат неубывает, и каждый ещё не взятый элемент не меньше последнего выданного. Минимум из оставшихся всегда лежит на одной из двух голов — поэтому смотреть дальше голов не нужно.
Равные ключи. При равенстве берите из левого массива: left[i] <= right[j]. На этом стоит устойчивость сортировки слиянием.
Время — линейное от суммы длин. Обратите внимание на пустые массивы: отдельной ветки для них быть не должно, цикл просто не сделает ни шага.
def merge(left, right):
"""Слияние двух неубывающих массивов в новый неубывающий массив."""
return []
print(merge([1, 4, 7], [2, 4, 8])) # ожидается [1, 2, 4, 4, 7, 8]
print(merge([], [1, 2])) # ожидается [1, 2]
print(merge([3], [])) # ожидается [3]
print(merge([1, 2, 3], [4, 5])) # ожидается [1, 2, 3, 4, 5]
# Ваш эксперимент: сколько сравнений делает слияние в лучшем и худшем случае?
# Добавьте счётчик и проверьте на [1, 2, 3] + [4, 5, 6] и на [1, 3, 5] + [2, 4, 6].
2. Сортировка слиянием
Рекурсивная схема ровно с лекции:
- база — ноль или один элемент, такой массив уже упорядочен;
- шаг — разрезать пополам, отсортировать половины тем же способом, слить.
[5 1 4 2 | 8 3 7 6]
/ \
[5 1 4 2] [8 3 7 6]
/ \ / \
[5 1] [4 2] [8 3] [7 6]
Сначала полностью завершается левый вызов, затем правый, и только потом слияние. На каждом уровне суммарно линейная работа, уровней $\Theta(\log n)$, итого $\Theta(n\log n)$ — всегда, в том числе на уже отсортированном входе.
Версия ниже возвращает новый список и не портит вход. Срезы a[:m] и a[m:] копируют данные: это те самые $\Theta(n)$ дополнительной памяти.
def merge_sort(a):
"""Новый неубывающий список из элементов a; вход не меняется."""
return []
print(merge_sort([5, 1, 4, 2, 8, 3, 7, 6])) # ожидается [1, 2, 3, 4, 5, 6, 7, 8]
print(merge_sort([]), merge_sort([7])) # ожидается [] [7]
original = [3, 1, 2]
merge_sort(original)
print(original) # ожидается [3, 1, 2] — вход не испорчен
check_sort(merge_sort)
Устойчивость по ключу
Теперь сортируем пары (ключ, нагрузка) только по ключу. Устойчивость означает: пары с равными ключами остались в том же относительном порядке, что во входе.
Сравнивайте именно ключ, x[0], а не пару целиком. Если сравнить пары целиком, порядок равных ключей задаст нагрузка — это уже другая задача.
def merge_sort_pairs(pairs):
"""Сортировка пар (ключ, нагрузка) по ключу; при равных ключах порядок как во входе."""
return []
print(merge_sort_pairs([(2, "a"), (1, "c"), (2, "b")]))
# ожидается [(1, 'c'), (2, 'a'), (2, 'b')] — 'a' раньше 'b', как во входе
print(merge_sort_pairs([(5, "x"), (5, "y"), (5, "z")]))
# ожидается [(5, 'x'), (5, 'y'), (5, 'z')] — порядок не меняется
print(merge_sort_pairs([(2, "b"), (1, "c"), (2, "a")]))
# ожидается [(1, 'c'), (2, 'b'), (2, 'a')]
# если вышло [(1, 'c'), (2, 'a'), (2, 'b')] — пары сравниваются целиком, а не по ключу
check_pairs_sort(merge_sort_pairs)
3. Разбиение Ломуто
Работаем с полуинтервалом [l, r) внутри массива, на месте. Опорный элемент — последний в диапазоне: pivot = a[r-1].
Перед обработкой позиции j держим четыре зоны:
| Зона | Что там |
|---|---|
[l, i) | элементы < pivot |
[i, j) | элементы >= pivot |
[j, r-1) | ещё не просмотрены |
a[r-1] | сам опорный |
Шаг: если a[j] < pivot, меняем a[i] и a[j] и увеличиваем i. В конце меняем a[i] и a[r-1] — опорный встаёт на окончательную позицию p = i, которую возвращаем.
Сравнение строгое <. Разбиение — это ещё не сортировка: обе части могут остаться неупорядоченными.
Пример с лекции: [4, 1, 6, 3, 2, 5] превращается в [4, 1, 3, 2, 5, 6], p = 4.
def partition_lomuto(a, l, r):
"""Разбивает a[l:r] по опорному a[r-1]; возвращает окончательную позицию опорного."""
return -1
a = [4, 1, 6, 3, 2, 5]
p = partition_lomuto(a, 0, len(a))
print(a, "p =", p) # ожидается [4, 1, 3, 2, 5, 6] p = 4
Проверка контракта
Своими глазами один пример — мало. Эта проверка написана за вас: она берёт случайные массивы, вызывает ваше разбиение и убеждается, что слева от опорного все элементы строго меньше, справа — не меньше, а сам набор элементов не изменился.
def check_partition(partition_fn, trials=200, seed=0):
"""Проверяет контракт разбиения на случайных массивах.
seed фиксирован, поэтому неудача воспроизводится.
"""
rng = random.Random(seed)
for _ in range(trials):
a = [rng.randint(-5, 5) for _ in range(rng.randint(1, 12))]
b = a[:]
p = partition_fn(b, 0, len(b))
if not isinstance(p, int) or not 0 <= p < len(b):
print("позиция вне диапазона:", a, "->", b, "p =", p)
return False
pivot = b[p]
if sorted(b) != sorted(a):
print("элементы потеряны:", a, "->", b)
return False
if any(x >= pivot for x in b[:p]) or any(x < pivot for x in b[p + 1:]):
print("контракт нарушен:", a, "->", b, "p =", p)
return False
print(f"ok: контракт держится на {trials} массивах")
return True
check_partition(partition_lomuto)
4. Быстрая сортировка
Опорный уже стоит на своём месте, поэтому его позиция в рекурсивные вызовы не входит:
def quick_sort(a, l, r):
if r - l <= 1:
return
p = partition_lomuto(a, l, r)
quick_sort(a, l, p)
quick_sort(a, p + 1, r)
Диапазоны [l, p) и [p+1, r) строго короче родительского даже при крайнем p — поэтому рекурсия заканчивается.
Параметр depth и переменная max_depth нужны только для замера: дальше мы посмотрим, как глубина зависит от входа. Сортировка идёт на месте, функция ничего не возвращает.
max_depth = 0
def quick_sort(a, l, r, depth=0):
"""Сортирует a[l:r] на месте разбиением Ломуто; depth — только для замера глубины."""
global max_depth
max_depth = max(max_depth, depth)
return None
b = [4, 1, 6, 3, 2, 5]
quick_sort(b, 0, len(b))
print(b) # ожидается [1, 2, 3, 4, 5, 6]
check_sort(lambda x: quick_sort(x, 0, len(x)), in_place=True)
Плохой вход и глубина рекурсии
У Ломуто с последним опорным элементом уже отсортированный массив различных чисел даёт вырожденное дерево: каждое разбиение отделяет один элемент, глубина растёт как $n$, а работа — как $n^2$.
Ячейка ниже сравнивает случайный вход и отсортированный на $n = 500$. Ожидаемый порядок величин: у случайного глубина — десятки, у отсортированного — около 500.
Отсюда практическое следствие: на $n$ в несколько тысяч такой вход упрётся в лимит глубины рекурсии Python и вы получите RecursionError вместо результата.
n = 500
inputs = {
"случайный": [random.randint(0, 10 ** 6) for _ in range(n)],
"уже отсортированный": list(range(n)),
}
for name, data in inputs.items():
max_depth = 0
b = data[:]
quick_sort(b, 0, len(b))
print(f"{name}: глубина {max_depth}, отсортирован: {is_sorted(b)}")
# Ваш эксперимент: добавьте вход list(range(n, 0, -1)) — массив, упорядоченный по убыванию.
# Предскажите глубину до запуска.
Случайный опорный элемент
Лечится не сам худший случай, а его привязка к конкретному входу: перед разбиением выбираем случайный индекс в [l, r) и меняем этот элемент с последним. Дальше — то же разбиение Ломуто.
Для различных ключей ожидаемое время становится $\Theta(n\log n)$ на любом фиксированном входе, но худший случай остаётся квадратичным: он просто перестаёт достигаться отсортированным массивом.
На массиве из одинаковых значений это не помогает: выбранный вариант Ломуто всё равно отделяет по одному элементу. Проверьте сами.
n = 500 # тот же размер, что выше, но задан локально: ячейку можно запускать отдельно
def quick_sort_random(a, l, r, depth=0):
"""То же, но опорный выбирается случайно и переносится в конец перед разбиением."""
global max_depth
max_depth = max(max_depth, depth)
return None
check_sort(lambda x: quick_sort_random(x, 0, len(x)), in_place=True)
for name, data in (("уже отсортированный", list(range(n))),
("все элементы равны", [7] * n)):
max_depth = 0
b = data[:]
quick_sort_random(b, 0, len(b))
print(f"{name}: глубина {max_depth}, отсортирован: {is_sorted(b)}")
# Ожидание: на отсортированном глубина падает до десятков,
# на массиве из равных значений остаётся около n.
5. Почему quick sort неустойчив
Слияние можно заставить брать при равенстве левый элемент — и сортировка слиянием становится устойчивой. У разбиения такого рычага нет: оно переставляет элементы обменами через весь диапазон.
Контрпример с лекции: пары (2, 'a'), (2, 'b'), (1, 'c'). Одно разбиение Ломуто уже переставляет 'a' и 'b' местами.
# partition_lomuto сравнивает элементы целиком. Здесь все сравнения идут между
# разными ключами (2 и 1), поэтому результат тот же, что при сравнении только ключа.
pairs = [(2, "a"), (2, "b"), (1, "c")]
p = partition_lomuto(pairs, 0, len(pairs))
print(pairs, "p =", p)
# ожидается [(1, 'c'), (2, 'b'), (2, 'a')] p = 0
# ключи упорядочены верно, но 'b' оказалась раньше 'a' — устойчивость сломана
6. По желанию и для контеста
Три упражнения. Трёхчастное разбиение нужно для обязательной задачи № 6 (75. Sort Colors) — идею разберём у доски, код пишете сами. quickselect (№ 7) и подсчёт инверсий (№ 9) — по желанию, для зачёта не требуются.
Трёхчастное разбиение — нужно для № 6
Лекция упоминала проблему одинаковых ключей: Ломуто на массиве равных значений вырождается. Лечение — разбить сразу на три части: < pivot, = pivot, > pivot. Тогда все равные опорному уходят из рекурсии за один проход.
Держим три границы и идём по массиву один раз: lt — конец зоны меньших, i — текущая позиция, gt — начало зоны больших. Функция возвращает пару (lt, gt): равные опорному занимают полуинтервал [lt, gt).
def partition_three(a, pivot):
"""Переставляет a на месте: сначала < pivot, потом = pivot, потом > pivot.
Возвращает (lt, gt): равные опорному лежат в полуинтервале [lt, gt).
"""
return (-1, -1)
b = [2, 0, 2, 1, 1, 0]
print(partition_three(b, 1), b) # ожидается (2, 4) [0, 0, 1, 1, 2, 2]
★ Порядковая статистика без полной сортировки
Разбиение даёт больше, чем сортировку. После него опорный стоит на окончательной позиции p. Если нужен элемент, который в упорядоченном массиве стоял бы на позиции k, то:
p == k— это и есть ответ;p > k— ответ слева, продолжаем в[l, p);p < k— ответ справа, продолжаем в[p+1, r).
Рекурсивный вызов нужен только один — как в бинарном поиске. Поэтому в среднем работа линейная, а не $n\log n$: полностью сортировать массив не требуется.
def kth_smallest(a, k):
"""Элемент, который стоял бы на позиции k (от нуля) в упорядоченном a; вход не менять."""
return -1
data = [3, 2, 1, 5, 6, 4]
print([kth_smallest(data, k) for k in range(len(data))]) # ожидается [1, 2, 3, 4, 5, 6]
print(data) # ожидается [3, 2, 1, 5, 6, 4] — вход не испорчен
# В задаче № 7 (215) просят k-й наибольший, и k там с единицы:
# ответ — kth_smallest(a, len(a) - k).
★ Слияние считает больше, чем сортирует
Инверсия — пара индексов $i < j$, для которой $a[i] > a[j]$. Число инверсий показывает, насколько массив далёк от упорядоченного: у отсортированного их ноль, у развёрнутого — максимум.
Двойным циклом это $\Theta(n^2)$. Но слияние уже знает ответ: когда вы берёте элемент из правой половины, он меньше всех оставшихся в левой — а значит, образует инверсию с каждым из них сразу. Добавьте к счётчику число оставшихся в левой половине.
Инверсии внутри половин считают рекурсивные вызовы, а слияние добавляет только те пары, у которых один элемент слева, другой справа.
def count_inversions(a):
"""Число пар i < j с a[i] > a[j]; вход не менять."""
return -1
print(count_inversions([2, 4, 1, 3, 5])) # ожидается 3
print(count_inversions([1, 2, 3])) # ожидается 0
print(count_inversions([3, 2, 1])) # ожидается 3
print(count_inversions([])) # ожидается 0
Контест
Задачи сдавайте в контест недели 5 на algo.ai.msu.ru. Номера LeetCode помогают найти условие.
Для зачёта семинара обязательны № 5 (912. Sort an Array) и № 6 (75. Sort Colors). Разминка и задачи по желанию их не заменяют.
| № | Задача | Статус на семинаре | Что из ноутбука |
|---|---|---|---|
| 1 | 88. Merge Sorted Array | Разминка | merge; здесь его просят сделать на месте, с конца |
| 2 | 977. Squares of a Sorted Array | Разминка | то же слияние, только две монотонные части нужно сначала увидеть |
| 3 | 283. Move Zeroes | Разминка | цикл Ломуто: указатель записи и указатель просмотра |
| 4 | 905. Sort Array By Parity | Разминка | разбиение по предикату; сравните схему Ломуто и встречные указатели |
| 5 | 912. Sort an Array | Обязательная | merge_sort — рабочий вариант; quick_sort годится на различных ключах |
| 6 | 75. Sort Colors | Обязательная | partition_three за один проход |
| 7 | 215. Kth Largest Element in an Array | По желанию | kth_smallest; сортировка проходит тесты, но на паре нужен quickselect |
| 8 | 56. Merge Intervals | По желанию | сортировка как подготовка к линейному проходу |
| 9 | 493. Reverse Pairs ★ | По желанию | count_inversions; условие считает пары с $a[i] > 2\cdot a[j]$ |
Порядок работы. Разминку № 1–4 закрывайте быстро, они на один приём каждая. Главная задача пары — № 5: в ней встроенную сортировку использовать нельзя по условию, и именно там пригодится случайный опорный. Дальше № 6. Остальное — по желанию и дома.
Типичный WA: при равенстве в слиянии берут из правой половины и теряют устойчивость; в Ломуто сравнивают нестрого; опорный оставляют в одном из рекурсивных диапазонов; в трёхчастном разбиении сдвигают i после обмена с зоной больших.
Типичный TLE и RecursionError: quick sort с последним опорным на отсортированном входе; полная сортировка там, где нужна одна порядковая статистика; пересборка списков срезами внутри цикла.
Про встроенную сортировку. В № 5 (912) она запрещена условием самой задачи. В № 8 (56) сортировка — только подготовительный шаг: там sorted() допустим и решением не считается. В проверках ноутбука sorted() — эталон.
Про № 5 и глубину рекурсии. В 912 длина массива до 5·10⁴. Уже отсортированный вход с последним опорным даёт вырожденное дерево и RecursionError (лимит Python около 1000). Рабочий рецепт — merge sort: $\Theta(n\log n)$ на любом входе и глубина $\Theta(\log n)$. Поднимать sys.setrecursionlimit под Ломуто не стоит: на различных ключах случайный опорный и так даёт глубину порядка логарифма, а на одинаковых ключах Ломуто по-прежнему отделяет один элемент — глубина около $n$ и время $\Theta(n^2)$, так что вместо RecursionError будет превышение по времени.
Шпаргалка
Слияние и merge sort:
- смотрим только две головы; при равенстве берём слева — это устойчивость;
- база рекурсии — ноль или один элемент;
- $\Theta(n\log n)$ всегда, в том числе на отсортированном входе;
- пиковая дополнительная память $\Theta(n)$, глубина $\Theta(\log n)$.
Разбиение и quick sort:
- Ломуто: опорный
a[r-1], зоны[l, i)и[i, j), строгое<, в конце опорный на позицииp; - позиция
pв рекурсивные вызовы не входит:[l, p)и[p+1, r); - отсортированный вход + последний опорный = вырожденное дерево, $\Theta(n^2)$ и глубина $n$;
- случайный опорный даёт ожидаемое $\Theta(n\log n)$ при различных ключах, но не гарантию в худшем случае;
- одинаковые ключи лечит трёхчастное разбиение, а не рандомизация;
- разбиение на месте — это $O(1)$ памяти у разбиения, но не у всей сортировки: остаётся стек вызовов;
- обычные разбиения неустойчивы.
Контракт Хоара (спросят на коллоквиуме):
- Ломуто возвращает окончательную позицию опорного
p: рекурсия на[l, p)и[p+1, r), опорный в вызовы не входит; - Хоар возвращает границу
b, причёмl < b < r: опорный не обязан стоять на месте, рекурсия на[l, b)и[b, r), обе части непусты.
Контест: № 5 (912) — до 5·10⁴ элементов. Рабочий рецепт — merge sort: $\Theta(n\log n)$ и глубина $\Theta(\log n)$; поднимать лимит рекурсии под Ломуто бессмысленно — на одинаковых ключах он даёт глубину около $n$ и время $\Theta(n^2)$.
Сдача
- Этот
.ipynbсеминаристу отправлять не нужно. - Для зачёта выполните № 5 — 912. Sort an Array и № 6 — 75. Sort Colors в контесте на algo.ai.msu.ru.
- Разминка № 1–4 и задачи № 7–9 выполняются по желанию, для зачёта не требуются.
- Если обязательные задачи не закончены на паре, дорешайте их в том же контесте после занятия.
- На неделе 6 — коллоквиум по лекциям 1–5. Слияние, merge sort, разбиение Ломуто и Хоара, худший случай и память входят в программу; спросят и трассировку на маленьком массиве.