$ sudo teach IT
РАЗДЕЛ 11 · УРОК 4

Быстрая сортировка (QuickSort)

QuickSort — один из самых быстрых алгоритмов сортировки на практике. Его секрет — стратегия «разделяй и властвуй»: выбрать опорный элемент, разделить массив на две части и рекурсивно отсортировать каждую. Просто в идее, мощно в деле.

⏱ ~30 минут ⚡ QuickSort O(n log n)

🧠 Разделяй и властвуй

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

Аналогия: нужно навести порядок в большой квартире. Подход «разделяй и властвуй»: раздели на комнаты, каждую комнату раздели на зоны, каждую зону на отдельные предметы — наводи порядок с маленького, потом всё сложится в большой результат.

🎯 QuickSort в трёх шагах:
1. Выбрать pivot (опорный элемент)
2. Разделить массив: всё меньше pivot — влево, всё больше — вправо
3. Рекурсивно применить тот же процесс к левой и правой частям

Почему это работает? После каждого разделения pivot стоит на своём правильном месте — элементы слева меньше него, справа больше. Если рекурсивно упорядочить обе половины, весь массив окажется отсортированным.

🎯 Как выбирают опорный элемент (pivot)

Выбор pivot критически влияет на производительность. Рассмотрим варианты:

Первый/последний элемент

Просто, но опасно: если массив уже отсортирован — вырождается в O(n²). Классическая ловушка.

Средний элемент

Хороший вариант для учебных примеров. Работает разумно на большинстве данных.

Случайный элемент

Лучшая стратегия на практике — рандомизация исключает вырождение для конкретных данных.

Медиана из трёх

Взять первый, средний и последний — выбрать медиану. Оптимальный выбор для промышленного кода.

В нашей учебной реализации будем использовать последний элемент как pivot — это самый простой вариант для понимания алгоритма. Для боевого кода лучше рандомизация.

📊 Пошаговая визуализация: [5, 3, 8, 1, 4]

Разберём как quickSort работает с массивом [5, 3, 8, 1, 4]. Для наглядности используем фильтрацию через filter — чуть медленнее «in-place» варианта, но идеально читаемо.

Уровень Массив Pivot left (меньше) right (больше)
Первый вызов [5, 3, 8, 1, 4] 4 [3, 1] [5, 8]
Лево [3, 1] [3, 1] 1 [] [3]
└ [3] [3] Один элемент — уже отсортирован ✅
Право [5, 8] [5, 8] 8 [5] []
└ [5] [5] Один элемент — уже отсортирован ✅
Результат [1] + [3] + [4] + [5] + [8] = [1, 3, 4, 5, 8] ✅

Посмотрите на структуру: каждый раз массив делится на две части относительно pivot. Pivot занимает своё финальное место, затем рекурсия обрабатывает левую и правую части независимо. Дерево вызовов растёт в глубину log n — отсюда O(n log n).

💻 Наглядная реализация через filter

Начнём с самой читаемой версии — через filter. Она не самая эффективная по памяти, зато идеально иллюстрирует алгоритм.

function quickSort(arr) {
  // Базовый случай: массив из 0 или 1 элемента уже отсортирован
  if (arr.length <= 1) return arr;

  const pivot = arr[arr.length - 1]; // берём последний элемент как pivot

  // Все элементы МЕНЬШЕ pivot
  const left = arr.slice(0, -1).filter(x => x <= pivot);

  // Все элементы БОЛЬШЕ pivot
  const right = arr.slice(0, -1).filter(x => x > pivot);

  // Рекурсивно сортируем обе части, потом соединяем
  return [...quickSort(left), pivot, ...quickSort(right)];
}

console.log(quickSort([5, 3, 8, 1, 4]));
// [1, 3, 4, 5, 8]

console.log(quickSort([10, 2, 7, 5, 1, 9, 3]));
// [1, 2, 3, 5, 7, 9, 10]

