Рекурсивное программное решение заданий 19-21 на теорию игр в ЕГЭ

Автор статьи: Андрей Рогов

Введение

Эта статья — четвёртая в серии статей по теории игр. До этого рассмотрели аналитическое решение, решение в электронных таблицах и динамическое программное решение. Рекурсивный подход позволяет сократить необходимый объём кода, но при этом он более требователен к ресурсам компьютера.

Основные компоненты рекурсии:

  • базовый случай (условие остановки)
    «Точка выхода» из рекурсии — простейшая ситуация, когда функция не вызывает себя, а сразу возвращает результат.
    Без этого рекурсия будет бесконечной.
  • рекурсивный случай\
    Здесь функция вызывает себя с изменёнными параметрами, приближая задачу к базовому случаю

Решая задачу аналитически, мы определяем для каждой позиции, какая она:

  • конечная позиция — игра заканчивается, потому что достигнуто окончательное число камней
  • В1 — выигрышная позиция за один ход: существует хотя бы один ход в конечную позицию
  • П1 — проигрышная позиция за один ход: все ходы ведут в В1
  • В2 — выигрышная позиция за два хода: существует хотя бы один ход в П1
  • П2 — проигрышная позиция за один (не гарантированно) или два хода: все ходы ведут в В1 или В2

Базовыми случаями здесь будут конечные позиции, все остальные — рекурсивными.

Задача 1. Одна куча камней, два разных хода

Ссылка на задачу

Воспользуемся теми же обозначениями, что и в статье о динамическом методе решения:

img

Конечные позиции все начиная от 301 и больше.

def g(s):
    # Базовый случай
    if s >= 301:
        return 5

Для описания рекурсивных случаев добавим функцию, вычисляющую возможные ходы из текущей позиции.
Если существует ход из позиции S, при котором функция будет равна 5, позиция окажется выигрышной за 1 ход.

Воспользуемся сразу сокращением кода — функцией any().

def m(s):
    return s + 3, s * 5

def g(s):
    # Базовый случай
    if s >= 301:
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i) == 5 for i in move(s)):
        return 1

Аналогично динамическому решению каждая последующая позиция вычисляется в соответствии с её определением.

def m(s):
    return s + 3, s * 5

def g(s):
    # Базовый случай
    if s >= 301:
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i) == 5 for i in m(s)):
        return 1  # победа первым ходом
    # Если все ходы ведут в В1
    if all(g(i) == 1 for i in m(s)):
        return -1  # поражение первым ходом
    # Если есть хотя бы один ход в проигрышную позицию
    if any(g(i) == -1 for i in m(s)):
        return 2  # победа вторым ходом
    # Если все ходы ведут в В1 или В2
    if all(g(i) > 0 for i in m(s)):
        return -2  # поражение первым ходом
    return 0  # неопределённый случай

Осталось перебрать все возможные начальные значения S и вывести подходящие на экран.

for s in range(1, 301):
    if g(s) == -1:
        print(s)

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

Мемоизация — техника оптимизации, при которой вычисленные результаты сохраняются для повторного использования. Её можно выполнить вручную, сохраняя все значения в словарь.

def m(s):
    return s + 3, s * 5

def g(s):
    # Если вариант уже был вычислен
    if s in d:
        return d[s]
    # Базовый случай
    if s >= 301:
        d[s] = 5
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i) == 5 for i in m(s)):
        d[s] = 1
        return 1  # победа первым ходом
    # Если все ходы ведут в В1
    if all(g(i) == 1 for i in m(s)):
        d[s] = -1
        return -1  # поражение первым ходом
    # Если есть хотя бы один ход в проигрышную позицию
    if any(g(i) == -1 for i in m(s)):
        d[s] = 2
        return 2  # победа вторым ходом
    # Если все ходы ведут в В1 или В2
    if all(g(i) > 0 for i in m(s)):
        d[s] = -2
        return -2  # поражение первым ходом
    d[s] = 0
    return 0  # неопределённый случай


d = dict()
for s in range(1, 301):
    if g(s) == -1:
        print(s)

Однако в язык Python встроен декоратор lru_cache, позволяющий автоматически запоминать значения. Название LRU означает least recently used — «наименее недавно использованный».

