Сложность алгоритмов и паттерны задач

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

Первая часть серии «Алгоритмы и структуры данных». Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки. Следующая тема — массивы и хеширование.

Что скрывает O‑большое

O(f(n)) — верхняя оценка роста с точностью до константы при больших n. Она отвечает на вопрос «что будет, если данных станет в 10 раз больше», но не «что быстрее сейчас».

  • Константы и младшие члены отброшены: 100·n и n — оба O(n).
  • Кэш не учтён: обход массива и связного списка — оба O(n), но массив в разы быстрее (подробнее про кэш).
  • На малых n простой квадратичный алгоритм часто выигрывает. Поэтому Timsort в Python и Java сортирует куски по 32–64 элемента вставками, а sort в Go — отрезки до 12 элементов.

Как растут функции

Грубая, но рабочая оценка: простой цикл на C, Go или Java делает около 10⁸ шагов в секунду. Python — в 30–100 раз меньше, если не уходит в C (numpy, встроенные функции).

Двигайте n. Шкала полос логарифмическая: каждое деление — в 1000 раз больше. Зелёная отметка — укладывается в секунду при 10⁸ шагов/с.

Ограничения подсказывают решение

В условии задачи всегда есть n ≤ …. Это подсказка: лимит времени около секунды, значит, допустимо порядка 10⁸ шагов. Отсюда — какую сложность от вас ждут.

На собеседовании ограничения часто не называют — спросите сами: «сколько элементов? влезает ли в память? сколько запросов?». Это и есть первый шаг к выбору алгоритма.

n досложностьчто обычно это
10n!перебор перестановок
202ⁿ · nперебор подмножеств, битовые маски
500n³тройной цикл, ДП по отрезкам, Флойд
5 000n²двойной цикл, ДП на двух строках
10⁶n log nсортировка, куча, бинпоиск, дерево
10⁸nодин проход: окно, указатели, хеш
большеlog n, 1бинпоиск, формула, математика

Проверьте себя

В условии: «дано n ≤ 18 заданий, у каждого время и награда…». Какую сложность, скорее всего, ждут?

Если бы хватало O(n log n), дали бы n до 10⁵. Маленькое n — намёк, что полиномиального решения нет или оно сложное: 2¹⁸ · 18 ≈ 4,7 млн шагов — мгновенно. А 18! ≈ 6,4 · 10¹⁵ — это годы. Перестановки при таком n заменяют ДП по маскам: состояние — «какие задания уже сделаны».

Амортизированная сложность

Динамический массив (list, []T, ArrayList, JS Array) при нехватке места выделяет новый и копирует всё. Отдельный append бывает O(n), но в среднем по любой последовательности — O(1).

  • Если ёмкость растёт в k раз, копирований всего меньше n · 1/(k−1) — константа на элемент.
  • Если растёт на константу, копирований ~n²/(2c) — каждый append в среднем O(n).
  • Реальные множители: Go ×2 (с 256 элементов ~×1,25), Java ×1,5, V8 ×1,5 + 16, CPython ×1,125 + 6. Память и сборщик мусора — в «Цене роста».

Память и глубина рекурсии

Память тоже сложность. Рекурсия тратит O(глубины) памяти на стек вызовов: обход сбалансированного дерева — O(log n), обход «дерева-списка» — O(n). А стек маленький и с жёстким пределом.

  • Python: sys.getrecursionlimit() = 1000 кадров — RecursionError.
  • JS (V8) — около 10 тыс. кадров, Java — 10–50 тыс. при стеке 1 МБ (-Xss), Go растит стек горутины до 1 ГБ.
  • Рекурсия на пользовательских данных (JSON, дерево комментариев, граф) — переписывайте на явный стек. Как устроен сам стек — в презентации про стек и кучу.

Сколько стоят встроенные операции

Самые дорогие ошибки — одна строчка, которая выглядит как O(1). В Python list — массив указателей, set и dict — хеш-таблицы, str неизменяемая.

То же в других языках: Java ArrayList.add(0, x) и remove(Object) — O(n), LinkedList.get(i) — O(n); JS arr.includes, indexOf, unshift — O(n); в Go вставка в начало слайса — copy всего хвоста.

