Модуль 5 · Урок 5.5
Рекурсия и запоминание
Функция, которая вызывает себя: базовый случай, стек незавершённых вызовов и @cache против повторных вычислений.
Факториал 4 — это 4, умноженное на факториал 3. Сумма цифр числа — последняя цифра плюс сумма цифр остальных. Многие задачи сами сводятся к такой же задаче поменьше. Рекурсивная функция вызывает саму себя для меньшего случая; незавершённые вызовы ждут в стеке, а @cache запоминает уже найденные ответы. Каждый вызов — та же рамка, что в уроке о функциях, только рамки одной функции складываются стопкой. Алгоритмы на рекурсии разобраны в темах экзамена; здесь — механизм языка.
Проследим, как рекурсия считает факториал 4! = 1 · 2 · 3 · 4 = 24. Потом на числах Фибоначчи увидим цену повторных вычислений.
Рекурсия
Рекурсия решает задачу через меньший случай той же задачи. Рекурсия — вызов функции из самой себя, напрямую или через другие функции. Она удобна, когда решение естественно выражается через решение меньшего случая.
Факториал неотрицательного целого n — произведение чисел от 1 до n, причём 0! = 1. Отсюда n! = n * (n - 1)! при n > 0.
def factorial(n): if n == 0: return 1 return n * factorial(n - 1) print(factorial(4))Ветка n == 0 — базовый случай, решаемый без дальнейшего обращения. Остальное — переход к меньшему случаю. Для корректности нужно убедиться, что переходы обязательно приводят к базе: n каждый раз уменьшается на 1 и от неотрицательного числа дойдёт до 0.
Тонкости:отрицательное n
Для отрицательного n база не наступит никогда. Надёжная функция проверяет это первой строкой и останавливает программу с понятным сообщением: raise ValueError("нужно n >= 0"). Подробно raise — в уроке об исключениях.
Стек вызовов
Каждый незавершённый вызов ждёт результата следующего и хранится в стеке вызовов. Для factorial(4) вызов ждёт результата factorial(3), тот — factorial(2), затем factorial(1) и factorial(0). После базы возвращения идут в обратном порядке: 1, 1, 2, 6, 24. Каждый вызов ждёт, пока ответит следующий: factorial(4) нужно 4 × результат для 3, factorial(3) — 3 × результат для 2, и так до factorial(0), который сразу знает ответ 1. Пошагово это выглядит так:
- factorial(4) ждёт результат factorial(3).
- factorial(3) ждёт результат factorial(2).
- factorial(2) ждёт результат factorial(1).
- factorial(1) ждёт результат factorial(0).
- factorial(0) — базовый случай, ответ 1 без новых вызовов.
- factorial(0) возвращает 1. Вызов снимается со стека.
- factorial(1) возвращает 1 = 1 × 1. Вызов снимается со стека.
- factorial(2) возвращает 2 = 2 × 1. Вызов снимается со стека.
- factorial(3) возвращает 6 = 3 × 2. Вызов снимается со стека.
- factorial(4) возвращает 24 = 4 × 6. Стек пуст — ответ готов.
Каждый незавершённый вызов хранит собственные параметры и место продолжения. Эта последовательность хранится в стеке вызовов. Стек — структура «последним вошёл, первым вышел»: первым завершается самый глубокий вызов.
У Python ограничена глубина рекурсивных вызовов. Если базовый случай недостижим, стек переполняется:
def down(n): return down(n - 1) down(5)RecursionError означает, что вложенных вызовов стало больше допустимого. Узнать предел можно через стандартный модуль sys — набор средств для связи с интерпретатором. Он подключается так же, как math в уроке о модулях.
import sys print(sys.getrecursionlimit())sys.setrecursionlimit(5000) поднимает предел. Без необходимости поднимать его до огромного числа нельзя считать исправлением алгоритма: стек и память конечны. Python не превращает рекурсию в цикл сам, поэтому для длинной цепочки вызовов обычно лучше написать цикл.
Мемоизация
Прямая рекурсия может много раз вычислять одно и то же. Пусть последовательность задана как F(0) = 0, F(1) = 1, а далее F(n) = F(n - 1) + F(n - 2). Прямой рекурсивный код многократно вычисляет одинаковые значения. Например, при вычислении F(5) значение F(3) понадобится и прямо, и внутри F(4). Посчитаем вызовы:
calls = 0 def fibonacci_slow(n): global calls calls += 1 if n < 2: return n return fibonacci_slow(n - 1) + fibonacci_slow(n - 2) print(fibonacci_slow(20), calls)global calls разрешает функции менять внешний счётчик. Для 21 различного значения F(0)…F(20) понадобилось почти 22 тысячи вызовов. Число вызовов растёт очень быстро: для n = 10 их 177, для 20 — 21 891, для 25 — 242 785, а для 30 — уже 2 692 537. Откуда столько? Вот все вызовы уже для F(5):
Мемоизация — сохранение результата для уже рассмотренных аргументов. При следующем таком же запросе результат берётся из памяти. В Python есть cache в стандартном модуле functools; from functools import cache подключает это имя:
from functools import cache @cachedef fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) print(fibonacci(10), fibonacci(90))Строка @cache над def добавляет функции память: перед вычислением она ищет ответ для этого аргумента, а после — сохраняет его.
Тонкости:что такое декоратор
@cache — декоратор: функция, которая получает функцию и возвращает её обёрнутую версию. Запись означает почти то же, что fibonacci = cache(fibonacci) после определения. Свои декораторы — в дополнительном уроке.
Теперь каждое F(k) вычисляется один раз: для F(n) нужно всего n + 1 разных вычислений. Поэтому F(90) считается мгновенно. Но глубина первого спуска остаётся линейной, а кэш хранит все результаты. Кэш не устраняет ограничения рекурсивной глубины.
Аргументы кэшируемой функции — числа, строки, кортежи; список передать нельзя. Результат должен определяться аргументами. Если функция зависит от изменяющейся глобальной переменной, файла или времени, кэш может возвращать устаревшие данные. Очистить его можно fibonacci.cache_clear().
Модель вызова
Модуль «Функции» складывается в одну модель вызова. Вызов функции — это новая рамка со своими переменными (урок о функциях). В неё попадают аргументы — по позиции, по имени или значения по умолчанию, созданные один раз при def (урок о договорённости функции). Функции и имена можно брать из модулей (урок о модулях) и передавать другим функциям, как key сортировке (урок о сортировке). Незавершённые вызовы лежат стопкой — это и есть стек вызовов, а рекурсия лишь кладёт в стопку вызовы той же функции.
В пошаговом запуске это видно так: вызов добавляет новую рамку, где параметры уже связаны с аргументами; присваивание внутри меняет только верхнюю рамку; return убирает её и отдаёт значение в строку вызова. При рекурсии рамок одной функции становится несколько, и у каждой своё n.
Итог
Рекурсия сводит задачу к меньшей такой же, стек хранит ждущие вызовы, а кэш убирает повторные вычисления. Вот что понадобится дальше — и в темах ЕГЭ о рекурсии.
- РекурсияРекурсивная функция: базовый случай без вызова и переход к меньшему случаю, который обязательно доходит до базы.
- Стек вызововНезавершённые вызовы ждут в стеке; первым завершается самый глубокий.
RecursionErrorНедостижимая база —RecursionError; предел —sys.getrecursionlimit(), поднимать его — не исправление алгоритма.@cacheПовторные вычисления делают прямую рекурсию медленной;@cacheвычисляет каждое значение один раз.- Ограничения кэшаАргументы кэша — числа, строки, кортежи, не списки; кэш не убирает ограничение глубины.
Проверьте себя
Ответь сначала сам, потом открой ответ.
Составь таблицу вызовов для factorial(3): что ждёт каждый вызов и что возвращает.
ОтветСкрыть ответ
factorial(3) ждёт 3 × f(2), f(2) — 2 × f(1), f(1) — 1 × f(0), f(0) возвращает 1; ответы 1, 1, 2, 6.
Функция s(n) возвращает 0 при n == 0 и n + s(n - 1) иначе. Чему равно s(5)? Что случится при s(-1)?
ОтветСкрыть ответ
15; при s(-1) база не достигается — RecursionError.
Почему @cache не поможет функции down из урока?
ОтветСкрыть ответ
Каждый вызов получает новый аргумент, повторов нет, а база недостижима: бесконечный спуск остаётся.
Почему функцию f(items), где items — список, нельзя декорировать @cache и вызывать со списком?
ОтветСкрыть ответ
Список нельзя сделать ключом кэша, как и ключом словаря — будет TypeError.
Практика
Для проверки ответов нужен JavaScript. Условия, подсказки и решения доступны ниже.
Вызовы fibonacci_slow(5)
Сколько всего вызовов сделает fibonacci_slow(5) из урока и сколько раз среди них вычисляется F(2)? Посмотри на дерево вызовов в уроке.
Запишите два числа через пробел: всего вызовов, затем сколько раз вычисляется F(2).
Подсказка
Каждый вызов F(n) при n ≥ 2 порождает два новых: F(n − 1) и F(n − 2).
Разбор
Дерево вызовов F(5) содержит 15 узлов; F(2) встречается в нём трижды.
Ответ: 15 3
Глубина рекурсии power(3, 4)
Функция power(a, n) возвращает 1 при n == 0 и a * power(a, n - 1) иначе. Чему равно power(3, 4) и сколько вызовов power лежит в стеке одновременно в самый глубокий момент?
def power(a, n): if n == 0: return 1 return a * power(a, n - 1) print(power(3, 4))Запишите два числа через пробел: результат, затем наибольшее число вызовов в стеке одновременно.
Подсказка
Вызовы идут для n = 4, 3, 2, 1, 0 и ждут друг друга, пока не дойдут до базы.
Разбор
3⁴ = 81. В самый глубокий момент ждут вызовы для n = 4, 3, 2, 1 и выполняется вызов для n = 0 — пять.
Ответ: 81 5
Напиши функцию с @cache: F(10)
Функция задана так: F(n) = F(n - 1) + 2 * F(n - 2), F(0) = 0, F(1) = 1. Напиши её с @cache. Вызови её для 10, напечатав результат и запиши, что напечатала программа.
Введите число.
Подсказка
База — n < 2, вернуть n; иначе — формула из условия.
Разбор
Например:
from functools import cache @cachedef F(n): if n < 2: return n return F(n - 1) + 2 * F(n - 2) print(F(10))Значения: 0, 1, 1, 3, 5, 11, 21, 43, 85, 171, 341.
Ответ: 341
Напиши рекурсивную функцию: сумма цифр
Напиши рекурсивную функцию суммы цифр: сумма цифр n равна n % 10 плюс сумма цифр n // 10, а для 0 она равна 0. Вызови её для 9051, напечатав результат и запиши, что напечатала программа.
Введите число.
Подсказка
База — n == 0; переход — n % 10 + digit_sum(n // 10).
Разбор
Например:
def digit_sum(n): if n == 0: return 0 return n % 10 + digit_sum(n // 10) print(digit_sum(9051))1 + 5 + 0 + 9 = 15.
Ответ: 15