Алгоритмы с нуля
Классы сложности
Ты теперь знаешь, что значит нотация 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).
Заметь драматическую разницу: O(n) и O(n log n) почти одинаковы для больших n. O(n²) в тысячи раз хуже. O(2ⁿ) просто безумный.
Тебе нужно найти число в отсортированном списке из 1 000 000 элементов. Должен ли ты использовать бинарный поиск (O(log n)) или линейный поиск (O(n))?
У тебя есть O(n²) алгоритм. Для n = 1 000 он делает 1 000 000 операций. Для n = 10 000 примерно сколько операций?
Алгоритм, который генерирует все перестановки n элементов. Какой его Big-O?
Ты хочешь найти максимальный элемент в неотсортированном списке. Должен ли ты сначала отсортировать его и потом взять последний элемент, или просканировать его один раз?
Упорядочи эти классы от самого быстрого к самому медленному (при росте n):
- O(n!)
- O(n²)
- O(2ⁿ)
- O(n log n)
- 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 класс ты определённо избежишь?
Есть семь главных классов сложности, упорядоченных от самого быстрого к самому медленному:
- O(1) — Постоянное время. Прямой доступ, без циклов. Мгновенно.
- O(log n) — Логарифмическое время. Раздели пополам на каждом шаге (бинарный поиск). ~20 операций для миллиона элементов.
- O(n) — Линейное время. Один цикл. Коснись каждого элемента один раз. Практично для любого размера.
- O(n log n) — Линеарифмическое время. Умная сортировка или разделяй-и-властвуй. ~20 миллионов операций для миллиона элементов.
- O(n²) — Квадратичное время. Вложенные циклы. Практично только для малых входов (до нескольких тысяч).
- O(2ⁿ) — Экспоненциальное время. Все подмножества, наивная рекурсия. Только для крошечных входов (n ≤ ~30).
- O(n!) — Факториальное время. Все перестановки. Только для очень крошечных входов (n ≤ ~10).
Линия между «разрешимым» и «невозможным» пробегает между O(n log n) и O(n²). Когда ты проектируешь алгоритм, выбери класс, который подходит для размера твоей задачи. Для миллиона элементов тебе нужны O(1), O(log n), O(n) или O(n log n). Всё остальное слишком медленно.