$ sudo teach IT
Модуль 9 · Сортировка

Урок 9.3 — Быстрая сортировка (Quick Sort)

Изучим один из самых быстрых алгоритмов сортировки — Quick Sort. Разделяй и властвуй, выбор pivot и рекурсия.

«Быстрая сортировка» (Quick Sort) получила своё название не просто так. В среднем это самый быстрый из известных алгоритмов сортировки сравнением. Его придумал Энтони Хоар (Tony Hoare) в 1959 году, когда работал над переводом текстов на иностранные языки. Сегодня Quick Sort лежит в основе многих стандартных библиотек сортировки.

🔱

Принцип «Разделяй и властвуй»

Разделяй и властвуй (Divide and Conquer) — это стратегия, в которой задача разбивается на меньшие подзадачи того же типа, решаются они, а затем результаты объединяются.

Quick Sort работает так:

  1. Выбрать опорный элемент (pivot) — любой элемент массива (обычно первый, последний или средний).
  2. Разделить (partition) — переставить элементы так, чтобы слева от pivot оказались элементы меньше него, а справа — больше или равные.
  3. Рекурсивно применить шаги 1-2 к левой и правой частям.
  4. Базовый случай — подмассив из 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). Мы рассмотрим схему Ломуто — она проще для понимания.

C# · Partition (схема Ломуто)
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:

Пошагово · Partition
Начало: [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 = последний элемент.

Визуализация · Quick Sort
Вызов 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]
💡 Глубина рекурсии зависит от того, насколько удачно мы выбираем pivot. В идеале — O(log n). В худшем случае (всегда выбираем минимальный или максимальный элемент) — O(n).
💻

Рекурсивная реализация на C#

Теперь соединим Partition и рекурсию в полную реализацию Quick Sort:

C# · 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 (чтобы избежать худшего случая):

C# · Quick Sort (случайный 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 обычно применяют несколько оптимизаций:

  1. Случайный pivot — избегает худшего случая на отсортированных массивах.
  2. Switch на Insertion Sort — для маленьких подмассивов (< 10-20 элементов) используем Insertion Sort. Он быстрее на малых данных.
  3. Трехстороннее разделение (3-way partition) — если много повторяющихся элементов, делим на три части: < pivot, == pivot, > pivot. Это улучшает производительность на данных с множеством дубликатов.
  4. Хвостовая рекурсия (tail recursion) — вместо двух рекурсивных вызовов, делаем рекурсию только для меньшей части, а большую обрабатываем итеративно. Экономит память стека.
C# · Quick Sort с оптимизациями
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)

💡 Ответы: 1 — A, 2 — B (худший случай), 3 — B, 4 — B, 5 — B
🎮

Задача

Задание: Quick Sort для списка строк

Реализуй Quick Sort для сортировки массива строк по алфавиту. Требования:

  1. Создай массив из 8-10 имён или городов
  2. Реализуй QuickSort для string[]
  3. Используй случайный pivot
  4. Выводи массив после каждого partition (как в примере)
  5. Добавь счётчик количества сравнений (чтобы увидеть разницу с Bubble Sort)

Пример данных: "Москва", "Париж", "Лондон", "Токио", "Нью-Йорк", "Берлин", "Рим", "Мадрид"

Подсказка: для сравнения строк используй string.Compare(a, b) или a.CompareTo(b). Для случайного pivot используй Random.Next(left, right + 1).

💡 В задаче важно не просто скопировать код, а адаптировать его для string[]. Строки сравниваются через CompareTo(), а не через >.
📌

Что важно запомнить

1️⃣
Quick Sort — «разделяй и властвуй». Выбираем pivot, разделяем массив, рекурсивно сортируем части.
2️⃣
Partition — ключевая операция. Переставляет элементы так, чтобы все «меньше pivot» были слева, «больше» — справа.
3️⃣
Средняя сложность O(n log n), худшая O(n²). Случайный pivot практически устраняет риск худшего случая.
4️⃣
In-place, нестабильный. Дополнительная память — O(log n) для стека рекурсии (в среднем).
5️⃣
На практике: используй оптимизированную версию со случайным pivot и переключением на Insertion Sort для малых массивов.

Тест: 9.3: Быстрая сортировка (Quick Sort)

3 вопроса

Сортировка строк

Premium