awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Классы сложности

Суть Не все алгоритмы одинаковы. Изучи семь Big-O классов, которые ты встретишь снова и снова, от O(1) постоянного времени до O(n!) факториального времени, и узнай, когда каждый практичен.
◷ 19 min

Ты теперь знаешь, что значит нотация Big-O и как её читать. Но какие Big-O классы ты действительно встретишь? Что они значат на простом английском? Насколько они быстры на практике? Этот урок — каталог семи самых частых классов сложности, упорядоченных от самого быстрого к самому медленному. Для каждого ты увидишь форму, которую он имеет в коде, интуицию масштабирования (сколько операций для n = 1 000 000?) и линию между «практичным» и «невозможным».

Цель

После этого урока ты можешь назвать и узнать все семь основных Big-O классов (O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ), O(n!)), объяснить, что каждый значит, дать пример кода, который его даёт, и знать, какие из них практичны в больших масштабах, а какие нет.

Идея

Есть семь Big-O классов, которые охватывают почти все алгоритмы, которые ты когда-нибудь напишешь или увидишь. Вот они, упорядоченные от самого быстрого (наименьшая стоимость) к самому медленному (наибольшая стоимость):

O(1) — Постоянное время Алгоритм делает одинаковое число операций независимо от того, насколько большой вход. Один поиск, одна проверка, готово. Масштабируется как плоская линия. Для n = 1 000 000: всё ещё 1 операция.

Форма кода: Прямой доступ, без циклов.

function getFirst(array) {
  return array[0];  // O(1)
}

O(log n) — Логарифмическое время Алгоритм делит задачу пополам на каждом шаге (бинарный поиск, разделяй и властвуй). Log₂ от 1 000 000 — примерно 20, поэтому это очень быстро даже для огромных входов. Для n = 1 000 000: примерно 20 операций.

Из курса математики Логарифмы

Форма кода: Раздели область поиска пополам на каждой итерации.

function binarySearch(numbers, target) {
  let left = 0, right = numbers.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (numbers[mid] === target) return mid;
    if (numbers[mid] < target) left = mid + 1;
    else right = mid - 1;
  }
  return -1;  // O(log n)
}

O(n) — Линейное время Алгоритм трогает каждый элемент один раз. Для n = 1 000 000: 1 000 000 операций. Это практично для почти любого размера входа.

Форма кода: Один цикл по входу.

function sumAll(numbers) {
  let sum = 0;
  for (let num of numbers) {
    sum += num;
  }
  return sum;  // O(n)
}

O(n log n) — Линеарифмическое время Алгоритм сортирует или делит с умом. Для n = 1 000 000: 1 000 000 × 20 ≈ 20 000 000 операций. Всё ещё практично; это цена большинства эффективных алгоритмов сортировки (merge sort, quicksort).

Форма кода: Часто один цикл, который делает O(log n) работы за итерацию, или алгоритм разделяй-и-властвуй.

// Merge sort делает O(n log n) работы всего
function mergeSort(numbers) {
  if (numbers.length <= 1) return numbers;
  const mid = Math.floor(numbers.length / 2);
  const left = mergeSort(numbers.slice(0, mid));
  const right = mergeSort(numbers.slice(mid));
  return merge(left, right);  // O(n log n)
}

O(n²) — Квадратичное время Вложенные циклы: для каждого элемента ты трогаешь все остальные элементы. Для n = 1 000 000: 1 триллион операций. Практично только для малых входов (до нескольких тысяч элементов).

Форма кода: Вложенные циклы.

function allPairs(numbers) {
  for (let i = 0; i < numbers.length; i++) {
    for (let j = i + 1; j < numbers.length; j++) {
      console.log(numbers[i], numbers[j]);
    }
  }  // O(n²)
}

