Learning Platform
Глоссарий Troubleshooting
Урок 15.03 · 30 мин
Начальный
quicksortpartitionpivotLomutoHoarein-place

Второй кит O(n log n)

Quicksort — второй фундаментальный алгоритм сортировки. По асимптотике он такой же, как merge sort (O(n log n) в среднем), но в практической работе часто быстрее merge sort, потому что:

  • in-place — не требует O(n) дополнительной памяти;
  • cache-friendly — partition читает массив последовательно, лучше работает с CPU cache;
  • простая константа — мало overhead, мало вспомогательных операций.

Но у него есть подвох: в худшем случае работает O(n^2). И этот worst case — не редкость на «плохих» данных. Поэтому реальные библиотеки не используют чистый quicksort — используют introsort (quicksort с fallback на heapsort при глубокой рекурсии) или Timsort (merge + insertion).

Несмотря на это, понимать quicksort обязательно — он лежит в основе многих современных алгоритмов и часто появляется на собеседованиях.

Идея

  1. Выбираем «опорный» элемент — pivot.
  2. Partition: переставляем массив так, чтобы все элементы меньше pivot были слева, все больше — справа.
  3. После partition pivot стоит на своём финальном месте.
  4. Рекурсивно сортируем левую и правую части.
def quicksort(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = partition(arr, lo, hi)
        quicksort(arr, lo, p - 1)
        quicksort(arr, p + 1, hi)

Lomuto partition

Самый простой вариант partition: pivot = arr[hi] (последний элемент).

def partition(arr, lo, hi):
    pivot = arr[hi]
    i = lo - 1
    for j in range(lo, hi):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[hi] = arr[hi], arr[i + 1]
    return i + 1

Идея:

  • i отслеживает «границу» меньших-равных pivot.
  • j идёт по всему диапазону.
  • Если arr[j] <= pivot, инкрементим i и меняем arr[i] и arr[j].
  • В конце меняем arr[i+1] с pivot — pivot оказывается на месте.

Пример: [3, 6, 8, 2, 7, 4, 1, 5], pivot=5.

  • j=0: 3 ≤ 5, i=0, swap(0,0): [3, 6, 8, 2, 7, 4, 1, 5].
  • j=1: 6 > 5, пропуск.
  • j=2: 8 > 5.
  • j=3: 2 ≤ 5, i=1, swap(1,3): [3, 2, 8, 6, 7, 4, 1, 5].
  • j=4: 7 > 5.
  • j=5: 4 ≤ 5, i=2, swap(2,5): [3, 2, 4, 6, 7, 8, 1, 5].
  • j=6: 1 ≤ 5, i=3, swap(3,6): [3, 2, 4, 1, 7, 8, 6, 5].
  • Финальный swap(4, hi): [3, 2, 4, 1, 5, 8, 6, 7]. Pivot=5 на месте 4.

После partition: левая часть [3, 2, 4, 1], правая [8, 6, 7]. Сортируем рекурсивно.

Lomuto partition step-by-step

Pivot = последний элемент. Указатели i и j двигаются.

start[3, 6, 8, 2, 7, 4, 1, 5]pivot=5 (arr[hi]), i=-1, j=0
j=3[3, 2, 8, 6, 7, 4, 1, 5]arr[j]=2 <= 5, swap(1,3), i=1
j=5[3, 2, 4, 6, 7, 8, 1, 5]arr[j]=4 <= 5, swap(2,5), i=2
j=6[3, 2, 4, 1, 7, 8, 6, 5]arr[j]=1 <= 5, swap(3,6), i=3
final[3, 2, 4, 1, 5, 8, 6, 7]swap(i+1=4, hi=7) — pivot на месте, возвращаем 4

Hoare partition

Альтернатива — Hoare partition (исторически первая). Указатели идут навстречу друг другу.

def hoare_partition(arr, lo, hi):
    pivot = arr[(lo + hi) // 2]
    i = lo - 1
    j = hi + 1
    while True:
        i += 1
        while arr[i] < pivot:
            i += 1
        j -= 1
        while arr[j] > pivot:
            j -= 1
        if i >= j:
            return j
        arr[i], arr[j] = arr[j], arr[i]

def quicksort_hoare(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = hoare_partition(arr, lo, hi)
        quicksort_hoare(arr, lo, p)
        quicksort_hoare(arr, p + 1, hi)

Hoare:

  • В среднем делает меньше swap’ов, чем Lomuto.
  • Сложнее в реализации, легче ошибиться с границами.
  • Pivot не оказывается на финальной позиции; возвращается не индекс pivot, а индекс конца левой части.

В современных libs (например, в std::sort C++) обычно используют Hoare-like подходы.

Сложность

Сложность quicksort

O(n log n) в среднем, O(n^2) в худшем случае.

bestO(n log n)Pivot всегда медиана: дерево рекурсии сбалансировано
averageO(n log n)Random pivot: ожидание сбалансированности
worstO(n^2)Pivot всегда минимум или максимум: дерево вырождается в цепочку
памятьO(log n)Стек рекурсии — глубина log n в среднем; worst O(n)
in-placeдаКроме рекурсивного стека — никаких массивов
cacheхорошийPartition читает массив последовательно
стабильностьнетSwap'ы переставляют равные элементы случайно
скорость на практикевысокаяИз-за in-place и cache-friendly чаще быстрее merge sort
опасностьO(n^2)На уже отсортированном массиве с naive-pivot — деградация

Когда O(n^2) — реально

С pivot = последний элемент (Lomuto) на уже отсортированном массиве quicksort деградирует. Каждый partition разделяет массив на n-1 и 0 элементов. Дерево рекурсии — цепочка длиной n. Работа на каждом уровне — O(n). Итого O(n^2).

import time

def quicksort_naive(arr, lo=0, hi=None):
    # та же реализация Lomuto, что выше
    ...

# уже отсортированный массив
arr_sorted = list(range(10000))
t = time.perf_counter()
quicksort_naive(arr_sorted[:])
print(time.perf_counter() - t)
# ~2-5 секунд — это O(n^2)

10 000 элементов и 5 секунд — катастрофа. На случайном массиве той же длины — миллисекунды.

Защита: random shuffle и random pivot

Решений два:

1. Random shuffle перед quicksort

Перед запуском перемешиваем массив:

import random

def quicksort_safe(arr):
    random.shuffle(arr)   # O(n), гарантирует random order
    quicksort(arr)

После shuffle вероятность patological-сlucha -> 0. Average O(n log n) с очень высокой вероятностью.

2. Random pivot

Внутри partition выбираем pivot случайно, а не arr[hi]:

import random

def partition_random(arr, lo, hi):
    rand_idx = random.randint(lo, hi)
    arr[rand_idx], arr[hi] = arr[hi], arr[rand_idx]   # перенесли в позицию hi
    # дальше обычный Lomuto
    ...

Это даёт ту же гарантию: даже на отсортированном массиве quicksort работает O(n log n) в среднем.

3. Median-of-three

Третий приём: pivot = медиана из {arr[lo], arr[mid], arr[hi]}. Гарантирует, что на отсортированном массиве не возьмём крайний элемент. На большинстве реальных данных работает.

Современные libs используют комбинацию: median-of-three (или median-of-medians) + переключение на heapsort при глубокой рекурсии (introsort).

Зачем quicksort, если есть Timsort

Timsort быстрее на реальных данных (адаптивный). Но quicksort:

  1. In-place — критично для embedded и для огромных массивов в RAM.
  2. Меньше allocations — Timsort требует O(n) temp buffer; quicksort — только stack.
  3. Простая реализация — quicksort короче Timsort.
  4. Standard в C++/Rust — std::sort обычно introsort.

В Python для пользовательского кода всегда sorted(). Знание quicksort нужно для интервью и для понимания std::sort, std::partition, std::nth_element в C++.

Quickselect: топ-k без полной сортировки

Quicksort partition имеет другое применение: quickselect. Найти k-й по величине элемент без полной сортировки.

def quickselect(arr, k):
    """Возвращает k-th smallest element. O(n) в среднем."""
    if len(arr) == 1:
        return arr[0]
    pivot = arr[len(arr) // 2]
    lows = [x for x in arr if x < pivot]
    highs = [x for x in arr if x > pivot]
    pivots = [x for x in arr if x == pivot]
    if k < len(lows):
        return quickselect(lows, k)
    elif k < len(lows) + len(pivots):
        return pivot
    else:
        return quickselect(highs, k - len(lows) - len(pivots))

Это O(n) в среднем — мы рекурсивно идём только в одну часть. Worst case O(n^2), но с median-of-medians можно гарантировать O(n).

В Python есть heapq.nlargest(k, arr) и heapq.nsmallest(k, arr) — это O(n log k), не O(n), но более стабильно и без рекурсии.

DE-кейс: топ-100 пользователей по revenue

Сценарий: миллион пользователей, нужно вывести 100 с наибольшим revenue. Sort всей таблицы — O(n log n) = ~20М операций.

Через quickselect-like — O(n + k log k) = ~1М + 600 = ~1М операций. В 20 раз быстрее.

В Python это heapq.nlargest(100, users, key=lambda u: u.revenue) — внутри O(n log k).

В Spark/SQL: ORDER BY revenue DESC LIMIT 100. Хороший движок применяет top-k оптимизацию (priority queue), не full sort.

Попробуй сам

import random
import time

def quicksort(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = partition(arr, lo, hi)
        quicksort(arr, lo, p - 1)
        quicksort(arr, p + 1, hi)

def partition(arr, lo, hi):
    pivot = arr[hi]
    i = lo - 1
    for j in range(lo, hi):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[hi] = arr[hi], arr[i + 1]
    return i + 1

# тест на случайных
random.seed(0)
arr_random = [random.randint(0, 10**6) for _ in range(10_000)]
arr_sorted = sorted(arr_random)
arr_reverse = arr_sorted[::-1]

for name, arr in [("random", arr_random), ("sorted", arr_sorted), ("reverse", arr_reverse)]:
    a = arr[:]
    t = time.perf_counter()
    try:
        quicksort(a)
        elapsed = time.perf_counter() - t
        print(f"{name}: {elapsed*1000:.1f} ms")
    except RecursionError:
        print(f"{name}: RecursionError (too deep)")

Ожидаемое:

  • random: 10-30 мс,
  • sorted: RecursionError или 2-5 секунд (O(n^2) + stack overflow),
  • reverse: то же самое.

Теперь повторите с защитой:

import random
random.shuffle(arr_sorted)   # уже не отсортирован
quicksort(arr_sorted)        # сработает за O(n log n)

В следующем уроке — почему quicksort часто быстрее merge sort на одних и тех же данных, несмотря на одинаковую асимптотику. Виноваты cache и in-place.

Проверка знанийKnowledge check
У вас quicksort с pivot = arr[hi] (Lomuto). На уже отсортированном массиве 100 000 int что произойдёт и как защититься?
ОтветAnswer
Произойдёт деградация в O(n^2): каждый partition разделит массив на n-1 элементов слева и 0 справа (потому что pivot — максимум). Дерево рекурсии — цепочка длиной 100 000, что (а) приведёт к RecursionError из-за лимита Python ~1000 уровней, (б) даже без лимита — это 10^10 операций, минуты или часы. Защита: (1) random shuffle перед сортировкой — гарантирует random data, average O(n log n); (2) random pivot — внутри partition выбирать pivot случайно из диапазона [lo..hi]; (3) median-of-three — pivot = медиана из {arr[lo], arr[mid], arr[hi]}; (4) introsort — переключаться на heapsort при глубине рекурсии > 2*log(n). Стандарт в production-libs (C++ std::sort) — introsort. Никогда не используйте «naive» Lomuto без защиты в проде.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Лучший, средний, худший случаи quicksort?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5