Кратко и по делу. Почему и когда quicksort деградирует до O(n2)O(n^2)O(n2)
- Неправильный выбор опорного элемента (pivot). Если на каждом шаге разбиение даёт очень несбалансированные части, например размеры n−1n-1n−1 и 000, то рекуррентное соотношение T(n)=T(n−1)+Θ(n)T(n)=T(n-1)+\Theta(n)T(n)=T(n−1)+Θ(n)
даёт T(n)=Θ(n2)T(n)=\Theta(n^2)T(n)=Θ(n2). Это происходит, например, при сортировке уже отсортированных или обратно отсортированных массивов, если всегда брать первый/последний элемент как pivot. - Много одинаковых ключей + обычная двухсторонняя (двухчастная) схема разбиения (Lomuto/простая Hoare). Тогда каждый раз один из частей может быть почти того же размера, и получается та же квадратичная деградация. - Атаки на детерминированный выбор pivot (враждебные входы), если алгоритм использует фиксированное правило (например, всегда средний индекс) — можно сконструировать последовательности, приводящие к плохому разбиению. Практические эвристики и модификации (с обоснованием) - Рандомизация pivot: - Выбирать pivot случайно или предварительно перемешать массив (Fisher–Yates). Обоснование: ожидаемая сложность становится Θ(nlogn)\Theta(n\log n)Θ(nlogn) и исчезает зависимость от порядка входа; трудно сконструировать худший случай. - Median-of-three / Tukey ninther: - Для каждого вызова брать pivot = медиана {A[lo],A[mid],A[hi]}\{A[lo],A[mid],A[hi]\}{A[lo],A[mid],A[hi]} (median-of-three); для больших массивов — медиана трёх медиан (Tukey ninther). Обоснование: уменьшает шанс плохого разбиения на уже- или почти-отсортированных данных с низкой накладной. - Трёхстороннее разбиение (Dutch National Flag): - Разбивать на три части: pivot. Особенно эффективно при многих равных ключах — алгоритм работает близко к Θ(n)\Theta(n)Θ(n) при большом числе дубликатов и избегает деградации. - Использовать Hoare partition или улучшенную реализацию Lomuto: - Hoare часто делает меньше обменов/сравнений и лучше ведёт себя на практике. (Нужно аккуратно обрабатывать границы при рекурсии.) - Мелкие подмассивы — insertion sort: - Для подмасивов размера ≤k\le k≤k (типично k=8…32k=8\ldots32k=8…32, часто 161616) переключаться на insertion sort. Обоснование: константы меньшие на коротких участках, суммарный выигрыш. - Tail-recursion elimination / рекурсия на меньшей части: - Всегда рекурсивно обрабатывать меньшую из частей, а для большей — цикл (или рекурсия в конце). Гарантирует глубину стека Θ(logn)\Theta(\log n)Θ(logn) в среднем и в лучшем случае. - Introsort (практическая гарантия): - Запускать quicksort с рандом/median-of-three; если глубина рекурсии превышает clog2nc\log_2 nclog2n (обычно c=2c=2c=2), переключаться на heapsort для оставшейся части. Обоснование: комбинация даёт скорость quicksort на "обычных" данных и гарантированное Θ(nlogn)\Theta(n\log n)Θ(nlogn) в худшем случае. - Детерминированная гарантия через median-of-medians: - Можно выбирать точную медиану за Θ(n)\Theta(n)Θ(n) (алгоритм selection), тогда каждый pivot даёт гарантированно сбалансированное разбиение и общее Θ(nlogn)\Theta(n\log n)Θ(nlogn) в худшем случае. Обоснование: строгие гарантии; недостаток — большая константа, поэтому редко используется на практике. - Защита от враждебных входов: - Обязательное предварительное перемешивание массива или использование случайного pivot устраняет возможность атакующих входов. Рекомендуемая практическая конфигурация - Перемешать массив (или использовать случайный pivot). - Pivot = median-of-three (или ninther для очень больших массивов). - Использовать Hoare partition или 3-way partitioning при подозрении на много дубликатов. - Для подмассивов длины ≤16\le 16≤16 — insertion sort. - Реализовать tail-recursion elimination (рекурсия только на меньшей части). - Ввести introsort: при глубине > 2log2n2\log_2 n2log2n — переход на heapsort. Эти меры дают: на реальных данных — быструю работу близко к Θ(nlogn)\Theta(n\log n)Θ(nlogn) с малыми константами; в худшем случае — гарантию Θ(nlogn)\Theta(n\log n)Θ(nlogn) (через introsort или median-of-medians).
Почему и когда quicksort деградирует до O(n2)O(n^2)O(n2) - Неправильный выбор опорного элемента (pivot). Если на каждом шаге разбиение даёт очень несбалансированные части, например размеры n−1n-1n−1 и 000, то рекуррентное соотношение
T(n)=T(n−1)+Θ(n)T(n)=T(n-1)+\Theta(n)T(n)=T(n−1)+Θ(n) даёт T(n)=Θ(n2)T(n)=\Theta(n^2)T(n)=Θ(n2). Это происходит, например, при сортировке уже отсортированных или обратно отсортированных массивов, если всегда брать первый/последний элемент как pivot.
- Много одинаковых ключей + обычная двухсторонняя (двухчастная) схема разбиения (Lomuto/простая Hoare). Тогда каждый раз один из частей может быть почти того же размера, и получается та же квадратичная деградация.
- Атаки на детерминированный выбор pivot (враждебные входы), если алгоритм использует фиксированное правило (например, всегда средний индекс) — можно сконструировать последовательности, приводящие к плохому разбиению.
Практические эвристики и модификации (с обоснованием)
- Рандомизация pivot:
- Выбирать pivot случайно или предварительно перемешать массив (Fisher–Yates). Обоснование: ожидаемая сложность становится Θ(nlogn)\Theta(n\log n)Θ(nlogn) и исчезает зависимость от порядка входа; трудно сконструировать худший случай.
- Median-of-three / Tukey ninther:
- Для каждого вызова брать pivot = медиана {A[lo],A[mid],A[hi]}\{A[lo],A[mid],A[hi]\}{A[lo],A[mid],A[hi]} (median-of-three); для больших массивов — медиана трёх медиан (Tukey ninther). Обоснование: уменьшает шанс плохого разбиения на уже- или почти-отсортированных данных с низкой накладной.
- Трёхстороннее разбиение (Dutch National Flag):
- Разбивать на три части: pivot. Особенно эффективно при многих равных ключах — алгоритм работает близко к Θ(n)\Theta(n)Θ(n) при большом числе дубликатов и избегает деградации.
- Использовать Hoare partition или улучшенную реализацию Lomuto:
- Hoare часто делает меньше обменов/сравнений и лучше ведёт себя на практике. (Нужно аккуратно обрабатывать границы при рекурсии.)
- Мелкие подмассивы — insertion sort:
- Для подмасивов размера ≤k\le k≤k (типично k=8…32k=8\ldots32k=8…32, часто 161616) переключаться на insertion sort. Обоснование: константы меньшие на коротких участках, суммарный выигрыш.
- Tail-recursion elimination / рекурсия на меньшей части:
- Всегда рекурсивно обрабатывать меньшую из частей, а для большей — цикл (или рекурсия в конце). Гарантирует глубину стека Θ(logn)\Theta(\log n)Θ(logn) в среднем и в лучшем случае.
- Introsort (практическая гарантия):
- Запускать quicksort с рандом/median-of-three; если глубина рекурсии превышает clog2nc\log_2 nclog2 n (обычно c=2c=2c=2), переключаться на heapsort для оставшейся части. Обоснование: комбинация даёт скорость quicksort на "обычных" данных и гарантированное Θ(nlogn)\Theta(n\log n)Θ(nlogn) в худшем случае.
- Детерминированная гарантия через median-of-medians:
- Можно выбирать точную медиану за Θ(n)\Theta(n)Θ(n) (алгоритм selection), тогда каждый pivot даёт гарантированно сбалансированное разбиение и общее Θ(nlogn)\Theta(n\log n)Θ(nlogn) в худшем случае. Обоснование: строгие гарантии; недостаток — большая константа, поэтому редко используется на практике.
- Защита от враждебных входов:
- Обязательное предварительное перемешивание массива или использование случайного pivot устраняет возможность атакующих входов.
Рекомендуемая практическая конфигурация
- Перемешать массив (или использовать случайный pivot).
- Pivot = median-of-three (или ninther для очень больших массивов).
- Использовать Hoare partition или 3-way partitioning при подозрении на много дубликатов.
- Для подмассивов длины ≤16\le 16≤16 — insertion sort.
- Реализовать tail-recursion elimination (рекурсия только на меньшей части).
- Ввести introsort: при глубине > 2log2n2\log_2 n2log2 n — переход на heapsort.
Эти меры дают: на реальных данных — быструю работу близко к Θ(nlogn)\Theta(n\log n)Θ(nlogn) с малыми константами; в худшем случае — гарантию Θ(nlogn)\Theta(n\log n)Θ(nlogn) (через introsort или median-of-medians).