O(2ⁿ) — Экспоненциальное время На каждый дополнительный элемент операции удваиваются. Для n = 20: 1 миллион операций. Для n = 30: 1 миллиард. Для n = 40: 1 триллион. Практично только для очень малых входов (n ≤ ~30).

Форма кода: Рекурсивное исследование всех подмножеств или комбинаций.

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);  // O(2ⁿ) — создаёт экспоненциальное дерево
}

O(n!) — Факториальное время На каждый дополнительный элемент операции умножаются: 1, 2, 6, 24, 120, 720, … . Для n = 10: 3 600 000 операций. Для n = 12: 480 000 000 операций. Практично только для крошечных входов (n ≤ ~10).

Форма кода: Создай и проверь все перестановки.

function allPermutations(items) {
  if (items.length <= 1) return [items];
  const result = [];
  for (let i = 0; i < items.length; i++) {
    const rest = items.slice(0, i).concat(items.slice(i + 1));
    for (let perm of allPermutations(rest)) {
      result.push([items[i], ...perm]);
    }
  }
  return result;  // O(n!)
}

Эти семь классов составляют основной спектр. Некоторые алгоритмы падают между ними (O(n^1.5), O(n²log n)), но семь выше — это те, которые ты встретишь чаще всего.

Код

Давайте увидим все семь классов в реальном коде и поймём, когда каждый появляется.

Класс 1: O(1) — Получить по индексу

function getIndex(array, index) {
  return array[index];  // Всегда 1 поиск, независимо от размера массива
}

Класс 2: O(log n) — Бинарный поиск

function binarySearch(sortedArray, target) {
  let left = 0, right = sortedArray.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (sortedArray[mid] === target) return mid;
    if (sortedArray[mid] < target) left = mid + 1;
    else right = mid - 1;
  }
  return -1;  // Каждая итерация исключает половину пространства поиска
}

Класс 3: O(n) — Линейное сканирование

function findMax(numbers) {
  let max = numbers[0];
  for (let i = 1; i < numbers.length; i++) {
    if (numbers[i] > max) max = numbers[i];
  }
  return max;  // Коснись каждого элемента один раз
}

Класс 4: O(n log n) — Merge sort

function mergeSort(arr) {
  if (arr.length <= 1) return arr;
  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));
  return merge(left, right);
  // Глубина рекурсии: log n
  // Слияние на каждой глубине: n операций
  // Всего: n log n
}

Класс 5: O(n²) — Bubble sort

function bubbleSort(arr) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = 0; j < arr.length - i - 1; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;  // Два вложенных цикла: n²
}

Класс 6: O(2ⁿ) — Все подмножества (power set)

function powerSet(items) {
  if (items.length === 0) return [[]];
  const rest = powerSet(items.slice(1));
  return rest.concat(rest.map(subset => [items[0], ...subset]));
  // Для каждого нового элемента число подмножеств удваивается
}

Класс 7: O(n!) — Все перестановки

function permutations(items) {
  if (items.length <= 1) return [items];
  const result = [];
  for (let i = 0; i < items.length; i++) {
    const first = items[i];
    const rest = items.slice(0, i).concat(items.slice(i + 1));
    for (let perm of permutations(rest)) {
      result.push([first, ...perm]);
    }
  }
  return result;  // (n-1)! × n = n! всего перестановок
}

Попробуй их на малых входах, чтобы почувствовать разницу:

Вывод
 
Пошаговый разбор

Давайте пробежим, как экспоненциальная сложность взрывается. Пробеги наивный рекурсивный Fibonacci:

1 function fib(n) {
2 if (n <= 1) return n;
3 return fib(n-1) + fib(n-2);
4 }

Каждый дополнительный n создаёт примерно в 2× больше рекурсивных вызовов. Это O(2ⁿ).

Сложность

Вот полный спектр семи классов, масштабированный к n = 1 000 000:

