Learning Platform
Глоссарий Troubleshooting
Урок 03.01 · 25 мин
Начальный
Big-OComplexityAsymptotic analysis

Зачем вообще нужна сложность

Представьте ситуацию. Junior пишет первый скрипт для дедупликации строк:

def dedupe(items):
    result = []
    for x in items:
        if x not in result:
            result.append(x)
    return result

На 100 строках работает мгновенно. На 1000 — тоже. Junior радуется и заливает в Airflow. В проде на 10M строк скрипт работает 6 часов вместо 6 секунд. Что произошло?

Произошло то, что скорость функции зависит от размера входа не линейно. Чтобы предсказать, как код поведёт себя на больших данных, нужен инструмент, который описывает эту зависимость. Этот инструмент — Big-O.

Определение в двух словах

Big-O — это запись, описывающая скорость роста времени работы функции при росте размера входа n. Формально:

f(n) = O(g(n)), если существуют константы C и n₀ такие, что f(n) <= C * g(n) для всех n >= n₀.

В переводе на человеческий: функция f(n) растёт не быстрее, чем g(n), начиная с какого-то размера. C — это «коэффициент пропорциональности», который мы игнорируем. n₀ — порог, начиная с которого утверждение работает.

Зачем такая абстрактная формулировка? Чтобы можно было сравнивать алгоритмы независимо от железа. На быстром CPU всё работает в 10 раз быстрее, чем на медленном — но если алгоритм O(n²), то и на быстром CPU при n=1M он будет в миллионы раз медленнее, чем O(n) на медленном CPU.

Почему константы исчезают

Это самая частая претензия к Big-O от новичков: «но ведь константы важны!». Да, важны на практике (мы об этом потом много раз). Но в асимптотическом анализе — нет, и вот почему.

Возьмём две функции:

  • f(n) = 100n + 5000 (линейная с большими константами)
  • g(n) = n² (квадратичная)

Кажется, что f всегда больше g — у неё константа 100 и плюс 5000. На маленьких n так и есть:

Линейная с большими константами против квадратичной

При малых n линейная больше. Но при росте n квадратичная неизбежно обгоняет — потому что коэффициент 100 фиксированный, а n растёт.

n=10100*10+5000 = 6000f(10) — линейная функция с константой 100 и offset 5000
n=1010*10 = 100g(10) — квадратичная, на маленьком n незначительно
n=100100*100+5000 = 15000f(100), линейная
n=100100*100 = 10000g(100), квадратичная — уже почти догнала
n=1000100*1000+5000 = 105000f(1000), линейная
n=10001000*1000 = 1000000g(1000), квадратичная — обогнала в 10 раз
n=10000100*10000+5000 = 1.005Mf(10000), линейная
n=1000010000*10000 = 100Mg(10000), квадратичная — обогнала в 100 раз

При n=100 квадратичная уже почти догнала линейную, при n=1000 — обогнала в 10 раз, при n=10000 — в 100 раз. Чем больше n, тем меньше значат константы. Поэтому в Big-O мы пишем:

  • f(n) = 100n + 5000 -> O(n)
  • g(n) = n² -> O(n²)

Игнорируем коэффициент 100, игнорируем константу 5000, оставляем только доминирующий член — то, что растёт быстрее всего.

Ключевые классы сложности

Запомните этот список — он покрывает 99% случаев, которые встретятся в работе junior DE:

Классы сложности от лучшего к худшему

При n=1000000: O(1) — 1 операция, O(n) — миллион, O(n²) — триллион. Это уже разница между миллисекундой и часами.

O(1)КонстантнаяВремя не зависит от n. Пример: получить элемент массива по индексу, dict-lookup.
O(log n)ЛогарифмическаяРастёт очень медленно. Пример: бинарный поиск, операции в сбалансированном дереве.
O(n)ЛинейнаяВремя пропорционально размеру. Пример: проход по массиву, sum(list).
O(n log n)Линейно-логарифмическаяЛучшая возможная для сортировки сравнениями. Пример: merge sort, Timsort.
O(n^2)КвадратичнаяДвойной цикл по массиву. Дедупликация наивная, bubble sort.
O(2^n)ЭкспоненциальнаяУдваивается при n+1. Перебор всех подмножеств. На n>30 уже непрактично.
O(n!)ФакториальнаяПеребор всех перестановок. На n>15 уже миллиарды операций.

Чтобы прочувствовать разницу, посмотрим на конкретные числа. Пусть одна базовая операция занимает 1 наносекунду (примерно правда для simple Python operations на современном CPU):

Сколько времени займёт алгоритм на разных n

При n=10^6 разница O(n) и O(n^2) — это 1 миллисекунда против 17 минут. Это не 'математическая абстракция', это твой рабочий день.

n=100O(1): 1 nsКонстанта независимо от n
n=100O(log n): 7 nslog2(100) = ~7
n=100O(n): 100 nsЛинейно
n=100O(n^2): 10 us100*100 = 10000 операций
n=10000O(1): 1 nsКонстанта независимо от n
n=10000O(log n): 14 nslog2(10000) = ~14
n=10000O(n): 10 us10000 операций
n=10000O(n^2): 100 ms100M операций
n=1MO(1): 1 nsКонстанта
n=1MO(log n): 20 nslog2(1M) = ~20
n=1MO(n): 1 msМиллион операций — миллисекунда
n=1MO(n^2): ~17 minТриллион операций — нерабочая программа

