Оценка сложности: почему решение не проходит
Чему научитесь
- Прикидывать количество шагов до написания кода
- Читать ожидаемую сложность по ограничениям задачи
- Различать O(n), O(n log n) и O(n²) на практике
- Замечать скрытые проходы внутри коротких записей
Как устроено занятие
Сначала разбор с примерами и короткими проверками понимания — они нужны, чтобы поймать непонятое сразу, а не через три темы.
Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.
После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.
Сколько это займёт
Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.
Сколько успевает компьютер
Вы уже сталкивались с вердиктом «превышено время» и знаете первое правило: перемножить длины циклов и сравнить с десятью миллионами. Пришло время разобраться, откуда взялось это число и что делать, когда его не хватает.
Ориентир простой. За одну секунду Python успевает порядка простых операций. Компилируемые языки — примерно в тридцать раз больше, около .
Простая операция — это одно сравнение, одно сложение, одно обращение к элементу списка. Не вызов метода, который сам пробегает по данным, — об этом отдельно.
Отсюда получается таблица, которую полезно помнить наизусть:
| сколько шагов | Python | вывод |
|---|---|---|
| 0,1 с | свободно | |
| 1 с | впритык, но обычно проходит | |
| 10 с | не проходит | |
| и больше | минуты и часы | даже не пробуйте |
Обратите внимание: разница между «проходит» и «не проходит» — это не разница в аккуратности кода. Это разница в количестве шагов, то есть в самом подходе.
Как считают количество шагов
Точное количество операций никого не интересует — важен порядок роста: как меняется работа, когда данных становится вдвое больше.
Записывают это буквой и оставляют только главное: константы и младшие слагаемые отбрасывают.
| запись | смысл | пример |
|---|---|---|
| не зависит от данных | формула, обращение по номеру | |
| делим пополам | быстрое возведение в степень | |
| один проход | сумма, максимум, словарь частот | |
| проход плюс сортировка | sort, sorted |
|
| все пары | вложенные циклы по одному списку | |
| все тройки | три вложенных цикла |
Почему отбрасывают константы: разница между и — это разница в разы, а между и при ста тысячах — в сто тысяч раз. Второе решает судьбу решения, первое почти никогда.
Отдельно про : множитель — это примерно 17 при и 20 при миллионе. То есть сортировка дороже одного прохода в двадцать раз, а квадрат — в сто тысяч. Между ними пропасть, и сортировки бояться не нужно.
Проверка: пройдёт ли
В задаче до , и решение перебирает все пары элементов.
Что будет?
Ограничения — это подсказка
В олимпиадных задачах ограничения не случайны: их выбирают так, чтобы отсечь неверный подход и пропустить верный. Поэтому по ним читается ожидаемая сложность.
| ограничение | что должно получиться |
|---|---|
| формула или | |
| один проход, | |
| или | |
| можно | |
| можно | |
| перебор всех вариантов |
Читать эту таблицу нужно до того, как писать код. Увидев до двухсот тысяч, вы сразу знаете: перебора пар не будет, нужен словарь, множество или сортировка.
И наоборот: если в условии до двух тысяч, а вы придумали хитрое линейное решение — скорее всего, вы усложняете. Квадрат там разрешён специально.
Маленькое ограничение — это разрешение, а не ловушка. Оно говорит: «тут можно в лоб».
Почему Python требует аккуратности
Два обстоятельства, из-за которых оценка «на глаз» в Python обманывает.
Первое: цикл дорог, а встроенное дёшево. Метод sort, функция sum, проверка in для множества написаны на C и работают в десятки раз быстрее того же самого, набранного циклом. Поэтому sum(a) предпочтительнее ручного накопления — не ради красоты, а ради скорости.
Второе: стоимость строки не видна. Вот примеры, где одна короткая запись стоит целого прохода:
| запись | кажется | на самом деле |
|---|---|---|
x in a для списка |
одно действие | |
a.count(x) |
одно действие | |
a.index(x) |
одно действие | |
a[1:] в цикле |
одно действие | на каждый срез |
s += ch для строки |
одно действие | создание новой строки |
Каждая из них внутри цикла превращает в — незаметно, потому что вложенности в тексте нет.
Проверка простая: если внутри цикла стоит что-то, что само проходит по данным, сложность умножается. Именно так решение из шести аккуратных строк получает превышение времени.
Проверка: во сколько раз
Решение работало за и укладывалось в секунду при .
Во сколько примерно раз дольше будет работать решение за при том же ? Введите степень десяти — то есть если ответ «в миллион раз», введите 6.
Три ошибки этого занятия
Оценка по виду кода, а не по смыслу. Шесть строк могут работать дольше, чем тридцать. Считать надо шаги, а не строки.
Забытая стоимость встроенного. count, index, in по списку, срезы — всё это проходы. Внутри цикла они дают квадрат.
Оптимизация не того. Когда сложность неверна, мелкие улучшения бесполезны: ускорение в два раза не спасёт решение, которому не хватает тысячекратного. Сначала меняют подход, потом уже отлаживают мелочи.
Как проверять себя
- посмотрите на ограничения и назовите ожидаемую сложность до написания кода;
- найдите самый вложенный цикл и посчитайте, сколько раз выполнится его тело;
- проверьте, нет ли внутри скрытого прохода — метода или среза;
- перемножьте и сравните с таблицей.
Практика: пятнадцать задач
Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.
Новых приёмов сегодня нет — всё решается тем, что вы уже знаете: формулой, словарём, множеством, сортировкой. Новое здесь другое: ограничения подобраны так, что прямолинейный способ не проходит.
Поэтому порядок работы такой: сначала прочитать ограничение, назвать нужную сложность, и только потом выбирать средство. В нескольких задачах ограничение, наоборот, маленькое — там квадрат разрешён, и усложнять не надо.
Оцените три решения
Для задачи «дан список из чисел, найдите количество пар элементов с равными значениями» написали три решения. Ограничение: до .
Первое
count = 0
for i in range(len(a)):
for j in range(i + 1, len(a)):
if a[i] == a[j]:
count += 1
Второе
count = 0
for value in set(a):
c = a.count(value)
count += c * (c - 1) // 2
Третье
counts = {}
for value in a:
counts[value] = counts.get(value, 0) + 1
count = 0
for key in counts:
c = counts[key]
count += c * (c - 1) // 2
Все три считают правильно. Оцените сложность каждого, скажите, какие пройдут по времени, и объясните, почему второе решение опаснее, чем кажется.