О чём лекция
Квадратичные сортировки на миллионе элементов работают часы. Чтобы сделать быстрее, нужны два инструмента. Рекурсия — функция, которая вызывает саму себя. Слияние — склеить два отсортированных массива в один за линейное время. Вместе они дают сортировку слиянием за . В конце — теорема: быстрее сортировка сравнением не бывает.
Как читать
Доказательства в блоках «Не из лекции» дописаны к конспекту: на лекции их не было. Всё остальное — с доски.
Маршрут
- Почему мало → Квадратичные сортировки
- Функция вызывает себя → Рекурсия
- Слияние и сортировка слиянием → Сортировка слиянием
- Быстрее нельзя → нижняя оценка сортировки
- Оценки и память → Асимптотические оценки, Время работы алгоритма
1. Зачем быстрее
BubbleSort, InsertionSort, SelectionSort — .
секунд — почти три часа.
2. Рекурсия
Рекурсия — вызов в функции самой себя.
Факториал
Вычислить факториал: .
Определение:
- : (аксиома);
- : .
Пример: .
функ. factorial(n)
если n = 0
return 1
return n * factorial(n − 1)Как раскрывается вызов:
.
Не из лекции: доказательство по индукции
База:
factorial(0)возвращает 1 = 0!.Переход: пусть
factorial(n − 1)возвращает . Тогдаfactorial(n)возвращает — ровно по определению.Рекурсивная функция повторяет рекурсивное определение, поэтому и доказывается она индукцией. Раскрытие на доске — та же индукция, выписанная целиком.
Не из лекции: время и память
Время. Вызовов : от
factorial(n)доfactorial(0). Каждый делает работы, не считая вложенного вызова: .Память. Каждый вызов ждёт, пока вернётся следующий, и всё это время хранит свои данные — хотя бы своё
n. В самый глубокий момент живы все вызовов сразу. Они лежат на стеке вызовов (call stack), и . Циклres = res * iпосчитал бы то же с .Стек небольшой. Проверено на C++ (GCC 16,
-O0, стек 8 МБ по умолчанию в Linux): рекурсия глубиной отрабатывает, глубиной — падает с Segmentation fault.
Восходящая и нисходящая рекурсия
| Восходящая | Нисходящая |
|---|---|
| аргумент увеличивается | аргумент уменьшается |
| пример: , → | пример: , → |
На рисунках обе — лестницы, упирающиеся в граничное условие: восходящая поднимается к нему снизу, как к потолку, нисходящая спускается к нему, как к полу.
Не из лекции: восходящий пример целиком
, . Раскрываем: . До границы нужно шагов, и . Например, .
Граничное условие должно быть достижимо. Для восходящей рекурсии это значит, что начать надо снизу: полезет в и до никогда не дойдёт. Так же нисходящий
factorial(−1)никогда не дойдёт до 0. Бесконечная рекурсия на практике кончается переполнением стека.
Подробнее — Рекурсия.
3. Функция Merge
Два отсортированных массива сливаем воедино, сохраняя отсортированность.
L R
A = [▧|▧|▧|▧| ──▶ ] B = [▧|▧| ──▶ ]
│ │ └─────────────┐ │
▼ ▼ ▼ ▼
C = [▧ ▧ ▧ ▧ ▧ ▧ | ]
idУказатель L бежит по A, R — по B, в C пишем меньший из A[L] и B[R].
- Вопрос:
C.size = A.size + B.size. - Вопрос:
id = L + R— позиция в C равна числу уже взятых элементов.
Алгоритм
функ. Merge(A, B, lenA, lenB, result)
L = R = 0
while (L < lenA and R < lenB)
если A[L] ≤ B[R] // ≤ — для устойчивости
result[L + R] = A[L]
L++
иначе
result[L + R] = B[R]
R++
while (L < lenA)
result[L + R] = A[L]
L++
while (R < lenB)
result[L + R] = B[R]
R++Примечание на полях к A[L] ≤ B[R]: «[A | B] — для устойчивости». A стоит
в исходном массиве левее B, поэтому при равенстве берём из A.
, . — A, B, result созданы не нами.
Не из лекции: доказательство правильности
Инвариант:
result[0], …,result[L+R−1]— это наименьших элементов A и B, по неубыванию, и каждый из них не большеA[L]иB[R].
- Вначале ,
resultпуст — верно.- Первый цикл: A и B отсортированы, поэтому меньший из
A[L]иB[R]— наименьший из ещё не взятых. Он не меньше всего, что уже вresult, и встаёт в конец — порядок сохранён.- Когда один массив кончился, остаток другого уже отсортирован и не меньше всего взятого. Два последних цикла переносят его как есть.
Время: каждая итерация любого из трёх циклов увеличивает или на 1. Всего итераций ровно : .
Не из лекции: почему
≤даёт устойчивостьВнутри A элементы берутся слева направо — их порядок сохраняется, внутри B тоже. Остаётся пара равных , : в исходном массиве левее. При
A[L] ≤ B[R]первым уходит — порядок сохранён. При<первым ушёл бы , и Merge стал бы неустойчивым.
4. Разговор про память
Обычно A и B — не отдельные массивы, а две соседние части одного:
left A ↗ M B ↗ right
arr = [ ... | ...................... | ............ | ... ]Две области:
int* A = arr + left // lenA = M − left
int* B = arr + M // lenB = right − M, .
На полях справа — маленький рисунок: отрезок с отметками 1 и 2. Смысл по фото не разобрать.
функ. Merge(arr, left, M, right, result)
// A → [left, M) B → [M, right) arr ← result
L = R = 0
A = arr + left
B = arr + M
while (L < lenA and R < lenB)
если A[L] ≤ B[R]
result[L + R] = A[L]
L++
иначе
result[L + R] = B[R]
R++
while (L < lenA)
result[L + R] = A[L]
L++
while (R < lenB)
result[L + R] = B[R]
R++
for i = 0 ... (L + R)
arr[i + left] = result[i]lenA и lenB в этой версии не вычисляются, но следуют из записи выше:
, . Последний цикл копирует результат
обратно в arr на место обеих частей.
Не из лекции: что такое
arr + leftВ C++ имя массива ведёт себя как адрес его первого элемента, а
arr + left— адрес элементаarr[left]. ПоэтомуA[L]— этоarr[left + L], аB[R]—arr[M + R]. A и B — не копии, а «окна» в тот же массив. Указатели в курсе C++ пока не разбирали.
Не из лекции: почему нужен отдельный
resultПисать сразу в
arrнельзя. Пустьarr = [3, 1], , . Первым берётсяB[0] = 1и пишется на позициюleft + 0— прямо поверхA[0] = 3, которую ещё не прочитали. Тройка потеряна.В общем случае запись идёт на позицию
left + L + R, а непрочитанная часть A — это . Пока A не кончился, взять элемент из B — значит записать его на позициюleft + L + Rleft + L, а при это непрочитанный элемент A. Уже первый же элемент из B () пишется ровно поверхA[L]. Отсюда буфер — и у сортировки слиянием.
5. Merge Sort: схема
[==============|===============] ← N ┐
[======|=======] [======|======] ← N/2 │
. . . . . . . . . . ← N/4 │ k разделений,
[] [] [] . . . . . . . . . [] ← N массивов ┘ N/2^k = 1, k = log₂N
↘↙ ↘↙ . . . ┐
[__] [__] . . . │ слияние
↘ ↙ │
[________] [________] ... │
[==============================] ┘Сначала делим пополам, пока не останутся массивы из одного элемента — таких N. Массив из одного элемента уже отсортирован. Потом сливаем соседние обратно.
Асимптотика: .
Не из лекции: откуда — аккуратно
Работа уровня. На одной глубине рекурсии части не пересекаются, их суммарная длина не больше N. Merge части длины стоит , значит, все слияния одного уровня вместе — .
Число уровней. Отрезок длины делится на части и . По индукции на глубине длина любой части не больше (используется ). При это 1 — деление закончилось. Уровней не больше , и N не обязано быть степенью двойки.
Вызовы. Дерево рекурсии — двоичное с N листьями, в нём вызовов, на каждый работы помимо Merge.
Итого . Слияние копирует все элементы на любом входе, поэтому это и — даже на отсортированном массиве.
6. Рекурсивная часть
функ. recursiveMergeSort(arr, left, right, buffer)
// надо отсортировать arr[left; right)
если right − left = 1
return
M = (right + left) / 2 // M = left + (right − left) / 2
recursiveMergeSort(arr, left, M, buffer)
recursiveMergeSort(arr, M, right, buffer)
Merge(arr, left, M, right, buffer)
returnMergeSort(arr, N)
buffer = [ ... ] // размера N
recursiveMergeSort(arr, 0, N, buffer)
delete bufferИтог: , , устойчивая — да.
Пустой массив
При вызов
recursiveMergeSort(arr, 0, 0, buffer): , , и функция снова вызывает себя с теми же — бесконечно. Проверено на C++: программа падает с Segmentation fault, переполнив стек. Граничное условие должно бытьесли right − left ≤ 1.
Не из лекции: доказательство правильности
Сильная индукция по длине .
- : один элемент уже отсортирован, функция ничего не делает.
- : , и . Обе половины непустые и короче — по предположению индукции рекурсивные вызовы их сортируют. Merge двух отсортированных частей даёт отсортированный отрезок. ∎
Непустота половин важна: если бы одна половина совпала со всем отрезком, рекурсия не продвинулась бы — как в случае выше.
Не из лекции: почему и зачем один
buffer
- Буфер на N элементов — .
- Стек: глубина рекурсии вызовов — .
Вместе . Буфер создаётся один раз в обёртке и передаётся во все вызовы: Merge пишет в
buffer[0 … right−left)и сразу копирует обратно, так что одному вызову он больше не нужен, и следующий может писать туда же. Создавать буфер в каждом Merge — те же памяти в моменте, но выделений памяти, а это медленно.
Не из лекции: устойчивость
Индукцией по длине: половины отсортированы устойчиво, все элементы левой половины в исходном массиве левее правой, а Merge с
≤при равенстве берёт левый. Прогон на 2000 случайных массивах с повторами подтверждает.
Не из лекции: детали для C++
- Середина
left + (right − left) / 2, как записано в комментарии на доске, а не(right + left) / 2: сумма может переполнитьint. Та же причина, что в бинарном поиске.delete bufferс доски: массив, созданный черезnew[], в C++ освобождают черезdelete[], а неdelete.
7. Теорема о сортировках сравнением
Теорема. Никакая сортировка сравнением не работает быстрее .
На доске доказательство начато и брошено: «Д… ☹».
Сравнение на :
Логарифм двоичный: .
Не из лекции: доказательство
Сортировка сравнением узнаёт о данных только результаты сравнений, и все её действия определяются этими результатами.
Запустим её на N разных элементах и нарисуем работу как двоичное дерево: узел — сравнение, две ветки — два исхода, лист — конец. Вход проходит путь от корня к листу.
Два входа с разным порядком элементов не могут прийти в один лист: тогда алгоритм получил бы одинаковые ответы на все сравнения и переставил бы оба входа одинаково, а одна перестановка не сортирует два разных порядка. Значит, листьев не меньше . У дерева высоты не больше листьев:
Среднее неравенство: в хотя бы множителей не меньше . Высота — число сравнений на худшем входе, и оно . ∎
Следствие: сортировка слиянием асимптотически оптимальна среди сортировок сравнением.
Теорема — о худшем случае и только о сортировках, которые сравнивают элементы. Алгоритмы, которые пользуются значениями иначе, под неё не попадают.
Подробнее — Сортировка слиянием, Сортировка.
Грабли
- Рекурсия без достижимого граничного условия — бесконечные вызовы и
падение по переполнению стека:
factorial(−1), восходящая при границе . - Глубокая рекурсия падает даже с правильной базой: стек ограничен. Факториал рекурсией — памяти, циклом — .
A[L] < B[R]вместо≤— Merge работает, но сортировка теряет устойчивость.- Слияние прямо в
arrбез буфера затирает непрочитанные элементы. right − left = 1как база — бесконечная рекурсия на пустом массиве. Нужно≤ 1.(right + left) / 2в C++ — переполнение на больших индексах.
После этой лекции должно получаться
- написать рекурсивный факториал и доказать его индукцией;
- объяснить, почему рекурсивный факториал занимает памяти;
- отличать восходящую рекурсию от нисходящей и проверять, что граничное условие достижимо;
- написать Merge, доказать его через инвариант и объяснить, при чём тут
≤; - объяснить, зачем Merge нужен буфер;
- написать сортировку слиянием и вывести по уровням;
- доказать, что сортировка сравнением не бывает быстрее .