Вступление: Проблема «забывчивого» компьютера
Проблема большинства программ не в том, что процессор медленно считает, а в том, что он делает одну и ту же работу тысячи раз подряд.
По умолчанию компьютер «забывчив». Когда вы вызываете функцию, она каждый раз начинает выполнение с чистого листа. Ей всё равно, что миллисекунду назад вы уже передавали ей эти же самые аргументы и она уже находила ответ — она будет честно пересчитывать его заново, тратя ресурсы процессора и ваше время.
Почему функции работают долго?
Обычно это происходит по трем причинам:
Сложные математические расчеты (обработка графики, криптография).
Работа с внешними данными (запросы к API или тяжелые запросы к базе данных).
Рекурсия — когда функция вызывает саму себя.
Пример: бесполезная работа
Самый наглядный пример — вычисление чисел Фибоначчи через рекурсию. Чтобы функция нашла 40-е число, ей нужно сначала найти 39-е и 38-е. Но чтобы найти 39-е, ей снова нужно найти 38-е (которое она только что искала для основного вызова) и 37-е.
В итоге дерево вызовов растет как снежный ком. Программа превращается в безумного бухгалтера, который каждый раз пересчитывает всю кассовую книгу с первой страницы, чтобы просто добавить одну новую запись в конец.
Мемоизация решает именно эту проблему: она дает функции «блокнот», куда та записывает результат один раз, чтобы в следующий раз просто подсмотреть его, а не вычислять снова.
Что такое мемоизация?
Если максимально коротко: мемоизация — это кэширование результатов функции.
Это технический прием, при котором программа запоминает, какой результат выдала функция для конкретных входных данных. Если вы вызываете функцию с теми же аргументами еще раз, она не тратит время на вычисления, а просто берет готовый ответ из памяти.
Суть процесса
Функция получает аргументы (например,
x=5).Программа проверяет: «Считали ли мы уже это для
x=5?»Если да — мгновенно возвращает сохраненный результат.
Если нет — выполняет расчет, сохраняет его в память и выдает ответ.
Аналогия: Таблица умножения vs Вычисления в столбик
Представьте, что вам нужно умножить .
Без мемоизации (вычисление в столбик): Вы каждый раз берете листок, множите 2 на 2, потом 2 на 1, складываете результаты... Вы делаете честную работу, тратите 10–20 секунд. Если я попрошу вас сделать это 100 раз подряд, вы 100 раз будете писать в столбик. Это и есть работа обычной функции.
С мемоизацией (таблица умножения): Вы один раз в жизни вычислили, что
, и зазубрили это. Теперь, когда вы слышите эти числа, ваш мозг не считает — он делает lookup (поиск) в памяти. Это срабатывает мгновенно.
Итог: Мы меняем процессорное время (вычисления) на оперативную память (хранение таблицы ответов). В современном программировании память стоит дешево, а время пользователя — дорого, поэтому такая сделка почти всегда выгодна.
Как это работает «под капотом»
На техническом уровне мемоизация — это просто дополнительная прослойка кода перед основной логикой функции. Вместо того чтобы сразу выполнять расчеты, функция сначала обращается к хранилищу.
Алгоритм «Спросил — Посчитал — Записал — Отдал»:
Спросил: Функция получает входные данные (аргументы). Она первым делом лезет в кэш и проверяет: «Есть ли у меня ключ, соответствующий этим аргументам?»
Посчитал: Если ключа нет (промах кэша), функция выполняет свой основной код — те самые тяжелые вычисления, ради которых всё затевалось.
Записал: Полученный результат не просто возвращается пользователю, а сохраняется в кэш вместе с аргументами.
Отдал: Теперь, когда результат в памяти, функция его возвращает. При следующем вызове с теми же данными всё закончится на первом пункте.
Роль словаря (dict)
В Python идеальным инструментом для этого является словарь. Почему именно он?
Скорость: Поиск по ключу в словаре (хеш-таблице) происходит почти мгновенно (
), независимо от того, лежит там 10 значений или 10 миллионов.
Структура: Аргументы функции становятся ключами, а результат вычислений — значениями.
Важный нюанс: Чтобы это работало, аргументы функции должны быть хэшируемыми (неизменяемыми). Вы не сможете использ��вать список (list) как ключ кэша, потому что он может измениться. Обычно используют числа, строки или кортежи (tuple).
Как это выглядит в логике кода:
cache = {} def heavy_task(n): # 1. Спросил if n in cache: return cache[n] # 2. Посчитал result = n * n # Представим, что это очень долго # 3. Записал cache[n] = result # 4. Отдал return result
Вся «магия» под капотом — это банальная проверка наличия ключа в dict перед тем, как нагружать процессор.
Пишем мемоизацию вручную
Чтобы понять, как это работает, сначала напишем «плохой» код, а потом добавим в него память.
1. До: Обычная рекурсия (Медленно)
Эта функция вычисляет числа Фибоначчи. Она чистая и красивая, но абсолютно бесполезная для больших чисел.
def fib(n): if n <= 1: return n return fib(n-1) + fib(n-2) # Попробуйте запустить это для n=40 print(fib(40))
Почему это тормозит: Для fib(40) функция сделает более 100 миллионов вызовов. Она будет бесконечно пересчитывать одни и те же значения, потому что каждый вызов «забывает» результат предыдущего.
2. После: Ручная мемоизация (Быстро)
Добавим словарь memo, который будет хранить уже вычисленные значения.
# Наш кэш (блокнот) memo = {} def fib_memo(n): # 1. Сначала проверяем, есть ли ответ в кэше if n in memo: return memo[n] # Базовые случаи if n <= 1: return n # 2. Считаем и ЗАПИСЫВАЕМ результат в кэш memo[n] = fib_memo(n-1) + fib_memo(n-2) return memo[n] print(fib_memo(40))
Сравнение скорости
Если замерить время выполнения на среднем ноутбуке для n=35:
Версия | Время выполнения | Количество операций |
|---|---|---|
Обычная рекурсия | ~2–5 секунд | Миллионы |
С мемоизацией | ~0.00001 сек | 35–40 |
Итог: Для обычная функция может заставить вас ждать минуту, а версия с мемоизацией выдаст ответ мгновенно. Мы просто перестали делать лишнюю работу, превратив экспоненциальный рост сложности в линейный. Теперь программа считает каждое число в ряду ровно один раз.
Профессиональный подход: Декоратор @lru_cache
В реальных проектах никто не создает словари руками для кэширован��я. В Python это считается «изобретением велосипеда», потому что в стандартной библиотеке уже есть готовый инструмент — модуль functools.
Как ускорить функцию одной строчкой
Самый простой способ внедрить мемоизацию — использовать декоратор @lru_cache. Вам нужно просто «навесить» его над вашей функцией.
from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n <= 1: return n return fib(n-1) + fib(n-2)
Всё. Python сам создаст словарь, сам будет проверять наличие аргументов в нем и сам запишет результат. Ваша медленная рекурсивная функция мгновенно стала реактивной.
Что такое maxsize и зачем он нужен?
LRU расшифровывается как Least Recently Used (наименее недавно использованный). Это алгоритм управления памятью.
Параметр maxsize определяет, сколько результатов функция будет хранить в памяти:
maxsize=None: Кэш неограничен. Функция будет помнить вообще всё. Это быстро, но если входных данных миллионы, программа может сожрать всю оперативную память.maxsize=128(по умолчанию): Функция запомнит только 128 последних уникальных вызовов.Как только лимит исчерпан, Python удалит самый старый результат, к которому давно не обращались, чтобы освободить место для нового.
Зачем это нужно в реальности?
Представь сервер, который обрабатывает запросы пользователей. Если ты поставишь maxsize=1000, сервер будет помнить ответы для тысячи самых активных пользователей. Как только придет 1001-й, сервер «забудет» того, кто заходил дольше всех. Это идеальный баланс между скоростью и экономией оперативной памяти.
Итог: Используй @lru_cache, когда хочешь мемоизацию «здесь и сейчас», не усложняя код лишними словарями. Это стандарт индустрии.
Когда мемоизация НЕ нужна (и даже вредна)
Мемоизация — это не «волшебная кнопка» для ускорения всего подряд. Если использовать её неправильно, вы получите либо сломанную логику, либо утечку памяти.
1. Функции с побочными эффектами
Мемоизация «пропускает» выполнение тела функции и просто отдает старый ответ. Если функция должна что-то сделать, а не только посчитать, кэш всё испортит.
Пример: Функция, которая отправляет письмо или печатает лог в консоль.
Что случится: Первый раз письмо уйдет и сообщение напечатается. При втором вызове с теми же данными Python просто вернет результат
True, но письмо не отправится, и в консоли будет пусто. Тело функции просто не запустится.
2. Недетерминированные функции (зависимость от внешнего мира)
Кэшировать можно только те функции, которые для одного и того же x всегда возвращают один и тот же y.
Время: Если функция возвращает «текущее время», мемоизация заставит её вечно показывать время первого вызова.
API и Базы данных: Если вы кэшируете запрос к курсу валют, ваша программа будет показывать утренний курс даже вечером, когда всё изменилось.
Рандом: Функция
get_random_bonus(user_id)всегда будет выдавать один и тот же «рандомный» бонус одному и тому же юзеру.
3. Проблема «раздувания» памяти
Кэш — это словарь, который живет в оперативной памяти.
Бесконечный рост: Если у функции миллионы возможных входных аргументов и вы используете
@lru_cache(maxsize=None), ваш кэш будет бесконечно расти, пока не съест всю память и программа не вылетит с ошибкойOut of Memory.Тяжелые объекты: Если функция возвращает огромные списки или объекты (например, загружает в память 1 ГБ данных из файла), кэширование пары десятков таких вызовов мгновенно забьет RAM.
4. Проблема методов классов (Self)
Если вы повесите @lru_cache на метод класса, возникнет скрытая проблема:
class Worker: @lru_cache def heavy_method(self, n): ...
Кэш будет хранить ссылку на self (объект класса). Из-за этого сборщик мусора Python не сможет удалить объект из памяти, даже если он вам больше не нужен. Это классическая причина «утечек памяти» в Python-приложениях.
Итог: когда НЕ использовать
Если функция меняет внешнее состояние (файлы, БД, экран).
Если результат функции зависит от внешних факторов (время, погода, случайность).
Если входных данных слишком много, а память ограничена.
С методами классов (лучше использовать
cached_propertyили ручное управление).
Заключение
Мемоизация — это самый простой способ оптимизации кода «малой кровью». Вам не нужно переписывать алгоритм или менять архитектуру, достаточно просто дать функции память. Главное — помнить, что вы меняете память на время: программа станет потреблять больше RAM, но перестанет нагружать процессор бесполезными повторами.
Чтобы понять, стоит ли внедрять мемоизацию в конкретном случае, сверьтесь с этим чек-листом.
Чек-лист: когда использовать мемоизацию
Если вы ответили «Да» на все 5 пунктов — мемоизация вам необходима:
Функция «чистая»? (Для одних и тех же входных данных она ВСЕГДА возвращает один и тот же результат).
Функция тяжелая? (Она делает сложные расчеты, парсит большие объемы данных или совершает рекурсивные вызовы).
Аргументы часто повторяются? (Вызываете функцию с одними и теми же параметрами многократно в течение одного сеанса работы).
Аргументы хэшируемые? (На вход подаются строки, числа или кортежи, а не списки или другие изменяемые объекты).
Памяти достаточно? (Результаты работы функции не весят по гигабайту каждый, и их хранение не уронит сервер).
Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.
Вердикт:
Если это математика или сложная обработка текста — используйте.
Если это работа с сетью, временем или рандомом — забудьте.