Анализ алгоритмов: асимптотика, инварианты и доказательство корректности
Есть два вопроса, которые нужно задать любому куску кода, прежде чем выпускать его в прод: он вообще правильный? и что с ним будет, когда данных станет в тысячу раз больше?
Первый вопрос — про корректность, второй — про сложность. Оба звучат как «академическая теория», и оба на самом деле про деньги: инцидент в три часа ночи почти всегда оказывается либо нарушенным инвариантом («здесь не могло быть null, но оно есть»), либо квадратичной асимптотикой, которая мирно спала, пока таблица не выросла с 10 тысяч строк до миллиона.
Эта статья — фундамент всего трека. Дальше, разбирая сортировки, графы и динамическое программирование, мы будем постоянно пользоваться словарём отсюда: O, Θ, инвариант, рекуррентность, амортизация, нижняя оценка. Карта трека — в статье Алгоритмы: карта трека.
1. Зачем нужна абстракция: почему нельзя просто замерить время
Наивный ответ на «какой алгоритм быстрее» — запустить оба и посмотреть на секундомер. Проблема в том, что секундомер меряет не алгоритм, а конкретный запуск конкретной реализации на конкретном железе с конкретными данными. Поменяйте хоть один из четырёх факторов — и результат может перевернуться:
- другой процессор, другой размер L2-кэша, другая частота памяти;
- другой язык и рантайм: Python против C — разница в 50–100 раз на том же алгоритме;
- другой компилятор и флаги оптимизации;
- другие входные данные: почти отсортированный массив против случайного.
Нам нужна характеристика, которая описывает сам алгоритм и переживает смену железа. Такой характеристикой стала функция роста: как меняется число элементарных операций при росте размера входа n. Константы при этом отбрасываются — не потому, что они не важны (они важны, к этому вернёмся в разделе 9), а потому, что они и есть та часть, которая зависит от железа и реализации.
Модель вычислений: RAM
Чтобы «число операций» было определено, нужна модель машины. Классическая — RAM (Random Access Machine): память — массив ячеек с доступом за константу, элементарные операции (сложение, сравнение, присваивание, обращение по индексу) стоят единицу, машинное слово вмещает индекс входа (обычно O(log n) бит).
Модель — это соглашение, и у него есть цена. RAM врёт в трёх местах, о которых стоит помнить с самого начала:
- Доступ к памяти не константный. L1-кэш ~1 нс, RAM ~100 нс, SSD ~100 мкс. Отсюда практика, разобранная в Практической оптимизации и в алгоритмах внешней памяти.
- Арифметика не всегда константная. Умножение 4096-битных чисел — не одна операция; в теории чисел это принципиально.
- Параллелизм не учитывается — для него нужны свои модели (параллельные алгоритмы).
Пока держим в голове: асимптотика — первое приближение, очень полезное и иногда недостаточное.
2. Асимптотические обозначения строго
Интуитивно O — это «не хуже чем», Ω — «не лучше чем», Θ — «ровно такого порядка». Формально это множества функций.
Пусть f, g: ℕ → ℝ⁺.
f(n) = O(g(n))⟺ существуютc > 0иn₀, такие чтоf(n) ≤ c·g(n)для всехn ≥ n₀. Верхняя оценка с точностью до константы, начиная с некоторого места.f(n) = Ω(g(n))⟺ существуютc > 0иn₀:f(n) ≥ c·g(n)приn ≥ n₀. Нижняя оценка.f(n) = Θ(g(n))⟺ одновременноOиΩ. Точный порядок роста.f(n) = o(g(n))⟺f(n)/g(n) → 0. Строго меньший порядок:n = o(n log n).f(n) = ω(g(n))⟺f(n)/g(n) → ∞.
Аналогия: O — это ≤, Ω — ≥, Θ — =, o — <, ω — >, но в мире функций и с точностью до константного множителя.
Знак равенства здесь — исторический ляп: O(g) — это множество, и корректнее писать f ∈ O(g). Поэтому запись читается только слева направо: n = O(n²) верно, O(n²) = n бессмысленно.
Что чаще всего понимают неправильно
O — не «худший случай», а Θ — не «средний». Это ортогональные измерения. Сначала вы фиксируете сценарий (худший / средний / лучший вход), получаете функцию, и только потом описываете её рост через O/Θ. Совершенно законно сказать: «время работы quicksort в худшем случае Θ(n²), а математическое ожидание при случайном пивоте Θ(n log n)».
O(n²) — корректная (хоть и слабая) оценка для сортировки слиянием. O — верхняя граница, никто не обещал её точность. Если вы знаете точный порядок — говорите Θ, это информативнее. В индустрии O почти всегда употребляют в смысле Θ для худшего случая; это удобная небрежность, но на собеседовании или в статье лучше быть точным.
Асимптотика ничего не говорит про малые n. Θ(n log n)-алгоритм с константой 1000 проиграет Θ(n²) с константой 1 на всех n < ~14000.
Иерархия роста
Полезно помнить порядок «по нарастающей»:
Θ(1) ≺ Θ(α(n)) ≺ Θ(log log n) ≺ Θ(log n) ≺ Θ(log²n) ≺ Θ(√n) ≺ Θ(n)
≺ Θ(n log n) ≺ Θ(n²) ≺ Θ(n³) ≺ Θ(2ⁿ) ≺ Θ(n!) ≺ Θ(nⁿ)
Здесь α(n) — обратная функция Аккермана, растущая настолько медленно, что для любых реальных n она ≤ 4; она возникает в анализе СНМ (система непересекающихся множеств), см. структуры данных.
Практическое правило порядков для одной секунды на типичном CPU (условно, 10⁸–10⁹ простых операций):
| Сложность | Допустимое n |
Типичный пример |
|---|---|---|
O(log n) |
10¹⁸ | бинарный поиск |
O(√n) |
10¹⁶ | проверка простоты перебором делителей |
O(n) |
10⁸ | один проход, два указателя |
O(n log n) |
10⁶–10⁷ | сортировка, sweep line |
O(n²) |
10⁴ | простые ДП, матрица смежности |
O(n³) |
500 | Флойд–Уоршелл, умножение матриц |
O(2ⁿ · n) |
20–22 | ДП по подмножествам |
O(n!) |
10–11 | полный перебор перестановок |
Эту таблицу стоит выучить: она за пять секунд отвечает на вопрос «зайдёт ли решение» и на продовый вопрос «выдержим ли мы рост нагрузки в 10 раз».
Немного истории
Статья Кнута, где он навёл порядок с Ω и Θ, — «Big Omicron and Big Omega and Big Theta», SIGACT News, 1976: dl.acm.org/doi/10.1145/1008328.1008329.
3. Как считать сложность кода
Базовые правила сложения и умножения:
- последовательные блоки — максимум:
O(f) + O(g) = O(max(f, g)); - вложенные циклы — произведение;
- константный множитель отбрасывается, младшие члены поглощаются.
Разберём типичные шаблоны.
# 1) Θ(n) — один линейный проход
total = 0
for x in data: # n итераций, тело Θ(1)
total += x
# 2) Θ(n²) — вложенный цикл по всем парам
for i in range(n):
for j in range(n):
check(i, j)
# 3) Θ(n²), а не Θ(n log n): треугольный цикл
for i in range(n):
for j in range(i, n): # n + (n-1) + ... + 1 = n(n+1)/2 итераций
check(i, j)
# 4) Θ(log n) — параметр делится, а не уменьшается
while lo < hi:
mid = (lo + hi) // 2
...
# 5) Θ(n log n) — внешний линейный, внутренний логарифмический
for i in range(n):
j = 1
while j < n:
j *= 2
Главная ловушка новичков — скрытая сложность вызываемых функций. В Python это особенно больно:
# Выглядит как Θ(n), на деле Θ(n²):
result = ""
for chunk in chunks: # n итераций
result += chunk # копирование всей строки: Θ(len(result))
# Правильно — Θ(n):
result = "".join(chunks)
# Ещё пример: `x in lst` — это Θ(n), `x in set` — Θ(1) в среднем
seen = []
for x in data:
if x in seen: # линейный поиск внутри цикла → Θ(n²)
...
seen.append(x)
Справочник по стоимости операций CPython — обязателен к прочтению: wiki.python.org/moin/TimeComplexity. Аналоги есть для любого языка: сложность контейнеров в cppreference, Big-O коллекций Java в javadoc, документация к Go-слайсам и мапам (Go overview).
Общая методика анализа:
4. Рекуррентности: дерево рекурсии и мастер-теорема
Для рекурсивных алгоритмов время описывается рекуррентным соотношением. Классика «разделяй и властвуй» (подробнее — в статье о рекурсии):
T(n) = a · T(n/b) + f(n)
где a — число подзадач, n/b — их размер, f(n) — стоимость разбиения и слияния.
Самый наглядный способ — дерево рекурсии: нарисовать работу на каждом уровне и сложить.
Мастер-теорема (CLRS, гл. 4)
Сравниваем f(n) с «весом листьев» n^(log_b a):
- Листья доминируют. Если
f(n) = O(n^(log_b a − ε))для некоторогоε > 0, тоT(n) = Θ(n^(log_b a)). - Баланс. Если
f(n) = Θ(n^(log_b a) · log^k n),k ≥ 0, тоT(n) = Θ(n^(log_b a) · log^(k+1) n). - Корень доминирует. Если
f(n) = Ω(n^(log_b a + ε))и выполняется условие регулярностиa·f(n/b) ≤ c·f(n)приc < 1, тоT(n) = Θ(f(n)).
Разбор примеров:
| Рекуррентность | a, b |
n^(log_b a) |
Случай | Ответ |
|---|---|---|---|---|
T(n)=T(n/2)+Θ(1) — бинпоиск |
1, 2 | n⁰ = 1 |
2 (k=0) |
Θ(log n) |
T(n)=2T(n/2)+Θ(n) — merge sort |
2, 2 | n |
2 (k=0) |
Θ(n log n) |
T(n)=2T(n/2)+Θ(1) — обход дерева |
2, 2 | n |
1 | Θ(n) |
T(n)=7T(n/2)+Θ(n²) — Штрассен |
7, 2 | n^2.807 |
1 | Θ(n^log₂7) |
T(n)=2T(n/2)+Θ(n²) |
2, 2 | n |
3 | Θ(n²) |
T(n)=T(n−1)+Θ(n) — quicksort worst |
— | — | не применима | Θ(n²) |
Последняя строка важна: мастер-теорема работает только для деления в разы, а не вычитания. Для T(n) = T(n−1) + f(n) просто суммируйте: T(n) = Σ f(i).
Есть и случаи «между» — когда f(n) меньше n^(log_b a), но не полиномиально меньше (например, f(n) = n/log n); мастер-теорема их не покрывает. Тогда — метод подстановки или теорема Акра–Бацци, которая ещё и допускает неравные размеры подзадач (T(n) = T(n/3) + T(2n/3) + n — рекуррентность для медианы медиан и для несбалансированного quicksort, ответ Θ(n log n)).
Метод подстановки: угадать и доказать индукцией
Докажем, что T(n) = 2T(⌊n/2⌋) + n ≤ c·n·log₂n для подходящего c.
База: n = 2: T(2) = 2T(1) + 2 = 4 ≤ c·2·1 при c ≥ 2.
Переход: предположим T(k) ≤ c·k·log₂k для всех k < n. Тогда
T(n) ≤ 2·c·(n/2)·log₂(n/2) + n
= c·n·(log₂n − 1) + n
= c·n·log₂n − c·n + n
≤ c·n·log₂n (при c ≥ 1)
Обратите внимание на технику: индукционная гипотеза должна быть достаточно сильной. Попытка доказать T(n) ≤ c·n провалится, и это само по себе сигнал, что оценка неверна.
5. Амортизационный анализ
Иногда «худший случай одной операции» — обманчиво пессимистичная метрика. Классика — push в динамический массив (list в Python, vector в C++, слайс в Go): обычно это Θ(1), но при переполнении ёмкости нужно выделить новый буфер вдвое больше и скопировать всё содержимое — Θ(n).
Значит ли это, что n вызовов push стоят Θ(n²)? Нет: дорогие операции редки.
Три стандартных метода (Tarjan, «Amortized Computational Complexity», 1985 — epubs.siam.org/doi/10.1137/0606031):
1. Агрегатный метод. Считаем суммарную стоимость n операций и делим на n. Копирования происходят при размерах 1, 2, 4, …, 2^k ≤ n, суммарно 1 + 2 + 4 + … + n < 2n копирований. Плюс n дешёвых вставок → < 3n операций всего, то есть Θ(1) амортизированно.
2. Бухгалтерский метод (accounting). Назначаем каждой операции «цену» 3 условные единицы: 1 тратим на саму вставку, 2 кладём «в банк» на счёт элемента. Когда наступает удвоение, в банке накоплено ровно столько, чтобы оплатить копирование всех элементов. Инвариант: баланс банка никогда не отрицателен.
3. Метод потенциалов. Вводим функцию Φ(D) — «запасённую энергию» структуры. Амортизированная стоимость: ĉᵢ = cᵢ + Φ(Dᵢ) − Φ(Dᵢ₋₁). Тогда Σĉᵢ = Σcᵢ + Φ(Dₙ) − Φ(D₀) ≥ Σcᵢ, если Φ(Dₙ) ≥ Φ(D₀) — то есть сумма амортизированных стоимостей ограничивает сверху реальную.
Для динамического массива берём Φ = 2·len − cap:
- обычная вставка:
c = 1,ΔΦ = 2→ĉ = 3; - вставка с удвоением при
len = cap = k:c = k + 1, до операцииΦ = 2k − k = k, послеΦ = 2(k+1) − 2k = 2→ĉ = (k+1) + 2 − k = 3.
Ровно 3 в обоих случаях. Красиво и, что важнее, механически проверяемо.
Практические следствия
- Коэффициент роста имеет значение. Рост в 2 раза даёт
ĉ = 3; рост в 1.5 раза —ĉ ≈ 4, но лучше переиспользует освобождённую память (сумма предыдущих блоков может покрыть следующий запрос). Поэтому вstd::vectorGCC/Clang коэффициент 2, а в MSVC и вVecRust — компромиссы; Python CPython наращивает примерно на 12.5% (list_resizeв listobject.c). - Амортизация ≠ гарантия по латентности. Если у вас p99-SLA 10 мс, а одна операция из миллиона занимает 200 мс из-за рехеширования, «амортизированно
O(1)» вас не спасёт. Real-time системы используют инкрементальные структуры, которые размазывают переезд по многим операциям. Тот же вопрос — про stop-the-world паузы GC. - Амортизация ломается при откате. Если после каждого дорогого удвоения делать
pop, который сжимает массив вдвое, вы получите чередованиеΘ(n)-операций. Поэтому реальные реализации сжимают только при заполнении ниже 1/4, а не 1/2 — создают гистерезис.
6. Корректность: инварианты цикла
Тесты показывают наличие ошибок, а не их отсутствие (Дейкстра). Доказательство — показывает отсутствие. На практике вам не нужно формально верифицировать каждую функцию, но уметь формулировать инвариант нужно постоянно: это то, что превращает «вроде работает» в «работает, и я знаю почему».
Классическая схема доказательства цикла состоит из трёх шагов (CLRS, гл. 2):
Это индукция, замаскированная под цикл: инициализация — база, сохранение — переход, завершение — извлечение вывода.
Пример 1: сортировка вставками
def insertion_sort(a: list[int]) -> None:
"""Сортировка вставками. Время: O(n²) худший/средний, O(n) на почти
отсортированных данных. Память: O(1) дополнительно, in-place, стабильная."""
for i in range(1, len(a)):
# ИНВАРИАНТ: подмассив a[0..i-1] содержит те же элементы,
# что были в нём изначально, но в отсортированном порядке.
key = a[i]
j = i - 1
while j >= 0 and a[j] > key:
a[j + 1] = a[j]
j -= 1
a[j + 1] = key
- Инициализация.
i = 1: подмассивa[0..0]из одного элемента тривиально отсортирован, элементы те же. - Сохранение. Внутренний цикл сдвигает вправо все элементы, строго большие
key, и вставляетkeyна освободившееся место. Порядок среди сдвинутых сохранён,keyоказался правее всех≤ keyи левее всех> key→a[0..i]отсортирован. - Завершение. Цикл кончается при
i = n, инвариант даёт:a[0..n-1]отсортирован и содержит исходные элементы. Это ровно определение корректной сортировки.
Обратите внимание: инвариант говорит про два свойства — упорядоченность и сохранение мультимножества элементов. Забыть второе — типичная ошибка: алгоритм, заполняющий массив нулями, «отсортирован», но неверен.
Пример 2: бинарный поиск и три способа ошибиться
def lower_bound(a: list[int], target: int) -> int:
"""Индекс первого элемента >= target (или len(a), если такого нет).
Предусловие: a отсортирован по неубыванию.
Время: O(log n). Память: O(1)."""
lo, hi = 0, len(a) # полуинтервал [lo, hi)
# ИНВАРИАНТ: ответ лежит в [lo, hi];
# все a[k] < target для k < lo
# все a[k] >= target для k >= hi
while lo < hi:
mid = lo + (hi - lo) // 2 # без переполнения; в Python не критично,
# в C/C++/Java — обязательно
if a[mid] < target:
lo = mid + 1 # a[mid] точно левее ответа
else:
hi = mid # a[mid] — кандидат, но не отбрасываем его
return lo # lo == hi, инвариант даёт ответ
Разбор по схеме:
- Инициализация.
lo=0, hi=n: оба «все …»-условия пусты, значит истинны. - Сохранение. Если
a[mid] < target, то по отсортированности всеa[k] < targetприk ≤ mid, и сдвигlo = mid+1инвариант сохраняет. Иначеa[mid] ≥ target, и всеa[k] ≥ targetприk ≥ mid, сдвигhi = midкорректен. - Завершение. Нужен вариант — целочисленная мера, строго убывающая и ограниченная снизу. Здесь
hi − lo: приlo < hiимеемlo ≤ mid < hi, значит веткаlo = mid+1строго увеличиваетlo, аhi = midстрого уменьшаетhi. Мера убывает минимум на 1 и ≥ 0 → цикл завершается. Приlo = hiинвариант даёт ровно определениеlower_bound.
Три классические ошибки, которые доказательство ловит мгновенно:
mid = (lo + hi) // 2переполняется в языках с 32-битнымint. Знаменитая заметка Джошуа Блоха «Nearly All Binary Searches and Mergesorts are Broken» — баг прожил в JDK девять лет: research.google/blog/extra-extra-read-all-about-it….hi = mid - 1в полуинтервальной версии теряет ответ (инвариант «всеa[k] ≥ targetприk ≥ hi» ломается).lo = midвместоlo = mid + 1не уменьшает вариант приhi = lo + 1→ бесконечный цикл. Именно поэтому вариант нужно выписывать отдельно: инвариант отвечает за «правильно», вариант — за «когда-нибудь закончится».
Формальная база всего этого — тройки Хоара {P} S {Q} из статьи 1969 года «An Axiomatic Basis for Computer Programming»: dl.acm.org/doi/10.1145/363235.363259. Правило для цикла: если {I ∧ B} S {I}, то {I} while B do S {I ∧ ¬B}.
Индукция для рекурсии
Для рекурсивных функций схема иная: сильная индукция по размеру входа.
def merge_sort(a: list[int]) -> list[int]:
"""Сортировка слиянием. Время: Θ(n log n) всегда. Память: Θ(n)."""
if len(a) <= 1:
return a # БАЗА: список длины 0 или 1 отсортирован
mid = len(a) // 2
left = merge_sort(a[:mid]) # ГИПОТЕЗА: вернёт отсортированный left
right = merge_sort(a[mid:]) # ГИПОТЕЗА: вернёт отсортированный right
return merge(left, right) # ЛЕММА: merge двух сортированных даёт сортированный
def merge(x: list[int], y: list[int]) -> list[int]:
res, i, j = [], 0, 0
# ИНВАРИАНТ: res отсортирован и содержит все элементы x[:i] и y[:j];
# любой элемент res <= любого из x[i:] и y[j:]
while i < len(x) and j < len(y):
if x[i] <= y[j]: # <= вместо < обеспечивает стабильность
res.append(x[i]); i += 1
else:
res.append(y[j]); j += 1
res.extend(x[i:]); res.extend(y[j:])
return res
Обязательное условие корректности рекурсии: аргумент рекурсивного вызова строго меньше по некоторому вполне упорядоченному отношению. len(a[:mid]) < len(a) выполнено, потому что при len(a) ≥ 2 имеем 1 ≤ mid ≤ len(a) − 1. Забудьте про этот случай — и получите RecursionError на входе длины 1.
Кстати, в реальном мире это не паранойя: баг в Timsort из OpenJDK/Android/Python нашли именно формальной верификацией — инвариант на стеке ранов не выполнялся, и при особых входах случался ArrayIndexOutOfBoundsException. Работа de Gouw et al., CAV 2015: «OpenJDK’s java.utils.Collection.sort() is broken». Подробнее про Timsort — в статье о сортировках.
7. Нижние оценки: доказать, что быстрее нельзя
Верхняя оценка — это «я умею за O(f)». Нижняя — гораздо более сильное утверждение: «никто не сможет быстрее Ω(g) в такой-то модели».
Дерево решений для сортировок сравнением. Любой алгоритм, который узнаёт о данных только через сравнения aᵢ ? aⱼ, можно представить бинарным деревом: узлы — сравнения, листья — итоговые перестановки. Различных перестановок n!, каждая должна быть достижима, значит листьев ≥ n!. Бинарное дерево высоты h имеет не более 2^h листьев, следовательно 2^h ≥ n!, откуда по формуле Стирлинга:
h ≥ log₂(n!) = Θ(n log n)
Это тот самый барьер Ω(n log n), поэтому merge sort и heap sort оптимальны в модели сравнений. А radix sort и counting sort обходят его не «нарушая теорему», а работая в другой модели — они читают биты/цифры ключей, а не только сравнивают их.
Аргумент противника (adversary). Ещё одна техника: воображаемый оппонент отвечает на запросы алгоритма так, чтобы максимально долго сохранять неопределённость. Например, поиск максимума требует ≥ n − 1 сравнений: каждое сравнение «выбивает» максимум одного кандидата, а исключить нужно n − 1.
Про то, что бывает, когда нижних оценок нет и задача, похоже, экспоненциальна, — в NP-полноте и приближениях.
8. Память, случайность и «средний случай»
Память. Считается так же, как время, но не забывайте про две частые невидимки:
- стек рекурсии:
Θ(log n)у quicksort с рекурсией по меньшей половине, ноΘ(n)в худшем случае, если рекурсировать наивно; - промежуточные копии:
a[:mid]в примере выше — этоΘ(n)аллокаций, поэтому «in-place merge sort» из учебника на самом деле требуетΘ(n)дополнительной памяти.
Различают ещё входную память (её обычно не считают) и рабочую; алгоритм с O(log n) рабочей памяти называется работающим в логарифмическом пространстве.
Средний случай требует распределения. Фраза «в среднем O(n log n)» бессмысленна без указания, по какому распределению берётся среднее. Обычно предполагают равновероятность всех перестановок входа — сильное и часто неверное допущение (реальные данные почти отсортированы, или наоборот, приходят от злоумышленника).
Рандомизация переносит случайность внутрь алгоритма. Randomized quicksort даёт E[T(n)] = Θ(n log n) для любого входа — потому что случайность берётся из генератора, а не из данных. Это принципиально сильнее, и это тема рандомизированных алгоритмов.
Правый нижний угол — «галактические алгоритмы»: асимптотически прекрасные, но с константами, делающими их бесполезными на любых реальных данных. Умножение матриц за O(n^2.3719) (Alman–Williams, arxiv.org/abs/2010.05846) — чистая теория; на практике используют Штрассена и то лишь от n ≈ 1000.
9. Константы, которые нельзя игнорировать
Асимптотика — модель, а модель можно применить не по адресу. Где именно O врёт:
- Малые
n. Все промышленные сортировки переключаются на insertion sort для коротких участков:std::sort(introsort Массера) — на 16 элементах, CPythonlist.sort— наminrunиз 32–64 элементов (см. подробный listsort.txt). - Кэш.
Θ(n)обход связного списка может быть в 10–50 раз медленнееΘ(n)обхода массива: у списка каждый шаг — промах кэша. Отсюда популярность плоских структур (std::vectorвместоstd::list, B-деревья вместо бинарных). - Аллокации и GC.
Θ(n log n)merge sort с созданием новых списков может проигратьΘ(n²)in-place сортировке наn = 5000из-за давления на аллокатор. - Предсказание ветвлений. Ветвление с непредсказуемым исходом стоит ~15 циклов штрафа. Branchless-варианты бинарного поиска бывают быстрее «оптимального» кода.
- Скрытые константы в структурах. Фибоначчиева куча даёт
O(1)амортизированныйdecrease-keyи теоретически ускоряет Дейкстру, но на практике проигрывает бинарной куче почти всегда (кратчайшие пути).
Правило: асимптотика решает, какой алгоритм; константы решают, какая реализация. Сначала уберите квадрат, потом деритесь за проценты.
10. Эмпирическая проверка: doubling test
Теорию нужно проверять. Самый простой и надёжный приём — тест удвоения (Sedgewick, algs4.cs.princeton.edu/14analysis): если T(n) ≈ c·n^b, то T(2n)/T(n) → 2^b. Значит, показатель степени восстанавливается как b = log₂(T(2n)/T(n)).
import math
import random
import time
from typing import Callable
def doubling_test(func: Callable[[list[int]], object],
n0: int = 1000, steps: int = 7, repeats: int = 3) -> None:
"""Оценивает эмпирический показатель степени b в T(n) ~ c * n^b.
Отношение T(2n)/T(n) даёт 2^b: 2 → линейный, ~2.1-2.3 → n log n, 4 → квадрат.
"""
prev = None
n = n0
print(f"{'n':>9} {'время, с':>12} {'T(2n)/T(n)':>12} {'оценка b':>10}")
for _ in range(steps):
data = [random.randint(0, 10**9) for _ in range(n)]
best = min(_timed(func, data) for _ in range(repeats)) # min устойчивее среднего:
# шум всегда добавляет время
if prev is None:
print(f"{n:>9} {best:>12.5f} {'—':>12} {'—':>10}")
else:
ratio = best / prev
b = math.log2(ratio) if ratio > 0 else float("nan")
print(f"{n:>9} {best:>12.5f} {ratio:>12.2f} {b:>10.2f}")
prev, n = best, n * 2
def _timed(func, data) -> float:
payload = list(data) # копия: чтобы in-place алгоритм не получил
# уже отсортированный вход на второй прогон
start = time.perf_counter()
func(payload)
return time.perf_counter() - start
if __name__ == "__main__":
doubling_test(sorted) # ожидаем ratio ≈ 2.1-2.2, b ≈ 1.1
Что здесь принципиально и часто делается неправильно:
min, а не среднее. Шум операционной системы, JIT, GC всегда добавляет время и никогда не убавляет; минимум ближе к «чистой» стоимости.- Свежие данные на каждый прогон. Иначе in-place сортировка на втором прогоне получит отсортированный массив и покажет
O(n). - Прогрев. Для JIT-языков (Java, C#, JS) первые сотни итераций меряют компилятор, а не алгоритм. Используйте JMH, BenchmarkDotNet,
criterion(Rust),go test -bench. - Достаточно большие
n. Наn < 10⁴вы меряете кэш и накладные расходы, а не рост.
Для системных замеров: perf stat (Linux), hyperfine для CLI-программ, py-spy/cProfile для Python. Подробнее — в практической оптимизации.
11. Как это выглядит в проде
Сложность как поверхность атаки. Если злоумышленник управляет входом, он может целенаправленно попасть в худший случай:
- Hash-flooding. Подобрав ключи с одинаковым хешем, атакующий превращает
O(1)словарь вO(n)список и кладёт сервер. Классическая работа Crosby & Wallach, USENIX 2003 «Denial of Service via Algorithmic Complexity Attacks» (usenix.org). Ответ индустрии — SipHash со случайным секретом на процесс: сегодня он в Python (PYTHONHASHSEED), Rust, Ruby, Perl, ядре Linux. - ReDoS. Регулярное выражение с вложенными квантификаторами даёт экспоненциальный backtracking. 2 июля 2019 года Cloudflare положил значительную часть своего трафика на 27 минут одним регулярным выражением с
.*.*=.*в WAF — детальный и очень честный разбор: blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019. Защита: движки на автоматах (RE2, Goregexp, Rustregex) с гарантиейO(n). - Квадратичный парсинг. Zip-бомбы, XML entity expansion, «billion laughs» — всё это атаки на сложность, а не на память как таковую.
Ревью-эвристики, которые ловят 90% инцидентов:
- запрос к БД внутри цикла — это
N+1иO(n)round-trip’ов; list.contains/array.includesвнутри цикла — квадрат, заменяетсяset/map;- конкатенация строк в цикле — квадрат, заменяется буфером/
join; - рекурсия по данным пользователя без ограничения глубины — переполнение стека;
- сортировка внутри цикла вместо одной сортировки снаружи;
- пагинация через
OFFSET n— линейна поn, при глубокой пагинации используйте keyset-пагинацию (см. data engineering).
Асимптотика в capacity planning. Практический перевод: «мы вырастем в 10 раз за год». Для O(n) это ×10 к времени, для O(n log n) — примерно ×13, для O(n²) — ×100. Последнее означает, что система, отвечающая за 200 мс, будет отвечать 20 секунд. Именно это и есть тот момент, когда «академическая теория» превращается в постмортем.
Инварианты в проде — это не только доказательства. Формализованный инвариант естественно превращается в:
assert/ контракт (Debug.Assert,invariant()в Elixir-стиле через guard’ы);- property-based тест: Hypothesis (Python), QuickCheck (Haskell), fast-check (TS), StreamData (Elixir);
- инвариант БД: constraint, unique index, check;
- метрика/алерт: «баланс счетов сходится», «очередь не растёт монотонно».
Property-based тестирование — самый дешёвый способ получить 80% пользы от доказательств: вы формулируете инвариант («после сортировки список упорядочен и является перестановкой исходного»), а фреймворк ищет контрпример:
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_merge_sort_is_correct(xs):
result = merge_sort(xs)
assert all(result[i] <= result[i + 1] for i in range(len(result) - 1)) # упорядочен
assert sorted(result) == sorted(xs) # перестановка
Два assert — ровно два свойства из инварианта раздела 6. Это и есть практическое применение теории.
12. Чек-лист анализа
Перед тем как считать алгоритм готовым:
- Модель. Что считаю элементарной операцией? Что такое
n— длина массива, число рёбер, битовая длина числа? - Время. Худший случай в
Θ. Средний — если указано распределение. Отдельно — амортизация, если есть редкие дорогие операции. - Память. Рабочая память, стек рекурсии, промежуточные копии.
- Корректность. Предусловие, постусловие, инвариант каждого цикла, вариант (мера завершения) каждого цикла и рекурсии.
- Границы. Пустой вход, один элемент, все элементы равны, максимальный размер, переполнение типов, отрицательные числа.
- Устойчивость к злому входу. Что произойдёт, если данные подберёт атакующий?
- Эмпирика. Doubling test подтверждает теоретическую степень?
Мини-итог
- Асимптотика описывает алгоритм, а не запуск;
O,Ω,Θ— верхняя, нижняя и точная границы, а «худший/средний случай» — независимое от них измерение. - Рекуррентности решаются деревом рекурсии, мастер-теоремой или подстановкой с индукцией; для неравных разбиений — Акра–Бацци.
- Амортизационный анализ (агрегат, бухгалтерия, потенциал) объясняет, почему редкие
Θ(n)-операции не портят общуюΘ(1)— но не даёт гарантий по p99-латентности. - Корректность цикла = инициализация + сохранение + завершение; отдельно нужен вариант, доказывающий, что цикл вообще закончится.
- Нижние оценки (дерево решений, аргумент противника) говорят, когда улучшать уже нечего.
- Константы, кэш и аллокации решают исход, когда асимптотика одинакова; проверяйте теорию тестом удвоения и профилировщиком.
Источники
- Cormen, Leiserson, Rivest, Stein. Introduction to Algorithms, 4-е изд. — главы 2–5 (инварианты, асимптотика, рекуррентности, амортизация): mitpress.mit.edu
- Sedgewick, Wayne. Algorithms, 4-е изд., раздел 1.4 «Analysis of Algorithms» — тильда-нотация и doubling hypothesis: algs4.cs.princeton.edu/14analysis
- Skiena. The Algorithm Design Manual, 3-е изд. — глава 2, прикладной взгляд на анализ: algorist.com
- Knuth. Big Omicron and Big Omega and Big Theta, SIGACT News, 1976: dl.acm.org/doi/10.1145/1008328.1008329
- Hoare. An Axiomatic Basis for Computer Programming, CACM, 1969: dl.acm.org/doi/10.1145/363235.363259
- Tarjan. Amortized Computational Complexity, SIAM J. Alg. Disc. Meth., 1985: epubs.siam.org/doi/10.1137/0606031
- Crosby, Wallach. Denial of Service via Algorithmic Complexity Attacks, USENIX 2003: usenix.org
- Cloudflare. Details of the Cloudflare outage on July 2, 2019: blog.cloudflare.com
- CPython listsort.txt — как теория превращается в промышленный код: github.com/python/cpython
- Hypothesis — property-based тестирование на Python: hypothesis.readthedocs.io
Что дальше
Мы научились измерять и доказывать. Пора применить это к самой изученной задаче в информатике — сортировке: там встретятся и нижняя оценка Ω(n log n), и инварианты, и амортизация, и та самая борьба констант, из-за которой промышленные сортировки — гибриды.