Сортировка Хоара: как устроена быстрая сортировка и когда выбрать другой алгоритм

Сортировка Хоара: как устроена быстрая сортировка и когда выбрать другой алгоритм Полезное

Быстрая сортировка (quicksort) — это алгоритм упорядочивания массива по принципу «разделяй и властвуй»: массив делится на две части относительно опорного элемента, части сортируются рекурсивно. Схема Хоара (Hoare partition scheme) — конкретный способ разбиения, предложенный Тони Хоаром в 1960 году, отличный от более распространенной в учебниках схемы Ломуто. Дальше разберем разбиение по шагам на маленьком массиве, приведем рабочий код и сравним быструю сортировку с другими методами.

Quicksort, разбиение и опорный элемент — не одно и то же

В статьях про быструю сортировку три термина часто сливают в один, хотя это разные уровни:

  • Quicksort — весь алгоритм: выбор опоры, разбиение, рекурсия на двух частях.
  • Разбиение (partition) — один шаг: переставить элементы массива так, чтобы слева оказались значения не больше опорного, справа — не меньше.
  • Опорный элемент (pivot) — значение, относительно которого идет разбиение на этом шаге.

Схема Ломуто и схема Хоара по-разному переставляют элементы внутри одного и того же шага разбиения, и это дает разный практический результат, о котором ниже. Коротко о разнице: в схеме Ломуто опору обычно берут последним элементом подмассива и одним указателем последовательно передвигают все элементы меньше опоры в начало, а в конце ставят опору на границу — после этого опора гарантированно стоит на своем финальном месте в отсортированном массиве. Схема Хоара работает двумя встречными указателями и делает меньше перестановок в среднем, но, как показано ниже, не гарантирует опоре финальную позицию сразу после одного разбиения.

Пошаговый разбор схемы Хоара на массиве

Возьмем массив [8, 3, 1, 9, 5, 4] и опорный элемент — первый элемент, 8 (в классической схеме Хоара опору берут именно так). Заводим два указателя: i перед началом массива, j после конца.

Шаг 1. i сдвигается вправо, пока не найдет элемент не меньше опоры: A[0]=8 уже не меньше 8, значит i=0. j сдвигается влево, пока не найдет элемент не больше опоры: A[5]=4 подходит, j=5. Так как i<j, меняем местами A[0] и A[5]. Массив: [4, 3, 1, 9, 5, 8].

Шаг 2. i идет дальше от 0: A[1]=3 меньше опоры, A[2]=1 меньше опоры, A[3]=9 не меньше — стоп, i=3. j идет дальше от 5: A[4]=5 не больше опоры — стоп, j=4. Так как i<j, меняем A[3] и A[4]. Массив: [4, 3, 1, 5, 9, 8].

Шаг 3. i от 3: A[4]=9 не меньше опоры — стоп, i=4. j от 4: A[3]=5 не больше опоры — стоп, j=3. Теперь i>=j (4>=3), разбиение закончено, функция возвращает j=3.

Важный нюанс, который часто упрощают неверно: в схеме Хоара опорный элемент не обязан встать на свое финальное место после разбиения. Здесь 8 осталась на индексе 5, а не заняла границу раздела — это упрощение верно для схемы Ломуто, но не для схемы Хоара. Границей раздела служит возвращенный индекс j=3: рекурсия дальше идет по подмассивам [0..3] и [4..5].

Если продолжить рекурсию тем же способом на [4, 3, 1, 5] и на [9, 8], в итоге получится отсортированный массив [1, 3, 4, 5, 8, 9] — можно проверить теми же двумя правилами сдвига указателей на каждом подмассиве.

Код на Python

def hoare_partition(arr, lo, hi):
    pivot = arr[lo]
    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(arr, lo=0, hi=None):
    if hi is None:
        hi = len(arr) - 1
    if lo < hi:
        p = hoare_partition(arr, lo, hi)
        quicksort(arr, lo, p)
        quicksort(arr, p + 1, hi)
    return arr


data = [8, 3, 1, 9, 5, 4]
print(quicksort(data))

Вывод в консоли:

[1, 3, 4, 5, 8, 9]

Это тот же результат, что получился при ручном разборе — массив отсортирован по возрастанию. Рекурсия останавливается, когда lo >= hi, то есть подмассив состоит из одного элемента или пуст: такой подмассив уже отсортирован по определению.

Сложность: от чего зависит O(n log n) или O(n^2)

Средняя сложность быстрой сортировки — O(n log n): при более-менее сбалансированном разбиении на каждом уровне рекурсии массив делится примерно пополам, а уровней получается порядка log n.

Худшая сложность — O(n^2). Она возникает, если разбиение раз за разом получается вырожденным — опора оказывается самым маленьким или самым большим элементом подмассива, и одна из двух частей пустая. Это систематически происходит на уже отсортированном или отсортированном в обратном порядке массиве, если опору всегда берут первым или последним элементом — как в примере выше. На практике это лечат случайным выбором опоры или медианой трех элементов (первого, среднего, последнего), что делает вырожденное разбиение маловероятным на реальных данных.

