v1.0.0

Задание 16 · Рекурсивные алгоритмы

Задание 16ЕГЭ по информатике5 задачБесплатно

Рекурсивные алгоритмы

Вычисление значений функции, заданной через саму себя: от одного базового случая до больших аргументов и алгебраических сокращений.

Теория

Вычисляем F(5) по правилу

Пусть известно, что F(1) = 1, а каждое следующее значение получается по правилу F(n) = 2·F(n − 1) + 1 при n > 1. Формула сама по себе ничего не считает — чтобы найти F(n), сначала нужно знать F(n − 1). Найдём F(5), поднимаясь от того, что уже известно.

Разобранный пример
Найдите F(5), если F(1) = 1 и F(n) = 2·F(n − 1) + 1
Каждое следующее значение выражается через предыдущее — начнём с того, что уже дано, и будем подниматься вверх.
  1. F(1) = 1 — это значение дано, вычислять его не нужно.
  2. F(2) = 2·F(1) + 1 = 2·1 + 1 = 3.
  3. F(3) = 2·F(2) + 1 = 2·3 + 1 = 7.
  4. F(4) = 2·F(3) + 1 = 2·7 + 1 = 15.
  5. F(5) = 2·F(4) + 1 = 2·15 + 1 = 31.

Зачем нужны два условия

Рекурсивное определение работает только потому, что в нём одновременно есть два условия. Первое — начальное значение (его называют базовым случаем): F(1) = 1 просто дано, вычислять его не нужно. Второе — правило перехода: F(n) = 2·F(n − 1) + 1 при n > 1, которое показывает, как получить следующее значение из предыдущего.

Работает это как ряд костяшек домино: F(1) — костяшка, которую толкнули вручную, а правило перехода — то, что заставляет каждую следующую костяшку падать от предыдущей. Без первого толчка (без базового случая) ни одна костяшка не упадёт, и цепочка F(5) → F(4) → F(3) → … никогда не остановится.

Почему это вообще определяет функцию

На первый взгляд определение через саму себя похоже на логический круг: чтобы найти F(5), нужно знать F(4), а чтобы найти F(4) — знать F(3), и так далее. Круга здесь на самом деле нет.

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

Проверьте себя
Дано F(1) = 5 и F(n) = F(n − 1) + 3 при n > 1. Можно ли подставить n = 1 в рекуррентную формулу, чтобы найти ещё одно значение?
Нет. Формула работает только при n > 1, а F(1) — отдельно заданный базовый случай. Такая подстановка потребовала бы не определённое в условии значение F(0).
Чему равно F(3) для той же функции?
Сначала F(2) = 5 + 3 = 8, затем F(3) = 8 + 3 = 11. Двигаться нужно от базового случая вверх по одному шагу.

Рекурсивная функция в коде

Ту же идею можно записать программой, где функция обращается сама к себе:

def F(n):    if n == 1:        return 1  # Базовый случай: значение уже известно    return 2 * F(n - 1) + 1  # Шаг: сначала находим F(n - 1) print(F(5))

Вызов F(5) не может сразу вернуть число — сначала нужно узнать F(4), для которого нужно F(3), и так далее, пока не будет достигнут базовый случай F(1). После этого каждый вызов возвращает своё значение туда, откуда был вызван, и подъём происходит в обратном порядке.

return делает две вещи одновременно: завершает текущий вызов и передаёт вычисленное число туда, откуда функция была вызвана. В строке return 2 * F(n - 1) + 1 выражение F(n - 1) — это не текст и не номер, а конкретное число, которое вернул вложенный вызов.

Когда рекурсию заменяет цикл

У рекурсивной записи есть практическая цена: каждый незавершённый вызов остаётся в стеке вызовов, ожидая результата вложенного. Для F(2024) это значит две тысячи с лишним вложенных вызовов одновременно — на Python это упирается в ограничение глубины рекурсии и завершается ошибкой ещё до того, как будет достигнут базовый случай:

def F(n):    if n == 1:        return 1    return n * F(n - 1) print(F(2024))  # RecursionError: превышена глубина рекурсии

В каждый момент нужно только последнее найденное значение, поэтому тот же результат надёжнее получить циклом, который перезаписывает одну переменную, ничего не откладывая в стек:

f = 1  # Начинаем с известного F(1) for n in range(2, 2025):    f = n * f  # Новое значение заменяет предыдущее print(f)

Общий шаблон для функции с одним предыдущим значением:

