K-я порядковая статистика за линейное время
Найти k-й по величине элемент, не сортируя массив. Тот же partition, но рекурсия идёт только в одну сторону.
4 мин
-я порядковая статистика — это элемент, который окажется на -м месте, если массив отсортировать по возрастанию.
Очевидное решение: отсортировать и взять по индексу, . Но сортировка отвечает на гораздо более трудный вопрос, чем задан. Нас интересует одно место, а мы наводим порядок везде — и за это платим.
Оказывается, можно за .
Идея
Возьмём случайный элемент и посчитаем, сколько в массиве элементов, не превосходящих его. Пусть их .
Если , то -й по возрастанию элемент не больше : маленьких элементов набралось достаточно, и искомый — среди них. Значит, искать надо только в левой половине.
Если , то элементов, не больших , слишком мало, и искомый лежит правее. Но номер придётся пересчитать: среди правой половины нам нужен уже не -й, а -й — первые элементов остались слева.
В обоих случаях половина массива отбрасывается насовсем.
Код
Разделение — то же самое partition, что и в быстрой сортировке.
// k-й по возрастанию (нумерация с единицы) в полуинтервале [l, r).
int kth_element(vector<int>& a, int l, int r, int k) {
if (r - l == 1) return a[l];
int pos = partition(a, l, r);
int cnt = pos - l; // столько ушло в левую половину
if (cnt >= k) return kth_element(a, l, pos, k);
return kth_element(a, pos, r, k - cnt);
}
Базовый случай — один элемент: если отрезок сузился до него, он и есть ответ.
Почему это линия
Разница с быстрой сортировкой — в одной строке: там два рекурсивных вызова, здесь один. И этого достаточно, чтобы сменилась асимптотика.
Возьмём идеальный случай: каждый раз делим пополам. Тогда работа складывается так:
Это сумма геометрической прогрессии, и она меньше — сколько бы слагаемых ни было. Не , а именно : уровней по-прежнему логарифм, но каждый следующий вдвое дешевле предыдущего, и всё вместе сходится.
Сравните с сортировкой: там на каждом уровне работа снова равна , потому что обрабатываются обе половины.
| Уровень | Сортировка: обе половины | Поиск -го: одна половина |
|---|---|---|
| 1 | ||
| 2 | ||
| 3 | ||
| … | … | … |
| Итого | меньше |
Со случайным опорным рассуждение то же, что и для быстрой сортировки: примерно каждый второй шаг уменьшает отрезок хотя бы в раза, сумма снова оказывается геометрической прогрессией и снова сходится к — просто с большей константой.
А без случайности можно?
Всё сказанное опирается на случайный выбор опорного: гарантии тут вероятностные. Существует и детерминированный алгоритм с честным в худшем случае — «медиана медиан».
Идея: разбить массив на группы по пять элементов, в каждой найти медиану (это на группу), затем рекурсивно найти медиану этих медиан и взять её опорным. Такой опорный гарантированно отсекает хотя бы массива, откуда и берётся линейность.
На практике им не пользуются: константа получается большой, и случайный опорный оказывается быстрее почти всегда. Знать про него стоит ради одного вывода — линейность здесь не «повезло со случайностью», а свойство самой задачи.
В C++ это уже написано
nth_element(a.begin(), a.begin() + k, a.end());
// теперь a[k] — тот элемент, который стоял бы там после сортировки
Функция ничего не возвращает, а переставляет массив: элемент с индексом k встаёт на своё место, слева от него оказываются все не большие, справа — все не меньшие. Внутри половин порядка нет, и он не нужен.
Работает она в среднем за линию — то есть заметно быстрее, чем sort, если вам действительно нужен один элемент, а не весь порядок.