Разберём код построчно:

  • if (arr.length <= 1) return arr — базовый случай рекурсии: один элемент или пустой массив уже «отсортированы»
  • pivot = arr[arr.length - 1] — берём последний элемент как опорный
  • filter(x => x <= pivot) — все меньшие или равные уходят влево
  • filter(x => x > pivot) — все большие уходят вправо
  • [...quickSort(left), pivot, ...quickSort(right)] — рекурсия + сборка результата

💡 Рекурсия — ключ к пониманию: функция вызывает саму себя для меньших подзадач. Каждый вызов делает немного меньше работы, пока не дойдёт до базового случая — массива из одного элемента. Это и есть сила divide & conquer.

🔢 Почему O(n log n)

Давайте поймём, откуда берётся n log n интуитивно.

При хорошем выборе pivot каждый вызов делит массив примерно пополам. Это означает:

  • log n уровней рекурсии — каждый раз массив делится пополам, и так пока не дойдём до единичных элементов
  • На каждом уровне — n работы — суммарно на каждом уровне рекурсии обрабатываем все n элементов
  • Итого: n × log n

Визуализация для n = 8:

Уровень 0: [8 элементов]                    — 8 операций
Уровень 1: [4 элемента] [4 элемента]         — 4 + 4 = 8 операций
Уровень 2: [2][2]       [2][2]               — 2+2+2+2 = 8 операций
Уровень 3: [1][1][1][1] [1][1][1][1]         — 8 × 1 = 8 операций
                                               Итого: 4 уровня × 8 = log₂(8) × 8
Лучший/Средний

O(n log n) — pivot делит поровну

Худший случай

O(n²) — pivot всегда минимальный или максимальный

Память

O(log n) — стек рекурсии (in-place вариант)

⚠️ Худший случай O(n²) происходит, когда pivot всегда оказывается минимальным или максимальным элементом — тогда одна из частей пустая, а другая содержит n−1 элементов. Именно поэтому для отсортированных данных с pivot = последний элемент — это ловушка.

⚖️ QuickSort vs пузырьковая: сравнение

Наглядно видно, почему QuickSort вытеснил пузырьковую сортировку в реальном коде:

n элементов 🫧 Пузырьковая O(n²) ⚡ QuickSort O(n log n) Выигрыш
100 10 000 664 15×
1 000 1 000 000 9 966 100×
10 000 100 000 000 132 877 750×
1 000 000 ~32 года ~20 сек 50 000×

При миллионе элементов QuickSort быстрее пузырьковой сортировки в 50 000 раз. Это и есть сила алгоритмов.

✅ Итоги урока

  • QuickSort использует стратегию «разделяй и властвуй»
  • Три шага: выбрать pivot, разделить на меньшее/большее, рекурсивно отсортировать части
  • Наглядная реализация через filter — хорошо для понимания, чуть медленнее по памяти
  • Сложность O(n log n) в среднем, O(n²) в худшем (неудачный pivot)
  • Рандомизация pivot защищает от вырождения в O(n²)
  • При миллионе элементов — в 50 000 раз быстрее пузырьковой сортировки
  • В реальном коде — используйте встроенный .sort(), но QuickSort важен для понимания divide & conquer

Следующий урок — сортировка слиянием: ещё один divide & conquer, но с гарантированным O(n log n) даже в худшем случае. 🔀

Быстрая сортировка (QuickSort)

6 вопросов

Быстрая сортировка через filter

Реализуйте функцию quickSort(arr), которая сортирует массив чисел по возрастанию с помощью быстрой сортировки (QuickSort). Используйте последний элемент как pivot. Для разделения используйте метод filter. Базовый случай: массив из 0 или 1 элемента. Возвращайте новый отсортированный массив, не мутируя исходный.

QuickSort со случайным pivot

Реализуйте функцию quickSortRandom(arr), которая работает как быстрая сортировка, но выбирает pivot случайным образом (используйте Math.random). Базовый случай: массив из 0 или 1 элемента. Разделение через filter. Возвращайте новый массив.