EduBrick

K-я порядковая статистика за линейное время

Найти k-й по величине элемент, не сортируя массив. Тот же partition, но рекурсия идёт только в одну сторону.

4 мин

kk-я порядковая статистика — это элемент, который окажется на kk-м месте, если массив отсортировать по возрастанию.

Очевидное решение: отсортировать и взять по индексу, O(nlogn)O(n \log n). Но сортировка отвечает на гораздо более трудный вопрос, чем задан. Нас интересует одно место, а мы наводим порядок везде — и за это платим.

Оказывается, можно за O(n)O(n).

Идея

Возьмём случайный элемент xx и посчитаем, сколько в массиве элементов, не превосходящих его. Пусть их cntcnt.

Если cntkcnt \ge k, то kk-й по возрастанию элемент не больше xx: маленьких элементов набралось достаточно, и искомый — среди них. Значит, искать надо только в левой половине.

Если cnt<kcnt < k, то элементов, не больших xx, слишком мало, и искомый лежит правее. Но номер придётся пересчитать: среди правой половины нам нужен уже не kk-й, а (kcnt)(k - cnt)-й — первые cntcnt элементов остались слева.

В обоих случаях половина массива отбрасывается насовсем.

Код

Разделение — то же самое 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);
}

Базовый случай — один элемент: если отрезок сузился до него, он и есть ответ.

Почему это линия

Разница с быстрой сортировкой — в одной строке: там два рекурсивных вызова, здесь один. И этого достаточно, чтобы сменилась асимптотика.

Возьмём идеальный случай: каждый раз делим пополам. Тогда работа складывается так:

n+n2+n4+n8+n + \frac{n}{2} + \frac{n}{4} + \frac{n}{8} + \ldots

Это сумма геометрической прогрессии, и она меньше 2n2n — сколько бы слагаемых ни было. Не nlognn \log n, а именно 2n2n: уровней по-прежнему логарифм, но каждый следующий вдвое дешевле предыдущего, и всё вместе сходится.

Сравните с сортировкой: там на каждом уровне работа снова равна nn, потому что обрабатываются обе половины.

Уровень Сортировка: обе половины Поиск kk-го: одна половина
1 nn nn
2 nn n/2n/2
3 nn n/4n/4
Итого nlognn \log n меньше 2n2n

Со случайным опорным рассуждение то же, что и для быстрой сортировки: примерно каждый второй шаг уменьшает отрезок хотя бы в 43\tfrac{4}{3} раза, сумма снова оказывается геометрической прогрессией и снова сходится к O(n)O(n) — просто с большей константой.

А без случайности можно?

Всё сказанное опирается на случайный выбор опорного: гарантии тут вероятностные. Существует и детерминированный алгоритм с честным O(n)O(n) в худшем случае — «медиана медиан».

Идея: разбить массив на группы по пять элементов, в каждой найти медиану (это O(1)O(1) на группу), затем рекурсивно найти медиану этих медиан и взять её опорным. Такой опорный гарантированно отсекает хотя бы 30%30\% массива, откуда и берётся линейность.

На практике им не пользуются: константа получается большой, и случайный опорный оказывается быстрее почти всегда. Знать про него стоит ради одного вывода — линейность здесь не «повезло со случайностью», а свойство самой задачи.

В C++ это уже написано

nth_element(a.begin(), a.begin() + k, a.end());
// теперь a[k] — тот элемент, который стоял бы там после сортировки

Функция ничего не возвращает, а переставляет массив: элемент с индексом k встаёт на своё место, слева от него оказываются все не большие, справа — все не меньшие. Внутри половин порядка нет, и он не нужен.

Работает она в среднем за линию — то есть заметно быстрее, чем sort, если вам действительно нужен один элемент, а не весь порядок.