ЕГЭ · Информатика · Сложные задачи: теория игр и часть 2
Решение игр перебором на Python
Автоматизировать анализ игры функцией win/lose.
🎯 ЕГЭ информатика: этот урок закрывает задание(я) 19, 20, 21.
- ⚠Путают выигрышную и проигрышную позицию (кто может выиграть, а кто вынужден проиграть)
- ⚠В №20/21 не учитывают, первым или вторым ходит игрок и за сколько ходов
Определение
Рекурсивный анализ — опиши игру функциями: moves(s) — все позиции за один ход из s; далее рекурсивно оцени каждую позицию.
| win(s) | True, если существует ход в позицию, где соперник проигрывает |
|---|---|
| lose(s) | True, если ВСЕ ходы ведут в позиции, где соперник выигрывает |
| Задания 19–21 | перебор начальных S + проверка нужного свойства |
Внимательно кодируй условие окончания и кто победитель: «первым получивший кучу ≥ N» или «≤ N». Ошибка в пороге даёт неверный ответ при верной логике.
Разбор примера
Каркас анализа игры
Игра из этого модуля: за один ход из кучи убирают 3 или 6 камней либо уменьшают её втрое; выигрывает тот, кто первым получит кучу не более чем в 30 камней. Как проверить позицию программой?
Показать решение по шагам
- 1.
from functools import lru_cache T = 30 # игра кончается, когда камней не больше T def moves(s): return [s - 3, s - 6, s // 3] @lru_cache(None) def win(s): # ходящий выигрывает, если есть ход, заканчивающий игру # или отдающий сопернику проигрышную позицию return any(m <= T or not win(m) for m in moves(s)) print(win(93))
Ответ: win(93) = False: из 93 ходящий (Петя) проигрывает — на этом и строятся ответы заданий 19–21
Разбор примера
Универсальная программа для линий 19–21
Написать программу, которая по описанию игры отвечает на все три типа вопросов, и проверить её на заданиях банка.
Показать решение по шагам
- 1.
from functools import lrucache
KONEC = 30 # игра кончается при куче не больше 30
def hody(s): return [x for x in (s - 3, s - 6, s // 3) if x >= 1]def konech(s): return s <= KONEC@lru_cache(None) def win1(s):"""Ходящий выигрывает первым же ходом.""" return any(konech(h) for h in hody(s))
@lru_cache(None) def win2(s):"""Ходящий выигрывает вторым ходом при любой игре соперника."""
for h in hody(s): if konech(h): continue # это уже победа первым ходом if all((not konech(g)) and win1(g) for g in hody(h)): return True return False - 2. Шаг 1. Как читать
win1. Ходящий выигрывает сразу, если существует ход, после которого куча стала не больше тридцати. Это прямой перевод определения «победитель — сделавший последний ход». - 3. Шаг 2. Как читать
win2. Перебираем наш первый ход h. Он не должен сам заканчивать игру (иначе это победа первым ходом). Дальше ходит соперник: из h он может пойти в любое g, и при каждом g должно выполняться два условия — соперник не выиграл (not konech(g)) и мы выигрываем следующим ходом (win1(g)). Квантор здесь именно «при любом g», отсюдаall. - 4.
Шаг 3. Теперь три вопроса банка выражаются короткими условиями.
Линия 19: «Петя не может выиграть за один ход, но при любом ходе Пети Ваня выигрывает своим первым» — это
not win1(S)и одновременно все ходы ведут в позиции, гдеwin1истинно.Линия 20: «у Пети есть выигрышная стратегия: он не выигрывает первым ходом, но выигрывает вторым» — это
not win1(S) and win2(S).Линия 21: «у Вани есть выигрышная стратегия, он выигрывает первым или вторым ходом, но не всегда первым» — проверяем, что при каждом ходе Пети Ваня выигрывает, и что хотя бы в одной ветви ему нужен второй ход.
- 5.
Шаг 4. Код для линии 19:
for S in range(31, 400): if win1(S): continue if all((not konech(h)) and win1(h) for h in hody(S)): print(S) break - 6. Шаг 5. Программа печатает 93 — совпадает с ручным разбором дерева.
- 7.
Шаг 6. Код для линии 20 — одна строка условия:
otvet = [S for S in range(31, 400) if not win1(S) and win2(S)] print(otvet[:2]) - 8. Шаг 7. Печатается [96, 97]. Задание просит два наименьших значения, и ответ записывают подряд: 9697.
- 9. Шаг 8. Проверим 96 руками. Ходы из 96: 93, 90 и 32 — ни один не кончает игру, значит Петя первым ходом не выигрывает ✔ Петя выбирает ход в 93. Оттуда Ваня может пойти в 90, 87 или 31 — ни один ход не заканчивает игру, а из каждой этой позиции Петя выигрывает первым ходом (90 → 30, 87 → 29, 31 → 28) ✔ Значит, Петя выигрывает вторым ходом при любой игре Вани.
- 10. Шаг 9. Для линии 21 проверяем S = 102. Ходы Пети: 99, 96 и 34. Ни один не кончает игру. Из 34 Ваня выигрывает сразу (34 − 6 = 28). А из 99 и 96 Ваня первым ходом не выигрывает, зато ходит в 93 — и дальше, как в шаге 8, добивает вторым ходом. Значит, Ваня выигрывает всегда, но не всегда первым ходом — ровно то, что требует условие. Ответ 102 ✔
- 11. Шаг 10. Все три ответа программы совпали с эталонными ответами банка. Под свой вариант меняются три вещи: значение KONEC, список ходов в функции
hodyи условие отбора. Телоwin1иwin2остаётся прежним.
Ответ: 93; 96 и 97; 102
Разбор примера
Почему нельзя обойтись обычной разметкой В и П
Разобраться, почему для линий 19–21 недостаточно разделить позиции на выигрышные и проигрышные, как в классической теории игр.
Показать решение по шагам
- 1. Шаг 1. Классическая разметка отвечает на вопрос «кто выигрывает при правильной игре». Она двоичная: позиция либо выигрышная, либо проигрышная, и никаких оттенков.
- 2. Шаг 2. А вопросы линий 19–21 спрашивают за сколько ходов. «Выигрывает своим первым ходом», «выигрывает вторым ходом», «первым или вторым» — это уже не «кто», а «когда».
- 3.
Шаг 3. Разница видна на примере. Позиция S = 93 проигрышная для Пети — это верно, но недостаточно: линия 19 требует, чтобы Ваня выигрывал именно первым своим ходом, а не когда-нибудь. Позиция, где Ваня выигрывает третьим ходом, тоже проигрышная для Пети, но условию не удовлетворяет.
Именно поэтому в программе заведены две разные функции,
win1иwin2, а не однаwin. - 4.
Шаг 4. Как устроена классическая функция для сравнения:
@lru_cache(None) def vyigrysh(s):"""Выигрывает ли тот, чей ход."""
if konech(s): return False # игра кончена, ходивший до нас победил return any(konech(h) or not vyigrysh(h) for h in hody(s)) - 5. Шаг 5. Она отвечает «да» или «нет» и годится для вопросов вида «у кого есть выигрышная стратегия». Для S = 93 она вернёт
False— Петя проигрывает. Но какой это будет ход Вани, первый или пятый, она не скажет. - 6. Шаг 6. Практический вывод: читайте, что именно спрашивает вопрос. Если про «выигрышную стратегию» без указания числа ходов — хватит одной функции
vyigrysh. Если про первый или второй ход — нужныwin1иwin2, и они пишутся по определению, а не выводятся изvyigrysh. - 7. Шаг 7. И совет про отладку: печатайте таблицу.
for S in range(31, 110): print(S, int(win1(S)), int(win2(S)))показывает сразу всю картину, и видно, где начинаются и кончаются интересные диапазоны. По такой таблице проверяются все три ответа одновременно, и ошибку в условии отбора видно мгновенно.
Ответ: Нужны отдельные функции для «выигрывает первым ходом» и «выигрывает вторым ходом»
Как переписать программу под свой вариант
Сюжет «Петя и Ваня» в банке ФИПИ один, но числа и ходы меняются. Переписывать программу приходится каждый раз, и делать это надо в трёх местах.
Условие окончания игры. Строка KONEC = 30 и функция konech. Формулировки бывают разные: «игра завершается, когда камней становится не менее 40» — тогда сравнение переворачивается на s >= 40; «когда камней становится ровно 0» — тогда s == 0. Прочитайте эту фразу дважды: она задаёт границу всего перебора.
Набор ходов. Функция hody. «Убрать 1 или 2 камня» — это (s - 1, s - 2). «Удвоить или добавить один» — (s * 2, s + 1), и тогда куча растёт, а не убывает. «Уменьшить втрое с округлением вниз» — s // 3. Проверьте округление: в условии всегда сказано, в какую сторону, и // даёт именно вниз.
Направление игры. Если ходы увеличивают кучу, меняется не только hody, но и отсечения: вместо «слишком мало» проверяется «слишком много». И перебор S идёт в другую сторону.
Что не меняется никогда — тело функций win1 и win2. Они написаны через hody и konech, и потому переносятся из варианта в вариант дословно.
Три частые ошибки при переписывании.
Забыть про нижнюю границу хода. Фильтр if x >= 1 в hody нужен, чтобы куча не ушла в ноль и минус. Без него рекурсия может не остановиться.
Перепутать квантор. «Существует ход» — это any, «при любом ходе» — all. В win1 стоит any (нам достаточно одного удачного хода), в win2 внутри — all (соперник может пойти как угодно, и мы должны выигрывать всегда). Перепутать их — значит получить правдоподобный, но неверный ответ.
Забыть очистить кэш. Если вы меняете KONEC или hody и запускаете программу заново в той же сессии, lru_cache вернёт старые значения. Перезапускайте программу целиком или вызывайте win1.cache_clear().
И последнее: проверяйте программу на задании, ответ к которому известен. В любом сборнике есть разобранный вариант; если ваша программа воспроизводит его ответ, ей можно доверять и на своём.
Разбор примера
Таблица позиций: видим все три ответа сразу
Напечатать таблицу позиций для игры «убрать 3, убрать 6 или уменьшить втрое; игра кончается при куче не больше 30» и прочитать по ней ответы всех трёх заданий блока.
Показать решение по шагам
- 1.
print(' S сразу вторым') for S in range(90, 106): print(f'{S:3d} {int(win1(S)):5d} {int(win2(S)):6d}') - 2. Шаг 1. Что печатается. Первый столбец — размер кучи, второй — выигрывает ли ходящий первым же ходом, третий — выигрывает ли он вторым ходом при любой игре соперника.
- 3. Шаг 2. Строки 90, 91, 92: в столбце «сразу» стоят единицы. Из этих позиций ходящий заканчивает игру делением на три: ⌊90/3⌋ = 30, ⌊91/3⌋ = 30, ⌊92/3⌋ = 30.
- 4. Шаг 3. Строки 93, 94, 95: в обоих столбцах нули. Ходящий не выигрывает ни первым, ни вторым ходом — это проигрышные позиции. Отсюда ответ линии 19: минимальное такое S равно 93.
- 5. Шаг 4. Строки 96, 97, 98, 99: в столбце «сразу» ноль, в столбце «вторым» единица. Ходящий (то есть Петя) выигрывает вторым ходом. Два наименьших значения — 96 и 97, это ответ линии 20.
- 6. Шаг 5. Строки 100 и 101: в столбце «сразу» ноль, в «вторым» единица — значит, выигрывает снова Петя, а не Ваня. Для линии 21 они не подходят.
- 7. Шаг 6. Строка 102: оба нуля, то есть позиция проигрышная для Пети. Но, в отличие от 93–95, Ваня выигрывает здесь не всегда первым ходом — это видно, если построить дерево, и это ответ линии 21.
- 8. Шаг 7. Вот почему таблица удобнее трёх отдельных запусков: все три ответа читаются из одной картинки, и видно, как позиции группируются блоками. Блок 93–95 проигрышный, блок 96–99 выигрышный вторым ходом, 100–101 тоже, а со 102 начинается следующий проигрышный блок.
- 9. Шаг 8. Практический совет: печатайте таблицу шире, чем нужно, скажем от 85 до 115. Лишние строки ничего не стоят, зато видны границы блоков, и ошибка в условии отбора сразу бросается в глаза — например, если «выигрышные» позиции идут вперемешку без всякой структуры.
- 10. Шаг 9. И про диапазон перебора. Начинать с 31 обязательно (условие говорит S ≥ 31), а верхнюю границу берут с запасом — 400 или 1000. Позиции повторяются блоками, поэтому ответ находится в первой же сотне, но узнать это заранее нельзя.
Ответ: 93; 96 и 97; 102 — все три ответа видны в одной таблице
Дерево игры: как его рисовать и когда останавливаться
Программа даёт ответ, но в задании иногда просят описать выигрышную стратегию, а для этого нужно дерево. Рисовать его надо аккуратно и по правилам.
Корень — начальная позиция S. От него отходят ветви по числу возможных ходов: в нашей игре три.
Уровни чередуются. Первый уровень — ходы Пети, второй — ответы Вани, третий — второй ход Пети. Полезно подписывать, чей ход на каждом уровне: путаница здесь — главный источник неверных ответов.
Лист дерева — позиция, где игра закончилась, то есть камней стало не больше тридцати. Рядом с листом пишут, кто сделал последний ход: он и победил.
Когда останавливаться. Дерево строят до того уровня, о котором спрашивает вопрос. Для линии 19 хватает двух уровней: ход Пети и ответ Вани. Для линий 20 и 21 нужны три. Строить дальше незачем — и нельзя: дерево растёт втрое с каждым уровнем, и на четвёртом в нём уже под сотню узлов.
Что сокращает работу. Во-первых, ветви, ведущие в уже разобранные позиции, не рисуют заново — пишут ссылку. Во-вторых, если в ветви выигрыш уже доказан, остальные её продолжения не нужны: для существования хода достаточно одного. В-третьих, наоборот: если требуется «при любом ходе соперника», проверять надо все его ходы, и пропустить хотя бы один нельзя.
Типичная ошибка при рисовании — перепутать кванторы на уровнях. На своём уровне игрок выбирает лучший ход, значит достаточно одной хорошей ветви. На уровне соперника выбор не за нами, значит хорошими должны быть все ветви. Это чередование «существует — для любого» и есть суть дерева игры, и именно оно записано в программе как чередование any и all.
И последнее: дерево и программа проверяют друг друга. Если нарисованное дерево говорит одно, а перебор другое, ошибка почти всегда в дереве — в пропущенной ветви или в перепутанном уровне.
Задание №19 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №19 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №19 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №20 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.
Задание №21 в формате экзамена
Ответить и проверить себя — после бесплатной регистрации.