ЕГЭ · Информатика · Рекурсия и динамическое программирование (задания 13 и 16)
Что такое рекурсия: база и шаг
Понять устройство рекурсивной функции и не попасть в бесконечный вызов.
🎯 ЕГЭ информатика: задания 13 и 16. №13 — анализ и построение алгоритмов: подсчёт числа программ исполнителя; №16 — рекурсивные алгоритмы.
- ⚠Ошибаются в базе рекурсии (условии остановки)
- ⚠Считают не то значение или зацикливаются без мемоизации
Функция, которая вызывает саму себя
Определение
Рекурсия — функция, вызывающая саму себя с меньшими аргументами, приближаясь к простому случаю.
Что происходит в памяти: стек вызовов
Рекурсия выглядит как фокус: функция вызывает сама себя, и это как-то работает. Никакого фокуса нет — работает стек. Стек — это структура данных, устроенная как стопка тарелок: положить и снять можно только сверху. По-английски принцип называют LIFO — последним пришёл, первым ушёл. Каждый раз, когда программа вызывает функцию, она кладёт на стек кадр вызова: значения аргументов, локальные переменные и адрес, куда вернуться после завершения. Пока функция не закончила работу, её кадр лежит на стеке и занимает память. Посмотрим на F(4) при правиле F(n) = n · F(n−1), F(1) = 1: Кладём на стек F(4) → она не может ответить, пока не узнает F(3), поэтому кладём F(3) → та ждёт F(2) → та ждёт F(1). На стеке одновременно четыре кадра. F(1) — база, она отвечает сразу: 1. Её кадр снимается со стека, значение возвращается в F(2). Теперь F(2) может досчитать: 2 · 1 = 2. Её кадр снимается, значение уходит в F(3): 3 · 2 = 6. Затем F(4): 4 · 6 = 24. Из этого устройства следуют два практических вывода. Вывод 1. Без базы программа падает. Если условия остановки нет или шаг не уменьшает аргумент, кадры кладутся на стек бесконечно, память кончается, и Python сообщает об ошибке RecursionError: maximum recursion depth exceeded. По умолчанию предел глубины в Python около тысячи вызовов. Вывод 2. Глубина рекурсии — это расход памяти. Для F(100000) рекурсивное решение не годится, а цикл справится, потому что он не хранит сто тысяч кадров. Исторически именно стек сделал рекурсию возможной в языках программирования: механизм хранения кадров на стеке был реализован при создании компилятора Алгола-60 в начале 1960-х годов, и с тех пор так работают почти все языки.
Отсюда и практическое правило отладки: если программа падает с RecursionError, проверять надо не соотношение, а достижимость базы. Чаще всего аргумент меняется не в ту сторону (F(n + 1) вместо F(n - 1)) или база задана для n = 1, а рекурсия проскакивает её и уходит в ноль и дальше. Печать аргумента в первой строке функции показывает это за один запуск.

