O(n) - Линейная Сложность

Умеренная

O(n) - это когда работы становится ровно во столько же раз больше, во сколько выросли данные. 10 элементов - 10 шагов. 100 элементов - 100 шагов. Никаких сюрпризов: сколько данных, столько и работы. Это самый простой и понятный класс сложности - примерно так люди и представляют себе «обработать список», даже без всякой математики.

Проблема

Фраза «этот код проходит по массиву» сама по себе ничего не говорит. Один цикл может быть безобидным O(n), а другой - незаметно прятать внутри себя ещё один проход по тем же данным и превращаться в O(n²). На 100 элементах разницы не видно вообще. На 100 000 - один вариант отработает за секунды, а другой будет тормозить часами. Нужно уметь заранее отличать «работа растёт вместе с данными» от «работа растёт быстрее данных» - до того, как программа реально начнёт тормозить у пользователей.

Решение

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

Как это работает

У O(n) есть точное определение: существует число c, такое что работы не больше, чем c, умноженное на n, начиная с какого-то размера входа. У linearSearch в худшем случае - ровно n сравнений: не n + 5, не n / 2, а именно n. Здесь c = 1 - это самый чистый пример O(n), какой только бывает.

Пропорциональность хорошо видна на числах. Поиск в массиве из 1 000 элементов в худшем случае - 1 000 сравнений. Поиск в массиве из 1 000 000 элементов - 1 000 000 сравнений: данных стало в 1000 раз больше, и работы стало ровно в 1000 раз больше, не больше и не меньше. Для сравнения: у алгоритма с O(n²) (как Bubble Sort или Insertion Sort из раздела сортировок) тот же рост данных в 1000 раз превращает 1 000 000 операций в 1 000 000 000 000 - в миллион раз больше, а не в тысячу.

Разница с O(n²) видна прямо в коде. У Bubble Sort и Insertion Sort есть цикл внутри цикла: внешний проходит по всем n элементам, а внутри него - ещё один проход, тоже по n элементам. Отсюда n · n = n² сравнений. У linearSearch внутри цикла нет второго цикла - только простые действия вроде сравнения и возврата значения. Поэтому сложность остаётся n · O(1) = O(n), а не n · n.

Самая частая ошибка - спрятанный O(n) внутри другого O(n). Код вида for (const x of arr) { if (arr.includes(x)) { ... } } выглядит как один цикл, но .includes() - это тоже проход по всему массиву, и он запускается на каждой итерации внешнего цикла. На массиве из 10 000 элементов это уже не 10 000 операций, а до 100 000 000. Код остаётся O(n²) - просто вторая n спряталась внутри готового метода вместо явного for.

Когда об алгоритме говорят просто «O(n)» без уточнений, почти всегда имеют в виду худший случай. У linearSearch лучший случай - O(1) (нужный элемент оказался первым), средний случай на случайных данных - примерно n / 2 сравнений, а худший случай - все n сравнений (элемента нет вообще, или он последний). Разница между этими тремя случаями - отдельная тема, ей посвящён материал «Лучший/средний/худший случай».

На однопроходных O(n)-алгоритмах держится обработка больших объёмов данных: подсчёт строк в лог-файле, вычисление контрольной суммы файла, поиск через grep. Все они читают вход ровно один раз, без повторных проходов и без загрузки всего файла в память сразу - именно за счёт линейности они справляются с файлами в десятки гигабайт.

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

Нюансы выбора

Задачи, где нужно увидеть все данные целиком - сумма, среднее, максимум, минимум, подсчёт вхождений. Результат просто нельзя получить, не посмотрев на каждый элемент хотя бы раз.

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

Против O(log n) - если данные уже отсортированы или под рукой есть хеш-таблица, O(n) почти всегда стоит заменить на бинарный поиск или прямой доступ по ключу. Здесь линейность - уже не предел возможностей, а упущенная возможность ускориться.

Как кирпичик, а не как цель - простые однопроходные операции (map, filter, sum) свободно комбинируются друг с другом и остаются в сумме O(n) - если, конечно, не вкладывать их одну в другую по тем же данным.

Примеры в коде

Утилиты Unix wc, grep, cat - все они читают файл за один проход, поэтому спокойно работают с файлами, которые целиком не помещаются в оперативную память.

Обработка потоков событий (Kafka, лог-агрегаторы) - каждое событие обрабатывается один раз, сразу при поступлении, без возврата к предыдущим. Линейность здесь заложена прямо в архитектуру.

Контрольные суммы и хеш-функции (MD5, SHA-256) - считаются за один проход по байтам файла, поэтому время расчёта растёт линейно вместе с размером файла.

Bubble Sort и Insertion Sort (в разделе сортировок) - наглядный пример противоположной ситуации: их внутренний цикл превращает один внешний O(n)-проход во вложенный O(n²). Хороший пример того, чем O(n) точно не является.

Когда применять

  • Когда данные нужно обработать целиком хотя бы раз: посчитать сумму, найти максимум, скопировать элементы. Такие задачи сами требуют «тронуть» каждый элемент минимум один раз.
  • Когда данные не отсортированы и строить отдельную структуру (дерево, индекс) ради одного разового поиска не имеет смысла - один проход обойдётся дешевле подготовки.

Примеры из практики

  • Array.prototype.map/filter/forEach в JavaScript и списковые включения в Python - под капотом это один проход по всем элементам, O(n) по самому устройству метода.
  • grep читает файл построчно, ровно один раз - классический пример O(n) относительно размера файла, без предварительного построения индекса.

Похожие алгоритмы

O(1) - Константная Сложность

O(1) - это когда объём работы вообще не зависит от того, сколько данных на входе. Массив из 10 элементов или из 10 000 000 - разницы никакой, операция займёт одно и то же время. Это самый быстрый класс сложности из всех и одновременно теоретический потолок скорости: быстрее просто не бывает.

Быстрая

O(log n) - Логарифмическая Сложность

O(log n) - это когда каждый шаг алгоритма отбрасывает половину оставшихся данных, вместо того чтобы проверять их по одной. Массив из 1 000 000 элементов такой алгоритм разберёт всего за 20 шагов, а не за миллион. Это самый заметный переход от «медленно» к «быстро»: данных стало в тысячи раз больше, а шагов - всего на несколько штук.

Быстрая

O(n log n) - Линеарифмическая Сложность

O(n log n) - это когда алгоритм делит данные пополам снова и снова (получается log n уровней), а на каждом уровне честно обрабатывает все n элементов. Итоговая работа - произведение этих двух чисел, не сумма. Это класс, в котором живёт почти любая быстрая сортировка общего назначения: заметно быстрее, чем перебор всех пар (O(n²)), но чуть дороже, чем один проход по данным (O(n)).

Умеренная

Best, Average, and Worst Case

Лучший, средний и худший случай - это не отдельный класс сложности, а способ описать, что один и тот же алгоритм может работать по-разному в зависимости от того, какие именно данные ему достались. Три отдельных значения Big O для одного алгоритма - не прихоть, а необходимость: одно число часто просто врёт о том, чего ждать на практике.

Сравнение