К содержанию

Модуль 6 · Урок 6.6

Скорость и память программы

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

5 задач

Программа проверяет все пары чисел файла. На десяти числах она отвечает мгновенно, на ста тысячах — не дождаться. Код тот же, данные больше. Скорость программы определяется тем, как растёт число действий с ростом данных, а не тем, насколько коротко она записана.

Научимся оценивать этот рост для одного прохода, всех пар и деления пополам, узнаем цену знакомых операций и памяти и измерим время сами.

Временная сложность

Временная сложность описывает, как растёт работа с размером входа. Размер входа обозначают n — например, число строк файла. Один проход по файлу делает около n действий, и это записывают O(n) — «порядка n». Все пары — около n² действий, O(n²). Это не секунды, а то, во сколько раз вырастет работа, если данных станет больше.

Тонкости:что строго значит O(n)

O(n) — верхняя оценка: при достаточно большом n число действий не больше c · n для некоторой постоянной c. На практике так называют характерный порядок роста алгоритма.

Порядки роста удобно сравнить на одном размере входа. Каждая клетка шкалы — в тысячу раз больше предыдущей:

При ста тысячах элементов один проход — 100 тысяч действий, сортировка — около 1,7 миллиона, а все пары — 10 миллиардов. Удвоение n удваивает работу 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)
499500 499500

Откуда формула, видно на сетке всех пар: пары с 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

Миллион делится пополам до единицы всего 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) из-за сдвига
Срез длины kO(k) времени и новой памяти
sum, min, maxO(n) — один проход
Поиск в dict и setОбычно O(1)
sortedO(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))
20 10

Второй проходит файл один раз и хранит только 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)
20 10

Время у обоих 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Разность показаний до и после; один запуск шумный.

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

Ответь сначала сам, потом открой ответ.

Вопрос 1

Нужно проверить миллион запросов принадлежности среди ста тысяч различных целых. Какая структура подходит?

ОтветСкрыть ответ
Ответ

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

Вопрос 2

Какие операции выполняются за постоянное время: a[500], x in a для списка, d[key] для словаря, len(a)?

ОтветСкрыть ответ
Ответ

a[500], d[key] и len(a); x in a для списка — до n сравнений.

Вопрос 3

Почему очередь из миллиона заявок плохо обслуживать через list.pop(0)?

ОтветСкрыть ответ
Ответ

Каждый pop(0) сдвигает все оставшиеся элементы: всего около n²/2 сдвигов; deque.popleft забирает первый без сдвига.

Вопрос 4

Найти сумму чисел в файле можно, прочитав все числа в список, или одним проходом со счётчиком. Чем отличаются эти способы по памяти?

ОтветСкрыть ответ
Ответ

Список занимает память по числу чисел, проход со счётчиком — постоянную.

Вопрос 5

Два цикла: внешний по n элементам, внутренний всегда ровно 10 шагов. Какой порядок роста?

ОтветСкрыть ответ
Ответ

O(n): всего 10n шагов.

Вопрос 6

Перебор всех подмножеств 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)

Введите число.

Что напечатает программа?

n = 1000steps = 0while n > 1:    n //= 2    steps += 1print(steps)

Введите число.

Что напечатает программа?

n = 50steps = 0for i in range(n):    for j in range(10):        steps += 1print(steps)

Введите число.

Сравнения при поиске в списке

В списке numbers тысяча разных чисел, числа x среди них нет. Сколько сравнений сделает x in numbers, пока не ответит False?

Введите число.

Сколько подмножеств

Сколько подмножеств у набора из 20 предметов, включая пустое и сам набор?

Введите число без пробелов.