Quick Sort (быстрая сортировка)

Краткое описание

Алгоритм «разделяй и властвуй»: выбирает опорный элемент (pivot), разбивает массив на элементы меньше/больше pivot и рекурсивно сортирует части. В среднем Quick Sort работает за O(n log n), но при неудачном выборе pivot может деградировать до O(n²).

Когда использовать

  • Большие массивы в памяти — на практике Quick Sort часто очень быстрый из‑за небольших накладных расходов и хорошего поведения на кэше
  • Ограниченная доп. память — типичная реализация сортирует «на месте» (in-place), доп. память в среднем O(log n) на стек рекурсии
  • Нужна скорость, но не нужна стабильность — Quick Sort обычно нестабилен (может менять порядок равных элементов)
  • Можно контролировать pivot — рандомизация pivot заметно снижает шанс попасть в худший случай на «плохих» входах

Когда не стоит использовать:

  • Нужна гарантированная O(n log n) в худшем случае — Quick Sort может дать O(n²) при неудачном выборе pivot
  • Требуется стабильность — порядок равных элементов может измениться
  • Ограниченный стек — рекурсия может быть глубокой в худшем случае (O(n))

С объяснением логики работы

/**
 * Quick Sort (in-place) с рандомизированным pivot.
 * Сортирует массив чисел по возрастанию.
 *
 * Идея:
 * 1) Выбираем pivot (опорный элемент)
 * 2) Делим массив так, чтобы:
 *    - слева были элементы <= pivot
 *    - справа были элементы  > pivot
 * 3) Рекурсивно сортируем левую и правую часть
 */
 
// Вспомогательная функция: обмен элементов массива местами
function swap(arr, i, j) {
  [arr[i], arr[j]] = [arr[j], arr[i]];
}
 
/**
 * Разбиение Lomuto partition:
 * - pivot берём как arr[right]
 * - i указывает, куда ставить следующий элемент <= pivot
 * Возвращает итоговый индекс pivot после разбиения.
 */
function partitionLomuto(arr, left, right) {
  const pivot = arr[right];       // опорный элемент
  let i = left;                   // граница области "<= pivot"
 
  // j пробегает по всем элементам, кроме pivot (right)
  for (let j = left; j < right; j++) {
    // Если текущий элемент должен быть слева (<= pivot)
    if (arr[j] <= pivot) {
      // Меняем местами arr[j] и arr[i], тем самым расширяя левую часть
      swap(arr, i, j);
      i++;
    }
  }
 
  // После цикла i — место, куда должен встать pivot
  swap(arr, i, right);
  return i;
}
 
/**
 * Основная функция Quick Sort.
 * Сортирует arr "на месте" и возвращает arr (для удобства).
 */
function quickSort(arr, left = 0, right = arr.length - 1) {
  // Базовый случай: 0 или 1 элемент — уже отсортировано
  if (left >= right) return arr;
 
  // Рандомизируем pivot: выбираем случайный индекс и ставим его в конец (right),
  // чтобы partitionLomuto всегда использовал arr[right] как pivot.
  const pivotIndex = left + Math.floor(Math.random() * (right - left + 1));
  swap(arr, pivotIndex, right);
 
  // Разбиваем массив, получаем позицию pivot в окончательном месте
  const p = partitionLomuto(arr, left, right);
 
  // Теперь:
  // arr[left .. p-1] <= arr[p]
  // arr[p+1 .. right] > arr[p]
  // Рекурсивно сортируем обе части
  quickSort(arr, left, p - 1);
  quickSort(arr, p + 1, right);
 
  return arr;
}
 
// Пример
const a = [10, 7, 8, 9, 1, 5];
quickSort(a);
console.log(a); // [1, 5, 7, 8, 9, 10]

С анализом сложностей

/**
 * Quick Sort (in-place, randomized pivot)
 *
 * ВРЕМЕННАЯ СЛОЖНОСТЬ:
 * - Лучший случай:   O(n log n) — разбиения близки к половинам (pivot каждый раз “удачный”)
 * - Средний случай:  O(n log n) — ожидаемая сложность при случайном/рандомизированном pivot
 * - Худший случай:   O(n²)      — разбиения крайне неравномерные (например, 0 и n-1)
 *
 * ПРОСТРАНСТВЕННАЯ СЛОЖНОСТЬ:
 * - Доп. память:     O(1)       — разбиение делаем без дополнительных массивов (in-place)
 * - Стек рекурсии:   O(log n) в среднем, O(n) в худшем случае (глубокая рекурсия)
 *
 * ВАЖНО:
 * - Обычно НЕ стабильный алгоритм: равные элементы могут поменяться местами.
 * - Рандомизация pivot снижает вероятность худшего случая на "плохих" входах.
 */
 
function swap(arr, i, j) {
  [arr[i], arr[j]] = [arr[j], arr[i]];
}
 
function partitionLomuto(arr, left, right) {
  // Время: O(right-left) сравнений, т.е. O(n) на текущем уровне
  // Память: O(1)
  const pivot = arr[right];
  let i = left;
 
  for (let j = left; j < right; j++) {
    // Каждая проверка O(1), всего ~n проверок на уровне
    if (arr[j] <= pivot) {
      // Swap O(1)
      swap(arr, i, j);
      i++;
    }
  }
 
  swap(arr, i, right);
  return i;
}
 
function quickSort(arr, left = 0, right = arr.length - 1) {
  // База рекурсии — O(1)
  if (left >= right) return arr;
 
  // Рандомизированный pivot: O(1)
  const pivotIndex = left + Math.floor(Math.random() * (right - left + 1));
  swap(arr, pivotIndex, right);
 
  // Разбиение: O(n) на текущем диапазоне
  const p = partitionLomuto(arr, left, right);
 
  // Рекурсия:
  // T(n) = T(k) + T(n-k-1) + O(n), где O(n) — partition
  // - При хороших разбиениях: глубина ~log n, суммарно O(n log n)
  // - При плохих разбиениях: глубина ~n, суммарно O(n²)
  quickSort(arr, left, p - 1);
  quickSort(arr, p + 1, right);
 
  return arr;
}