| База рекурсии | условие, при котором функция сразу возвращает готовое значение, не вызывая себя |
|---|---|
| Рекурсивный шаг | сводит задачу к такой же, но меньшего размера |
| Пример: факториал | F(1)=1 (база); F(n)=n·F(n−1) (шаг); F(4)=4·3·2·1=24 |
Рекурсивная функция и рекурсивная процедура: две разные задачи 16
В задании 16 встречаются два разных типа рекурсивных алгоритмов, и путать их нельзя. Тип 1: функция, которая возвращает число. Задано соотношение вида «F(n) = 0, если n ≤ 1; F(n) = F(n−1) + n², если n > 1», и спрашивают значение F(28). Здесь нужно посчитать одно число, и удобнее всего идти снизу вверх, заполняя таблицу от базы. Тип 2: процедура, которая ничего не возвращает, но печатает. Например: ```
def F(n):
if n > 0:F(n - 1) print(n) F(n - 2) ``` И спрашивают: что напечатает F(5), или сколько всего символов будет выведено, или какая цифра окажется на третьем месте. Здесь таблица не поможет — важен порядок выполнения. Решают такие задачи двумя способами. Способ А: раскрутить вручную по правилу «сверху вниз, слева направо». Команды внутри функции выполняются по очереди, и вложенный вызов выполняется полностью, прежде чем управление вернётся к следующей строке. Способ Б: просто запустить программу. На экзамене разрешён компьютер с интерпретатором, и переписать пять строк быстрее и надёжнее, чем раскручивать дерево на бумаге. Важная деталь порядка. В примере выше вызов F(n−1) стоит до печати, значит, сначала напечатаются все меньшие значения, а n — позже. Если переставить строки местами, вывод изменится полностью. Именно на этом и строят задания: два варианта отличаются только положением строки print.
Разбор примера
Раскрутка рекурсивной процедуры вручную
Что напечатает вызов F(3)?
def F(n):
if n > 0:
print(n)F(n - 1) F(n - 2)
Показать решение по шагам
- 1. F(3): n > 0, поэтому печатаем 3, затем вызываем F(2), затем F(1).
- 2. F(2): печатаем 2, вызываем F(1), затем F(0).
- 3. F(1) (внутри F(2)): печатаем 1, вызываем F(0) и F(−1) — оба сразу заканчиваются, потому что условие n > 0 ложно.
- 4. F(0) (внутри F(2)): ничего не делает. На этом F(2) завершена, вывод от неё: 2, 1.
- 5. Возвращаемся в F(3) и выполняем второй вызов — F(1): печатает 1.
- 6. Собираем вывод по порядку: 3 2 1 1.
- 7. Приём проверки: рисуйте дерево вызовов и обходите его слева направо, сверху вниз, выписывая печать в тот момент, когда до неё доходит очередь. Если бы строка print стояла после обоих вызовов, вывод был бы «1 1 2 3» — тот же набор чисел, но обратный порядок.
Ответ: 3 2 1 1
Самая частая ошибка — забыть базу или сделать так, что шаг не уменьшает аргумент. Тогда вызовы уходят в бесконечность (переполнение стека). В задании 16 база всегда прописана в условии.
| Вручную по таблице | выписываем F(1), F(2), F(3)… до нужного n. Годится, когда n невелико (до 15–20) и соотношение простое. Самый надёжный способ без компьютера |
|---|---|
| Итеративно на Python | словарь или список значений и один цикл от базы до n. Работает при любом n, память не расходуется на кадры вызовов |
| Рекурсивно с мемоизацией | @lrucache(None) над функцией: значение считается один раз и запоминается. Удобно, когда соотношение сложное и переписывать его в цикл долго |
| Рекурсивно без мемоизации | так делать не надо при n больше 30: для соотношения с двумя вызовами число операций растёт экспоненциально, и программа не досчитает |
Где теряют балл в задании 16
Ошибка 1. Неверно прочитана база. «F(n) = 0, если n ≤ 1» означает, что нулём равны и F(1), и F(0), и F(−5). А запись «F(1) = 2; F(2) = 3» задаёт две базы, и таблицу надо начинать с третьего значения. Ошибка 2. Путают n и F(n). В соотношении F(n) = F(n−1) + n² второе слагаемое — это квадрат самого n, а не квадрат предыдущего значения функции. Ошибка 3. Считают рекурсивно без мемоизации. Для F(n) = F(n−1) + F(n−2) наивная рекурсия при n = 40 делает свыше трёхсот миллионов вызовов. Программа либо считает минутами, либо падает. Ошибка 4. Забывают про предел глубины. Если решение всё-таки рекурсивное и n велико, нужно import sys; sys.setrecursionlimit(10000), иначе Python остановится на глубине около тысячи. Ошибка 5. В задачах с печатью отвечают набором чисел вместо последовательности. Спрашивают обычно именно порядок вывода, и переставленные цифры — уже неверный ответ. Ошибка 6. Отвечают значением функции, когда спрашивают сумму цифр. Формулировку «в ответе укажите сумму цифр числа F(20)» пропускают глазами и пишут само число.
И напоследок про стек вызовов. Каждый незавершённый вызов хранит свои локальные переменные и точку возврата, поэтому глубокая рекурсия расходует память пропорционально глубине. Для задач ЕГЭ этого хватает с запасом, но понимать механизм стоит: именно стек объясняет, почему печать после рекурсивного вызова выводит числа в обратном порядке и почему при бесконечной рекурсии программа падает не сразу, а исчерпав отведённое ей место.
Разбор примера
Ручная раскрутка факториала
Дано: F(1) = 1; F(n) = n · F(n−1) при n > 1. Найти F(5).
Показать решение по шагам
- 1. F(5) = 5 · F(4).
- 2. F(4) = 4 · F(3), F(3) = 3 · F(2), F(2) = 2 · F(1), F(1) = 1.
- 3. Подставляем снизу вверх: F(2)=2, F(3)=6, F(4)=24, F(5)=120.
Ответ: F(5) = 120
Разбор примера
То же соотношение на Python: три строки вместо таблицы
Как посчитать F(12) программой, если считать вручную долго или n велико.
Показать решение по шагам
- 1.
F = {1: 1, 2: 2, 3: 3} for n in range(4, 13): F[n] = F[n-1] + 2*F[n-3] print(F[12]) - 2. Словарь F хранит уже посчитанные значения; ключ — номер, значение — F(номер). Три базовых значения записываем сразу, как они заданы в условии.
- 3. Цикл идёт снизу вверх, от 4 до 12 включительно (range(4, 13) даёт числа до 12). В момент вычисления F[n] все нужные значения — F[n−1] и F[n−3] — уже лежат в словаре, поэтому ни одно из них не считается дважды.
- 4. Такой способ работает при любом n: замените 13 на 1001 и получите F(1000) мгновенно, тогда как наивная рекурсия не досчитает и до сорокового значения.
- 5. Если в задании просят сумму цифр ответа, добавьте строку: print(sum(int(c) for c in str(F[12]))).
Ответ: Программа печатает 355
Вопрос на проверку
Что произойдёт, если в рекурсивной функции убрать базовый случай?
Ответить и проверить себя — после бесплатной регистрации.
Вопрос на проверку
Почему рекурсия не может работать без стека вызовов?
Ответить и проверить себя — после бесплатной регистрации.
Вопрос на проверку
Программа выдала ошибку RecursionError: maximum recursion depth exceeded. Что это означает?
Ответить и проверить себя — после бесплатной регистрации.
Разбор примера
Печать до и после вызова: почему порядок разный
Проследить работу двух почти одинаковых процедур и объяснить, почему они печатают числа в противоположном порядке.
Первая: если n > 0, то напечатать n, затем вызвать себя от n − 1. Вторая: если n > 0, то вызвать себя от n − 1, затем напечатать n.
Что выведет каждая при n = 3?
Показать решение по шагам
- 1.
def P1(n): if n > 0: print(n)P1(n - 1)
def P2(n): if n > 0:P2(n - 1) print(n)
- 2. Шаг 1. Разбираем P1(3). Условие 3 > 0 истинно, печатается 3, затем вызывается P1(2).
- 3. Внутри P1(2): печатается 2, вызывается P1(1). Внутри P1(1): печатается 1, вызывается P1(0). В P1(0) условие ложно — ничего не происходит, вызов завершается.
- 4. Шаг 2. Дальше вызовы сворачиваются в обратном порядке, но печатать им уже нечего: печать стояла до рекурсивного вызова и уже случилась. Итог P1: 3, 2, 1.
- 5. Шаг 3. Разбираем P2(3). Условие истинно, но печать стоит после вызова, поэтому сначала вызывается P2(2), а печать тройки откладывается.
- 6. Внутри P2(2) точно так же откладывается печать двойки и вызывается P2(1). Внутри P2(1) откладывается печать единицы и вызывается P2(0), который сразу заканчивается.
- 7. Шаг 4. Теперь вызовы сворачиваются, и отложенная печать срабатывает: сначала завершается P2(1) — печатается 1; затем P2(2) — печатается 2; затем P2(3) — печатается 3. Итог P2: 1, 2, 3.
- 8. Шаг 5. Вывод, который стоит запомнить: печать до вызова даёт прямой порядок, печать после вызова — обратный. Причина в стеке: отложенные действия выполняются в порядке, обратном порядку вызовов.
- 9. Шаг 6. Этим приёмом пользуются, когда нужно вывести что-то задом наперёд, не переворачивая данные: например, напечатать цифры числа слева направо, хотя достаются они справа налево через
% 10. - 10. Шаг 7. И третий вариант, который встречается в заданиях: печать и до, и после вызова. Для n = 3 такая процедура даст 3, 2, 1, 1, 2, 3 — сначала все «входы», потом все «выходы» в обратном порядке. Если в задании просят число напечатанных символов, считать надо именно так: каждый уровень рекурсии печатает дважды.
Ответ: P1 печатает 3 2 1, P2 печатает 1 2 3
Рекурсия против цикла: что выбрать
Почти всё, что делает рекурсия, умеет и обычный цикл. Выбор между ними — не вопрос вкуса, и на экзамене он решается быстро.
Рекурсия удобнее, когда задача естественно сводится к себе же меньшего размера и когда таких сведений несколько. Рекуррентное соотношение F(n) = F(n−1) + F(n−2) переносится в рекурсию дословно, а в цикл — только после размышлений о том, какие значения хранить. Задачи с обходом дерева, подсчётом путей, разбором вариантов пишутся рекурсией в три строки.
Цикл удобнее, когда шаг простой и линейный: пройти по списку, накопить сумму, найти максимум. Рекурсия здесь ничего не даёт, зато тратит стек.
Три практических соображения.
Глубина. Каждый рекурсивный вызов занимает место в стеке, и Python по умолчанию разрешает около тысячи вложенных вызовов. Для F(50) это не проблема, для F(5000) — уже да, и нужна строка sys.setrecursionlimit(100000). Цикл глубины не имеет вовсе.
Скорость. Вызов функции дороже витка цикла в несколько раз. На задачах ЕГЭ это незаметно, но если рекурсия работает подозрительно долго, первым делом проверьте не скорость, а наличие кэша: почти всегда дело в том, что одно значение считается тысячи раз.
Читаемость и риск ошибки. Рекурсия короче, и в ней меньше мест, где можно ошибиться в индексах. Зато у неё есть своя фирменная ошибка — забытая или недостижимая база, после которой программа падает с RecursionError. Цикл в такой ситуации просто зациклится и будет работать молча, что хуже.
Практический вывод для ЕГЭ: линии 13 и 16 пишите рекурсией с @lru_cache, линии 17, 24, 25 и 26 — циклом. Это не догма, но так короче и надёжнее в подавляющем большинстве вариантов.
Как переписать рекурсию циклом
Иногда рекурсию просят заменить циклом — или это делают сами, чтобы обойти ограничение на глубину. Перевод механический, и знать его полезно.
Линейная рекурсия (один вызов, аргумент меняется на единицу) превращается в обычный цикл с накопителем. Факториал F(n) = n · F(n−1) становится:
rezultat = 1
for i in range(2, n + 1):
rezultat *= i
Рекурсия с несколькими предыдущими значениями превращается в цикл со скользящим окном из переменных: сколько значений в соотношении, столько переменных и нужно.
Рекурсия с произвольными переходами (вызовы вида F(n // 2) или F(n − 7)) в простой цикл не переписывается — там нужна полная таблица, заполняемая в правильном порядке. Именно поэтому в линиях 13 и 16 удобнее оставить рекурсию с кэшем: порядок заполнения она выбирает сама.
Рекурсия с двумя ветвями и деревом вариантов (подсчёт путей, обход графа) переписывается через явный стек или очередь: то, что рекурсия хранила в стеке вызовов, программа хранит в списке. Кода становится больше, зато глубина перестаёт быть ограничением.
Общее наблюдение: рекурсия и цикл эквивалентны по возможностям, различаются только удобством. Любой алгоритм, записанный одним способом, можно записать и другим, и выбор делается по тому, какая запись короче и в какой меньше шансов ошибиться.
Одно предупреждение напоследок. Переписывая рекурсию циклом, легко потерять порядок вычислений: цикл идёт по возрастанию индекса, а соотношение может требовать значений, которых на этом шаге ещё нет. Проверяется это просто — выпишите, от каких индексов зависит F[i], и убедитесь, что все они меньше i. Если есть зависимость от большего индекса, простым циклом не обойтись, и надо либо менять направление обхода, либо возвращаться к рекурсии с кэшем.
Задание №16 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №13 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №16 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №16 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.