Декоратор использует словарь: результат выполнения функции кешируется под ключом, соответствующим вызову функции и предоставленным аргументам. Если кеш заполнен, удаляется элемент, который использовался наиболее давно. У декоратора lru_cache есть два параметра:

  • maxsize (по умолчанию 128) определяет максимальный размер кеша, то есть результаты скольких вызовов функций будут сохранены. Если установить maxsize=None, то все будут сохраняться до тех пор, пока не закончится память
  • typed (по умолчанию False) — если установлено значение True, то результаты вызова функции с аргументами разных типов (например, f(3) и f(3.0)) будут сохраняться как отдельные записи. Если установлено значение False, то результаты вызова функции с аргументами разных типов будут сохранены в одну запись
from functools import lru_cache


def m(s):
    return s + 3, s * 5

@lru_cache(None)
def g(s):
    # Базовый случай
    if s >= 301:
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i) == 5 for i in m(s)):
        return 1  # победа первым ходом
    # Если все ходы ведут в В1
    if all(g(i) == 1 for i in m(s)):
        return -1  # поражение первым ходом
    # Если есть хотя бы один ход в проигрышную позицию
    if any(g(i) == -1 for i in m(s)):
        return 2  # победа вторым ходом
    # Если все ходы ведут в В1 или В2
    if all(g(i) > 0 for i in m(s)):
        return -2  # поражение первым ходом
    return 0  # неопределённый случай


for s in range(1, 301):
    if g(s) == -1:
        print(s)

Вывод программы:

img

Таким образом, рекурсивное решение сохраняет все преимущества рассмотренных решений:

  • мнемонически совпадает с аналитическим решением
  • не требует ручных вычислений
  • позволяет писать код копированием фрагментов
  • адаптируется ко всем стандартным для ЕГЭ формулировкам

К этим преимуществам добавляется компактность кода.

Задача 2. Убирание камней

Ссылка на задачу

img

Сложность представляет только определение верхней границы перебора начального количества камней. При определении позиций дважды используется «сильный» ход (для В1 и В2) и дважды — «слабый». Возьмём верхнюю границу S с запасом: 30 × 42 × 2 = 960. Округлим до 1000.

Приведём полный код.

from functools import lru_cache


def m(s):
    return s - 3, s - 5, s // 4

@lru_cache(None)
def g(s):
    # Базовый случай
    if s <= 30:
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i) == 5 for i in m(s)):
        return 1  # победа первым ходом
    # Если все ходы ведут в В1
    if all(g(i) == 1 for i in m(s)):
        return -1  # поражение первым ходом
    # Если есть хотя бы один ход в проигрышную позицию
    if any(g(i) == -1 for i in m(s)):
        return 2  # победа вторым ходом
    # Если все ходы ведут в В1 или В2
    if all(g(i) > 0 for i in m(s)):
        return -2  # поражение первым ходом
    return 0  # неопределённый случай


for s in range(31, 1000):
    if g(s) == -1:
        print(s)

Вывод программы:

img

Ответом на задание № 19 будет 124, на задание № 20 — 127 и 128, на задание № 21 — 132.

Как видим, в коде изменились функция ходов m(s), базовый случай и границы цикла при переборе начального количества камней.

Задача 3. Две кучи камней

Ссылка на задачу

img

Подход к решению прежний, но, поскольку в задании присутствует две кучи камней, код необходимо адаптировать. Добавим в функции второй аргумент.

Для поиска ответа на задание № 19, в котором речь идёт о неудачном ходе, заменим функцию all() для поиска позиций проигрышных за один ход на any(), поскольку нам подойдут позиции, из которых хотя бы один ход проигрышный.

from functools import lru_cache


def m(s, t):
    return (s + 1, t), (s * 3, t), (s, t + 1), (s, t * 3)

@lru_cache(None)
def g(s, t):
    # Базовый случай
    if s + t >= 65:
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i, j) == 5 for i, j in m(s, t)):
        return 1  # победа первым ходом
    # Если все ходы ведут в В1
    if any(g(i, j) == 1 for i, j in m(s, t)):
        return -1  # поражение первым ходом
    # Если есть хотя бы один ход в проигрышную позицию
    if any(g(i, j) == -1 for i, j in m(s, t)):
        return 2  # победа вторым ходом
    # Если все ходы ведут в В1 или В2
    if all(g(i, j) > 0 for i, j in m(s, t)):
        return -2  # поражение первым ходом
    return 0  # неопределённый случай