f = base_value for n in range(first_n, target + 1):    f = ...  # Формула через предыдущее значение f print(f)
Проверьте себя
Для вычисления F(2024) каждое следующее значение зависит только от предыдущего. Что надёжнее в Python: прямая рекурсия или цикл с одной переменной?
Цикл с одной переменной: ему не нужен глубокий стек вызовов, и он хранит ровно то значение, которое понадобится на следующем шаге.
Если переменная f уже хранит F(1), с какого значения n должен начинаться цикл для вычисления F(target)?
С n = 2: первое значение уже известно. Чтобы обработать target включительно, граница Python-цикла будет range(2, target + 1).

Когда нужны два предыдущих значения

Иногда следующее значение зависит не от одного, а от двух предыдущих: F(n) = F(n − 1) + F(n − 2). Тогда одного базового значения недостаточно — уже для F(3) нужны сразу F(2) и F(1), поэтому определение обязано задать оба сразу: F(1) = 2, F(2) = 3.

Разобранный пример
Найдите F(6), если F(1) = 2, F(2) = 3 и F(n) = F(n − 1) + F(n − 2)
Как и раньше, поднимаемся от известных значений вверх — только теперь на каждом шаге нужно держать в уме два последних числа, а не одно.
  1. F(3) = F(2) + F(1) = 3 + 2 = 5.
  2. F(4) = F(3) + F(2) = 5 + 3 = 8.
  3. F(5) = F(4) + F(3) = 8 + 5 = 13.
  4. F(6) = F(5) + F(4) = 13 + 8 = 21.

Зачем хранить значения, а не считать заново

Если функцию с двумя предыдущими значениями записать рекурсивно, у неё появляется собственная проблема — не с глубиной, а с повторами:

def F(n):    if n == 1:        return 2    if n == 2:        return 3    return F(n - 1) + F(n - 2)

Вызов F(6) запускает F(5) и F(4). Но F(5), в свою очередь, снова запускает F(4) — то же самое значение считается заново, хотя уже вычислялось. При больших n количество повторных вычислений растёт очень быстро. Решение — не пересчитывать, а один раз сохранить каждое найденное значение, например списком:

target = 6F = [0] * (target + 1) # Два базовых значения нужны до первого шагаF[1] = 2F[2] = 3 for n in range(3, target + 1):    F[n] = F[n - 1] + F[n - 2]  # Сохраняем один раз print(F[target])

Хранить весь список не обязательно — для следующего значения нужны только два последних:

f_prev2, f_prev1 = 2, 3 for n in range(3, 7):    # Правая часть использует оба старых значения до присваивания    f_prev2, f_prev1 = f_prev1, f_prev1 + f_prev2 print(f_prev1)

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

Проверьте себя
Функция задана как F(1) = 1, F(2) = 1, F(n) = F(n − 1) + F(n − 2). Что станет главной проблемой прямой рекурсии при F(40)?
Не глубина, а огромное количество повторных вычислений: одни и те же значения вызываются заново из разных ветвей. Здесь лучше цикл, список, пара переменных или кеширование.
Почему два последних значения нельзя бездумно обновлять двумя последовательными присваиваниями?
Первое присваивание перезапишет одно из старых значений, и второе уже сложит не ту пару. Нужно сначала сохранить новое значение или использовать параллельное присваивание Python.

Большие n: сокращаем, а не считаем

Если аргумент огромный — 2024, 100 000 — а нужен не сам F(n), а отношение или разность двух соседних значений, считать всю последовательность необязательно. Обычно достаточно раскрыть только несколько последних шагов и что-то сократить.

Разобранный пример
Найдите F(100) / F(98), если F(1) = 2 и F(n) = n·F(n − 1)
Выразим F(100) и F(99) через F(98) — до него раскрывать не нужно.
  1. F(99) = 99·F(98).
  2. F(100) = 100·F(99) = 100·99·F(98).
  3. F(100) / F(98) = 100·99·F(98) / F(98) = 100·99 = 9900.
Проверьте себя
Если F(n) = n·F(n − 1), нужно ли вычислять всю последовательность от F(1), чтобы найти F(2024) / F(2022)?
Нет. Достаточно раскрыть два последних шага: F(2024) = 2024·2023·F(2022), после чего F(2022) сокращается.

Общий алгоритм решения

Разные задания линии 16 сводятся к одному и тому же алгоритму:

Общий метод
Как решать задание 16
  1. Определите область n. n натуральное, n ≥ 0, или задано отдельно — от этого зависит, с какого числа начинать.
  2. Найдите базовые значения. Их не нужно вычислять — они даны в условии напрямую.
  3. Определите зависимость. Одно предыдущее значение, два предыдущих, разные формулы для чётных/нечётных n или две связанные функции сразу.
  4. Проверьте, что аргумент приближается к базе. F(n) → F(n − 1) → F(n − 2) → … должно дойти до уже известного значения.
  5. Выберите способ вычисления. Маленький аргумент — таблица вручную; обычный аргумент — цикл; несколько предыдущих значений — список или пара переменных; огромный аргумент с дробью или разностью — алгебраическое сокращение.
  6. Считайте снизу вверх и проверьте границы цикла. range(a, b) не включает b — если нужно значение F(target), верхняя граница должна быть target + 1.

