Асимптотика: O-нотация и оценка «уложусь ли по времени»

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

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

Зачем это нужно

Представь, что ты придумал решение и уже хочешь его кодить. Стоп. Сначала ответь на один вопрос: сколько примерно операций оно сделает на максимальном тесте? Если ответ больше, чем процессор успеет за 1–2 секунды, ты напишешь правильный, но бесполезный код и получишь TLE. Умение в уме оценить сложность до написания кода экономит часы и отделяет сильных участников от слабых. Асимптотика — это не теория ради теории, а инструмент принятия решений «какой алгоритм здесь вообще имеет смысл».

Идея «на пальцах»

O-большое отвечает на вопрос «как быстро растёт время?», игнорируя детали. Если алгоритм делает 3n + 5 операций — это O(n): при удвоении n время примерно удваивается. Если 2n^2 + 100n — это O(n^2): при удвоении n время вырастает вчетверо. Константы (3, 2, 100) отбрасываются, потому что на больших n решает именно форма роста, а не множители. Нам не важно, 2 секунды или 4 — важно, что O(n^2) при n=10^6 — это 10^12 операций, и это безнадёжно.

Лестница сложностей

Запомни этот порядок от лучшего к худшему — это твоя шкала.

СложностьНазваниеПример
O(1)константаобращение по индексу, арифметика
O(log n)логарифмбинарный поиск
O(n)линейнаяодин проход по массиву
O(n log n)линейно-логарифм.сортировка, многие «хорошие» алгоритмы
O(n^2)квадратдвойной вложенный цикл
O(2^n)экспонентаперебор всех подмножеств
O(n!)факториалперебор всех перестановок

Посмотрим на конкретные числа для n = 10^6 — разница огромна.

import math

n = 1_000_000
print("O(1):       ", 1)
print("O(log n):   ", round(math.log2(n)))
print("O(n):       ", n)
print("O(n log n): ", round(n * math.log2(n)))
print("O(n^2):     ", n * n)

Здесь видно, что O(n log n) для миллиона — всего ~2·10^7 операций (мгновенно), а O(n^2) — 10^12 (часы). Логарифм же превращает миллион в каких-то двадцать шагов — поэтому бинарный поиск так быстр.

Вывод:

O(1):        1
O(log n):    20
O(n):        1000000
O(n log n):  19931569
O(n^2):      1000000000000

Главное правило прикидки

За 1–2 секунды современный судья выполняет порядка 10^8–10^9 простых операций на C++. На Python считай скромнее — 10^7–10^8, потому что язык интерпретируемый. Отсюда рабочая эвристика: число операций твоего алгоритма должно быть примерно ≤ 10^8. Подставь максимальное n в формулу сложности — и сравни.

Что значит «растёт быстрее»

Чтобы прочувствовать разницу классов, посмотрим, как меняется объём работы при увеличении n. Линейная сложность растёт пропорционально, квадратичная — как квадрат, а экспонента взрывается так, что при n=40 опережает всё на порядки. Эта таблица — наглядное «почему» за всеми правилами урока.

print(f'{"n":>6}{"O(n)":>12}{"O(n^2)":>16}{"O(2^n)":>18}')
for n in [10, 20, 40]:
    print(f'{n:>6}{n:>12}{n * n:>16}{2 ** n:>18}')

Обрати внимание на последнюю колонку: при переходе от n=20 к n=40 значение O(n) просто удвоилось, O(n^2) выросло вчетверо, а O(2^n) — в миллион раз. Вот почему экспоненциальные решения годятся лишь для крошечных n, а для больших нужен полиномиальный алгоритм.

Вывод:

     n        O(n)          O(n^2)            O(2^n)
    10          10             100              1024
    20          20             400           1048576
    40          40            1600     1099511627776

Обратный приём: читаем сложность из ограничений

Самое мощное — это идти от ограничений к алгоритму. Размер n в условии почти всегда подсказывает нужную асимптотику. Эту таблицу полезно знать наизусть.

Ограничение на nДопустимая сложностьЧто подходит
n ≤ 11O(n!)перебор перестановок
n ≤ 25O(2^n)перебор подмножеств, meet-in-the-middle
n ≤ 500O(n^3)Флойд, интервальное ДП
n ≤ 5000O(n^2)двойной цикл, простое ДП
n ≤ 10^5..10^6O(n log n)сортировка, бинпоиск, дерево отрезков
n ≤ 10^8..10^9O(n) или O(log n)линейный проход / формула

Видишь n ≤ 20 — почти наверняка ждут перебор подмножеств за O(2^n). Видишь n = 10^5 — квадрат не пройдёт, нужен O(n log n). Это сужает поиск идеи в разы.

Тонкости, на которых ошибаются

  • Сложность — про худший случай. «Обычно быстро» не считается: судья найдёт злой тест.
  • Память тоже имеет асимптотику. Массив n×n при n=10^5 — это 10^10 ячеек, никакой памяти не хватит (MLE).
  • Константа иногда решает. Два алгоритма O(n log n) могут отличаться в 5 раз по скорости; на грани TLE это важно, особенно в Python.
  • Логарифм почти бесплатен. Домножить алгоритм на log n (например, обернуть в бинпоиск) — обычно не страшно: log n ≈ 20 даже для миллиона.

Попробуй сам

Дано n ≤ 2·10^5 и нужно для каждого элемента массива найти ближайший справа больший. Прикинь: квадрат — это (2·10^5)^2 = 4·10^10, не пройдёт. Значит, нужно O(n) или O(n log n) — здесь работает стек за O(n) (мы вернёмся к этому в разделе про структуры данных). Видишь, как ограничение само подсказало класс решения?

Итог

  • O-большое описывает скорость роста времени, отбрасывая константы и младшие члены.
  • Ориентир: алгоритм должен делать ≤ ~10^8 операций (на Python — скромнее).
  • Ограничение на n подсказывает нужную сложность: n=20 → 2^n, n=5000 → n^2, n=10^5 → n log n.
  • Оценивай сложность до кодинга — это экономит время и спасает от TLE.
Проверьте себя
1. Сколько примерно простых операций успевает выполнить решение за 1–2 секунды (ориентир)?
AОколо 100
BОколо 10^3
CПорядка 10^8
DПорядка 10^15
2. В условии n ≤ 20. Какой класс решения, скорее всего, ожидается?
AO(n^2)
BO(2^n) — перебор подмножеств
CO(n log n)
DO(1)
3. Алгоритм делает 2n^2 + 100n + 50 операций. Какова его асимптотика?
AO(n)
BO(n log n)
CO(n^2)
DO(2^n)
4. Почему для n = 10^5 алгоритм за O(n^2) обычно не проходит?
AНе хватит памяти под массив
BЭто ~10^10 операций — слишком много за пару секунд
CO(n^2) всегда даёт неверный ответ
DPython не умеет вложенные циклы
Поддержать проект