1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 2 из 7

Quickselect: разбить и спуститься в одну сторону

Коротко

Quickselect (быстрый выбор) находит k-ю порядковую статистику за Θ(n) в СРЕДНЕМ, используя разбиение из быстрой сортировки. Выбираем опорный, разбиваем массив на «меньше опорного» и «больше опорного» - опорный встаёт на свою окончательную позицию p. Дальше главное отличие от быстрой сортировки: мы НЕ идём в обе стороны. Если k == p - опорный и есть ответ. Если k < p - нужный элемент слева, спускаемся ТОЛЬКО в левую часть; если k > p - только в правую. Каждый шаг отбрасывает целую часть массива, и работа n + n/2 + n/4 + … = Θ(n) вместо Θ(n·log n) у сортировки. Один спуск вместо двух ветвей - вот вся идея.

Если ты умеешь написать quickselect (разбить, сравнить k с позицией опорного, спуститься в одну сторону) и объяснить, почему в среднем это Θ(n) через сумму n + n/2 + n/4 + …, листай до «Теперь сам».

Как думаешь, что если сортировать только нужную половину

Быстрая сортировка разбивает массив опорным и рекурсивно сортирует ОБЕ половины. Но нам для задачи выбора порядок обеих половин не нужен - нужен один элемент ранга k. После разбиения опорный стоит на своей финальной позиции p: слева от него всё меньше, справа всё больше. И тут видно главное: если наш k меньше p - искомое ТОЧНО в левой части, правую можно вообще не трогать. Если больше - наоборот. Зачем сортировать половину, в которой ответа заведомо нет? Выбросим её целиком и продолжим только в нужной. Быстрая сортировка ветвится надвое, а выбор - спускается по одной ветке. Из этой экономии и рождается линия вместо n·log n.

На что это похоже

Толстый телефонный справочник и фамилия на букву «П». Ты не читаешь его подряд - открываешь примерно посередине, видишь букву «Л» и мгновенно ОТБРАСЫВАЕШЬ всю первую половину: «П» дальше. Открываешь середину оставшегося, видишь «Т» - отбрасываешь хвост. Ты каждый раз выкидываешь целый КУСОК, а не листаешь. Quickselect делает то же с массивом: разбиение говорит «твой элемент в этой половине», и вторую ты выбрасываешь не глядя. Разница с двоичным поиском лишь в том, что «середину» задаёт не индекс, а разбиение по опорному, - но идея «выбросить половину и продолжить в одной» та же.

Механизм: разбиение и спуск в одну часть

Возьмём разбиение Ломуто (проще для выбора): опорный - последний элемент, проходим массив, собирая слева все, кто меньше опорного, и в конце ставим опорный за ними - на позицию p.

выбор(a, lo, hi, k):        # ищем k-ю статистику среди a[lo..hi], k - глобальный индекс (с 0)
    если lo == hi:  вернуть a[lo]          # один элемент - он и есть ответ
    p = разбить(a, lo, hi)                 # опорный встал на p; слева < a[p], справа > a[p]
    если k == p:  вернуть a[p]             # опорный - ровно k-й, нашли
    если k <  p:  вернуть выбор(a, lo, p-1, k)   # ответ слева
    иначе:        вернуть выбор(a, p+1, hi, k)   # ответ справа

Ключевое - ОДНА из двух рекурсивных веток, а не обе. Позиция p после разбиения - это истинный ранг опорного: раз слева от него ровно p − lo элементов меньше, опорный стоит на своём месте навсегда. Сравнив k с p, мы точно знаем, в какой части искать, и вторую отбрасываем целиком.

после разбиения опорным 5: 012345 214589 < 5 опорный на p=3 > 5 ищем k=1: k < p=3 → ответ слева, правую (индексы 4,5) отбрасываем 214 589
Разбиение ставит опорный на его истинный ранг p. Сравнив k с p, спускаемся только в ОДНУ часть, вторую отбрасываем целиком - в этом отличие от быстрой сортировки.

Правило

Quickselect находит k-ю порядковую статистику за Θ(n) в среднем. Разбиваем массив опорным (опорный встаёт на свой истинный ранг p): если k == p - ответ найден; если k < p - рекурсия ТОЛЬКО в левую часть, если k > p - только в правую. В отличие от быстрой сортировки, спускаемся по ОДНОЙ ветке, отбрасывая вторую целиком. При сбалансированных разбиениях работа n + n/2 + n/4 + … = 2n = Θ(n). Та же операция разбиения, но одна рекурсия вместо двух - и n·log n превращается в n.

Разбор: почему в среднем Θ(n), а не Θ(n·log n)

