EduBrick

Оценка сложности: зайдёт ли решение

Как по ограничениям задачи понять, какой алгоритм от вас ждут, и прикинуть время работы до того, как код написан.

6 мин

Прежде чем писать код, полезно узнать, есть ли смысл его писать. Для этого нужны две вещи: асимптотика вашего решения и представление о том, сколько операций успевает компьютер.

Что означает O большое

Запись O(n)O(n) значит, что программа делает не больше чем cnc \cdot n операций для какой-то константы cc. Десять проходов по массиву — это O(n)O(n), потому что c=10c = 10. Проход по половине массива — тоже O(n)O(n), просто c=12c = \tfrac{1}{2}.

Слова «не больше» здесь важнее, чем кажется. Вот вопрос, на котором ошибается почти вся аудитория:

for (int i = 0; i < n; i++) sum += a[i];

Правда ли, что этот код работает за O(n2)O(n^2)? Правда. Он делает не больше n2n^2 операций — определение выполнено. Просто оценка бесполезно слабая. Когда вы считаете асимптотику, легко насчитать лишнего и получить формально верный, но ничего не значащий ответ.

Не только O

Раз OO — это «не больше», должны быть и другие обозначения, и они есть:

  • O(f)O(f) — не больше: верхняя оценка;
  • Ω(f)\Omega(f) — не меньше: нижняя оценка;
  • Θ(f)\Theta(f) — и то и другое: оценка точная.

Строго говоря, про сортировку слиянием правильно сказать Θ(nlogn)\Theta(n \log n): она не только не медленнее, но и не быстрее. Про сортировку вставками — O(n2)O(n^2) и Ω(n)\Omega(n): верхняя и нижняя оценки у неё разные, потому что время зависит от входа.

В разговоре почти всегда говорят OO, подразумевая Θ\Theta, и это нормально. Но когда речь заходит о доказательстве, что быстрее нельзя, нужен именно Ω\Omega — иначе утверждение не имеет смысла.

Сколько операций в секунду

Точного числа нет: оно зависит от процессора, от нагрузки, от того, какие именно операции вы делаете. Сложение и умножение стоят по-разному, деление и взятие по модулю — заметно дороже, промах мимо кеша дороже всего.

Рабочая оценка для C++ — примерно 31083 \cdot 10^8 операций в секунду. Если прикидка получилась близкой к границе, попробуйте поделить не на 31083 \cdot 10^8, а на 51085 \cdot 10^8: когда операции простые, столько тоже бывает.

Как этим пользоваться. Пусть в задаче n2104n \le 2 \cdot 10^4, а решение работает за O(n2)O(n^2). Подставляем: 41084 \cdot 10^8 операций. Делим на 31083 \cdot 10^8 — получаем примерно 1,3 секунды. При ограничении в секунду это «скорее не зайдёт, но попробовать можно».

Думайте вероятностями, а не порогом

Прикидка не даёт ответа «да» или «нет». Она даёт шансы:

Прикинутое число операций Шанс уложиться
10710^7 практически наверняка
10810^8 около 90%
10910^9 около 20%
101010^{10} практически никогда

Разброс берётся из того, чего вы про свой код не знаете: сколько там делений, как ложатся данные в кеш, сколько раз вы на самом деле проходите по массиву. Со временем это чувство приходит: «здесь у меня константа большая, значит будем считать по 10810^8».

Память — тоже ресурс

Ограничение по памяти в условии стоит рядом с ограничением по времени, и про него забывают чаще.

Прикидка такая же простая: один int — 4 байта, long long — 8. Массив из 10710^7 чисел типа int займёт 40 мегабайт; при лимите в 256 таких массивов можно завести шесть, а не шестьдесят.

В Python всё дороже: список из 10710^7 чисел — это не 40 мегабайт, а сотни, потому что каждый элемент там полноценный объект. Если память поджимает, спасают array из стандартной библиотеки или bytearray.

Отдельно стоит помнить про рекурсию: каждый вложенный вызов занимает место на стеке. Глубина 10610^6 в C++ обычно кончается падением, а в Python — исключением уже на тысяче.

Амортизация: «в среднем по всей работе»

Иногда одна операция дорогая, но дорогой она бывает редко, и в сумме всё дёшево.

Классический пример — push_back в вектор. Когда место кончается, вектор выделяет вдвое больше памяти и копирует всё туда: одна такая операция стоит O(n)O(n). Но происходит это после удвоений, то есть на nn добавлений приходится n/2+n/4+<nn/2 + n/4 + \ldots < n копирований. Значит, в среднем на операцию — константа.

Это называется амортизированной стоимостью, и её нельзя путать со «средним случаем». Средний случай — про вероятность, про то, какие данные пришли. Амортизация — про сумму по всей работе, и она выполняется всегда, без всяких предположений о входе.

На амортизации стоят два указателя и стек из статей про линейные алгоритмы: внутренний цикл там может сделать много шагов на одной итерации, но за всё время — не больше nn.

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

Автор задачи выбирает nn не случайно — он выбирает его так, чтобы прошло задуманное решение и не прошло наивное. Поэтому по ограничению часто видно, чего от вас хотят:

Ограничение на nn Ожидаемая асимптотика
101810^{18}, 10910^9 O(1)O(1) или O(logn)O(\log n)
10710^7 O(n)O(n)
10610^6 O(nlogn)O(n \log n)
10310410^3 \ldots 10^4 O(n2)O(n^2)
2525 O(2n)O(2^n)
1010 O(n!)O(n!)

Правило не железное, но срабатывает часто. Если nn до миллиарда, а вы придумали решение за O(n)O(n) — вы придумали не то решение.

Логарифм — это очень мало

Логарифм по основанию 2 от числа — это степень, в которую надо возвести двойку, чтобы получить это число: log216=4\log_2 16 = 4.

Главное про него — насколько он мал. log2109\log_2 10^9 — это меньше 30. Миллиард элементов, тридцать шагов. Именно поэтому множитель logn\log n в асимптотике почти ничего не стоит, а nlognn \log n живёт при nn до миллиона.

И ещё: основание логарифма в асимптотике не важно. Если каждый шаг уменьшает данные в 1,1 раза, то восьми шагов хватает, чтобы они уменьшились вдвое (1,182,141{,}1^8 \approx 2{,}14) — значит log1,1n\log_{1{,}1} n отличается от log2n\log_2 n не больше чем в восемь раз, то есть на константу, а константы OO большое не различает.

Быстрый ввод в C++

Поток cin по умолчанию синхронизирован с scanf и связан с cout. Обе связи стоят времени, и на больших входах именно они съедают лимит. Две строки в начале main их снимают:

ios::sync_with_stdio(false);
cin.tie(nullptr);

После этого пользоваться можно только cin и cout: смешивать их с scanf и printf уже нельзя, порядок вывода перестанет быть предсказуемым. В интерактивных задачах cin.tie(nullptr) тоже вредит — там вывод должен уходить сразу.

Ещё одна привычка: функция pow в олимпиадном коде не нужна. Она работает с вещественными числами, медленная и умеет ошибаться в последнем разряде. Целую степень двойки дают сдвиг 1 << k или обычное умножение.