К содержанию

Модуль 5 · Урок 5.5

Рекурсия и запоминание

Функция, которая вызывает себя: базовый случай, стек незавершённых вызовов и @cache против повторных вычислений.

4 задачи

Факториал 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))
24

Ветка 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. Пошагово это выглядит так:

  1. factorial(4) ждёт результат factorial(3).
  2. factorial(3) ждёт результат factorial(2).
  3. factorial(2) ждёт результат factorial(1).
  4. factorial(1) ждёт результат factorial(0).
  5. factorial(0) — базовый случай, ответ 1 без новых вызовов.
  6. factorial(0) возвращает 1. Вызов снимается со стека.
  7. factorial(1) возвращает 1 = 1 × 1. Вызов снимается со стека.
  8. factorial(2) возвращает 2 = 2 × 1. Вызов снимается со стека.
  9. factorial(3) возвращает 6 = 3 × 2. Вызов снимается со стека.
  10. factorial(4) возвращает 24 = 4 × 6. Стек пуст — ответ готов.

Каждый незавершённый вызов хранит собственные параметры и место продолжения. Эта последовательность хранится в стеке вызовов. Стек — структура «последним вошёл, первым вышел»: первым завершается самый глубокий вызов.

У Python ограничена глубина рекурсивных вызовов. Если базовый случай недостижим, стек переполняется:

def down(n):    return down(n - 1) down(5)
RecursionError: maximum recursion depth exceeded

RecursionError означает, что вложенных вызовов стало больше допустимого. Узнать предел можно через стандартный модуль sys — набор средств для связи с интерпретатором. Он подключается так же, как math в уроке о модулях.

import sys print(sys.getrecursionlimit())
1000

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)
6765 21891

global calls разрешает функции менять внешний счётчик. Для 21 различного значения F(0)…F(20) понадобилось почти 22 тысячи вызовов. Число вызовов растёт очень быстро: для n = 10 их 177, для 20 — 21 891, для 25 — 242 785, а для 30 — уже 2 692 537. Откуда столько? Вот все вызовы уже для F(5):

Каждый вызов порождает два новых. F(3) вычисляется дважды, F(2) — трижды, и с ростом n повторов становится всё больше. Если запомнить однажды найденное F(k), каждое значение понадобится вычислить один раз.

Мемоизация — сохранение результата для уже рассмотренных аргументов. При следующем таком же запросе результат берётся из памяти. В 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))
55 2880067194370816120

Строка @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 вычисляет каждое значение один раз.
  • Ограничения кэшаАргументы кэша — числа, строки, кортежи, не списки; кэш не убирает ограничение глубины.

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

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

Вопрос 1

Составь таблицу вызовов для factorial(3): что ждёт каждый вызов и что возвращает.

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

factorial(3) ждёт 3 × f(2), f(2) — 2 × f(1), f(1) — 1 × f(0), f(0) возвращает 1; ответы 1, 1, 2, 6.

Вопрос 2

Функция s(n) возвращает 0 при n == 0 и n + s(n - 1) иначе. Чему равно s(5)? Что случится при s(-1)?

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

15; при s(-1) база не достигается — RecursionError.

Вопрос 3

Почему @cache не поможет функции down из урока?

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

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

Вопрос 4

Почему функцию f(items), где items — список, нельзя декорировать @cache и вызывать со списком?

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

Список нельзя сделать ключом кэша, как и ключом словаря — будет TypeError.

Практика

Для проверки ответов нужен JavaScript. Условия, подсказки и решения доступны ниже.

Вызовы fibonacci_slow(5)

Сколько всего вызовов сделает fibonacci_slow(5) из урока и сколько раз среди них вычисляется F(2)? Посмотри на дерево вызовов в уроке.

Запишите два числа через пробел: всего вызовов, затем сколько раз вычисляется F(2).

Глубина рекурсии 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))

Запишите два числа через пробел: результат, затем наибольшее число вызовов в стеке одновременно.

Напиши функцию с @cache: F(10)

Функция задана так: F(n) = F(n - 1) + 2 * F(n - 2), F(0) = 0, F(1) = 1. Напиши её с @cache. Вызови её для 10, напечатав результат и запиши, что напечатала программа.

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

Напиши рекурсивную функцию: сумма цифр

Напиши рекурсивную функцию суммы цифр: сумма цифр n равна n % 10 плюс сумма цифр n // 10, а для 0 она равна 0. Вызови её для 9051, напечатав результат и запиши, что напечатала программа.

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