Урок 9.3 — Быстрая сортировка (Quick Sort)
Изучим один из самых быстрых алгоритмов сортировки — Quick Sort. Разделяй и властвуй, выбор pivot и рекурсия.
«Быстрая сортировка» (Quick Sort) получила своё название не просто так. В среднем это самый быстрый из известных алгоритмов сортировки сравнением. Его придумал Энтони Хоар (Tony Hoare) в 1959 году, когда работал над переводом текстов на иностранные языки. Сегодня Quick Sort лежит в основе многих стандартных библиотек сортировки.
Принцип «Разделяй и властвуй»
Разделяй и властвуй (Divide and Conquer) — это стратегия, в которой задача разбивается на меньшие подзадачи того же типа, решаются они, а затем результаты объединяются.
Quick Sort работает так:
- Выбрать опорный элемент (pivot) — любой элемент массива (обычно первый, последний или средний).
- Разделить (partition) — переставить элементы так, чтобы слева от pivot оказались элементы меньше него, а справа — больше или равные.
- Рекурсивно применить шаги 1-2 к левой и правой частям.
- Базовый случай — подмассив из 0 или 1 элемента уже отсортирован.
Магия в том, что после разделения pivot оказывается на своём окончательном месте. И левая, и правая части сортируются независимо. Объединять результаты не нужно — они уже на своих местах.
💡 Это ключевое отличие от Merge Sort: в Quick Sort мы сначала делаем работу (разделение), а потом рекурсивно обрабатываем части. В Merge Sort — сначала делим, а работа (слияние) происходит после рекурсии.
Выбор опорного элемента (Pivot)
Выбор pivot критически важен для скорости Quick Sort. Удачный выбор — и алгоритм работает за O(n log n). Неудачный — деградирует до O(n²).
Стратегии выбора pivot:
Первый или последний элемент
Самый простой выбор. Но опасный — если массив уже отсортирован (или почти отсортирован), алгоритм деградирует до O(n²).
Случайный элемент
Выбираем случайный элемент. Вероятность худшего случая становится пренебрежимо малой (1/n! — практически 0 для больших n).
Медиана трёх
Берём три элемента (первый, средний, последний) и выбираем из них средний по значению. Хорошо работает на практике, защищает от частично отсортированных массивов.
В нашей реализации будем использовать последний элемент как pivot (для простоты), а потом обсудим, как улучшить до случайного выбора.
Процедура Partition (разделение)
Partition — это ключевая операция в Quick Sort. Она переставляет элементы массива так, чтобы:
- Все элементы меньше pivot оказались слева
- Все элементы больше или равные pivot — справа
- Pivot оказался на своём окончательном месте
Самый популярный алгоритм partition — схема Ломуто (Lomuto) или схема Хоара (Hoare). Мы рассмотрим схему Ломуто — она проще для понимания.
static int Partition(int[] arr, int left, int right)
{
// Выбираем последний элемент как pivot
int pivot = arr[right];
// i — граница, слева от которой элементы меньше pivot
int i = left - 1;
// Проходим по всем элементам, кроме pivot
for (int j = left; j < right; j++)
{
// Если текущий элемент меньше или равен pivot
if (arr[j] <= pivot)
{
i++; // расширяем границу
// Меняем местами arr[i] и arr[j]
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
// Ставим pivot на своё место — между меньшими и большими
(arr[i + 1], arr[right]) = (arr[right], arr[i + 1]);
// Возвращаем индекс, на котором стоит pivot
return i + 1;
}
Разберём на примере. Массив [10, 7, 8, 9, 1, 5], pivot = последний = 5:
Начало: [10, 7, 8, 9, 1, 5], pivot = 5, i = -1
j=0: arr[0]=10, 10 <= 5? Нет → ничего не делаем
j=1: arr[1]=7, 7 <= 5? Нет → ничего
j=2: arr[2]=8, 8 <= 5? Нет → ничего
j=3: arr[3]=9, 9 <= 5? Нет → ничего
j=4: arr[4]=1, 1 <= 5? Да → i=0, меняем arr[0] и arr[4]: [1, 7, 8, 9, 10, 5]
После цикла: ставим pivot на место i+1 = 1.
Меняем arr[1] и arr[5]: [1, 5, 8, 9, 10, 7]
Возвращаем i+1 = 1 (индекс pivot)
Результат: слева от 5 — только 1 (меньше), справа — всё остальное (больше или равно).
Обрати внимание: после partition pivot (5) стоит на своём окончательном месте. Дальше нам нужно рекурсивно отсортировать левую часть (индексы 0..0) и правую часть (индексы 2..5).
Пошаговая визуализация Quick Sort
Рассмотрим массив [10, 80, 30, 90, 40, 50, 70]. Quick Sort с pivot = последний элемент.
Вызов QuickSort(arr, 0, 6):
[10, 80, 30, 90, 40, 50, 70] pivot=70
Partition: → [10, 30, 40, 50, 80, 90, 70]
Ставим pivot: → [10, 30, 40, 50, 70, 90, 80] pivotIndex=4
Рекурсия: QuickSort(arr, 0, 3) и QuickSort(arr, 5, 6)
QuickSort(arr, 0, 3):
[10, 30, 40, 50] pivot=50
Partition: → [10, 30, 40, 50] pivotIndex=3
Рекурсия: QuickSort(arr, 0, 2) и QuickSort(arr, 4, 3) — пусто
QuickSort(arr, 0, 2):
[10, 30, 40] pivot=40
Partition: → [10, 30, 40] pivotIndex=2
Рекурсия: QuickSort(arr, 0, 1) и пусто
QuickSort(arr, 0, 1):
[10, 30] pivot=30
Partition: → [10, 30] pivotIndex=1
Рекурсия: QuickSort(arr, 0, 0) — 1 элемент, возврат.
QuickSort(arr, 2, 1) — пусто, возврат.
QuickSort(arr, 5, 6):
[90, 80] pivot=80
Partition: → [80, 90] pivotIndex=5
Рекурсия: QuickSort(arr, 5, 4) — пусто.
QuickSort(arr, 6, 6) — 1 элемент, возврат.
Результат: [10, 30, 40, 50, 70, 80, 90]
Дерево рекурсии выглядит так:
[10, 80, 30, 90, 40, 50, 70]
/ \
[10, 30, 40, 50] [90, 80]
/ \ / \
[10, 30, 40] [50] [80] [90]
/ \
[10] [30, 40]
/ \
[30] [40]
Рекурсивная реализация на C#
Теперь соединим Partition и рекурсию в полную реализацию Quick Sort:
using System;
class Program
{
static void Main()
{
int[] arr = { 10, 80, 30, 90, 40, 50, 70 };
Console.WriteLine("Исходный массив: " + string.Join(", ", arr));
QuickSort(arr, 0, arr.Length - 1);
Console.WriteLine("Отсортированный: " + string.Join(", ", arr));
}
static void QuickSort(int[] arr, int left, int right)
{
if (left < right)
{
// Разделяем массив и получаем индекс pivot
int pivotIndex = Partition(arr, left, right);
Console.WriteLine($" pivot={arr[pivotIndex]} на [{pivotIndex}] → " +
string.Join(", ", arr));
// Рекурсивно сортируем левую часть
QuickSort(arr, left, pivotIndex - 1);
// Рекурсивно сортируем правую часть
QuickSort(arr, pivotIndex + 1, right);
}
}
static int Partition(int[] arr, int left, int right)
{
int pivot = arr[right]; // последний элемент — pivot
int i = left - 1;
for (int j = left; j < right; j++)
{
if (arr[j] <= pivot)
{
i++;
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
// Ставим pivot на место
(arr[i + 1], arr[right]) = (arr[right], arr[i + 1]);
return i + 1;
}
}
// Вывод:
// Исходный массив: 10, 80, 30, 90, 40, 50, 70
// pivot=70 на [4] → 10, 30, 40, 50, 70, 90, 80
// pivot=50 на [3] → 10, 30, 40, 50, 70, 90, 80
// pivot=40 на [2] → 10, 30, 40, 50, 70, 90, 80
// pivot=30 на [1] → 10, 30, 40, 50, 70, 90, 80
// pivot=80 на [6] → 10, 30, 40, 50, 70, 80, 90
if (left < right) — это и есть базовый случай. Если left >= right, значит, в подмассиве 0 или 1 элемент — сортировать нечего.Улучшенная версия со случайным выбором pivot (чтобы избежать худшего случая):
static Random _rand = new Random();
static int PartitionRandom(int[] arr, int left, int right)
{
// Выбираем случайный индекс между left и right
int randomIndex = _rand.Next(left, right + 1);
// Меняем случайный элемент с последним (наш pivot)
(arr[randomIndex], arr[right]) = (arr[right], arr[randomIndex]);
// Дальше — стандартный Partition с pivot = arr[right]
int pivot = arr[right];
int i = left - 1;
for (int j = left; j < right; j++)
{
if (arr[j] <= pivot)
{
i++;
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
(arr[i + 1], arr[right]) = (arr[right], arr[i + 1]);
return i + 1;
}
static void QuickSortRandom(int[] arr, int left, int right)
{
if (left < right)
{
int pivotIndex = PartitionRandom(arr, left, right);
QuickSortRandom(arr, left, pivotIndex - 1);
QuickSortRandom(arr, pivotIndex + 1, right);
}
}
Со случайным pivot вероятность худшего случая (O(n²)) становится практически нулевой. Это стандартная практика для production-кода.
Сложность алгоритма
Временная сложность
- Средний случай O(n log n) — pivot делит массив примерно пополам. Глубина рекурсии log n, на каждом уровне O(n) операций.
- Худший случай O(n²) — pivot всегда оказывается минимальным или максимальным элементом. Например, если массив уже отсортирован, а pivot — последний элемент. Каждый раз одна часть пустая, другая — n-1. Глубина рекурсии n, O(n²) операций.
- Лучший случай O(n log n) — pivot всегда делит массив ровно пополам.
Пространственная сложность
O(log n) в среднем — из-за рекурсивных вызовов (стек). В худшем случае O(n) (когда глубина рекурсии равна n). Сортировка in-place — дополнительных массивов не создаётся.
💡 O(n²) в худшем случае — главный недостаток Quick Sort. Именно поэтому в стандартных библиотеках (например, C# Introsort) используют гибрид: Quick Sort + Heap Sort, который гарантирует O(n log n) даже в худшем случае.
Плюсы и минусы Quick Sort
✅ Плюсы
- Очень быстрый в среднем (лучший на практике)
- In-place (не требует дополнительной памяти, O(log n) для стека)
- Эффективен на больших массивах
- Хорошая локальность кэша (работает с последовательными участками памяти)
- Легко реализовать рекурсивно
- Используется в основе Introsort (C#)
❌ Минусы
- Худший случай O(n²) при неудачном выборе pivot
- Нестабильная сортировка (порядок равных не сохраняется)
- Рекурсия — может быть StackOverflow для очень больших массивов
- Неэффективен на маленьких массивах (выгоднее Insertion Sort)
- Сложнее для понимания, чем Bubble Sort или Merge Sort
- Не гарантирует O(n log n) без гибридных стратегий
Оптимизации Quick Sort
Для реального использования Quick Sort обычно применяют несколько оптимизаций:
- Случайный pivot — избегает худшего случая на отсортированных массивах.
- Switch на Insertion Sort — для маленьких подмассивов (< 10-20 элементов) используем Insertion Sort. Он быстрее на малых данных.
- Трехстороннее разделение (3-way partition) — если много повторяющихся элементов, делим на три части: < pivot, == pivot, > pivot. Это улучшает производительность на данных с множеством дубликатов.
- Хвостовая рекурсия (tail recursion) — вместо двух рекурсивных вызовов, делаем рекурсию только для меньшей части, а большую обрабатываем итеративно. Экономит память стека.
static void QuickSortOptimized(int[] arr, int left, int right)
{
const int INSERTION_THRESHOLD = 15;
while (left < right)
{
// Оптимизация 1: для маленьких массивов — Insertion Sort
if (right - left + 1 < INSERTION_THRESHOLD)
{
InsertionSort(arr, left, right);
break;
}
// Оптимизация 2: случайный pivot
int pivotIndex = PartitionRandom(arr, left, right);
// Оптимизация 3: рекурсия только для меньшей части
if (pivotIndex - left < right - pivotIndex)
{
QuickSortOptimized(arr, left, pivotIndex - 1);
left = pivotIndex + 1; // большая часть — итеративно
}
else
{
QuickSortOptimized(arr, pivotIndex + 1, right);
right = pivotIndex - 1;
}
}
}
static void InsertionSort(int[] arr, int left, int right)
{
for (int i = left + 1; i <= right; i++)
{
int key = arr[i];
int j = i - 1;
while (j >= left && arr[j] > key)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
Такая реализация уже близка к тому, что используется в Introsort. На практике она будет работать очень быстро и безопасно.
Мини-тест
Проверь понимание Quick Sort.
Вопрос 1
Какой принцип используется в Quick Sort?
A) Разделяй и властвуй B) Жадный алгоритм C) Динамическое программирование
Вопрос 2
Что произойдёт, если всегда выбирать последний элемент как pivot в уже отсортированном массиве?
A) Быстрая сортировка (O(n log n)) B) Квадратичная сложность O(n²) C) Ничего, всё нормально
Вопрос 3
Какая средняя временная сложность Quick Sort?
A) O(n) B) O(n log n) C) O(n²)
Вопрос 4
Что возвращает функция Partition?
A) Отсортированный массив B) Индекс, на котором стоит pivot C) Количество элементов меньше pivot
Вопрос 5
Какая пространственная сложность Quick Sort в среднем?
A) O(1) B) O(log n) C) O(n)
Задача
Задание: Quick Sort для списка строк
Реализуй Quick Sort для сортировки массива строк по алфавиту. Требования:
- Создай массив из 8-10 имён или городов
- Реализуй QuickSort для
string[] - Используй случайный pivot
- Выводи массив после каждого partition (как в примере)
- Добавь счётчик количества сравнений (чтобы увидеть разницу с Bubble Sort)
Пример данных: "Москва", "Париж", "Лондон", "Токио", "Нью-Йорк", "Берлин", "Рим", "Мадрид"
Подсказка: для сравнения строк используй string.Compare(a, b) или a.CompareTo(b). Для случайного pivot используй Random.Next(left, right + 1).
string[]. Строки сравниваются через CompareTo(), а не через >.Что важно запомнить
Тест: 9.3: Быстрая сортировка (Quick Sort)
3 вопроса