операцияценавместо
lst.append(x)O(1)** амортизированно
lst.insert(0, x), lst.pop(0)O(n)collections.deque
x in lst, lst.index(x), lst.remove(x)O(n)set / dict
x in s, d[k]O(1)в среднем; худший — O(n)
lst[a:b], s[1:]O(b−a)копия! индексы, islice, memoryview
s += t в цикледо O(n²)''.join(parts), io.StringIO
sorted(lst), lst.sort()O(n log n)почти отсортированное — почти O(n)
heapq.heappush, bisect.bisectO(log n)но bisect.insort — O(n)

Проверьте себя

В data 10⁵ элементов. Какой фрагмент работает за квадратичное время?

pop(0) сдвигает все оставшиеся элементы: 10⁵ вызовов × в среднем 5 · 10⁴ сдвигов = 5 · 10⁹ перемещений указателей. Это секунды вместо миллисекунд. Для очереди есть collections.deque с popleft() за O(1). Остальные три — O(n).

Паттерны: как узнать задачу

Задач тысячи, приёмов — пара десятков. Учитесь узнавать приём по признакам в условии. Пять самых частых разберём по шагам, остальные — в следующих частях серии.

Два указателяотсортированный массив; пара или тройка с условием; слияние; «на месте»
Скользящее окнонепрерывный подотрезок или подстрока; «самый длинный / короткий, такой что…»
Префиксные суммымного запросов суммы на отрезке; «сколько подмассивов с суммой k»
Бинпоиск по ответу«минимальное X, при котором можно…»; проверка монотонна
Монотонный стек«ближайший больший / меньший»; гистограммы; «сколько дней до…»
Хеш-таблица«встречалось ли», пары с суммой, группировка, подсчёт — массивы и хеширование
Кучаtop-k, k-й по величине, слияние k списков, расписание — деревья и кучи
BFS / DFSсетка, связи, «кратчайшее число шагов», зависимости — графы
ДП и жадность«сколько способов», «минимальная стоимость», выбор с ограничением — ДП и жадные
Перебор с возвратом«все варианты», маленькое n, перестановки и подмножества

Два указателя

Найти в отсортированном массиве пару с суммой target. Перебор пар — O(n²). Указатели с двух концов — O(n): если сумма мала, левый элемент не подойдёт ни к одному из оставшихся, его можно выбросить.


def pair_sum(a, target):
    l, r = 0, len(a) - 1
    while l < r:
        s = a[l] + a[r]
        if s == target: return l, r
        if s < target: l += 1
        else: r -= 1
    return None

В жизни: шаг слияния в merge sort, Merge Join в PostgreSQL, слияние отсортированных сегментов в LSM-деревьях (RocksDB, Cassandra), git diff по отсортированным спискам.

Скользящее окно

Самая длинная подстрока без повторов. Правая граница идёт вперёд, левая — только догоняет. Каждый индекс входит в окно и выходит из него по разу: O(n) вместо O(n²) или O(n³).


def longest_unique(s):
    last, l, best = {}, 0, 0
    for r, ch in enumerate(s):
        if last.get(ch, -1) >= l:
            l = last[ch] + 1  # за повтор
        last[ch] = r
        best = max(best, r - l + 1)
    return best

В жизни: rate limiter со скользящим окном, скользящее среднее в метриках, окно TCP (сети), окна в Kafka Streams и Flink.

Префиксные суммы

Один проход O(n) — и потом сумма любого отрезка за O(1): P[r+1] − P[l]. Окупается, когда запросов много: 10⁵ запросов по 10⁵ элементам — 10⁵ вычитаний вместо 10¹⁰ сложений.


from itertools import accumulate
P = [0, *accumulate(a)]  # P[i] = sum(a[:i])
def range_sum(l, r):      # включительно
    return P[r + 1] - P[l]

«Сколько подмассивов с суммой k» — тоже префиксы: идём слева направо и считаем в dict, сколько раз встречалась сумма P − k. В жизни: счётчики Prometheus накопительные, и rate() — это разность префиксных сумм; суммы по прямоугольнику в обработке изображений.

Бинпоиск по ответу

Посылки едут по порядку, за D дней. Какая минимальная грузоподъёмность? Искать ответ напрямую трудно, а проверить «хватит ли C» — легко жадным проходом O(n). И если хватает C, хватит и C + 1.


