6. Сортировки
Квадратичные сортировки
Существует множество различных алгоритмов, сортирующих массив длины $n$ за $\Theta\left(n^{2}\right)$. Мы поговорим лишь о двух из них.
Сортировка выбором
Сортировка выбором (selection sort) на $i$-м шаге находит $i$-й по возрастанию элемент и ставит его на $i$-ю позицию. Поскольку первые $i-1$ элементов в этот момент уже стоят на своих позициях, достаточно просто найти минимальный элемент в подотрезке $[i, n)$.
|
|
Сортировка вставками
На $i$-м шаге сортировки вставками (insertion sort) первые $i$ элементов массива (образующие префикс длины $i$ ) расположены в отсортированном порядке. $i$-й шаг состоит в том, что $i$-й элемент массива вставляется в нужную позицию остортированного префикса.
|
|
Время работы
В обеих сортировках два вложенных цикла в худшем случае дают время работы $\Theta\left(n^{2}\right)$. Сортировка выбором полезна тем, что делает $\Theta(n)$ операций swap. Это свойство пригождается, когда сортируются тяжёлые объекты, и каждая операция swap занимает много времени.
Инверсией называют такую пару элементов на позициях $i < j$, что $a_{i} > a_{j}$. Последовательность элементов является отсортированной тогда и только тогда, когда в ней нет инверсий. Время работы сортировки вставками можно оценить как $\Theta(n+\operatorname{Inv}(a))$, где $\operatorname{Inv}(a)$ - количество инверсий в массиве $a$, так как на каждом шаге внутреннего цикла количество инверсий в массиве уменьшается ровно на один. Значит, на отсортированном (или почти отсортированном) массиве время работы сортировки вставками составит $O(n)$.
Стабильность
Сортировка называется стабильной, если она оставляет равные элементы в исходном порядке. Обычно такое свойство сортировки нужно, когда помимо данных, по которым производится сортировка, в элементах массива хранятся какие-то дополнительные данные. Например, если дан список участников соревнований в алфавитном порядке, и хочется отсортировать их по убыванию набранных баллов так, чтобы участники с равным числом баллов всё ещё шли в алфавитном порядке.
Сортировка выбором стабильной не является (например, массив $[5,5,3,1]$ после первого шага изменится на $[1,5,3,5]$, при этом порядок пятёрок поменялся). Сортировка вставками стабильна, так как меняет местами только соседние элементы, образующие инверсию, поэтому ни в какой момент времени не поменяет порядок равных элементов.
✍️ Любую сортировку можно сделать стабильной, если вместо исходных элементов сортировать пары (элемент, его номер в исходном массиве), и при сравнении пар сначала сравнивать элементы, а при равенстве номера.
Применение на практике
Сейчас мы перейдём в алгоритмам сортировки, работающим за $\Theta(n \log n)$. Квадратичные сортировки, благодаря малой константе в оценке времени работы, работают быстрее более сложных алгоритмов на коротких массивах. На практике часто применяется гибридный подход: алгоритм сортировки, использующий метод “разделяй и властвуй”, работает, пока массив не поделится на части достаточно малого размера, после чего на них запускается алгоритм квадратичной сортировки.
Сортировка слиянием (Merge sort)
Сортировка слиянием (Von Neumann, 1945) использует метод “разделяй и властвуй” следующим образом: массив делится на две части, каждая из них сортируется рекурсивно, после чего две отсортированных части сливаются при помощи метода двух указателей.
|
|
Время работы сортировки слиянием можно оценить с помощью рекуррентного соотношения $T(n)=2 \cdot T\left(\frac{n}{2}\right)+\Theta(n)$. По основной теореме о рекуррентных соотношениях получаем $T(n)=\Theta(n \log n)$.
Сортировка слиянием стабильна (поскольку функция merge не меняет относительный порядок равных элементов).
Недостатком сортировки слиянием является то, что она требует $\Theta(n)$ дополнительной памяти (вспомогательный массив в merge).
Быстрая сортировка (Quicksort)
Быстрая сортировка (Hoare, 1959) также использует метод “разделяй и властвуй”, но немного по-другому. Возьмём какой-нибудь элемент массива - $x$. Поделим массив на три части так, что в первой все элементы меньше $x$, во второй равны $x$, в третьей больше $x$ (это можно сделать за линейное от длины массива время, например, с помощью трёх вспомогательных массивов). Остаётся рекурсивно отсортировать первую и третью части.
|
|
На практике, чтобы алгоритм работал быстрее и использовал меньше дополнительной памяти, используют более хитрый способ деления массива на части:
|
|
Свойства
Функция partition работает корректно, то есть $i$ и $j$ не выходят за границы $l, r$, функция возвращает такое $m$, что $l \leqslant m < r$, все элементы $a[l, m]$ меньше или равны $x$, все элементы $a[m+1, r]$ больше или равны $x$.
Заметим, что такая реализация является нестабильной сортировкой.
Оценка времени работы
В худшем случае массив каждый раз будет делиться очень неравномерно, и почти все элементы будут попадать в одну из частей. Время работы в худшем случае можно оценить с помощью рекуррентного соотношения $T(n)=T(n-1)+\Theta(n)$, раскрыв которое, получаем $T(n)=\sum_{i=1}^{n} \Theta(i)=\Theta\left(n^{2}\right)$.
В лучшем же случае массив каждый раз будет делиться на две примерно равные части. Получаем рекуррентное соотношение $T(n)=2 \cdot T\left(\frac{n}{2}\right)+\Theta(n)$, тогда по основной теореме о рекуррентных соотношениях получаем $T(n)=\Theta(n \log n)$.
Оказывается, время работы алгоритма в среднем намного ближе к лучшему случаю, чем к худшему. Для того, чтобы это доказать, нам понадобится терминология из теории вероятностей.
Математическое ожидание случайной величины $X$, принимающей значение $x_{1}$ с вероятностью $p_{1}, x_{2}$ с вероятностью $p_{2}, \ldots, x_{n}$ с вероятностью $p_{n}$ $\left(p_{1}+\cdots+p_{n}=1\right)$ есть
Определение
Теорема
Математическое ожидание времени работы алгоритма быстрой сортировки есть $O(n \log n)$.
✍️ Отсюда следует и более сильное утверждение: время работы алгоритма есть $O(n \log n)$ с вероятностью, близкой в единице. Это следует из следующего утверждения, известного как неравенство Маркова: для неотрицательной случайной величины $X$ с математическим ожиданием $\mathbb{E}(X)$ вероятность того, что $X>k \cdot \mathbb{E}(X)$, не превосходит $\frac{1}{k}$. Это верно, так как в противном случае математическое ожидание $X$ оказалось бы больше $\frac{1}{k} \cdot k \cdot \mathbb{E}(X)=\mathbb{E}(X)$. В нашем случае, например, для $k=100$ получаем, что с вероятностью $99 \%$ время работы не превосходит $O(100 \cdot n \log n)=O(n \log n)$.
Взятие случайного элемента - достаточно медленная операция, поэтому на практике вместо случайного элемента часто берут какой-то конкретный, например самый левый, самый правый, или средний; также часто используют средний по значению из этих трёх. Для подобных версий алгоритма можно построить массив, на котором сортировка будет работать за $\Theta\left(n^{2}\right)$. С этим борются разными способами, например, когда глубина рекурсии превышает $\log n$, переключаются на какой-нибудь другой алгоритм сортировки.
На практике алгоритм быстрой сортировки оказывается одним из самых быстрых и часто используемых. Встроенная в C++ сортировка - std::sort, использует алгоритм Introsort, который начинает сортировать массив алгоритмом быстрой сортировки, на большой глубине рекурсии переключается на heapsort (который мы скоро изучим), а массивы совсем небольшой длины сортирует сортировкой вставками.
Поиск $k$-й порядковой статистики
$k$-я порядковая статистика на массиве из $n$ элементов - это $k$-й по возрастанию элемент. Например, при $k=1$ это минимум, при $k=n$ - максимум. Медиана - это элемент, который оказался бы в середине массива, если бы его отсортировали. Если длина массива чётна, то в нём есть две медианы на позициях $\left\lfloor\frac{n+1}{2}\right\rfloor$ и $\left\lceil\frac{n+1}{2}\right\rceil$. Для определённости под медианой будем иметь в виду $\left\lfloor\frac{n+1}{2}\right\rfloor$-ю порядковую статистику.
Минимум и максимум в массиве очень легко ищется за $O(n)$ одним проходом по всем элементам массива. $k$-й по возрастанию элемент так просто уже не найти.
Можно отсортировать массив за $O(n \log n)$, тогда $k$-я порядковая статистика окажется на $k$-й позиции (если нумеровать элементы массива, начиная с единицы). Однако существуют и более быстрые алгоритмы, находящие $k$-ю порядковую статистику для произвольного $k$ за $O(n)$.
Вернёмся к алгоритму быстрой сортировки. Когда мы поделили массив на две части, можно понять, в какой из этих частей находится $k$-й по возрастанию элемент: если размер левой части хотя бы $k$, то он находится в ней, иначе он находится в правой части. Тогда, если нас интересует не весь отсортированный массив, а только $k$-й по возрастанию элемент, можно сделать рекурсивный запуск только от той части, в которой он лежит.
|
|
В худшем случае такой алгоритм будет работать по-прежнему за $\Theta\left(n^{2}\right)$. Однако оказывается, что оценка среднего времени работы после такой оптимизации улучшается с $O(n \log n)$ до $O(n)$.
Теорема
Математическое ожидание времени работы алгоритма kthElement есть $O(n)$.
В C++ есть встроенная реализация этого алгоритма - std::nth_element.
✍️ Алгоритм можно модифицировать так, чтобы он работал за $O(n)$ в худшем случае. Это делается так: поделим массив на $n / 5$ групп по 5 элементов, в каждой за $O(1)$ найдём медиану. Теперь рекурсивным запуском алгоритма найдём медиану среди этих медиан, и уже её будем использовать в качестве разделителя. Тогда в половине групп хотя бы 3 элемента окажутся меньше разделителя, а в другой половине хотя бы 3 элемента окажутся больше разделителя. Значит каждая из частей, на которые поделился массив, будет иметь размер хотя бы $3 n / 10$. Получаем в худшем случае рекуррентное соотношение $T(n)=\Theta(n)+T(n / 5)+T(7 n / 10)$. Можно показать, что в этом случае верно $T(n)=\Theta(n)$.
Оценка снизу на время работы сортировки сравнениями
Алгоритм сортировки сравнениями может копировать сортируемые объекты и сравнивать их друг с другом, но никак не использует внутреннюю структуру объектов. Все сортировки, изученные нами до этого момента, являются сортировками сравнения. Можно показать, что никакая сортировка сравнениями не может в общем случае работать быстрее, чем за $\Theta(n \log n)$.
Теорема
Любой алгоритм сортировки сравнениями имеет время работы $\Omega(n \log n)$ в худшем случае.
Тем не менее, если обладать какой-то дополнительной информацией о свойствах сортируемых объектов, иногда можно воспользоваться этими свойствами, чтобы отсортировать объекты быстрее, чем за $\Theta(n \log n)$.
Сортировка подсчётом
Если известно, что все числа во входном массиве целые, неотрицательные и меньше некоторого $k$, то их можно отсортировать за $\Theta(n+k)$ (при этом понадобится $\Theta(k)$ вспомогательной памяти). Для этого посчитаем, сколько раз встретилось каждое число от 1 до $k$, после чего просто выпишем каждое число в ответ столько раз, сколько он встречалось в исходном массиве.
|
|
Ясно, что таким же образом можно сортировать целые числа, лежащие в диапазоне $[L, R)$, за $\Theta(n+(R-L))$.
Если воспользоваться ещё одним вспомогательным массивом, сортировку можно сделать стабильной:
|
|
Стабильная сортировка подсчётом пригодится нам в следующем алгоритме сортировки.
Поразрядная сортировка (Radix sort)
Пусть дан массив из $n$ чисел, записанных в $k$-ичной системе счисления и имеющих не более $d$ разрядов каждое. Отсортируем числа сортировкой подсчётом $d$ раз - сначала по младшему разряду, потом по следующему, и так далее, в конце - по старшему разряду. При этом будем пользоваться стабильной версией сортировки подсчётом.
После первого шага числа будут отсортированы по 0-му разряду, после второго по 1-му разряду, а при равенстве цифр в 1-м разряде - по 0-му. В конце числа будут отсортированы по $(d-1)$-му разряду, при равенстве цифр в $(d-1)$-м разряде - по ( $d-2)$-му,…, при равенстве цифр во всех разрядах, кроме 0-го - по 0-му. Значит числа просто окажутся отсортированы в порядке возрастания.
|
|
Каждый шаг алгоритма работает за $\Theta(n+k)$, тогда время работы всего алгоритма $\Theta(d(n+k))$. Поразрядная сортировка является стабильной.
С помощью поразрядной сортировки можно сортировать любые объекты, которые можно лексикографически упорядочить. Так, можно лексикографически отсортировать $n$ строк длины $d$ каждая, в записи которых используется $k$ различных символов, за $\Theta(d(n+k))$.
Пусть нам даны $n$ неотрицательных целых чисел, меньших $m$. Если мы переведём их в $k$-ичную систему счисления, то сможем отсортировать их поразрядной сортировкой за $\Theta\left(\left(1+\log _{k} m\right)(n+k)\right)$ (используя $\Theta\left(n\left(1+\log _{k} m\right)+k\right)$ дополнительной памяти). При $n=k$ получаем время работы $\left.\Theta\left(n+n \log _{n} m\right)\right)=\Theta\left(n+n \frac{\log m}{\log n}\right)$.