В прошлой статье мы познакомились с динамическим программированием и научились находить количество способов преобразовать одно число в другое.
Эти знания пригодятся нам для решения задания 13 ЕГЭ по информатике. В таких заданиях представлен исполнитель с заданным набором команд — как правило, это арифметические действия: сложение, умножение, извлечение корня и другие.
В условии указано начальное число, к которому применяются команды, и конечное число, которое нужно получить определённой комбинацией этих команд. Последовательность промежуточных результатов при выполнении команд называется траекторией вычислений.
В траекторию входят и начальное, и конечное числа. Например, программа «умножить на 2, прибавить 3» для старта 2 создаёт траекторию 2 → 4 → 7. Сами команды здесь важны не меньше результата: другая последовательность может привести к той же цели.
Исполнитель в условии — это упрощённая модель компьютера. Он не догадывается, не выбирает удобный путь и не нарушает правила: получает текущее состояние, выполняет одну команду и переходит в следующее.
Слово «алгоритм» связано с именем средневекового учёного аль-Хорезми. За много веков смысл стал шире, но идея осталась прежней: точная последовательность действий должна приводить к однозначному результату.
Если изобразить числа точками, а команды стрелками, получится ориентированный граф. Траектория вычислений — обычный путь по этому графу, а запрещённое число — вершина, в которую нам нельзя заходить.
Кроме того, на траекторию накладываются ограничения. Здесь стоит рассмотреть типы заданий 13:
Первый тип: траектория не должна содержать определённое число.
Второй тип: траектория обязательно должна содержать определённое число.
Третий тип: траектория должна содержать одно число и не содержать другое.
Четвёртый тип: ограничения накладываются на количество команд. Задачи этого типа пока что не встречались на реальном экзамене и являются авторскими. Так что отдельно разбирать их мы не будем.
Сейчас же мы вспомним код из предыдущей статьи и научимся решать задания первого типа.
Сначала разберём идею на маленьком примере, где все маршруты помещаются перед глазами. Так будет понятно, почему одна короткая проверка в функции отбрасывает не один шаг, а целую ветвь вычислений.
Алгоритм решения
Начнём с небольшой ретроспективы. Ранее мы искали количество способов преобразовать число 1 в 8, используя две команды:
Прибавить 3
Умножить на 2
Для решения мы написали такую функцию:
def f(x, y):
if x == y:
return 1
if x > y:
return 0
if x < y:
return f(x + 3, y) + f(x * 2, y)
Здесь x — начальное число (1), y — конечное (8). В коде реализованы два базовых случая и рекуррентное соотношение:
Базовый случай 1 (x == y): мы достигли цели — функция возвращает 1.
Базовый случай 2 (x > y): мы «перескочили» нужное число — этот путь не ведёт к цели, возвращаем 0.
Рекуррентное соотношение (x < y): применяем к x каждое действие и суммируем результаты: f(x + 3, y) + f(x * 2, y).
Почему именно сумма — мы разбирали ранее.
Итак, функция проверяет все возможные пути изменения числа. Например, при двукратном вызове f(x + 3, y) траектория будет такой: 1 → 4 → 7.
Давайте подумаем, как запретить программе проходить по траектории с нежелательным числом? Допустим, траектория не должна содержать число 4.
Логично добавить это ограничение ко второму базовому случаю: как траектория с «перескоком» нас не устраивает, также не подходит и траектория с запрещённым числом.
Следовательно, в нашей задаче мы бы изменили код таким образом:
def f(x, y):
if x == y:
return 1
if x > y or x == 4:
return 0
if x < y:
return f(x + 3, y) + f(x * 2, y)
Таким образом, у нас теперь есть два случая, когда траектория нам не подходит и функция должна возвращать 0:
Если число х оказывается больше конечного
Если число х является запрещённым
Посмотрим, что произошло с нашими тремя маршрутами из 1 в 8. Пути 1 → 4 → 8 и 1 → 2 → 4 → 8 содержат четвёрку, поэтому исчезают. Остаётся только траектория 1 → 2 → 5 → 8.
Важно: функция не пытается «перепрыгнуть» запрещённую точку и продолжить путь. Как только x стало равно 4, она возвращает 0. Этот ноль поднимается вверх по рекурсивной ветви и сообщает: подходящих продолжений здесь нет.
Поэтому запрещённое число проверяют в базовом случае. Базовый случай — это не просто конец вычислений, а правило, которое присваивает всей завершившейся ветви вклад 1 или 0.
Не спешите сразу печатать функцию. Сначала выпишите четыре вещи: старт, финиш, команды и запрещённые значения. Затем определите, увеличивают команды число или уменьшают его.
Направление нужно для проверки перескока. При движении вверх ветвь обрывается, когда x > y. При движении вниз — когда x < y. Сравнение зависит от команд, а не от привычки писать один и тот же знак.
Пример 1
Давайте теперь на практике проверим данную логику. Рассмотрим задание с такой формулировкой:
Исполнитель преобразует число на экране.
У исполнителя есть три команды, которые обозначены латинскими буквами: A. Прибавить 1 B. Умножить на 2 C. Возвести в квадрат
Программа для исполнителя – это последовательность команд.
Сколько существует программ, для которых при исходном числе 2 результатом является число 20, при этом траектория вычислений не содержит числа 11?
Траектория вычислений программы – это последовательность результатов выполнения всех команд программы.
Например, для программы CBA при исходном числе 4 траектория будет состоять из чисел 16, 32, 33.
Далее нам необходимо определить два условия базового случая:
Когда x > y
Когда x = 11
def f(x, y):
if x == y:
return 1
if x > y or x == 11:
return 0
Теперь, если x всё ещё меньше y будем вызывать функцию f с каждой из заданных команд:
def f(x, y):
if x == y:
return 1
if x > y or x == 11:
return 0
if x < y:
return f(x + 1, y) + f(x * 2, y) + f(x ** 2, y)
Осталось лишь добавить вывод на экран результата работы функции с аргументами 2 и 20:
def f(x, y):
if x == y:
return 1
if x > y or x == 11:
return 0
if x < y:
return f(x + 1, y) + f(x * 2, y) + f(x ** 2, y)
print(f(2, 20))
В результате получаем число 37.
Почему возведение в квадрат не требует отдельной логики? Для функции это всего лишь ещё одна стрелка из состояния x. Если x² перескочит 20, следующий вызов попадёт в базовый случай x > y и вернёт 0.
Зачем нужен кеш
До сих пор мы смотрели на рекурсию как на дерево: из каждого числа выходят несколько ветвей, соответствующих командам исполнителя. Но это дерево устроено не совсем обычно — разные ветви могут привести к одному и тому же числу.
В нашем примере число 4 можно получить сразу несколькими способами: прибавить к 2 единицу два раза, умножить 2 на 2 или возвести 2 в квадрат. После этого во всех случаях нужно решить одну задачу: сколько подходящих путей ведёт из 4 в 20?
Без кеша каждый вызов f(4, 20)заново строит всё дерево продолжений от 4 до 20. Программа снова проверяет те же числа, выполняет те же команды и получает тот же ответ. Чем длиннее траектории, тем больше становится такой повторной работы.
Кеш — это память о уже выполненных вызовах функции. Удобно представить его как таблицу: слева записаны аргументы функции, а справа — вычисленный для них результат. Для нашего примера одной из записей станет пара «(4, 20) → количество путей».
Когда f(4, 20) вызывается впервые, готовой записи ещё нет. Функция выполняет рекурсивные вычисления, получает ответ и сохраняет его в кеше. Такой первый вызов называют промахом кеша: значение пришлось вычислить.
Когда программа снова встречает f(4, 20), она находит сохранённый ответ и сразу возвращает его. Рекурсивное дерево второй раз не строится. Это уже попадание в кеш: результат удалось взять из памяти.
Важно, что кеш не склеивает разные программы исполнителя и не уменьшает ответ. Каждая ветвь всё равно получает количество продолжений из числа 4 и прибавляет его к своей сумме. Мы экономим вычисления, но считаем все траектории.
В Python готовый кеш для функций находится в модуле functools. Подключим из него lru_cache и поставим строку @lru_cache(None) непосредственно перед определением f:
from functools import lru_cache
@lru_cache(None)
def f(x, y):
if x == y:
return 1
if x > y or x == 11:
return 0
if x < y:
return f(x + 1, y) + f(x * 2, y) + f(x ** 2, y)
print(f(2, 20))
Строка со знаком @ называется декоратором. Декоратор позволяет изменить поведение функции, не переписывая её тело. lru_cache оборачивает f: перед обычным вычислением он проверяет, нет ли ответа для этих аргументов в кеше.
Имя LRU расшифровывается как Least Recently Used — «давно не использовавшийся». Если размер кеша ограничен, декоратор освобождает место, удаляя записи, к которым дольше всего не обращались.
В записи @lru_cache(None) значение None означает, что мы не задаём предел количества записей. Для задач ЕГЭ это удобно: возможных чисел немного, поэтому все найденные ответы можно хранить до конца работы программы.
После добавления декоратора имя f обозначает уже не исходную функцию, а её обёрнутую версию с кешем. Поэтому и первый вызов f(2, 20), и все рекурсивные вызовы внутри функции проходят через одну и ту же проверку памяти.
Ключом кеша становятся все аргументы вызова. Здесь это x и y, поэтому f(4, 20) и f(4, 30) считаются разными задачами. Числа подходят для ключей, поскольку их значение не меняется после создания.
Кеширование безопасно, когда одинаковые аргументы всегда дают одинаковый результат. Наша f именно такая: при неизменных командах и запрещённых числах ответ зависит только от текущего x и цели y.
При желании работу кеша можно увидеть с помощью f.cache_info(). Метод покажет число попаданий, промахов и сохранённых записей. Для решения задания эта строка не нужна, но она помогает проверить, что повторные вызовы действительно встречаются.
В маленьком примере программа справится и без кеша, поэтому ответ останется тем же — 37. Разница заметна на больших числах: с lru_cache каждая задача вида «сколько путей ведёт из x в цель» решается один раз, а все следующие обращения получают готовый ответ.
Пример 2
Рассмотрим ещё одно задание:
В этом задании нам необходимо добавить сразу два числа в условие. Но сложностей в этом нет никаких, просто также через оператор or проверяем равенство переменной x числам 9 и 16.
И ещё один момент: раньше мы всегда двигались по возрастанию: исходное число было меньше конечного. Здесь же обратная ситуация. Следовательно, все знаки сравнения (< и >) нам следует поменять на обратные!
Таким образом, если наше проверяемое число x будет либо меньше конечного (3), либо по траектории вычисления примет значения 9 или 16, то будет возвращаться 0. Это будет означать, что данная траектория вычислений нам не подходит.
А продолжать рекурсивно вызывать функцию f мы будем только, если переменная x больше конечного числа (y).
В остальном – без изменений. Код будет таким:
def f(x, y):
if x == y:
return 1
if x < y or x == 9 or x == 16:
return 0
if x > y:
return f(x - 1, y) + f(x - 2, y) + f(x // 3, y)
print(f(19, 3))
В конце работы программы получаем число 180.
Обратите внимание на целочисленное деление: 10 // 3 равно 3, а не 3,333… Команда сразу переводит исполнителя в целое состояние. Если результат окажется ниже цели, проверка x < y отсеет ветвь.
Пример 3
В заключение рассмотрим задание с такой формулировкой:
Исполнитель преобразует число на экране.
У исполнителя есть три команды, которые обозначены латинскими буквами: A. Прибавить 1 B. Умножить на 3
С. Умножить на 5
Программа для исполнителя – это последовательность команд.
Сколько существует программ, для которых при исходном числе 2 результатом является число 90, при этом траектория вычислений не содержит чисел 18 и 30 ?
Траектория вычислений программы – это последовательность результатов выполнения всех команд программы.
Например, для программы СBA при исходном числе 2 траектория будет состоять из чисел 10, 30, 31.
Снова работаем с двумя числами и движемся «вверх».
И решается данное задание следующим образом:
def f(x, y):
if x == y:
return 1
if x > y or x == 18 or x == 30:
return 0
if x < y:
return (f(x + 1, y) + f(x * 3, y) + f(x * 5, y))
print(f(2, 90))
В результате получаем число 145, которое и запишем в ответ.
Здесь удобно хранить запрещённые значения в множестве {18, 30}. Тогда проверка читается почти как условие задачи: если текущее число находится среди запрещённых, этот путь не учитываем.
FORBIDDEN = {18, 30}
def f(x, y):
if x == y:
return 1
if x > y or x in FORBIDDEN:
return 0
if x < y:
return (f(x + 1, y) + f(x * 3, y) + f(x * 5, y))
print(f(2, 90))
Итоги
В заданиях первого типа траектория не должна проходить через одно или несколько значений. Мы превращаем это ограничение в базовый случай: попадание в запрещённую вершину даёт 0 подходящих программ.
Сначала определите направление движения. Если все команды увеличивают число, перескок проверяется условием x > y. Если уменьшают — условием x < y. После этого примените каждую команду и сложите ответы ветвей.
Шаблон для возрастающей траектории:
from functools import lru_cache
FORBIDDEN = {*1}
@lru_cache(None)
def f(x, target):
if x in FORBIDDEN:
return 0
if x == target:
return 1
if x > target:
return 0
return f(*2, target) + f(*3, target)
print(f(*4, *5))
В шаблоне lru_cache выполняет ту же работу: запоминает результат для каждой пары (x, target). Убирать декоратор не обязательно — он не меняет логику подсчёта, а только избавляет программу от повторного обхода одинаковых продолжений.
Обозначения здесь такие:
*1 — запрещённые числа через запятую;
*2 и *3 — выражения для команд;
*4 — начальное число;
*5 — конечное. Если команд больше двух, добавьте ещё слагаемые.
Для убывающей траектории меняется только проверка перескока: используйте if x < target. Сами команды записывайте так, как они даны в условии: x - 1, x // 3 и так далее.
Всегда обращайте внимание на порядок базовых случаев, следите за знаками сравнения и проверяйте, что каждая команда приближает x к цели. Запрещённое значение должно обрывать ветвь, а не просто пропускаться.
В следующей статье рассмотрим обратное ограничение: траектория должна будет обязательно пройти через заданное число.
Разберём эту тему на бесплатном пробном занятии
Преподаватель покажет, как решать такие задачи, и подскажет, с чего начать подготовку. Группы до 8 человек, очно в Калининграде и онлайн.
Мы используем файлы cookie. Необходимые обеспечивают работу сайта: корзину и вход в личный кабинет. С вашего согласия мы также используем сервис Яндекс Метрика, чтобы анализировать посещаемость и улучшать сайт.
Подробнее — в политике конфиденциальности.