Сложность алгоритма показывает, как растёт число шагов или объём памяти, когда растут входные данные. Её записывают в O-нотации: O(n) — шагов примерно столько же, сколько элементов, O(n²) — каждый элемент сравнивается с каждым, O(log n) — каждый шаг отбрасывает половину данных. На 50 тестовых записях разница незаметна, на 100 000 она превращает миллисекунды в секунды, и чаще всего её убирает правильная структура данных — Set или Map вместо поиска по массиву.
После материала вы сможете
- оценить сложность простого алгоритма: O(1), O(log n), O(n), O(n log n), O(n²)
- найти вложенный цикл, спрятанный в includes, indexOf или find, и заменить его поиском через Set или Map
- написать бинарный поиск и проверить его тестами на граничные случаи
- объяснить, что гарантирует и чего не гарантирует Array.prototype.sort
Что знать заранее
Проверено на
Node.js 24.18 LTS (V8 13.6)node:test
Сверено с документацией: 11 октября 2026 г.
Содержание
Что такое алгоритм и зачем оценивать сложность
Алгоритм — последовательность действий, которая решает задачу и даёт одинаковый результат при каждом повторении. MDN в глоссарии добавляет главное для этого урока: эффективность алгоритмов сравнивают через алгоритмическую сложность, или O-нотацию (Big O).
Типичная история: функция поиска дублей в заказах отлично работала на тестовых 50 записях, а после импорта базы за три года страница отчёта стала открываться десятки секунд. Код не изменился — изменился объём данных. Оценка сложности позволяет увидеть такую проблему до того, как её увидят пользователи.
- Временная сложность — как растёт число операций при росте числа элементов n
- Пространственная сложность — сколько дополнительной памяти нужно алгоритму
- Обычно оценивают худший случай: искомого элемента нет, массив перевёрнут, все записи уникальны
Big O на пальцах: как растёт число шагов
O-нотация описывает не время в миллисекундах, а форму роста. Константы и мелкие слагаемые отбрасываются: цикл, который делает 3 действия на элемент, и цикл, который делает одно, — оба O(n). Важно другое: что будет с числом шагов, если данных станет в 10 или 1000 раз больше.
Проверим на практике и посчитаем не время, а сравнения — так результат одинаков на любом компьютере:
// Считаем не миллисекунды, а шаги — число сравнений
const range = (n) => Array.from({ length: n }, (_, i) => i);
// O(n): в худшем случае просматриваем все элементы
function linearSteps(list, target) {
let steps = 0;
for (const value of list) {
steps++;
if (value === target) break;
}
return steps;
}
// O(n²): каждый элемент сравниваем с каждым
function pairSteps(n) {
let steps = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) steps++;
}
return steps;
}
// O(log n): каждый шаг отбрасывает половину отсортированного массива
function binarySteps(sorted, target) {
let low = 0;
let high = sorted.length - 1;
let steps = 0;
while (low <= high) {
steps++;
const middle = Math.floor((low + high) / 2);
if (sorted[middle] === target) break;
if (sorted[middle] < target) low = middle + 1;
else high = middle - 1;
}
return steps;
}
const sizes = [10, 1000, 100000];
console.log(sizes.map((n) => linearSteps(range(n), -1))); // [ 10, 1000, 100000 ]
console.log(sizes.map((n) => binarySteps(range(n), -1))); // [ 3, 9, 16 ]
console.log(sizes.slice(0, 2).map((n) => pairSteps(n))); // [ 100, 1000000 ]node steps.mjs. Ищем отсутствующее значение -1 — это худший случай для линейного поиска.10
- O(log n)
- ≈ 3
- O(n)
- 10
- O(n log n)
- ≈ 33
- O(n²)
- 100
1 000
- O(log n)
- ≈ 10
- O(n)
- 1 000
- O(n log n)
- ≈ 10 000
- O(n²)
- 1 000 000
100 000
- O(log n)
- ≈ 17
- O(n)
- 100 000
- O(n log n)
- ≈ 1,7 млн
- O(n²)
- 10 млрд
Типовые классы сложности с примерами
O(1)
- Как растёт
- Не зависит от n
- Пример в JavaScript
- Чтение
array[i]иarray.length, арифметика над одним значением
O(log n)
- Как растёт
- Медленно: удвоение n добавляет один шаг
- Пример в JavaScript
- Бинарный поиск в отсортированном массиве
O(n)
- Как растёт
- Пропорционально n
- Пример в JavaScript
for...of,map,filter,find,includes,indexOf
O(n log n)
- Как растёт
- Чуть хуже линейного: n, умноженное на логарифм
- Пример в JavaScript
- Сортировка сравнением — в V8 это TimSort, о нём ниже
O(n²)
- Как растёт
- Квадратично: удвоение n — вчетверо больше работы
- Пример в JavaScript
- Вложенный цикл «каждый с каждым»;
includesвнутриfilter
С Map и Set есть тонкость. Спецификация не называет конкретную сложность, а требует, чтобы доступ к элементу был в среднем сублинейным, то есть быстрее O(n). MDN поясняет: внутри может быть хеш-таблица с O(1) или дерево поиска с O(log n). Поэтому корректно говорить «set.has в среднем быстрее, чем перебор», а не обещать O(1) в любых условиях. MDN для Set говорит прямо: has в среднем быстрее, чем Array.prototype.includes на массиве той же длины.
Поиск: линейный и бинарный
Линейный поиск проверяет элементы по очереди — O(n), работает на любом массиве. Бинарный поиск требует отсортированного массива, зато каждый шаг смотрит на середину оставшегося диапазона и отбрасывает половину. Для 100 000 элементов это не больше 17 сравнений вместо 100 000.
/**
* Бинарный поиск в массиве, отсортированном по возрастанию.
* Возвращает индекс найденного элемента или -1. Сложность — O(log n).
*/
export function binarySearch(sorted, target) {
let low = 0;
let high = sorted.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const value = sorted[middle];
if (value === target) return middle;
if (value < target) low = middle + 1; // искомое правее середины
else high = middle - 1; // искомое левее середины
}
return -1;
}В бинарном поиске легко ошибиться на единицу: написать low < high вместо low <= high или забыть + 1. Поэтому сразу пишем тесты на встроенном в Node.js модуле node:test: граничные случаи и сверку с indexOf на массивах разной длины.
import { test } from "node:test";
import assert from "node:assert/strict";
import { binarySearch } from "./binary-search.mjs";
const prices = [490, 900, 1200, 1500, 2300, 4100, 7800];
test("находит первый, средний и последний элемент", () => {
assert.equal(binarySearch(prices, 490), 0);
assert.equal(binarySearch(prices, 1500), 3);
assert.equal(binarySearch(prices, 7800), 6);
});
test("возвращает -1, если элемента нет", () => {
assert.equal(binarySearch(prices, 100), -1); // меньше всех
assert.equal(binarySearch(prices, 1000), -1); // между соседями
assert.equal(binarySearch(prices, 9999), -1); // больше всех
});
test("пустой массив и массив из одного элемента", () => {
assert.equal(binarySearch([], 5), -1);
assert.equal(binarySearch([5], 5), 0);
assert.equal(binarySearch([5], 7), -1);
});
test("совпадает с indexOf на массивах разной длины", () => {
for (let n = 0; n <= 64; n++) {
const evens = Array.from({ length: n }, (_, i) => i * 2); // 0, 2, 4, …
for (let target = -1; target <= n * 2; target++) {
assert.equal(binarySearch(evens, target), evens.indexOf(target), `n=${n}, target=${target}`);
}
}
});node --test binary-search.test.mjs → 4 теста, pass 4, fail 0. Если заменить low <= high на low < high, падают 3 из 4.Сортировка: что делает Array.prototype.sort
Сортировка — самая частая «тяжёлая» операция в прикладном коде. Что о ней важно знать по документации MDN:
sortсортирует массив на месте и возвращает тот же массив;toSortedвозвращает копию- без функции сравнения элементы превращаются в строки и сравниваются по кодам UTF-16, поэтому
100оказывается раньше25 - с ECMAScript 2019 сортировка обязана быть стабильной: равные элементы сохраняют исходный порядок
- временная и пространственная сложность спецификацией не гарантированы — они зависят от реализации
- функция сравнения должна быть последовательной: для одной пары всегда один ответ, иначе поведение не определено
// 1. Без функции сравнения элементы сравниваются как строки
console.log([25, 100, 3].toSorted()); // [ 100, 25, 3 ]
console.log([25, 100, 3].toSorted((a, b) => a - b)); // [ 3, 25, 100 ]
// 2. Строки по кодам UTF-16: «ё» оказывается после «я»
const words = ["яблоко", "ёж", "ель"];
console.log(words.toSorted()); // [ 'ель', 'яблоко', 'ёж' ]
console.log(words.toSorted((a, b) => a.localeCompare(b, "ru"))); // [ 'ёж', 'ель', 'яблоко' ] — по правилам русской сортировки ё идёт вместе с е
// 3. Стабильность (ES2019+): равные элементы сохраняют исходный порядок
const orders = [
{ id: 1, status: "new" },
{ id: 2, status: "paid" },
{ id: 3, status: "new" },
{ id: 4, status: "paid" },
];
const byStatus = orders.toSorted((a, b) => a.status.localeCompare(b.status));
console.log(byStatus.map((order) => order.id)); // [ 1, 3, 2, 4 ]
// 4. Сколько раз вызывается сравнение — деталь реализации V8, не гарантия спецификации
function countComparisons(list) {
let calls = 0;
list.toSorted((a, b) => {
calls++;
return a - b;
});
return calls;
}
let seed = 1;
const random = () => (seed = (seed * 48271) % 2147483647); // воспроизводимые «случайные» числа
const shuffled = Array.from({ length: 1000 }, random);
const alreadySorted = shuffled.toSorted((a, b) => a - b);
console.log(countComparisons(alreadySorted)); // 999
console.log(countComparisons(shuffled)); // 8630node sort.mjs. Число сравнений в последних двух строках — результат V8 13.6 (Node.js 24.18); в другом движке оно может отличаться.Что происходит внутри конкретного движка, рассказывает блог V8: начиная с V8 7.0 (2018) Array.prototype.sort реализован алгоритмом TimSort. Он стабилен, уже отсортированные последовательности обрабатывает за O(n), а в худшем случае укладывается в O(n log n); для слияния ему нужен временный массив до n/2 элементов. Пример выше это подтверждает: на уже отсортированных 1000 числах — 999 сравнений, на перемешанных — 8630, при n·log₂n ≈ 9966.
Время против памяти: индекс и кеш
Почти любое ускорение поиска покупается памятью. Индекс — дополнительная структура, которая хранит путь к данным по ключу: строим её один раз за O(n), а потом каждый поиск обходится без перебора. Пример — Map заказов по номеру:
const orders = Array.from({ length: 50000 }, (_, i) => ({ id: 100000 + i, total: (i % 90) * 100 }));
const requested = [100007, 149999, 123456, 999999];
// Без индекса: каждый запрос — find по всему массиву, O(n) на запрос
let scanned = 0;
const viaFind = requested.map((id) =>
orders.find((order) => {
scanned++;
return order.id === id;
}),
);
console.log(scanned); // 123465 — просмотрено элементов на 4 запроса
// С индексом: один проход O(n) на построение, дальше get в среднем быстрее O(n)
const byId = new Map(orders.map((order) => [order.id, order]));
const viaMap = requested.map((id) => byId.get(id));
console.log(byId.size); // 50000 — индекс занимает память
console.log(viaMap.map((order) => order?.total)); // [ 700, 4900, 5600, undefined ]
console.log(viaFind.every((order, i) => order === viaMap[i])); // truenode index.mjs. Четыре запроса через find просмотрели 123 465 элементов; через индекс — по одному обращению к Map на запрос.Поиск перебором
- Без дополнительной памяти
- O(n) на каждый запрос
- Подходит для одного-двух поисков или маленьких массивов
- Данные всегда актуальны — индекс не нужно обновлять
Индекс (Map, Set)
- Дополнительная память на каждый элемент
- O(n) один раз на построение, дальше в среднем быстрее O(n)
- Окупается при многих запросах
- При изменении данных индекс нужно обновлять
Та же логика работает на уровне всей системы: индекс в базе данных ускоряет выборку ценой места на диске и замедления записи, а кеш хранит готовый результат, чтобы не считать его заново, — ценой памяти и риска показать устаревшие данные.
Практика: убрать O(n²) из поиска дублей
Задача: в выгрузке 100 000 номеров заказов, часть номеров повторяется, нужно найти повторы. Частое решение — filter с indexOf: элемент дублирует, если его первое вхождение стоит раньше. Оно короткое и правильное, но indexOf внутри filter — тот самый спрятанный вложенный цикл.
// 100 000 номеров заказов, каждый тысячный — повтор уже встречавшегося
const ids = Array.from({ length: 100_000 }, (_, i) => (i % 1000 === 999 ? i - 500 : i));
// O(n²): indexOf внутри filter — вложенный цикл, только спрятанный
function duplicatesNaive(list) {
return list.filter((value, index) => list.indexOf(value) !== index);
}
// O(n): один проход, проверка через Set
function duplicatesWithSet(list) {
const seen = new Set();
const duplicates = [];
for (const value of list) {
if (seen.has(value)) duplicates.push(value);
else seen.add(value);
}
return duplicates;
}
function measure(label, fn) {
const start = performance.now();
const result = fn(ids);
console.log(`${label}: ${(performance.now() - start).toFixed(1)} мс`);
return result;
}
const naive = measure("filter + indexOf", duplicatesNaive);
const fast = measure("Set", duplicatesWithSet);
console.log(naive.length, fast.length); // 100 100
console.log(naive.every((value, i) => value === fast[i])); // trueВремя замеряем через performance.now(): MDN описывает его как метку времени в миллисекундах с дробной частью, отсчитанную по монотонным часам, которые не сдвигаются при переводе системного времени. В Node.js объект performance доступен глобально. Вот один запуск на машине автора:
$ node duplicates.mjs
filter + indexOf: 7581.0 мс
Set: 18.4 мс
100 100
trueОценить
filterпроходит n элементов,indexOfвнутри — до n. Итого O(n²): на 100 000 элементов — миллиарды сравнений.Выбрать структуру
Нужна быстрая проверка «уже встречали?» — это Set.
hasиaddв среднем быстрее перебора.Переписать
Один проход: встретили значение впервые —
add, повторно — в список дублей. Итого O(n) по времени и O(n) по памяти.Проверить
Сравнить результаты двух решений: одинаковое количество и порядок дублей. Только потом сравнивать время.
Как проверено2 · пройдено1 · частичносверено 11 октября 2026 г.
- запущено, работает
steps.mjs, sort.mjs, index.mjs, duplicates.mjs, ex-first.mjs, ex-common.mjs (RU и EN)
node 24.18.0 (V8 13.6) в scratchpad/kb/verify/algoritmy-i-slozhnost/{ru,en}; запуск с --import check-preload.mjs — перехват console.log и сверка с комментарием в той же строке: 22 из 22 совпали в каждой локали. Число вызовов компаратора (999 / 8630) повторено 3 раза — стабильно на V8 13.6
- запущено, работает
binary-search.mjs + binary-search.test.mjs (RU и EN)
node --test: tests 4, pass 4, fail 0 в обеих локалях; мутационная проверка (low <= high → low < high) — fail 3 из 4, тесты ловят ошибку на единицу
- частично
замеры времени duplicates.mjs
Иллюстрация на одной машине (Intel Core i7-14700, 64 ГБ, Windows 11 Pro, Node 24.18): 100 000 — filter+indexOf 7274–7895 мс, Set 13.8–24.1 мс (5 запусков); 50 000 — 1338–1592 мс и 5.3–8.4 мс (3 запуска). Подано в статье как пример, не как универсальные цифры
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.