К содержанию

Подтема 3 из 6

Повторные вызовы и таблица

Почему программа с двумя вызовами себя считает долго и как её ускорить: @cache, прогрев кеша и таблица значений циклом.

Повторные вызовы

Почему программа может считать минуты, хотя аргумент небольшой?

Если функция вызывает себя дважды, одни и те же значения считаются снова и снова. Возьмём F(n) = 1 при n ≤ 2; F(n) = F(n − 1) + F(n − 2) при n > 2 — это числа Фибоначчи: 1, 1, 2, 3, 5, 8, … Программа по определению:

def F(n):    if n <= 2:        return 1    return F(n - 1) + F(n - 2)  print(F(5))
5

Какие вызовы она делает для F(5), показывает левое дерево:

Без запоминания F(5) требует 9 вызовов: F(3) вычисляется дважды, F(2) — трижды. С запоминанием 7 вызовов, из них вычисляют только 5, а повторные берут готовый ответ.

С ростом n повторов становится лавина: для F(30) программа делает больше полутора миллионов вызовов, для F(40) — больше двухсот миллионов. А разных значений среди них всего сорок.

@cache

Как убрать повторы?

Запоминать каждое найденное значение и при повторном вызове брать готовый ответ. Это делает .

from functools import cache  @cachedef F(n):    if n <= 2:        return 1    return F(n - 1) + F(n - 2)  print(F(50))
12586269025

Строка from functools import cache подключает готовое запоминание, а @cache над def включает его для F. Формула остаётся прежней. Правое дерево на схеме — та же F(5) с @cache: повторные вызовы сразу получают ответ из памяти. Поэтому F(50) считается мгновенно.

Тонкости:lru_cache и как устроено запоминание

В разборах часто пишут @lru_cache(None) из того же модуля — это то же запоминание. У @lru_cache() без None память ограничена 128 значениями. Внутри это словарь «аргумент → значение»: перед вычислением функция смотрит, нет ли там ответа, а после кладёт его туда.

Прогрев кеша

А если программа с @cache всё равно падает с RecursionError?

Кеш не уменьшает глубину: первый вызов F(2026) всё равно спускается до базы через две тысячи ждущих вызовов. Глубину убирает прогрев — вызвать функцию по очереди, начиная от базы. Задача темы с прогревом:

