Как анализировать сложность кода
Увидеть готовое обозначение O(n²) гораздо проще, чем самому посмотреть на код и понять, откуда оно взялось. На практике никто не подписывает функцию так:
# O(n²)function process(numbers): ...Обычно перед нами просто код, и наша задача – определить, как будет расти количество работы и дополнительной памяти при увеличении входных данных. Для этого не нужно считать каждую инструкцию: достаточно научиться замечать несколько повторяющихся закономерностей.
Перед анализом нужно понять, что именно будет расти. Рассмотрим функцию:
function contains(numbers, target): for number in numbers: if number == target: return true return falseЗдесь размер задачи определяется количеством элементов в numbers, поэтому обозначим его n = numbers.. Если элементов становится больше, цикл потенциально выполняет больше итераций – именно n и будет использоваться при оценке сложности.
Но n не всегда означает размер массива. Если алгоритм работает со строкой, n может означать количество символов (text.), если с вершинами графа – количество вершин, если с файлами – количество файлов. n – это просто обозначение величины входа, от которой зависит объём работы.
Начнём с простого примера:
function firstTwoSum(numbers): first = numbers[0] second = numbers[1] return first + secondЗдесь выполняется фиксированное количество действий. Неважно, в массиве 10 элементов или 1 000 000 – функция всё равно работает только с двумя из них. Поэтому время O(1).
То же самое относится к большему количеству обычных инструкций: если взять три элемента и сложить их, действий станет больше, но их количество всё равно не зависит от n. Важно смотреть не на количество строк кода, а на то, растёт ли число выполняемых действий вместе с входными данными.
Теперь добавим цикл:
function printAll(numbers): for number in numbers: print(number)Если n = 5, тело цикла выполнится примерно 5 раз. Если n = 1 000 – примерно 1 000. Количество работы растёт вместе с n, поэтому это O(n) – один из самых распространённых случаев.
Рассмотрим код:
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 остаётся самая быстрорастущая часть.
Теперь сравним два случая. Циклы могут идти друг за другом:
function process(numbers): for number in numbers: print(number) for number in numbers: print(number)А могут находиться один внутри другого:
function process(numbers): for first in numbers: for second in numbers: print(first, second)В первом случае работа складывается: n + n, то есть O(n). Во втором внутренний цикл выполняется целиком для каждой итерации внешнего: n * n, то есть O(n²).
Эта разница встречается настолько часто, что её стоит научиться замечать сразу. Пошаговый разбор вложенных циклов есть в уроке Big O.
Важно смотреть не просто на наличие цикла, а на то, как изменяется его состояние. Например:
function reduce(n): while n > 1: n = n // 2Здесь значение уменьшается не на единицу, а сразу в два раза. Если начать с 32, получим 32 → 16 → 8 → 4 → 2 → 1 – всего несколько итераций. Такой рост – O(log n).
Поэтому правило «вижу один цикл – значит O(n)» не работает. Нужно смотреть, сколько раз этот цикл действительно сможет выполниться.
Если ветки не содержат циклов, выполнится только одна из них, и количество работы не зависит от размера входа – это O(1). Теперь поставим условие внутри цикла:
function countPositive(numbers): count = 0 for number in numbers: if number > 0: count += 1 return countПроверка условия выполняется для каждого элемента, но главный рост здесь создаёт цикл – O(n). Само наличие if не превращает алгоритм в более высокий класс сложности.
Представим функцию, у которой ветки делают разный объём работы:
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²). Это тот же принцип, что и при разговоре о худшем случае.
Посмотрим на вложенный цикл, который начинается не с начала массива, а сразу после текущего элемента:
[4, 7, 2, 9] четыре элемента: n = 4. Пар пока нет.Внутренний цикл здесь не всегда проходит n элементов: сначала почти n раз, затем чуть меньше, потом ещё меньше. Для пяти элементов количество пар будет 4 + 3 + 2 + 1, то есть меньше, чем 5 * 5.
Но если увеличить n, количество пар всё равно растёт примерно как квадрат n, поэтому сложность остаётся O(n²). Big O не требует, чтобы алгоритм выполнял ровно n² действий – нас интересует тип роста.
Представим функцию, которая работает сразу с двумя массивами:
function compare(first, second): for a in first: for b in second: print(a, b)Допустим, first., а second.. Внешний цикл выполняется n раз, внутренний – m раз для каждой итерации внешнего. Получаем n * m, то есть O(nm).
Не стоит автоматически писать O(n²): размеры двух массивов могут быть совершенно разными. Если в первом 10 элементов, а во втором 1 000 000, одна буква n скрыла бы важную информацию. Поэтому независимые размеры входных данных обычно обозначают разными переменными.
Посмотрим на пример, где один участок идёт за другим:
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²).
Порядок такой: оценить отдельные части, объединить их и оставить доминирующий рост. Это гораздо надёжнее, чем пытаться угадать ответ, просто взглянув на количество циклов.
Рассмотрим функцию, которая создаёт новый массив:
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.. Затем смотрим на работу – есть внешний цикл, внутри него ещё один цикл по части массива. Количество сравнений растёт примерно как количество возможных пар элементов, поэтому время O(n²).
Какие дополнительные данные создаёт алгоритм? Только несколько переменных – i и j. Их количество не растёт вместе с n, поэтому память O(1).
| Характеристика | Оценка | Почему |
|---|---|---|
| Время | O(n²) | Сравнений примерно столько, сколько пар элементов |
| Память | O(1) | Только i и j – их число не зависит от n |
Ту же задачу можно решить и иначе – используя дополнительную память, но сократив время работы. Анализ сложности как раз и позволяет сравнивать несколько решений одной задачи.
На первых этапах очень хочется использовать простые правила: «один цикл – 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 становится не набором правил для запоминания, а способом описать реальное поведение программы.
// теперь вы знаете
- Анализ начинается с размера входа:
n– это то, что растёт (элементы массива, символы строки, вершины графа) - Количество строк кода не определяет сложность: важно, растёт ли число действий вместе с
n - Один полный проход по входу – обычно
O(n) - Последовательные участки складываются:
n + n = 2n, а после упрощения –O(n) - Вложенные проходы перемножаются:
n * n, то естьO(n²) - Цикл не гарантирует
O(n): если задача на каждом шаге уменьшается вдвое, этоO(log n) ifсам по себе не повышает сложность, а у разных веток берут более дорогую- Внутренний цикл от
i + 1даёт меньше работы, чемn * n, но рост остаётся квадратичным - Независимые входы обозначают разными буквами:
nиmдаютO(nm)илиO(n + m) - Время и дополнительная память оцениваются отдельно: у одной и той же функции может быть
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., second.. Какова временная сложность этого кода?
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