Как анализировать сложность кода

Увидеть готовое обозначение O(n²) гораздо проще, чем самому посмотреть на код и понять, откуда оно взялось. На практике никто не подписывает функцию так:

# O(n²)function process(numbers):    ...

Обычно перед нами просто код, и наша задача – определить, как будет расти количество работы и дополнительной памяти при увеличении входных данных. Для этого не нужно считать каждую инструкцию: достаточно научиться замечать несколько повторяющихся закономерностей.

Сначала определяем размер входа

Перед анализом нужно понять, что именно будет расти. Рассмотрим функцию:

containsпсевдокод
function contains(numbers, target):    for number in numbers:        if number == target:            return true    return false

Здесь размер задачи определяется количеством элементов в numbers, поэтому обозначим его n = numbers.length. Если элементов становится больше, цикл потенциально выполняет больше итераций – именно n и будет использоваться при оценке сложности.

Но n не всегда означает размер массива. Если алгоритм работает со строкой, n может означать количество символов (text.length), если с вершинами графа – количество вершин, если с файлами – количество файлов. n – это просто обозначение величины входа, от которой зависит объём работы.

Код без повторений обычно даёт O(1)

Начнём с простого примера:

firstTwoSumпсевдокод
function firstTwoSum(numbers):    first = numbers[0]    second = numbers[1]    return first + second

Здесь выполняется фиксированное количество действий. Неважно, в массиве 10 элементов или 1 000 000 – функция всё равно работает только с двумя из них. Поэтому время O(1).

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

Один полный проход обычно даёт O(n)

Теперь добавим цикл:

printAllпсевдокод
function printAll(numbers):    for number in numbers:        print(number)

Если n = 5, тело цикла выполнится примерно 5 раз. Если n = 1 000 – примерно 1 000. Количество работы растёт вместе с n, поэтому это O(n) – один из самых распространённых случаев.

Последовательные участки складываются

Рассмотрим код:

processпсевдокод
function process(numbers):    for number in numbers:        print(number)    for number in numbers:        print(number)

Первый цикл – n итераций, второй тоже n. Итого n + n = 2n, а постоянный множитель для Big O не важен: получается O(n).

Теперь добавим к циклу обычные инструкции – например, взять первый элемент до цикла и последний после него. Условно получаем 1 + n + 1, то есть n + 2. При больших n главную роль играет n, поэтому снова O(n).

Правило простое: последовательные участки работы складываются, а затем в Big O остаётся самая быстрорастущая часть.

Вложенные циклы обычно перемножаются

Теперь сравним два случая. Циклы могут идти друг за другом:

process – подрядпсевдокод
function process(numbers):    for number in numbers:        print(number)    for number in numbers:        print(number)

А могут находиться один внутри другого:

process – вложеннопсевдокод
function process(numbers):    for first in numbers:        for second in numbers:            print(first, second)

В первом случае работа складывается: n + n, то есть O(n). Во втором внутренний цикл выполняется целиком для каждой итерации внешнего: n * n, то есть O(n²).

Эта разница встречается настолько часто, что её стоит научиться замечать сразу. Пошаговый разбор вложенных циклов есть в уроке Big O.

Цикл не всегда означает O(n)

Важно смотреть не просто на наличие цикла, а на то, как изменяется его состояние. Например:

reduceпсевдокод
function reduce(n):    while n > 1:        n = n // 2

Здесь значение уменьшается не на единицу, а сразу в два раза. Если начать с 32, получим 32 → 16 → 8 → 4 → 2 → 1 – всего несколько итераций. Такой рост – O(log n).

Поэтому правило «вижу один цикл – значит O(n)» не работает. Нужно смотреть, сколько раз этот цикл действительно сможет выполниться.

Условия сами по себе обычно не увеличивают сложность

Если ветки не содержат циклов, выполнится только одна из них, и количество работы не зависит от размера входа – это O(1). Теперь поставим условие внутри цикла:

countPositiveпсевдокод
function countPositive(numbers):    count = 0    for number in numbers:        if number > 0:            count += 1    return count

Проверка условия выполняется для каждого элемента, но главный рост здесь создаёт цикл – O(n). Само наличие if не превращает алгоритм в более высокий класс сложности.

Если ветки разные – смотрим на более дорогую

Представим функцию, у которой ветки делают разный объём работы:

processпсевдокод
function process(numbers, detailed):    if detailed:        for first in numbers:            for second in numbers:                print(first, second)    else:        for number in numbers:            print(number)

При detailed == false работа примерно O(n), при detailed == true – O(n²). Если мы хотим оценить верхнюю границу работы функции, нас интересует более дорогой вариант – O(n²). Это тот же принцип, что и при разговоре о худшем случае.

Не все вложенные циклы делают ровно n² операций

Посмотрим на вложенный цикл, который начинается не с начала массива, а сразу после текущего элемента:

Пары без повторов – по шагам
n = numbers.length
В массиве [4, 7, 2, 9] четыре элемента: n = 4. Пар пока нет.
pairs = 0
for i = 0; i < n; i += 1:
for j = i + 1; j < n; j += 1:
pairs += 1
40712293
Пар0