from functools import cache  @cachedef F(n):    if n == 1:        return 1    return n * F(n - 1)  for n in range(1, 2027):    F(n) print((F(2026) + 3 * F(2025)) // F(2024))
4108725

Цикл считает F(1), F(2), F(3), … по очереди. Когда доходит очередь до F(100), значение F(99) уже лежит в кеше — вызов делает один шаг вниз, а не сто. Ждущих вызовов всё время мало, и предел поднимать не нужно. Результат F(n) в цикле никуда не записываем: вызов нужен только ради кеша.

Прогревают всегда от базы. Если база сверху — например, F(n) = n при n ≥ 5000 и F(n) = F(n + 3) + 5 при n < 5000, — цикл идёт по убыванию: for n in range(5002, 0, -1). Тогда к вызову F(n) значение F(n + 3) уже готово.

Таблица циклом

Можно ли обойтись совсем без рекурсии?

Да: завести список, где f[n] хранит F(n), и заполнить его циклом по формуле. Задача темы таблицей:

f = [0] * 2027f[1] = 1for n in range(2, 2027):    f[n] = n * f[n - 1] print((f[2026] + 3 * f[2025]) // f[2024])
4108725

[0] * 2027 создаёт из 2027 нулей с номерами от 0 до 2026 — так появляется место для f[2026]. Элемент f[0] не используется, а остальные нули — заготовки, их заменит цикл. Когда цикл считает f[n], значение f[n − 1] уже готово.

Порядок заполнения задают зависимости. Если формула берёт значения слева — F(n − 1), F(n − 2), — список заполняют слева направо. Если справа — F(n + 1), — справа налево, от верхней базы:

Стрелки ведут от уже готовых ячеек к вычисляемой. Для F(n − 1) и F(n − 2) готовы ячейки слева — таблицу заполняют слева направо; для F(n + 1) готова ячейка справа — справа налево, от базы F(10) = 10.

Такой способ — сохранять ответы маленьких задач и строить из них следующие — называют динамическим программированием. Прогрев кеша делает то же самое, только значения хранит @cache.

Тонкости:заполнение справа налево

Функция из подтемы 1: F(n) = n при n ≥ 10 и F(n) = n + F(n + 1) при n < 10.

f = [0] * 11f[10] = 10for n in range(9, 0, -1):    f[n] = n + f[n + 1] print(f[7])
34

range(9, 0, -1) даёт 9, 8, …, 1: третье число — шаг −1, а 0 не входит.

Две функции

Что делать, если в условии две функции и они вызывают друг друга?

Записать обе по определению — каждую своей def — и вызывать их после того, как описаны обе. Например: F(1) = 1, G(1) = 2; при n > 1 F(n) = F(n − 1) + G(n − 1), G(n) = 2 · F(n − 1) + G(n − 1). Найдём F(5) + G(5). Вручную — таблицей по строкам:

nF(n)G(n)
112
21 + 2 = 32 · 1 + 2 = 4
33 + 4 = 72 · 3 + 4 = 10
47 + 10 = 172 · 7 + 10 = 24
517 + 24 = 412 · 17 + 24 = 58

Обе функции берут значения из предыдущей строки. Ответ: 41 + 58 = 99. Программой:

from functools import cache  @cachedef F(n):    if n == 1:        return 1    return F(n - 1) + G(n - 1)  @cachedef G(n):    if n == 1:        return 2    return 2 * F(n - 1) + G(n - 1)  print(F(5) + G(5))
99

F ссылается на G, описанную ниже, — это нормально: Python ищет G только в момент вызова. Главное, чтобы print стоял после обеих def. @cache нужен каждой функции: обе вызывают по две функции, и без запоминания при больших n повторы разрастаются, как у Фибоначчи.

Итого

  • Два вызова себябез запоминания значения считаются много раз
  • @cacheкаждое значение считается один раз
  • Глубинакеш её не уменьшает: поднять предел или прогреть кеш
  • Прогреввызвать F(n) по очереди, начиная от базы
  • Таблицаf[n] = F(n), цикл идёт от базы; список на N + 1 элемент
  • Две функциидве def, вызов после обеих

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

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

Вопрос 1
Почему программа для чисел Фибоначчи без @cache считает F(40) так долго, хотя разных значений всего сорок?
ОтветСкрыть ответ
Ответ
Каждый вызов вызывает себя дважды, и одни и те же значения считаются заново: F(38) — дважды, F(37) — трижды, и дальше повторов всё больше.
Вопрос 2
F(1) = 1; F(n) = F(n − 2) + n при n > 1. Добавили @cache и вызвали F(6). Поможет ли это?
ОтветСкрыть ответ
Ответ
Нет: аргументы 6, 4, 2, 0, −2, … не доходят до базы, а кеш хранит только уже посчитанные значения.
Вопрос 3
F(n) = n при n ≥ 3000; F(n) = F(n + 1) + 1 при n < 3000. С @cache вызов F(1) всё равно падает. В какую сторону прогревать кеш?
ОтветСкрыть ответ
Ответ
По убыванию, от базы: for n in range(3000, 0, -1). Тогда к вызову F(n) значение F(n + 1) уже готово.
Вопрос 4
Список создали как f = [0] * 2026. Можно ли записать значение в f[2026]?
ОтветСкрыть ответ
Ответ
Нет: номера идут от 0 до 2025. Для f[2026] нужен [0] * 2027 — на один элемент больше.
Вопрос 5
G(n) = G(n − 1) + 1, F(n) = F(n − 1) + G(n). Какую строку в цикле считать первой — для g[n] или для f[n]?
ОтветСкрыть ответ
Ответ
Сначала g[n]: формуле F(n) нужно уже новое G(n).

Практика

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

Три предыдущих значения

F(n) = 1 при n ≤ 3; F(n) = F(n − 1) + F(n − 2) + F(n − 3) при n > 3. Найди F(40).

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

Числа Фибоначчи с @cache

F(n) = 1 при n ≤ 2; F(n) = F(n − 1) + F(n − 2) при n > 2. Найди F(70).

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

Задача в формате экзамена: F(30)

Алгоритм вычисления значения функции F(n), где n — натуральное число, задан следующими соотношениями: F(n) = 2 при n ≥ 40; F(n) = F(n + 1) + F(n + 2) при n < 40. Чему равно значение функции F(30)?

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

Прогрев от верхней базы

F(n) = n при n ≥ 5000; F(n) = F(n + 2) + 1 при n < 5000. Найди F(1).

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

Таблица справа налево

F(n) = n при n ≥ 20; F(n) = F(n + 2) + n при n < 20. Найди F(15).

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

Половина аргумента

F(1) = 1; F(n) = F(n − 1) + F(n // 2) при n > 1. Найди F(100).

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

Две связанные функции

F(1) = 2, G(1) = 1; при n > 1 F(n) = F(n − 1) + G(n − 1), G(n) = 2 · F(n − 1). Найди F(4) + G(4).

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

Задача в формате экзамена: F(12) + G(12)

Алгоритм вычисления значений функций F(n) и G(n), где n — натуральное число, задан следующими соотношениями: F(1) = G(1) = 1; F(n) = F(n − 1) + 2 · G(n − 1), G(n) = F(n − 1) + G(n − 1) при n > 1. Чему равно значение выражения F(12) + G(12)?

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