Быстрая сортировка (QuickSort)
QuickSort — один из самых быстрых алгоритмов сортировки на практике. Его секрет — стратегия «разделяй и властвуй»: выбрать опорный элемент, разделить массив на две части и рекурсивно отсортировать каждую. Просто в идее, мощно в деле.
🧠 Разделяй и властвуй
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 вопросов