Внутренний цикл здесь не всегда проходит n элементов: сначала почти n раз, затем чуть меньше, потом ещё меньше. Для пяти элементов количество пар будет 4 + 3 + 2 + 1, то есть меньше, чем 5 * 5.

Но если увеличить n, количество пар всё равно растёт примерно как квадрат n, поэтому сложность остаётся O(n²). Big O не требует, чтобы алгоритм выполнял ровно n² действий – нас интересует тип роста.

Разные входы лучше обозначать разными буквами

Представим функцию, которая работает сразу с двумя массивами:

compareпсевдокод
function compare(first, second):    for a in first:        for b in second:            print(a, b)

Допустим, first.length = n, а second.length = m. Внешний цикл выполняется n раз, внутренний – m раз для каждой итерации внешнего. Получаем n * m, то есть O(nm).

Не стоит автоматически писать O(n²): размеры двух массивов могут быть совершенно разными. Если в первом 10 элементов, а во втором 1 000 000, одна буква n скрыла бы важную информацию. Поэтому независимые размеры входных данных обычно обозначают разными переменными.

Сложность функции может состоять из нескольких частей

Посмотрим на пример, где один участок идёт за другим:

processпсевдокод
function process(numbers):    for number in numbers:        print(number)    for first in numbers:        for second in numbers:            print(first, second)

Первый участок – O(n), второй – O(n²), вместе O(n + n²). Но при увеличении n квадратная часть растёт намного быстрее, поэтому итоговая сложность O(n²).

Порядок такой: оценить отдельные части, объединить их и оставить доминирующий рост. Это гораздо надёжнее, чем пытаться угадать ответ, просто взглянув на количество циклов.

Тот же подход работает и для памяти

Рассмотрим функцию, которая создаёт новый массив:

processпсевдокод
function process(numbers):    result = []    for number in numbers:        result.push(number * 2)    return result

По времени это один проход – O(n). По памяти создаётся новый массив размером около n – тоже O(n). А у поиска максимума проход такой же, но дополнительная память остаётся постоянной: время O(n), память O(1).

Поэтому при анализе кода полезно всегда задавать два отдельных вопроса: как растёт количество работы и какие дополнительные данные растут вместе с входом.

Пошаговый способ анализа

Чтобы не определять сложность на глаз, можно использовать один и тот же порядок действий:

После этого так же отдельно оценивается дополнительная память

Разберём полный пример

Посмотрим на функцию, которая проверяет, есть ли в массиве повторяющиеся значения:

Сначала определяем размер входа: n = numbers.length. Затем смотрим на работу – есть внешний цикл, внутри него ещё один цикл по части массива. Количество сравнений растёт примерно как количество возможных пар элементов, поэтому время O(n²).

Какие дополнительные данные создаёт алгоритм? Только несколько переменных – i и j. Их количество не растёт вместе с n, поэтому память O(1).

ХарактеристикаОценкаПочему
ВремяO(n²)Сравнений примерно столько, сколько пар элементов
ПамятьO(1)Только i и j – их число не зависит от n

Ту же задачу можно решить и иначе – используя дополнительную память, но сократив время работы. Анализ сложности как раз и позволяет сравнивать несколько решений одной задачи.

Не нужно угадывать Big O по внешнему виду

На первых этапах очень хочется использовать простые правила: «один цикл – O(n)», «два цикла – O(n²)», «нет циклов – O(1)». Иногда они дают правильный ответ, но не всегда.

Например, у цикла while n > 1: n = n // 2 сложность O(log n), хотя цикл всего один. А два прохода по разным массивам:

for value in first:    ...for value in second:    ...

при входах размера n и m дают O(n + m), а не O(n).

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

// теперь вы знаете

  1. Анализ начинается с размера входа: n – это то, что растёт (элементы массива, символы строки, вершины графа)
  2. Количество строк кода не определяет сложность: важно, растёт ли число действий вместе с n
  3. Один полный проход по входу – обычно O(n)
  4. Последовательные участки складываются: n + n = 2n, а после упрощения – O(n)
  5. Вложенные проходы перемножаются: n * n, то есть O(n²)
  6. Цикл не гарантирует O(n): если задача на каждом шаге уменьшается вдвое, это O(log n)
  7. if сам по себе не повышает сложность, а у разных веток берут более дорогую
  8. Внутренний цикл от i + 1 даёт меньше работы, чем n * n, но рост остаётся квадратичным
  9. Независимые входы обозначают разными буквами: n и m дают O(nm) или O(n + m)
  10. Время и дополнительная память оцениваются отдельно: у одной и той же функции может быть O(n) и O(1)

Проверьте себя

Какова временная сложность этой функции?

function first(numbers):
return numbers[0]

Какова временная сложность этого кода?

function process(numbers):
for number in numbers:
print(number)
for number in numbers:
print(number)

Какова временная сложность этой функции?

function reduce(n):
while n > 1:
n = n // 2

Есть два массива: first.length = n, second.length = m. Какова временная сложность этого кода?

for a in first:
for b in second:
print(a, b)

У функции два вложенных цикла. Можно ли только по этому факту утверждать, что её сложность O(n²)?

Как упростить O(n² + n + 10)?

Выберите все правильные утверждения.

Выбери все верные варианты

Каковы время и дополнительная память этой функции?

function copy(numbers):
result = []
for number in numbers:
result.push(number)
return result