for s in range(1, 59):
    if g(6, s) == -1:
        print(s)

Вывод программы:

img

Минимальное значение S = 7.

Для ответа на другие вопросы требуется вернуть all(), поскольку иначе нарушается стратегия игроков.

from functools import lru_cache


def m(s, t):
    return (s + 1, t), (s * 3, t), (s, t + 1), (s, t * 3)

@lru_cache(None)
def g(s, t):
    # Базовый случай
    if s + t >= 65:
        return 5
    # Рекурсивные случаи
    # Если есть хотя бы один ход в конечную позицию
    if any(g(i, j) == 5 for i, j in m(s, t)):
        return 1  # победа первым ходом
    # Если все ходы ведут в В1
    if all(g(i, j) == 1 for i, j in m(s, t)):
        return -1  # поражение первым ходом
    # Если есть хотя бы один ход в проигрышную позицию
    if any(g(i, j) == -1 for i, j in m(s, t)):
        return 2  # победа вторым ходом
    # Если все ходы ведут в В1 или В2
    if all(g(i, j) > 0 for i, j in m(s, t)):
        return -2  # поражение первым ходом
    return 0  # неопределённый случай


for s in range(1, 59):
    if g(6, s) == 2:
        print(s)

Вывод программы:

img

Ответы на задания: 19 — 7, 20 — 10 и 19, 21 — 18.

Заключение

Рекурсивный подход к решению задач на теорию игр на ЕГЭ по информатике демонстрирует баланс между лаконичностью кода и глубиной алгоритмического мышления. На примере рассмотренных заданий видно, что рекурсия позволяет:

  • точно моделировать логику игры — структура кода напрямую отражает правила и стратегии (выигрышные и проигрышные позиции)
  • сокращать объём кода по сравнению с итеративными методами — ключевые случаи (В1, П1, В2, П2) описываются компактными блоками с понятной семантикой
  • сохранять связь с аналитическим решением — код читается как формализованная запись рассуждений, что упрощает проверку и отладку
  • адаптироваться к разным условиям задач — изменение правил (ходы, критерии победы, количество куч) требует лишь корректировки функций m() и базового случая, не затрагивая общую логику

Ключевые технические приёмы, повышающие эффективность рекурсивного решения:

  • мемоизация (через @lru_cache) устраняет избыточные вычисления, делая алгоритм практически применимым даже к большим диапазонам начальных позиций

  • использование any() и all() позволяет кратко выразить логические условия (например, «существует хотя бы один выигрышный ход»)

  • чёткое разделение базового и рекурсивного случаев гарантирует корректность завершения рекурсии и предотвращает переполнение стека
    Ограничения метода:

  • без мемоизации рекурсия может быть крайне медленной из‑за многократного пересчёта одних и тех же позиций

  • в задачах с очень большими числами (тысячи и более) даже оптимизированная рекурсия может уступать динамическому программированию в скорости

Вывод

Рекурсивный метод — это мощное и элегантное средство для решения задач по теории игр в ЕГЭ по информатике. Он особенно ценен:

  • на этапе обучения (помогает понять структуру задачи)
  • при необходимости быстро реализовать решение с минимальной вероятностью ошибок
  • в случаях, где требуется гибкость при изменении условий

Для максимальной эффективности важно:

  1. Всегда определять базовый случай (условие остановки)
  2. Использовать мемоизацию для оптимизации
  3. Тщательно проверять граничные условия (начальные и конечные позиции)

Таким образом, рекурсия не только даёт правильный ответ, но и служит инструментом для развития алгоритмического мышления, необходимого как на экзамене, так и в дальнейшем изучении программирования.

Рекомендуем решить подборки задач для практики:

Источник: Яндекс Учебник — Рекурсивное программное решение заданий 19-21 на теорию игр в ЕГЭ. Каталог разборов: education.yandex.ru.

Назад к статьям Поделиться