КлассДля n = 1 000 000Практично?
O(1)1✓ Мгновенно
O(log n)20✓ Мгновенно
O(n)1 000 000✓ Быстро (< 1 сек)
O(n log n)20 000 000✓ Быстро (< 1 сек)
O(n²)1 000 000 000 000✗ Непрактично (1000+ часов)
O(2ⁿ)2^1 000 000✗ Невозможно
O(n!)1 000 000!✗ Невозможно

Для практического масштаба:

  • O(1), O(log n), O(n), O(n log n) быстрые. Используй эти.
  • O(n²) работает только для малых входов (n ≤ несколько тысяч).
  • O(2ⁿ) только для крошечных входов (n ≤ ~30).
  • O(n!) только для очень крошечных входов (n ≤ ~10).
input size n operations O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ)

Заметь драматическую разницу: O(n) и O(n log n) почти одинаковы для больших n. O(n²) в тысячи раз хуже. O(2ⁿ) просто безумный.

Практика 0 / 5

Тебе нужно найти число в отсортированном списке из 1 000 000 элементов. Должен ли ты использовать бинарный поиск (O(log n)) или линейный поиск (O(n))?

У тебя есть O(n²) алгоритм. Для n = 1 000 он делает 1 000 000 операций. Для n = 10 000 примерно сколько операций?

Алгоритм, который генерирует все перестановки n элементов. Какой его Big-O?

Ты хочешь найти максимальный элемент в неотсортированном списке. Должен ли ты сначала отсортировать его и потом взять последний элемент, или просканировать его один раз?

Упорядочи эти классы от самого быстрого к самому медленному (при росте n):

  1. O(n!)
  2. O(n²)
  3. O(2ⁿ)
  4. O(n log n)
  5. O(1)
Граничные случаи

Суперполиномиальная против полиномиальной. O(1), O(n), O(n log n), O(n²), O(n³) все полиномиальное время — они растут как степень n. O(2ⁿ) и O(n!) суперполиномиальные — они растут намного быстрее. В компьютерной науке граница между полиномиальной и суперполиномиальной это граница между «разрешимым в масштабе» и «только для крошечных входов». Эта разница появляется везде, от NP-полноты к квантовым вычислениям.

Частая ошибка

Путаница O(2ⁿ) и O(n²). Они звучат похоже, но они совершенно разные. O(n²) для n=100 это 10 000. O(2ⁿ) для n=100 это 2^100 ≈ 10^30, что невообразимо большое. Никогда их не путай. O(2ⁿ) это экспоненциальная; O(n²) это квадратичная.

Проверь себя
Викторина

Ты должен обработать миллион элементов. Какой Big-O класс ты определённо избежишь?

Итог

Есть семь главных классов сложности, упорядоченных от самого быстрого к самому медленному:

  1. O(1) — Постоянное время. Прямой доступ, без циклов. Мгновенно.
  2. O(log n) — Логарифмическое время. Раздели пополам на каждом шаге (бинарный поиск). ~20 операций для миллиона элементов.
  3. O(n) — Линейное время. Один цикл. Коснись каждого элемента один раз. Практично для любого размера.
  4. O(n log n) — Линеарифмическое время. Умная сортировка или разделяй-и-властвуй. ~20 миллионов операций для миллиона элементов.
  5. O(n²) — Квадратичное время. Вложенные циклы. Практично только для малых входов (до нескольких тысяч).
  6. O(2ⁿ) — Экспоненциальное время. Все подмножества, наивная рекурсия. Только для крошечных входов (n ≤ ~30).
  7. O(n!) — Факториальное время. Все перестановки. Только для очень крошечных входов (n ≤ ~10).

Линия между «разрешимым» и «невозможным» пробегает между O(n log n) и O(n²). Когда ты проектируешь алгоритм, выбери класс, который подходит для размера твоей задачи. Для миллиона элементов тебе нужны O(1), O(log n), O(n) или O(n log n). Всё остальное слишком медленно.

Продолжить восхождение ↑Время и память
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.