Структуры данных и алгоритмы в прикладной разработке: практическое руководство
Сложность без формул, скрытые квадратичные алгоритмы, Map и Set, стек, очередь и куча, деревья и графы, сортировка русских строк, LRU-кэш и поиск алгоритмических проблем в реальном проекте на JavaScript.
Структуры данных и алгоритмы у многих разработчиков ассоциируются с собеседованиями и задачами, которые не встречаются в реальной работе. Это ошибка восприятия. Каждый раз, когда список отфильтрованных товаров открывается секунду вместо десяти миллисекунд, страница «зависает» на сортировке таблицы или сервер исчерпывает память на обработке выгрузки, причина почти всегда в выборе структуры данных или алгоритма, а не в языке программирования или фреймворке. Эта статья — не учебник по теории, а практическое руководство для прикладного разработчика: как оценивать сложность без формул, какие структуры данных решают типовые задачи веб- и серверной разработки, где встроенные средства JavaScript работают не так, как кажется, и как находить алгоритмические проблемы в реальном коде. Сложность алгоритмов простыми словами Нотация «O большое» описывает, как растёт время работы или потребление памяти при росте объёма данных. Нас интересует не точное время, а характер роста. O(1) — константная. Время не зависит от объёма: получить элемент массива по индексу, найти значение в хеш-таблице по ключу. O(log n) — логарифмическая. При удвоении данных время растёт на одну операцию: бинарный поиск в отсортированном массиве, поиск в сбалансированном дереве, поиск по индексу в базе данных. O(n) — линейная. Время растёт пропорционально объёму: пройти по списку, найти элемент в неотсортированном массиве. O(n log n) — эффективная сортировка. O(n²) — квадратичная. Время растёт как квадрат объёма: вложенный цикл по тем же данным. При десяти тысячах элементов это сто миллионов операций. Практический смысл: на тестовых данных из ста записей O(n) и O(n²) неотличимы. Разница проявляется в продакшене, когда записей становится сто тысяч, и именно поэтому такие проблемы обнаруживаются поздно. Самая частая проблема: скрытый квадрат Типичный пример из реального кода — объединение двух списков. // O(n × m): для каждого заказа ищем клиента перебором const result = orders.map((order) = ({ ...order, client: clients.find((c) = c.id === order.clientId), })); Метод find перебирает массив клиентов для каждого заказа. При 50 000 заказов и 10 000 клиентов это до 500 миллионов сравнений. Решение — построить индекс один раз. // O(n + m): индекс по id, затем прямой доступ const clientsById = new Map(clients.map((c) = [c.id, c])); const result = orders.map((order) = ({ ...order, client: clientsById.get(order.clientId), })); Такие же скрытые квадраты создают includes , indexOf и filter внутри циклов, а также проверка «нет ли уже такого элемента» перебором массива. Поиск подобных мест в коде, обрабатывающем большие списки, — один из самых эффективных способов ускорить приложение. Массив Элементы хранятся подряд, доступ по индексу — O(1). Добавление и удаление в конце — амортизированно O(1), в начале и середине — O(n), потому что остальные элементы сдвигаются. На что обратить внимание в JavaScript: shift и unshift работают за O(n). Очередь на массиве с shift на больших объёмах медленная. includes , indexOf , find — линейный поиск. sort без функции сравнения сортирует как строки: [10, 9, 1].sort() даёт [1, 10, 9] . цепочки filter().map().reduce() создают промежуточные массивы — на очень больших данных это заметно по памяти. Хеш-таблица: Map и объект Вставка, поиск и удаление по ключу — в среднем O(1). Главный инструмент ускорения прикладного кода. Map или обычный объект Map принимает ключи любого типа, сохраняет порядок вставки, быстро сообщает размер и не имеет унаследованных ключей. Объект удобен для фиксированной структуры и сериализации в JSON, но ключи всегда строки или символы, а унаследованные свойства могут создавать неожиданные совпадения. Для словарей, заполняемых во время работы, — индексов, кэшей, счётчиков — Map обычно предпочтительнее. Практические применения // Группировка за один проход const byStatus = Map.groupBy(orders, (o) = o.status); // Подсчёт частоты const freq = new Map(); for (const tag of tags) freq.set(tag, (freq.get(tag) ?? 0) + 1); Map.groupBy и Object.groupBy доступны в современных браузерах и актуальных версиях Node.js; для старых окружений ту же группировку делают через reduce . Множество: Set Хранит уникальные значения, проверка наличия — O(1). const unique = [...new Set(emails)]; const allowedRoles = new Set(['admin', 'manager']); if (allowedRoles.has(user.role)) { /* ... */ } // Разница двух списков без квадрата const existing = new Set(existingIds); const toCreate = incoming.filter((item) = !existing.has(item.id)); Последний пример — типичная задача синхронизации данных: найти записи, которых ещё нет в системе. Вариант с existingIds.includes работает квадратично и незаметно замедляет импорт по мере роста базы. Стек и очередь Стек Последний добавленный элемент извлекается первым. В JavaScript — массив с push и pop . Применяется в отмене действий, обходе вложенных структур без рекурсии, разборе выражений и проверке парности скобок. function isBalanced(s) { const pairs = { ')': '(', ']': '[', '}': '{' }; const stack = []; for (const ch of s) { if ('([{'.includes(ch)) stack.push(ch); else if (ch in pairs stack.pop() !== pairs[ch]) return false; } return stack.length === 0; } Очередь Первый добавленный элемент извлекается первым. Массив с shift на больших объёмах медленный, поэтому для интенсивных очередей используют кольцевой буфер или очередь на двух индексах. class Queue { #items = []; #head = 0; enqueue(x) { this.#items.push(x); } dequeue() { if (this.#head = this.#items.length) return undefined; const x = this.#items[this.#head++]; if (this.#head 1024 this.#head * 2 this.#items.length) { this.#items = this.#items.slice(this.#head); this.#head = 0; } return x; } get size() { return this.#items.length - this.#head; } } Очереди — основа обработки задач в фоне, обхода графов в ширину, ограничения одновременных запросов. В распределённых системах ту же роль играют брокеры сообщений — подробнее в статье об event-driven архитектуре . Очередь с приоритетом и куча Когда нужно каждый раз извлекать элемент с наименьшим или наибольшим приоритетом — планировщик задач, поиск кратчайшего пути, выбор N лучших из потока, — сортировка массива после каждой вставки даёт O(n log n) на операцию. Двоичная куча выполняет вставку и извлечение за O(log n). class MinHeap { #a = []; push(item, priority) { this.#a.push({ item, priority }); let i = this.#a.length - 1; while (i 0) { const p = (i - 1) 1; if (this.#a[p].priority = this.#a[i].priority) break; [this.#a[p], this.#a[i]] = [this.#a[i], this.#a[p]]; i = p; } } pop() { const a = this.#a; if (a.length === 0) return undefined; const top = a[0]; const last = a.pop(); if (a.length 0) { a[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l a.length a[l].priority a[m].priority) m = l; if (r a.length a[r].priority a[m].priority) m = r; if (m === i) break; [a[m], a[i]] = [a[i], a[m]]; i = m; } } return top.item; } } Деревья Сбалансированные деревья поиска поддерживают поиск, вставку и удаление за O(log n) и хранят данные упорядоченно. В прикладном коде их редко пишут самостоятельно, но с ними постоянно работают опосредованно. Индексы баз данных обычно построены на B-деревьях. Понимание этого объясняет, почему индекс ускоряет поиск по значению и диапазону, почему порядок полей в составном индексе важен и почему лишние индексы замедляют запись. DOM — дерево, и обход глубоких структур интерфейса — работа с деревом. Иерархии в данных — категории каталога, структура организации, комментарии с ответами — хранятся и обрабатываются как деревья. Префиксное дерево используют для автодополнения и поиска по началу строки. // Построение дерева категорий из плоского списка за O(n) function buildTree(items) { const byId = new Map(items.map((i) = [i.id, { ...i, children: [] }])); const roots = []; for (const node of byId.values()) { const parent = node.parentId != null ? byId.get(node.parentId) : null; (parent ? parent.children : roots).push(node); } return roots; } Графы Граф — вершины и связи между ними. Прикладные примеры: зависимости задач, маршруты доставки, связи пользователей, рекомендации «с этим товаром покупают», граф зависимостей модулей при сборке. Обход в ширину: кратчайший путь по числу шагов function shortestPath(graph, start, target) { const queue = [[start]]; const visited = new Set([start]); for (let i = 0; i queue.length; i++) { const path = queue[i]; const node = path[path.length - 1]; if (node === target) return path; for (const next of graph.get(node) ?? []) { if (!visited.has(next)) { visited.add(next); queue.push([...path, next]); } } } return null; } Топологическая сортировка Порядок выполнения задач с зависимостями: сборка модулей, миграции, этапы процесса. Если сортировка невозможна, в зависимостях есть цикл — полезная проверка при конфигурации процессов. Для взвешенных графов — например, маршрутов с разной длительностью — используют алгоритм Дейкстры, где пригодится очередь с приоритетом из раздела выше. Сортировка и поиск встроенная сортировка в современных движках JavaScript работает за O(n log n) и устойчива — порядок равных элементов сохраняется; самостоятельно писать сортировку почти никогда не нужно; для строк на русском языке используйте localeCompare или Intl.Collator , иначе «ё» и регистр сортируются неправильно; если по массиву многократно ищут значения, быстрее один раз отсортировать и применять бинарный поиск или построить Map ; для выбора N лучших из большого потока не нужна полная сортировка — достаточно кучи размера N. const collator = new Intl.Collator('ru', { sensitivity: 'base' }); clients.sort((a, b) = collator.compare(a.name, b.name)); Кэширование как алгоритмическое решение Мемоизация — сохранение результата вычисления для повторного использования — превращает повторные дорогие вычисления в O(1). Но неограниченный кэш — это утечка памяти. Кэш с вытеснением давно не использовавшихся записей можно построить на Map , используя порядок вставки. class LRUCache { #map = new Map(); constructor(limit) { this.limit = limit; } get(key) { if (!this.#map.has(key)) return undefined; const value = this.#map.get(key); this.#map.delete(key); this.#map.set(key, value); return value; } set(key, value) { this.#map.delete(key); this.#map.set(key, value); if (this.#map.size this.limit) { this.#map.delete(this.#map.keys().next().value); } } } Для распределённого кэширования между серверами используют внешние хранилища — подробно в статье о Redis . Как находить алгоритмические проблемы в реальном проекте Тестировать на реалистичных объёмах. Генерировать данные в масштабе продакшена, а не на десяти записях. Профилировать. Профилировщик браузера или Node.js показывает функции, в которых тратится время. Интуиция часто ошибается. Искать вложенные переборы — find , filter , includes внутри циклов и map . Проверять запросы к базе в циклах — это тот же скрытый квадрат, только ещё и с сетевыми задержками. Смотреть на рост времени при удвоении данных: удвоение времени — линейный рост, учетверение — квадратичный. Общие приёмы ускорения фронтенда и серверной части — в статье о performance optimization . Производительность под реальными объёмами данных закладывается в архитектуру и проверяется в наших проектах разработки веб-проектов . Частые вопросы Нужно ли прикладному разработчику знать алгоритмы? Знать наизусть реализацию сложных алгоритмов не обязательно. Обязательно понимать сложность типовых операций, уметь выбрать подходящую структуру данных и распознать квадратичный код. Это напрямую влияет на скорость продукта и стоимость инфраструктуры. Когда стоит писать свою структуру данных? Редко. Встроенные Map , Set и массивы покрывают большинство задач. Собственные реализации оправданы для кучи, эффективной очереди, префиксного дерева и других структур, которых нет в стандартной библиотеке, или когда профилирование показало конкретное узкое место. Что быстрее: объект или Map? Для динамических словарей с частыми добавлениями и удалениями Map обычно работает лучше и надёжнее. Для фиксированных структур с известными полями объект привычнее и удо