Асимптотический анализ и Big O нотация: измерение временной сложности алгоритмов
Асимптотический анализ и Big O нотация: измерение временной сложности алгоритмов
Представьте, что вы написали алгоритм поиска дубликатов в массиве. На вашем рабочем ноутбуке он обрабатывает тысячу записей за секунды. Вы загружаете код на мощный сервер, подаете ему миллион записей, и программа «зависает» на час. Если мы измеряем эффективность кода в секундах, мы попадаем в ловушку: время выполнения зависит от тактовой частоты процессора, фоновых процессов операционной системы, языка программирования и даже температуры чипа в данный момент. Секунды не говорят ничего о самом алгоритме. На алгоритмических собеседованиях, в том числе в Яндексе, инженеров интересует не абсолютное время на конкретном железе, а то, как поведет себя алгоритм при масштабировании данных.
Для ответа на этот вопрос используется асимптотический анализ. Это математический подход, который оценивает, как изменяется количество базовых операций программы при стремлении размера входных данных к бесконечности. Размер входных данных традиционно обозначают переменной . Базовой операцией считается любое простое действие, занимающее фиксированное время: присваивание переменной, арифметическая операция, сравнение двух чисел или обращение к элементу массива по индексу.
Языком асимптотического анализа является нотация Big O (О большое). Запись вида описывает верхнюю границу роста количества операций. Проще говоря, она гарантирует, что при увеличении алгоритм сделает не больше, чем операций, умноженных на некий константный коэффициент. Big O описывает худший сценарий развития событий — пессимистичную оценку, которая так важна при проектировании высоконагруженных систем.
Главные правила вычисления сложности
Чтобы перевести реальный код на язык Big O, не нужно считать каждую строчку. Асимптотический анализ намеренно огрубляет картину, чтобы выделить главное. Для этого применяются два фундаментальных правила.
Правило 1: Отбрасывание констант. Если ваш алгоритм проходит по массиву из элементов дважды, он делает операций. Математически это записывается как . Однако в Big O константы игнорируются, и сложность сокращается до .
Почему мы так легко избавляемся от двойки? Суть асимптотики — в характере роста. Если размер данных увеличится в раз, время работы алгоритма увеличится в раз. Время работы алгоритма тоже увеличится ровно в раз. Константа влияет на наклон прямой на графике, но прямая остается прямой. При бесконечно больших разница между и меркнет по сравнению с тем, как ведет себя, например, квадратичная функция.
Правило 2: Отбрасывание менее значимых слагаемых. Часто алгоритм состоит из нескольких этапов. Допустим, сначала вы сортируете массив (что занимает операций в простейшем случае), а затем один раз проходите по нему циклом (еще операций). Общее количество действий: .
По правилам Big O мы оставляем только слагаемое с наибольшей скоростью роста. В данном случае это . Сложность алгоритма будет . Чтобы понять логику, подставим реальные числа. Пусть . Тогда . Общее число операций составит . Доля линейного прохода ( операций) в общем объеме работы составляет менее . При влияние линейного слагаемого станет микроскопическим. Младшие степени всегда поглощаются старшими.
Основные классы сложности
В программировании чаще всего встречаются несколько стандартных классов сложности. Понимание того, как они соотносятся друг с другом — базовый навык для оптимизации кода.
Константное время: Сложность означает, что время выполнения алгоритма вообще не зависит от размера входных данных. Будь в массиве десять элементов или миллиард, программа выполнит одно и то же количество действий.
def get_first_element(arr):
if not arr:
return None
return arr[0]
Обращение к элементу списка по индексу в Python происходит за один шаг, так как под капотом вычисляется точный адрес в памяти. К константным операциям также относятся базовые математические вычисления (сложение, умножение) и проверка условий if.
Логарифмическое время: Алгоритмы с такой сложностью на каждом шаге отбрасывают половину оставшихся данных. Классический пример из реальной жизни — поиск слова в бумажном словаре. Вы не читаете каждую страницу с первой до последней. Вы открываете словарь посередине, понимаете, в какой половине находится нужное слово, и повторяете процесс для этой половины.
Даже если в словаре миллион страниц, вам понадобится не более проверок, чтобы найти нужную. Увеличение размера данных в два раза () добавляет всего одну дополнительную операцию. Это крайне эффективный класс сложности, к которому мы будем стремиться в задачах на бинарный поиск.
Линейное время: Если алгоритму необходимо «посмотреть» на каждый элемент входных данных хотя бы один раз, его сложность будет не лучше .
def find_max(arr):
max_val = float('-inf')
for num in arr: # Цикл выполнится N раз
if num > max_val:
max_val = num
return max_val
Здесь размер массива напрямую диктует количество итераций. Если массив увеличится в раз, цикл выполнится в раз больше раз.
Квадратичное время: Такая сложность возникает, когда на каждый элемент массива нужно пройтись по всему массиву еще раз. Визуальный маркер квадратичной сложности — вложенные циклы, зависящие от одной и той же коллекции.
def print_all_pairs(arr):
n = len(arr)
for i in range(n): # Внешний цикл: N раз
for j in range(n): # Внутренний цикл: N раз
print(arr[i], arr[j])
Для массива из элементов функция напечатает пар. Для массива из элементов — уже миллион. Алгоритмы с асимптотикой часто не проходят тесты на платформах вроде LeetCode или в контестах Яндекса, если превышает . Главная задача на собеседовании — увидеть квадратичное решение (часто это перебор «в лоб») и придумать, как свести его к или .
Зависимость от нескольких переменных
Частая ошибка новичков — обозначать любой размер данных буквой . Это работает, если на вход подается одна коллекция. Но что, если алгоритм принимает два независимых массива разных размеров?
def print_two_arrays(arr_a, arr_b):
for a in arr_a:
print(a)
for b in arr_b:
print(b)
Здесь два последовательных цикла. Если размер arr_a равен , а размер arr_b равен , общая сложность составит . Мы не можем свести это к , потому что не знаем соотношение и . Возможно, равно , а равно миллиону.
Аналогично, если циклы вложены друг в друга:
def print_pairs_from_two_arrays(arr_a, arr_b):
for a in arr_a:
for b in arr_b:
print(a, b)
В этом случае на каждый из элементов первого массива мы делаем шагов во втором. Сложность будет . Если на собеседовании вам дают две строки или два массива, сразу вводите две переменные для оценки.
Худший, лучший и средний случаи
Рассмотрим классическую задачу: найти заданное число в массиве (линейный поиск).
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
Сколько операций выполнит этот код? Ответ зависит от входных данных.
- Лучший случай (Best Case): Искомое число находится на первой позиции
arr[0]. Цикл завершится на первой итерации. Сложность: (Омега большое используется для нижней границы). - Средний случай (Average Case): Искомое число находится где-то в середине или мы ищем множество случайных чисел. В среднем придется просмотреть половину массива, то есть элементов. Отбрасывая константу , получаем сложность (Тета большая описывает точную границу).
- Худший случай (Worst Case): Числа в массиве нет вообще, или оно стоит на самом последнем месте. Нам придется проверить каждый элемент без исключения. Сложность: .
В индустрии и на интервью по умолчанию говорят именно о худшем случае и используют нотацию Big O. Инженеру важно знать гарантии: код точно не будет работать дольше, чем . Оценивать систему по лучшему случаю опасно — это ведет к отказам серверов при пиковых или нестандартных нагрузках.
Комплексный разбор алгоритма
Чтобы закрепить навык, проанализируем функцию, состоящую из нескольких блоков. Попробуйте сначала оценить сложность самостоятельно, читая код сверху вниз.
def complex_algorithm(arr):
n = len(arr)
# Блок 1
print("Начало работы")
# Блок 2
for num in arr:
print(num)
# Блок 3
for i in range(n):
for j in range(n):
print(i, j)
# Блок 4
for i in range(100):
print("Константа")
Разберем по частям:
- Блок 1: Обычный вывод в консоль. Занимает .
- Блок 2: Одиночный цикл по массиву размера . Занимает .
- Блок 3: Вложенный цикл по тому же массиву. Внешний делает шагов, внутренний делает шагов на каждый шаг внешнего. Занимает .
- Блок 4: Цикл, который всегда выполняется ровно раз, независимо от размера входного массива
arr. Это константное время , которое по правилам превращается в .
Складываем все вместе: . Применяем правило отбрасывания констант: . Применяем правило отбрасывания младших слагаемых: функция растет значительно быстрее, чем . Линейным слагаемым можно пренебречь. Итоговая асимптотическая временная сложность всего алгоритма: .
Умение быстро читать код и сводить его к одной математической функции — это первый шаг к успешному прохождению алгоритмических секций. Интервьюер ожидает, что перед тем как писать код, вы озвучите: «Я планирую решить эту задачу за по времени». Это задает рамки ожиданий и показывает, что вы контролируете эффективность своего решения еще до его реализации.