Модуль 6 · Урок 6.6
Скорость и память программы
Как растёт работа с размером данных, какие операции дорогие, сколько памяти нужно и как измерить время.
Программа проверяет все пары чисел файла. На десяти числах она отвечает мгновенно, на ста тысячах — не дождаться. Код тот же, данные больше. Скорость программы определяется тем, как растёт число действий с ростом данных, а не тем, насколько коротко она записана.
Научимся оценивать этот рост для одного прохода, всех пар и деления пополам, узнаем цену знакомых операций и памяти и измерим время сами.
Временная сложность
Временная сложность описывает, как растёт работа с размером входа. Размер входа обозначают n — например, число строк файла. Один проход по файлу делает около n действий, и это записывают O(n) — «порядка n». Все пары — около n² действий, O(n²). Это не секунды, а то, во сколько раз вырастет работа, если данных станет больше.
Тонкости:что строго значит O(n)
O(n) — верхняя оценка: при достаточно большом n число действий не больше c · n для некоторой постоянной c. На практике так называют характерный порядок роста алгоритма.
Порядки роста удобно сравнить на одном размере входа. Каждая клетка шкалы — в тысячу раз больше предыдущей:
O(n) и учетверяет O(n²). Перебор всех подмножеств, O(2ⁿ), растёт ещё быстрее: при 30 элементах подмножеств больше миллиарда.Такой разрыв важнее любых сокращений записи: никакая короткая запись не превратит 10 миллиардов действий в 100 тысяч.
Подсчёт шагов
Число шагов считают по реальным границам циклов. Два вложенных цикла не всегда дают n². Если внутренний всегда делает 10 шагов, всего их 10n, то есть O(n). Если внутренний цикл идёт только по номерам после i, каждая пара проверяется один раз:
n = 1000steps = 0for i in range(n): for j in range(i + 1, n): steps += 1print(steps, n * (n - 1) // 2)Откуда формула, видно на сетке всех пар: пары с i < j — треугольник над диагональю.
n · n клеток убираем диагональ (n клеток) и делим остаток пополам: под диагональю те же пары. Пар вдвое меньше, чем n², но порядок роста тот же — O(n²).Если на каждом шаге число делится на 2, после k шагов оно уменьшается в 2 ** k раз. Поэтому до единицы нужно около log₂(n) шагов — столько раз n можно делить пополам:
n = 1_000_000steps = 0while n > 1: n //= 2 steps += 1print(steps)Миллион делится пополам до единицы всего 19 раз: 2¹⁹ ≈ 524 тысячи, 2²⁰ ≈ 1,05 миллиона. Запись 1_000_000 — то же число 1000000, подчёркивания только облегчают чтение.
Стоимость операций
Стоимость скрыта и внутри отдельных операций. Одна строка кода может пройти весь список. Ориентиры для знакомых операций:
| Операция | Обычная оценка |
|---|---|
len(list) и list[i] | O(1) |
x in list | До O(n) сравнений |
list.append(x), list.pop() | O(1) в среднем |
list.pop(0) | O(n) из-за сдвига |
| Срез длины k | O(k) времени и новой памяти |
sum, min, max | O(n) — один проход |
| Поиск в dict и set | Обычно O(1) |
sorted | O(n log n) сравнений |
Отсюда две частые ошибки. Срез a[i:i + k] в цикле каждый раз копирует до k элементов. Множество для проверок in строят один раз до цикла, а не внутри каждого запроса.
Тонкости:когда оценки хуже
Оценки предполагают, что сравнить или захешировать один элемент недорого. Для длинных строк и огромных целых это отдельная работа. Поиск в dict и set в редких худших случаях медленнее O(1).
Память
Память тоже растёт с данными, если их сохранять. Пространственная сложность описывает, сколько памяти нужно алгоритму. В уроке о файлах сумму и наибольшее считали двумя способами. Первый сохраняет все числа в список — память растёт с числом строк:
with open("numbers.txt", encoding="utf-8") as file: numbers = [int(line) for line in file]print(sum(numbers), max(numbers))Второй проходит файл один раз и хранит только total и best — два числа при любом размере файла:
total = 0best = Nonewith open("numbers.txt", encoding="utf-8") as file: for line in file: number = int(line) total += number if best is None or number > best: best = numberprint(total, best)Время у обоих O(n), а память — растущая и постоянная. Словарь частот занимает место по числу различных значений. Если в конце нужен sorted, все элементы всё равно придётся собрать. Рекурсия хранит незавершённые вызовы, кэш — вычисленные результаты; это тоже память.
Измерение времени
Время измеряют разностью показаний часов, но сначала улучшают порядок роста. perf_counter() из модуля time возвращает показание часов в секундах. Само показание ничего не значит, важна разность до и после. Проверим таблицу на опыте: ищем отсутствующее число тысячу раз в списке из ста тысяч чисел и в множестве из тех же чисел.
from time import perf_counter numbers = list(range(100_000))as_set = set(numbers) start = perf_counter()for _ in range(1000): found = -1 in numbersprint("список:", perf_counter() - start) start = perf_counter()for _ in range(1000): found = -1 in as_setprint("множество:", perf_counter() - start)Запусти у себя и сравни два числа. Множество окажется быстрее в тысячи раз: в списке каждый поиск проходит все сто тысяч чисел, в множестве — сразу смотрит на нужное место. Сами секунды зависят от компьютера и немного меняются от запуска к запуску, поэтому вывод здесь не показан.
Один запуск шумный: влияют другие программы на компьютере. Поэтому сначала улучшают порядок роста алгоритма, а секунды меряют, чтобы найти самое медленное место.
Итог
Быстрая программа — та, у которой работа медленно растёт с данными. Сначала оценивают порядок роста, потом меряют секунды. Вот что понадобится дальше.
- СложностьРост числа действий с размером входа, а не секунды.
- ПорядкиОдин проход —
O(n), все пары —O(n²), деление пополам —O(log n); парi < j— n(n − 1)/2. - Скрытая цена
x in list— до n сравнений,x in set— обычно один шаг;pop(0)сдвигает весь список. - ПамятьСписок растёт с данными, проход со счётчиками — нет; рекурсия и кэш тоже занимают память.
perf_counterРазность показаний до и после; один запуск шумный.
Проверьте себя
Ответь сначала сам, потом открой ответ.
Нужно проверить миллион запросов принадлежности среди ста тысяч различных целых. Какая структура подходит?
ОтветСкрыть ответ
Множество, построенное один раз: подготовка — один проход, каждый поиск — примерно один шаг. Поиск по списку для каждого запроса может потребовать до ста миллиардов сравнений.
Какие операции выполняются за постоянное время: a[500], x in a для списка, d[key] для словаря, len(a)?
ОтветСкрыть ответ
a[500], d[key] и len(a); x in a для списка — до n сравнений.
Почему очередь из миллиона заявок плохо обслуживать через list.pop(0)?
ОтветСкрыть ответ
Каждый pop(0) сдвигает все оставшиеся элементы: всего около n²/2 сдвигов; deque.popleft забирает первый без сдвига.
Найти сумму чисел в файле можно, прочитав все числа в список, или одним проходом со счётчиком. Чем отличаются эти способы по памяти?
ОтветСкрыть ответ
Список занимает память по числу чисел, проход со счётчиком — постоянную.
Два цикла: внешний по n элементам, внутренний всегда ровно 10 шагов. Какой порядок роста?
ОтветСкрыть ответ
O(n): всего 10n шагов.
Перебор всех подмножеств 30 предметов: сколько вариантов? Сколько времени он займёт, если за секунду проверяется 10 миллионов вариантов?
ОтветСкрыть ответ
2³⁰ = 1 073 741 824 варианта — около 107 секунд. Для 40 предметов — уже больше суток.
Практика
Для проверки ответов нужен JavaScript. Условия, подсказки и решения доступны ниже.
Что напечатает программа?
n = 100steps = 0for i in range(n): for j in range(i + 1, n): steps += 1print(steps)Введите число.
Подсказка
Пар с i < j ровно n · (n − 1) / 2.
Разбор
100 · 99 / 2 = 4950.
Ответ: 4950
Что напечатает программа?
n = 1000steps = 0while n > 1: n //= 2 steps += 1print(steps)Введите число.
Подсказка
Выпиши n по шагам: 1000, 500, 250, 125, …
Разбор
1000 → 500 → 250 → 125 → 62 → 31 → 15 → 7 → 3 → 1: девять делений.
Ответ: 9
Что напечатает программа?
n = 50steps = 0for i in range(n): for j in range(10): steps += 1print(steps)Введите число.
Подсказка
Внутренний цикл всегда делает 10 шагов: всего 10 · n.
Разбор
50 · 10 = 500 — это O(n), хотя циклов два.
Ответ: 500
Сравнения при поиске в списке
В списке numbers тысяча разных чисел, числа x среди них нет. Сколько сравнений сделает x in numbers, пока не ответит False?
Введите число.
Подсказка
in для списка сравнивает x с каждым элементом по очереди и останавливается только на совпадении.
Разбор
Совпадения нет, поэтому проверяются все 1000 элементов — худший случай O(n).
Ответ: 1000
Сколько подмножеств
Сколько подмножеств у набора из 20 предметов, включая пустое и сам набор?
Введите число без пробелов.
Подсказка
Каждый предмет либо берут, либо нет: 2 · 2 · … · 2, двадцать раз.
Разбор
2²⁰ = 1 048 576 — уже миллион вариантов для 20 предметов.
Ответ: 1048576