Сравним рекуррентности выбора и сортировки - разница в одной ветке. Быстрая сортировка при хорошем разбиении: T(n) = 2·T(n/2) + Θ(n) - ДВЕ подзадачи, итог Θ(n·log n). Quickselect: T(n) = T(n/2) + Θ(n) - ОДНА подзадача (спускаемся в одну половину). Разверни сумму работы: разбить весь массив стоит ≈ n, потом половину - ≈ n/2, потом четверть - ≈ n/4, и так далее: n + n/2 + n/4 + … = n·(1 + 1/2 + 1/4 + …) = 2n. Геометрический ряд со знаменателем 1/2 сходится к удвоенному первому члену, поэтому сумма - Θ(n), а не Θ(n·log n). Именно исчезновение ВТОРОЙ ветки убирает множитель log n: у сортировки уровней log n и каждый стоит n (итог n·log n), у выбора работа на каждом уровне падает вдвое и вся сумма схлопывается в линию. (Строго это средний случай при случайных разбиениях; про худший - следующий урок.)

Разбор: почему это тот же разбиение, что в быстрой сортировке

Полезно увидеть, что мы не изобретаем новый алгоритм, а переиспользуем разбиение. Разбиение делает ровно одно: ставит опорный на его окончательное место p и раскладывает остальных на «меньше» слева и «больше» справа - за один линейный проход, Θ(n). Быстрая сортировка вызывает его и рекурсивно упорядочивает ОБЕ стороны (порядок нужен весь). Quickselect вызывает то же разбиение, но, зная свой k, спускается в ОДНУ сторону - порядок другой стороны ему не нужен, как мы обсуждали в прошлом уроке. Отсюда важное следствие: quickselect переставляет элементы массива (как и сортировка), то есть работает на месте, O(1) дополнительной памяти (не считая стека рекурсии, а его можно убрать циклом). И ещё: вызвав выбор, ты бесплатно получаешь РАЗБИЕНИЕ массива вокруг ответа - слева всё меньше k-й статистики, справа больше; это готовый «топ-k» без отдельной сортировки.

Что ломается без этого

Первая ошибка - спускаться в ОБЕ стороны (как в быстрой сортировке): получишь Θ(n·log n) и потеряешь весь смысл выбора. Идёшь строго в одну - ту, где по сравнению k с p лежит ответ. Вторая - перепутать, что сравнивать: k - это ГЛОБАЛЬНЫЙ индекс искомого (с 0), а p - позиция опорного в том же массиве; сравнивать надо именно их, а не k с длиной части. Третья - забыть базу lo == hi (один элемент - ответ) и уйти в бесконечную рекурсию. Четвёртая - неверно перевести «k-я статистика» (с 1) в индекс: ищем элемент с индексом k−1. Пятая - думать, что раз это Θ(n) в среднем, то и в худшем: нет, при плохих опорных выбор деградирует до Θ(n²) - об этом следующий урок.

Зачем это в работе

Quickselect - стандартный способ найти медиану, перцентиль или топ-k за линейное время в среднем, без полной сортировки. В Python это statistics и библиотеки численного анализа (numpy.partition - ровно разбиение вокруг k-го без сортировки), в C++ - std::nth_element. Мониторинг задержек считает 99-й перцентиль на миллионах измерений именно так - сортировать всё было бы дороже в log n раз. Топ-k результатов (лучшие 10 из миллиона) - это выбор (n−k)-й статистики как порога плюс отбор всех, кто выше; выбор дал заодно и разбиение вокруг порога. Важно помнить оговорку: Θ(n) тут - средний случай, и стандартные реализации подстраховывают его от худшего (следующие два урока) - но идея «разбей и спустись в одну сторону» лежит в основе их всех.

Теперь сам

В массиве после разбиения [2, 1, 4, | 5 |, 8, 9] опорный 5 встал на позицию p = 3 (индексация с 0). Ты ищешь k = 4-й по порядку элемент (индекс 4). Куда спускается quickselect и почему правую часть трогаем, а левую - нет?

Ответ. Сравниваем k = 4 с p = 3: k > p, значит искомый элемент СПРАВА от опорного (в левой части и на самом опорном стоят элементы с индексами 0..3, а нам нужен индекс 4). Спускаемся ТОЛЬКО в правую часть - подмассив [8, 9] на индексах 4..5, - и ищем там тот же глобальный индекс 4. Левую часть ([2, 1, 4] и опорный 5) не трогаем вовсе: там лежат четыре наименьших элемента с индексами 0..3, среди них искомого 4-го по порядку заведомо нет. Отбросив половину, продолжаем разбиением уже в [8, 9]: следующее разбиение поставит 8 на индекс 4 - это и будет ответ. Так каждый шаг выкидывает целую часть, и сумма работы сходится к линии.

Главное

Quickselect находит k-ю порядковую статистику за Θ(n) в среднем тем же разбиением, что и быстрая сортировка: опорный встаёт на свой истинный ранг p, затем k == p - ответ, k < p - рекурсия только влево, k > p - только вправо. Главное отличие от сортировки - ОДНА ветка вместо двух: работа n + n/2 + n/4 + … = 2n = Θ(n), потому что второй уровень рекурсии исчез и множитель log n пропал. Работает на месте, попутно даёт разбиение вокруг ответа (готовый топ-k). Оговорка: Θ(n) - средний случай; при плохих опорных бывает Θ(n²) - об этом дальше.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.