Как делать11 октября 2026 г.6 мин чтения
Основы программированияШаг 7 из 15

Алгоритмы и сложность: O(n) без страха

Коротко

Сложность алгоритма показывает, как растёт число шагов или объём памяти, когда растут входные данные. Её записывают в 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 г.

Содержание
  1. 01Что такое алгоритм и зачем оценивать сложность
  2. 02Big O на пальцах: как растёт число шагов
  3. 03Типовые классы сложности с примерами
  4. 04Поиск: линейный и бинарный
  5. 05Сортировка: что делает Array.prototype.sort
  6. 06Время против памяти: индекс и кеш
  7. 07Практика: убрать O(n²) из поиска дублей

Что такое алгоритм и зачем оценивать сложность

Алгоритм — последовательность действий, которая решает задачу и даёт одинаковый результат при каждом повторении. MDN в глоссарии добавляет главное для этого урока: эффективность алгоритмов сравнивают через алгоритмическую сложность, или O-нотацию (Big O).

Типичная история: функция поиска дублей в заказах отлично работала на тестовых 50 записях, а после импорта базы за три года страница отчёта стала открываться десятки секунд. Код не изменился — изменился объём данных. Оценка сложности позволяет увидеть такую проблему до того, как её увидят пользователи.

  • Временная сложность — как растёт число операций при росте числа элементов n
  • Пространственная сложность — сколько дополнительной памяти нужно алгоритму
  • Обычно оценивают худший случай: искомого элемента нет, массив перевёрнут, все записи уникальны

Big O на пальцах: как растёт число шагов

O-нотация описывает не время в миллисекундах, а форму роста. Константы и мелкие слагаемые отбрасываются: цикл, который делает 3 действия на элемент, и цикл, который делает одно, — оба O(n). Важно другое: что будет с числом шагов, если данных станет в 10 или 1000 раз больше.

Проверим на практике и посчитаем не время, а сравнения — так результат одинаков на любом компьютере:

steps.mjs
// Считаем не миллисекунды, а шаги — число сравнений
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 млрд
Сколько шагов при разных n (округлённо, логарифм по основанию 2)

Типовые классы сложности с примерами

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.

binary-search.mjs
/**
 * Бинарный поиск в массиве, отсортированном по возрастанию.
 * Возвращает индекс найденного элемента или -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 на массивах разной длины.

binary-search.test.mjs
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 сортировка обязана быть стабильной: равные элементы сохраняют исходный порядок
  • временная и пространственная сложность спецификацией не гарантированы — они зависят от реализации
  • функция сравнения должна быть последовательной: для одной пары всегда один ответ, иначе поведение не определено
sort.mjs
// 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)); // 8630
Запуск: node 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 заказов по номеру:

index.mjs
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])); // true
Запуск: node index.mjs. Четыре запроса через find просмотрели 123 465 элементов; через индекс — по одному обращению к Map на запрос.

Поиск перебором

  • Без дополнительной памяти
  • O(n) на каждый запрос
  • Подходит для одного-двух поисков или маленьких массивов
  • Данные всегда актуальны — индекс не нужно обновлять

Индекс (Map, Set)

  • Дополнительная память на каждый элемент
  • O(n) один раз на построение, дальше в среднем быстрее O(n)
  • Окупается при многих запросах
  • При изменении данных индекс нужно обновлять

Та же логика работает на уровне всей системы: индекс в базе данных ускоряет выборку ценой места на диске и замедления записи, а кеш хранит готовый результат, чтобы не считать его заново, — ценой памяти и риска показать устаревшие данные.

Практика: убрать O(n²) из поиска дублей

Задача: в выгрузке 100 000 номеров заказов, часть номеров повторяется, нужно найти повторы. Частое решение — filter с indexOf: элемент дублирует, если его первое вхождение стоит раньше. Оно короткое и правильное, но indexOf внутри filter — тот самый спрятанный вложенный цикл.

duplicates.mjs
// 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
Первое решение на 100 000 элементов работает несколько секунд — это нормально, так и задумано.

Время замеряем через performance.now(): MDN описывает его как метку времени в миллисекундах с дробной частью, отсчитанную по монотонным часам, которые не сдвигаются при переводе системного времени. В Node.js объект performance доступен глобально. Вот один запуск на машине автора:

terminal
$ node duplicates.mjs
filter + indexOf: 7581.0 мс
Set: 18.4 мс
100 100
true
Иллюстрация, а не эталон: Intel Core i7-14700, Windows 11, Node.js 24.18. В трёх запусках подряд первое решение заняло 7,3–7,9 с, второе — 14–23 мс. На вашей машине цифры будут другими, порядок разницы — тот же.
  1. Оценить

    filter проходит n элементов, indexOf внутри — до n. Итого O(n²): на 100 000 элементов — миллиарды сравнений.

  2. Выбрать структуру

    Нужна быстрая проверка «уже встречали?» — это Set. has и add в среднем быстрее перебора.

  3. Переписать

    Один проход: встретили значение впервые — add, повторно — в список дублей. Итого O(n) по времени и O(n) по памяти.

  4. Проверить

    Сравнить результаты двух решений: одинаковое количество и порядок дублей. Только потом сравнивать время.

Как проверено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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.