lo, hi = max(w), sum(w)
while lo < hi:
    mid = (lo + hi) // 2
    if days_needed(w, mid) <= D: hi = mid
    else: lo = mid + 1
return lo        # O(n · log(sum))

Признак: «минимальное / максимальное X, при котором…» и монотонная проверка. В жизни: git bisect, подбор максимального размера батча, который не падает по памяти, поиск порога в нагрузочном тесте.

Монотонный стек

Для каждого элемента найти ближайший справа, который больше. В стеке ждут элементы, для которых ответ ещё не найден, — их значения убывают. Новый элемент выталкивает всех, кто меньше него, и становится их ответом.


ans, st = [-1] * len(a), []
for i, x in enumerate(a):
    while st and a[st[-1]] < x:
        ans[st.pop()] = x
    st.append(i)

Вложенный while не делает алгоритм квадратичным: каждый индекс кладут и вынимают ровно по разу — всего не больше 2n операций. Это тоже амортизированный анализ. Задачи: «сколько дней до потепления», наибольший прямоугольник в гистограмме, индикатор stock span.

Проверьте себя

Найти длину самого длинного подотрезка с суммой не больше K. Все числа положительные, n ≤ 10⁵. Какой приём?

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

Как решать задачу на собеседовании

Оценивают не только ответ, но и ход мысли. Интервьюер помогает тому, кто думает вслух, и не может помочь тому, кто молча пишет код.

Пройдите по шагам на классической задаче Two Sum. Не пропускайте перебор: он показывает, что вы поняли задачу, и даёт точку отсчёта для оптимизации.

Как готовиться

Работает

  • Идти по списку паттернов: 15–20 приёмов по 5–8 задач — это 100–150 задач, а не 1000.
  • После каждой задачи записать признак: «по чему я должен был узнать приём».
  • Решать с таймером 30–40 минут, вслух. Не вышло — читать разбор и через неделю решать заново.
  • Проговаривать сложность по времени и памяти до того, как спросят.
  • Пара пробных собеседований с живым человеком.

Не работает

  • Решать случайные задачи подряд и считать количество.
  • Сразу смотреть ответ, «понимать» и идти дальше.
  • Зубрить код решений: на собеседовании задачу чуть изменят.
  • Молча писать код с первой минуты, не уточнив условия.
  • Игнорировать граничные случаи: пустой ввод, один элемент, дубликаты, переполнение.

Случайный O(n²) в продакшене

  • N+1 запросов: ORM лениво грузит связь в цикле — 1 запрос за списком и ещё по одному на каждый элемент. Лечится select_related/prefetch_related, joinedload, JOIN FETCH, WHERE id IN (…).
  • Вложенный цикл по спискам: «для каждого заказа найти пользователя» через next(u for u in users …). Постройте dict по id один раз.
  • list.remove / x in list в цикле: невидимый второй цикл. На тестовых 100 строках незаметно, на 10⁵ в проде — минуты.

Двигайте размер данных и задержку до базы.

Как это увидеть у себя

  • Тест удвоения: запустите на n и 2n. Время ×2 — линейно, ×4 — квадратично, ×8 — куб.
  • Профилировщик: py-spy top --pid, py-spy record -o flame.svg, cProfile; Go — pprof; Java — async-profiler, JFR; Node — --cpu-prof, Chrome DevTools. Ищите широкую полку с list.remove, __contains__, index, includes.
  • Число запросов к базе на одну страницу: django-debug-toolbar, echo=True в SQLAlchemy, calls в pg_stat_statements.

Справа — настоящий замер в вашем браузере: удаление дублей через Array.includes и через Set.

Главное

  • O-нотация — про рост, а не про скорость. Константы, кэш и малые n решают на практике.
  • Около 10⁸ простых шагов в секунду (Python — 10⁶–10⁷). Ограничения задачи подсказывают нужную сложность.
  • Амортизированный O(1) — это рост ёмкости в разы. Рекурсия тратит память стека: O(глубины).
  • Знайте цену встроенных операций: pop(0), in по списку, срезы и склейка строк — источник случайного O(n²).
  • Два указателя, окно, префиксы, бинпоиск по ответу и монотонный стек превращают O(n²) в O(n) или O(n log n).
  • На собеседовании: уточнить → примеры → перебор → оптимизация → код → тесты → сложность.

Сначала правильно, потом быстро — но квадрат замечайте сразу.