Вот теперь становится понятно, почему наш junior с дедупликацией провалился. Его код — O(n²): для каждого элемента x он делает x not in result, что само по себе O(n) (поиск в list). Двойной вложенный обход.

  • На 1000 строк: 1M операций — миллисекунды, незаметно.
  • На 10M строк: 100 триллионов операций — те самые 6 часов в проде.

Решение — использовать set (O(1) на проверку in), и весь алгоритм становится O(n). На 10M строк работает секунды.

Big-O — это верхняя граница

Технически, Big-O это верхняя граница, а не «точная сложность». Это значит, например, что n — это тоже O(n²) (потому что n <= n² для n >= 1). Просто O(n) — более точная характеристика.

В разговорной речи (и в этом курсе) мы говорим «O(n)» имея в виду «асимптотически точная сложность, в худшем случае». Формально это называется Big-Theta (Θ), но никто этим не пользуется в практике.

Соседние обозначения, которые встретятся:

  • Big-O (O) — верхняя граница. «Не хуже, чем».
  • Big-Omega (Ω) — нижняя граница. «Не лучше, чем». Редко используется в практике.
  • Big-Theta (Θ) — точная асимптотика. То, что мы обычно имеем в виду под «O».

На собесе можно говорить «O» и все поймут. Если педантичный интервьюер прижмёт — скажете «Θ, конечно».

Худший / лучший / средний случай

У многих алгоритмов сложность зависит от входных данных, не только от размера. Поэтому различают:

  • Худший случай (worst case) — самые плохие данные. О нём чаще всего пишут big-O.
  • Лучший случай (best case) — самые удачные данные.
  • Средний случай (average case) — на «типичных» данных, обычно с некоторым распределением.

Пример — quicksort:

  • Worst: O(n²) — отсортированный массив с плохим выбором pivot.
  • Best: O(n log n) — pivot всегда делит пополам.
  • Average: O(n log n) — на случайных данных.

Когда говорят «quicksort — O(n log n)» — обычно имеют в виду average case. На собесе при сомнениях уточняйте, какой случай интересует.

Попробуй сам: измеряем разницу O(n) и O(n²)

Соберём настоящий бенчмарк дедупликации:

import timeit

def dedupe_list(items):
    """O(n^2) — медленная версия из примера"""
    result = []
    for x in items:
        if x not in result:
            result.append(x)
    return result

def dedupe_set(items):
    """O(n) — правильная версия через set"""
    return list(set(items))

# Замеры для разных n
for n in [100, 1000, 10_000, 100_000]:
    data = list(range(n)) * 2  # дубликаты для дедупа
    t_list = min(timeit.repeat(lambda: dedupe_list(data), number=1, repeat=3))
    t_set  = min(timeit.repeat(lambda: dedupe_set(data),  number=1, repeat=3))
    print(f"n={n:>7}: list O(n^2)={t_list*1000:>8.2f}ms, set O(n)={t_set*1000:>6.2f}ms, ratio={t_list/t_set:>8.0f}x")
TIP

До запуска: при n=100, во сколько раз list версия медленнее set? А при n=100000? Запишите свои предсказания.

Запустите код. Сверьтесь. Типичный вывод:

n=    100: list O(n^2)=    0.05ms, set O(n)=  0.01ms, ratio=     5x
n=   1000: list O(n^2)=    3.50ms, set O(n)=  0.05ms, ratio=    70x
n=  10000: list O(n^2)=  350.00ms, set O(n)=  0.50ms, ratio=   700x
n= 100000: list O(n^2)=35000.00ms, set O(n)=  5.00ms, ratio=  7000x

Заметьте: каждое удесятерение n увеличивает разницу в 10 раз. Это и есть «O(n²) делится на O(n) даёт O(n)» — разница ratio растёт линейно с n.

Что запомнить из этого урока

Шпаргалка по Big-O

Это базис, без которого следующие уроки не пойдут.

Big-OСкорость роста при n -> infinityНе точное время, а класс роста
Константы исчезают100n + 5000 -> O(n)Доминирующий член — то, что растёт быстрее
Ключевые классыO(1), O(log n), O(n), O(n log n), O(n^2), O(2^n)Запомните этот ряд — встретится везде
Big-O это про худший случайЕсли не сказано иноеЛучший/средний обсуждаются отдельно
Big-O это абстракцияРеальная скорость зависит от железа, cache, константBig-O говорит, что произойдёт при росте n. Не что произойдёт сейчас на конкретной машине

В следующем уроке — разбираем каждый класс сложности на конкретных примерах кода, с timeit-замерами.

Big-O через призму CPython и amortized analysis
Проверка знанийKnowledge check
Почему при сравнении f(n) = 100n + 5000 и g(n) = n^2 мы говорим, что g растёт быстрее, хотя на малых n именно f больше?
ОтветAnswer
Big-O — это про асимптотику, поведение при n -> infinity, а не при конкретных малых n. При росте n коэффициенты и offset (100 и 5000) становятся пренебрежимо малы по сравнению с n^2. На n=100 квадратичная уже почти догнала, на n=1000 обогнала в 10 раз, на n=10000 — в 100 раз. Поэтому в Big-O константы и offset отбрасываются, и f = O(n), g = O(n^2). При малых n это не работает, но на DE-задачах с миллионами строк именно асимптотика и определяет, успеет ли пайплайн закончиться за ночь.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Почему в Big-O мы отбрасываем константы и множители? Например, 100n + 5000 записывается как O(n).

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5