Практика

Вычислите последовательность

F(1) = 4, F(n) = F(n - 1) + 2n при n > 1. Найдите F(5).

Подсказка
Начните с F(1) и последовательно найдите F(2), F(3), F(4), F(5).
Решение

Вычислим значения снизу вверх.

  1. F(2) = 8
  2. F(3) = 14
  3. F(4) = 22
  4. F(5) = 32

Проследите рекурсивные вызовы

Функция задана так: F(1) = 1, F(2) = 2, F(n) = F(n - 1) + 2F(n - 2) при n > 2. Какое значение вернёт вызов F(5)?

Подсказка
Сначала найдите F(3), затем F(4). Каждый вызов должен дойти до одного из двух базовых случаев.
Решение

Раскрываем вызовы снизу вверх, не пропуская оба базовых случая.

  1. F(1) = 1 и F(2) = 2 — готовые базовые значения.
  2. F(3) = F(2) + 2F(1) = 2 + 2 · 1 = 4.
  3. F(4) = F(3) + 2F(2) = 4 + 2 · 2 = 8.
  4. F(5) = F(4) + 2F(3) = 8 + 2 · 4 = 16.

Та же рекуррентная формула в Python

def f(n):    if n == 1:        return 1    if n == 2:        return 2    return f(n - 1) + 2 * f(n - 2) print(f(5))  # 16

Используйте два предыдущих значения

F(1) = 1, F(2) = 2, F(n) = 2F(n - 1) + F(n - 2). Найдите F(5).

Подсказка
Храните два последних значения и не пропускайте F(3) и F(4).
Решение

Последовательно применим формулу.

  1. F(3) = 5
  2. F(4) = 12
  3. F(5) = 29

Посчитайте повторяющиеся вызовы

Пусть F(0) = 1, F(1) = 1, а при n > 1 функция возвращает F(n - 1) + F(n - 2). Сколько всего вызовов F произойдёт при вычислении F(6), включая самый первый вызов?

Подсказка
Обозначьте число вызовов через C(n). Для базовых случаев C(0) = C(1) = 1, а для остальных C(n) = 1 + C(n - 1) + C(n - 2).
Решение

Считаем не значения F, а количество входов в функцию. Единица в формуле C(n) учитывает текущий вызов, а два слагаемых — обе рекурсивные ветви.

Последовательно найдём размеры деревьев вызовов.

  1. C(0) = 1 и C(1) = 1.
  2. C(2) = 1 + 1 + 1 = 3.
  3. C(3) = 1 + 3 + 1 = 5, C(4) = 1 + 5 + 3 = 9.
  4. C(5) = 1 + 9 + 5 = 15, C(6) = 1 + 15 + 9 = 25.

Сохранение результатов устраняет повторные вычисления

from functools import cache @cachedef f(n):    if n <= 1:        return 1    return f(n - 1) + f(n - 2)

Сократите отношение

F(1) = 2, F(n) = nF(n - 1). Найдите F(100) / F(98).

Подсказка
Раскройте только F(100) и F(99), не вычисляя всю последовательность.
Решение

Выразим соседние значения через F(98).

  1. F(99) = 99F(98)
  2. F(100) = 100 · 99F(98)
  3. F(100) / F(98) = 9900

Итог

Что получилось

Рекурсивное определение — это не логический круг, а последовательность: одно или несколько первых значений уже известны, а каждое следующее выражается через уже найденные. Вся задача сводится к тому, чтобы понять, сколько предыдущих значений нужно формуле, и подняться от базы до нужного аргумента, ни разу не забежав вперёд.

Как считать — рекурсией, циклом, списком, парой переменных или алгебраическим сокращением — решает не личный вкус, а то, что именно даёт формула: маленький аргумент или огромный, одно предыдущее значение или два, число или дробь из соседних значений.

Теперь вы умеете

  • Находить значения рекуррентно заданной функции, поднимаясь от базового случая к нужному аргументу
  • Отличать функции с одним предыдущим значением от функций с двумя и более
  • Переводить рекуррентное определение в рекурсивную функцию, цикл, список или пару переменных
  • Замечать, когда простая рекурсия упирается в ограничение глубины или пересчитывает одно и то же значение много раз
  • Сокращать выражения с огромными аргументами, не вычисляя всю последовательность

Прогресс

Прогресс хранится только в этом браузере и появится после загрузки страницы.