Разберем худший случай на конкретном массиве: [1, 2, 3, 4, 5, 6], уже отсортированном по возрастанию, опора — первый элемент 1. Указатель i сразу останавливается на индексе 0, потому что A[0]=1 не меньше опоры. Указатель j идет от конца массива влево и останавливается только на индексе 0, потому что все элементы от 2 до 6 больше опоры. В итоге i и j совпадают на первом же шаге, разбиение возвращает j=0 без единой перестановки, и подмассивы получаются размером 1 и 5 — максимально несбалансированно. Если такое повторяется на каждом уровне рекурсии, глубина рекурсии становится n, а не log n, и общее время работы деградирует до O(n^2).

Память — в среднем O(log n) на стек рекурсивных вызовов, в худшем случае (без ограничения глубины) — O(n). Поэтому промышленные реализации, включая std::sort в C++ и сортировку в стандартной библиотеке многих языков, на практике используют не чистый quicksort, а гибрид (интросорт): при слишком глубокой рекурсии алгоритм переключается на heapsort, чтобы гарантировать O(n log n) в худшем случае.

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

Сравнение с другими способами сортировки

Метод Средняя сложность Худшая сложность Доп. память Устойчивость Когда применять
Быстрая (Хоара) O(n log n) O(n^2) O(log n) нет сортировка в памяти общего назначения
Пузырьковая O(n^2) O(n^2) O(1) да только обучение алгоритмам
Шейкерная (коктейльная) O(n^2) O(n^2) O(1) да маленький почти отсортированный массив
Расческа строго не выведена* O(n^2) O(1) нет простая альтернатива пузырьковой
Вставками O(n^2) O(n^2) O(1) да маленькие массивы, почти отсортированные данные
Пирамидальная (heapsort) O(n log n) O(n log n) O(1) нет нужна гарантия без всплеска памяти
Блочная (bucket sort) O(n + k) O(n^2) O(n + k) зависит от внутренней сортировки данные равномерно распределены по диапазону

Несколько уточнений к таблице. Расческа (comb sort) сравнивает не соседние, а удаленные друг от друга элементы, постепенно сокращая разрыв — обычно с коэффициентом сокращения около 1.3 на каждом проходе, это устраняет проблему «черепах» пузырьковой сортировки (маленьких значений в конце массива). *Про сложность расчески часто путают лучший и средний случай: доказанная оценка O(n log n) относится к лучшему случаю (почти отсортированный массив), а для среднего строгого результата нет — известна только более слабая оценка Ω(n^2/2^p), где p — число проходов с уменьшающимся разрывом; на случайных данных с коэффициентом 1.3 расческа на практике ведет себя близко к n log n, но это наблюдение, а не доказанная граница. Пирамидальная сортировка сначала строит из массива бинарную кучу, а затем поочередно извлекает максимум и ставит его в конец — это не то же самое, что просто искать максимум линейным проходом и переставлять его: построение кучи и есть источник гарантии O(n log n). Блочная сортировка быстро деградирует до O(n^2), если данные распределены неравномерно и почти все элементы попадают в одну корзину.

Выводы

  • Быстрая сортировка (quicksort) — это алгоритм целиком, разбиение (partition) — один его шаг, а схема Хоара — конкретный способ выполнить этот шаг, отличный от схемы Ломуто.
  • В схеме Хоара опорный элемент после разбиения не обязательно занимает свое финальное место в массиве — на подмассивы делит именно возвращенный индекс.
  • Средняя сложность быстрой сортировки O(n log n), худшая O(n^2) — худший случай типичен для уже отсортированных данных при наивном выборе опоры (первый или последний элемент).
  • Быстрая сортировка неустойчива и в худшем случае требует O(n) памяти на стек рекурсии, поэтому промышленные библиотеки используют гибрид с heapsort.
  • Для маленьких или почти отсортированных массивов практичнее сортировка вставками, для гарантии без риска O(n^2) — пирамидальная сортировка.

Где применяется / связь с практикой

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

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

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

Если пока не уверены, что тема подходит именно вам, можно начать с открытых уроков Otus по алгоритмам и структурам данных — расписание открытых уроков.

FAQ

Почему быструю сортировку иногда называют qsort?
Это историческое имя функции быстрой сортировки в стандартной библиотеке языка C, которое закрепилось как разговорный синоним алгоритма, хотя конкретная реализация внутри qsort зависит от компилятора.

Можно ли сделать быструю сортировку устойчивой?
Формально да, если хранить рядом с каждым элементом его исходный индекс и сравнивать по паре (значение, индекс) при равенстве ключей, но это увеличивает память и почти всегда проще взять устойчивый алгоритм вроде сортировки вставками или timsort.

Что произойдет, если в массиве все элементы одинаковые?
При наивном выборе опоры (первый или последний элемент) без специальной обработки равных значений это тоже вырожденный случай, близкий к худшему по времени; классическая схема Хоара переносит такие элементы в обе части почти поровну, что на практике ведет себя лучше, чем на отсортированном массиве, но по-прежнему требует случайного или медианного выбора опоры как общей защиты.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)