Оценка сложности: зайдёт ли решение
Как по ограничениям задачи понять, какой алгоритм от вас ждут, и прикинуть время работы до того, как код написан.
6 мин
Прежде чем писать код, полезно узнать, есть ли смысл его писать. Для этого нужны две вещи: асимптотика вашего решения и представление о том, сколько операций успевает компьютер.
Что означает O большое
Запись значит, что программа делает не больше чем операций для какой-то константы . Десять проходов по массиву — это , потому что . Проход по половине массива — тоже , просто .
Слова «не больше» здесь важнее, чем кажется. Вот вопрос, на котором ошибается почти вся аудитория:
for (int i = 0; i < n; i++) sum += a[i];
Правда ли, что этот код работает за ? Правда. Он делает не больше операций — определение выполнено. Просто оценка бесполезно слабая. Когда вы считаете асимптотику, легко насчитать лишнего и получить формально верный, но ничего не значащий ответ.
Не только O
Раз — это «не больше», должны быть и другие обозначения, и они есть:
- — не больше: верхняя оценка;
- — не меньше: нижняя оценка;
- — и то и другое: оценка точная.
Строго говоря, про сортировку слиянием правильно сказать : она не только не медленнее, но и не быстрее. Про сортировку вставками — и : верхняя и нижняя оценки у неё разные, потому что время зависит от входа.
В разговоре почти всегда говорят , подразумевая , и это нормально. Но когда речь заходит о доказательстве, что быстрее нельзя, нужен именно — иначе утверждение не имеет смысла.
Сколько операций в секунду
Точного числа нет: оно зависит от процессора, от нагрузки, от того, какие именно операции вы делаете. Сложение и умножение стоят по-разному, деление и взятие по модулю — заметно дороже, промах мимо кеша дороже всего.
Рабочая оценка для C++ — примерно операций в секунду. Если прикидка получилась близкой к границе, попробуйте поделить не на , а на : когда операции простые, столько тоже бывает.
Как этим пользоваться. Пусть в задаче , а решение работает за . Подставляем: операций. Делим на — получаем примерно 1,3 секунды. При ограничении в секунду это «скорее не зайдёт, но попробовать можно».
Думайте вероятностями, а не порогом
Прикидка не даёт ответа «да» или «нет». Она даёт шансы:
| Прикинутое число операций | Шанс уложиться |
|---|---|
| практически наверняка | |
| около 90% | |
| около 20% | |
| практически никогда |
Разброс берётся из того, чего вы про свой код не знаете: сколько там делений, как ложатся данные в кеш, сколько раз вы на самом деле проходите по массиву. Со временем это чувство приходит: «здесь у меня константа большая, значит будем считать по ».
Память — тоже ресурс
Ограничение по памяти в условии стоит рядом с ограничением по времени, и про него забывают чаще.
Прикидка такая же простая: один int — 4 байта, long long — 8. Массив из чисел типа int займёт 40 мегабайт; при лимите в 256 таких массивов можно завести шесть, а не шестьдесят.
В Python всё дороже: список из чисел — это не 40 мегабайт, а сотни, потому что каждый элемент там полноценный объект. Если память поджимает, спасают array из стандартной библиотеки или bytearray.
Отдельно стоит помнить про рекурсию: каждый вложенный вызов занимает место на стеке. Глубина в C++ обычно кончается падением, а в Python — исключением уже на тысяче.
Амортизация: «в среднем по всей работе»
Иногда одна операция дорогая, но дорогой она бывает редко, и в сумме всё дёшево.
Классический пример — push_back в вектор. Когда место кончается, вектор выделяет вдвое больше памяти и копирует всё туда: одна такая операция стоит . Но происходит это после удвоений, то есть на добавлений приходится копирований. Значит, в среднем на операцию — константа.
Это называется амортизированной стоимостью, и её нельзя путать со «средним случаем». Средний случай — про вероятность, про то, какие данные пришли. Амортизация — про сумму по всей работе, и она выполняется всегда, без всяких предположений о входе.
На амортизации стоят два указателя и стек из статей про линейные алгоритмы: внутренний цикл там может сделать много шагов на одной итерации, но за всё время — не больше .
Ограничения подсказывают ответ
Автор задачи выбирает не случайно — он выбирает его так, чтобы прошло задуманное решение и не прошло наивное. Поэтому по ограничению часто видно, чего от вас хотят:
| Ограничение на | Ожидаемая асимптотика |
|---|---|
| , | или |
Правило не железное, но срабатывает часто. Если до миллиарда, а вы придумали решение за — вы придумали не то решение.
Логарифм — это очень мало
Логарифм по основанию 2 от числа — это степень, в которую надо возвести двойку, чтобы получить это число: .
Главное про него — насколько он мал. — это меньше 30. Миллиард элементов, тридцать шагов. Именно поэтому множитель в асимптотике почти ничего не стоит, а живёт при до миллиона.
И ещё: основание логарифма в асимптотике не важно. Если каждый шаг уменьшает данные в 1,1 раза, то восьми шагов хватает, чтобы они уменьшились вдвое () — значит отличается от не больше чем в восемь раз, то есть на константу, а константы большое не различает.
Быстрый ввод в C++
Поток cin по умолчанию синхронизирован с scanf и связан с cout. Обе связи стоят времени, и на больших входах именно они съедают лимит. Две строки в начале main их снимают:
ios::sync_with_stdio(false);
cin.tie(nullptr);
После этого пользоваться можно только cin и cout: смешивать их с scanf и printf уже нельзя, порядок вывода перестанет быть предсказуемым. В интерактивных задачах cin.tie(nullptr) тоже вредит — там вывод должен уходить сразу.
Ещё одна привычка: функция pow в олимпиадном коде не нужна. Она работает с вещественными числами, медленная и умеет ошибаться в последнем разряде. Целую степень двойки дают сдвиг 1 << k или обычное умножение.