2025, Dec 20 12:01
Исправляем Quickselect: корректное разбиение при дубликатах
Почему Quickselect с разбиением Ломуто тормозит при дубликатах и K в середине, и как исправить: рандомизация равных и трёхпутевое разбиение без деградации.
Quickselect — базовый прием для поиска K‑го по величине элемента в массиве с ожидаемой линейной сложностью. Но даже он может упираться в лимит времени на вполне разумных данных. Если разбиение выполняется с фиксированным опорным элементом на конце и равные значения обрабатываются «в лоб», то большой массив с множеством дубликатов и K около середины способен загнать алгоритм в квадратичный режим. Понимание причины и способов её нейтрализовать — ключ к надежной реализации выбора ранга.
Постановка задачи
Рассмотрим прямолинейный Quickselect, который перемешивает входной массив, делает разбиение по схеме Ломуто с последним элементом в роли опорного и рекурсивно углубляется в ту часть, где находится искомый индекс. Поиск K‑го наибольшего сводится к индексу K‑го наименьшего через целевую позицию n − k при нулевой индексации.
import random
class Ranker(object):
def kth_largest(self, arr, kpos):
m = len(arr)
random.shuffle(arr)
return self._pick(arr, 0, m - 1, m - kpos)
def _pick(self, arr, lo, hi, idx):
p = self._split(arr, lo, hi)
if idx < p:
return self._pick(arr, lo, p - 1, idx)
elif idx > p:
return self._pick(arr, p + 1, hi, idx)
else:
return arr[p]
def _split(self, arr, lo, hi):
p = hi
i = lo
for j in range(lo, hi):
if arr[j] <= arr[p]:
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[p] = arr[p], arr[i]
return i
Где именно всё ломается
Замедление проявляется на входах с низкой энтропией — например, когда массив почти целиком состоит из одного и того же значения, а целевой индекс расположен около середины. В приведённой выше схеме Ломуто условие разбиения использует «меньше-или-равно». Если опорное значение встречается много раз, цикл переносит каждый равный элемент в левую часть и в итоге ставит сам опорный в самый конец секции. После этого рекурсия отбрасывает за шаг всего один элемент. Такой сценарий повторяется до достижения цели, и суммарное время вырождается в квадратичное. Перемешивание не спасает, потому что проблема не только в выборе опоры, а в обработке равных значений при разбиении.
Как исправить разбиение для равных значений
Чтобы разорвать этот худший сценарий, в разбиении нельзя сливать все элементы, равные опоре, на одну сторону. Практичный приём: переставлять элементы строго по условию «меньше», а для значений, равных опоре, выполнять перестановку лишь в половине случаев. Такое случайное распределение равных по обе стороны границы не даёт отрезку сжиматься по одному элементу за шаг и устраняет тайм-ауты на больших массивах с множеством дубликатов.
import random
class Ranker(object):
def kth_largest(self, arr, kpos):
m = len(arr)
random.shuffle(arr)
return self._pick(arr, 0, m - 1, m - kpos)
def _pick(self, arr, lo, hi, idx):
p = self._split(arr, lo, hi)
if idx < p:
return self._pick(arr, lo, p - 1, idx)
elif idx > p:
return self._pick(arr, p + 1, hi, idx)
else:
return arr[p]
def _split(self, arr, lo, hi):
p = hi
i = lo
for j in range(lo, hi):
if arr[j] < arr[p] or (arr[j] == arr[p] and random.getrandbits(1)):
arr[i], arr[j] = arr[j], arr[i]
i += 1
arr[i], arr[p] = arr[p], arr[i]
return i
Небольшое изменение сохраняет корректность и не меняет общую структуру Quickselect, но устраняет патологию, вызванную скоплениями одинаковых значений. Решение остаётся лёгким: без дополнительных проходов и памяти.
Почему это важно
В реальных данных дубликатов обычно много. Если разбиение уносит все равные в одну сторону, Quickselect будет снова и снова «снимать по одному элементу» и уйдёт в квадратичное время. Такой скрытый обрыв производительности легко не заметить при разработке — он проявляется лишь на крупных данных или на некоторых наборах тестов. Поэтому корректная обработка равных — не микрооптимизация, а страховка от худших сценариев.
Существуют и другие стратегии разбиения, которые хорошо работают при обилии дублей. Схема Хоара обычно ведёт себя лучше по мере роста числа повторов, а трёхпутевое разбиение (вариант «голландского флага») — распространённый способ эффективно отделять области «меньше», «равно» и «больше». Во всех подходах идея одна: не позволять равным значениям перекосить рекурсию.
Итоги
Если Quickselect вылетает по времени на больших массивах при крупном K, внимательно проверьте, как разбиение обрабатывает равные. Избегайте «воронки меньше-или-равно» в схеме Ломуто: рандомизируйте размещение равных или перейдите на метод, который балансирует их изначально. Тестируйте на массивах, где доминирует одно повторяющееся число и цель находится около середины, — так вы убедитесь, что реализация не деградирует. С такой защитой Quickselect остаётся надёжным инструментом для выбора K‑го по величине элемента.