Украинское сообщество программистов

Играем в Ним при помощи ленивых вычислений и бесконечных списков

Дмитрий Астапов
Опубликовано 12.08.2007 в Статьи

Добрый вечер.

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

Зачем это нужно? Допустим, решение какой-то задачи требует обработки нескольких массивов/списков/наборов данных. Если мы описываем решение в терминах операций над конкретными элементами (”a[i]=b[j]+c[j-i+1,j]“), у нас всегда есть риск упустить какой-то частный случай или совершить простейшую (и от этого вдвойне досадную) ошибку. Все наверняка хоть раз да и встречались с классическими ошибками типа “на единичку больше/меньше” и “выход за границы массива”.

Если же мы сумеем сформулировать решение в терминах операций над “целыми” списками/массивами, мы не только уменьшаем описанный риск, но и, как правило, получаем более короткий и читаемый код. Впрочем, довольно вступлений, перейдем к сути.
Для иллюстрации будет использоваться язык Haskell, для полного понимания происходящего полезно экспериментировать с приводимым кодом в какой-нибудь интерактивной среде (GHC, Helium или Hugs).

Задача: реализовать оптимальную стратегию игры в Wythoff’s game. Описание игры “на пальцах” выглядит так: представим себе шахматную доску, бесконечную в направлении вправо-вниз. На какой-то ее клетке стоит ферзь. Играют двое. На своем ходу игрок может передвинуть ферзя (по обычным правилам) на любую клетку, но только в направлении влево или вверх (или влево-вверх). Выигрывает тот, кто поставит ферзя в левый верхний угол доски.

Другое описание этой же игры выглядит так: есть две кучки одинаковых предметов. На своем ходу игрок может взять произвольное кол-во предметов из любой кучки, или же из обоих кучек сразу. Выигрывает тот, кто возьмет последний предмет. Игра выглядит детской и простой, но на самом деле является важным инструментом в теории игр: к ней и ее ближайшему родственнику – классической игре в Ним – сводится целый класс т.н. комбинаторных игр и изучению ее свойств посвящено множество книг и статей.

Выигрышная стратегия заключается в том, чтобы ставить ферзя только на такие (оптимальные) позиции, из которых оппонент, во-первых, не сможет закончить игру, и, во-вторых, не сможет сделать ход в какую-то другую оптимальную позицию, обладающую такими же свойствами. Если пронумеровать строки и столбцы шахматной доски, начиная с нуля, то координаты оптимальных позиций будут таковы (за подробным объяснением см. сюда):

Номер позиции (n) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Строка 1 3 4 6 8 9 11 12 14 16 17 19 21 22 24
Столбец 2 5 7 10 13 15 18 20 23 26 28 31 34 36 39

Если мы каким-то образом вычислили список координат оптимальных позиций (назовем его “winning_positions”, предполагая, что он выглядит вот так: [(1,2),(3,5),(4,7),...]), то поиск оптимального хода реализуется “в лоб” (да простят меня за английские комментарии):


module Main where

import Data.List (delete)

-- | Find optimal move (da,db) for game position (a,b).
-- Here, `da' and `db' are numbers that should be
-- substracted from `a' and `b', respectively.
move a b = move' a b winning_positions

-- | Find position in the list of winning positions that
-- could be reached from (a,b) by either:
-- 1)substracting solely from `a' or `b'
-- 2)substracting simultaneously from `a' and `b'
move' a b ((x,y):rest)
  | a == b = (a,b)
  | a == x = (0,b-y)
  | a == y = (0,b-x)
  | b == y = (a-x,0)
  | b == x = (a-y,0)
  | a-x == b-y = (a-x,a-x)
  | a-y == b-x = (a-y,a-y)
  | otherwise = move' a b rest

main = loop
loop =
  do putStrLn "Enter two number that describe game position and press Enter"
     l <- getLine
     let [a,b] = map read $ words l
     putStr "My move: "
     putStrLn $ show $ move a b
     loop

Дело за малым – получить список оптимальных позиций. Можно увидеть (или сходить по приведенной мною ссылке и почитать :) ), что “строка” является суммой “порядкового номера” позиции и “столбца”, а столбец, в свою очередь, является минимальным числом из ряда [1..], которое еще не было использовано в качестве “строки” или “столбца”.

Так и запишем (я постарался снабдить листинги гиперссылками на примеры использования задействованных функций, чтобы не перегружать текст примерами и объяснениям, так что если вы не знаете, что делают ($), map, zipWith и т.п. – кликайте на ссылки и читайте):


column = <a href="http://www.zvon.org/other/haskell/Outputprelude/zipWith_f.html">zipWith</a> (+) [1..] row
row    = not_yet_used

winning_positions = <a href="http://www.zvon.org/other/haskell/Outputprelude/zip_f.html">zip</a> row column

Осталось определить “not_yet_used” – список, в котором на позиции N стоит минимальное число, не использованное в N-1 предыдущих оптимальных позициях. Очевидно, что можно взять список [1..] и, двигаясь последовательно по списку выигрышных позиций, вычеркивать из [1..] использованные строки и столбцы. После каждого вычеркивания “выписывать отдельно” минимальное неиспользованное число (оно будет первым в оставшемся списке) и двигаться дальше. Код практически дословно повторяет сказанное:


not_yet_used = <a href="http://www.zvon.org/other/haskell/Outputprelude/map_f.html">map</a> <a href="http://www.zvon.org/other/haskell/Outputprelude/head_f.html">head</a> <a href="http://www.zvon.org/other/haskell/Outputprelude/D_o.html">$</a> <a href="http://www.zvon.org/other/haskell/Outputprelude/scanl_f.html">scanl</a> remove [1..] winning_positions
  where remove lst (a,b) = <a href="http://haskell.org/ghc/docs/latest/html/libraries/base/Data-List.html#v%3Adelete">delete</a> b $ delete a lst

Вот и все, алгоритм реализован, и вы можете проверить, что, например, “move 12 6″ выдает правильный ход “(2,0)”, переводящий игрока в оптимальную позицию (10,6).

Теперь приглядимся повнимательнее к тому, как функции используют друг друга. Для вычисления “column” используется “[1..]” и “row”. Для вычисления “row” используется “not_yet_used”. Для нее, в свою очередь, нужны “winning_positions”. Для вычисления которой нужны … все те же “row” и “column”. Всё, круг замкнулся (см. рисунок).
Dependencies between functions in Nim implementation

Получилась эдакая змея, кусающая себя за хвост. Простите, спросите вы, но как все это может работать?

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

А запускается вся эта карусель тогда, когда “scanl” из функции “not_yet_used” выдает на-гора начало своего результата – список [1..]. Первый элемент этого списка тут же попадает в значение “row”, после чего можно вычислить первый элемент результата “column”, после чего можно вычислить первую пару из “winning_positions”, которую можно использовать для вычисления следующего значения “not_yet_used” и т.д. и т.п (см. схему после этого абзаца). При этом вам вовсе не обязательно самому рисовать подобные картинки и прослеживать зависимости – за вас это сделает компилятор :)

Nim evaluation scheme

Получившийся в результате список оптимальных позиций является бесконечным во всех практических смыслах этого слова. Например, если попытаться его напечатать (”print winning_positions”), то среда исполнения будет выводить оптимальные позиции до тех пор, пока вы ее не прервете.

Причем, для операций с бесконечными списками вовсе не требуется бесконечно много памяти :) Если скомпилировать программу, печатающую список всех оптимальных позиций и запустить ее, то можно заметить, что она занимает в памяти около 10 мб, и это значение со временем увеличивается очень плавно. А все дело в том, что значения “winnig_positions”, выведенные на экран, уже не требуются для дальнейших вычислений и тут же собираются сборщиком мусора, равно как и значения списоков “row” и “column”. Таким образом, в любой момент времени в памяти хранится по одному значению из списков “row”, “column” и “winning_position” + в памяти находится диапазон натуральных чисел от следующего минимально-неиспользованного до максимально-использованного (его использует в своих вычислениях функция “not_yet_used”). “Удлинение” этого диапазона и вызывает увеличение объема потребляемой памяти.

А неиспользованные элементы (или непрерывные диапазоны элементов) бесконечных списков представляются в Haskell в виде так называемых thunk-ов – “ссылок” на код, который их вычислит. Когда элемент “потребляется” для какого-то вычисления, thunk заменяется на вычисленное значение. Таким образом (упрощая), для хранения списка всех натуральных чисел [1..] достаточно хранить число “1″ + thunk с описанием, как вычислить последующие числа.

Надеюсь, что это краткий экскурс в ленивые вычисления с использованием бесконечных списков оказался для вас не только забавным, но и полезным. Удачи, до новых встреч!

PS
Disclaimer: данный код не является самым умным, самым быстрым или самым оптимальным способом решения поставленной задачи.

Теги: , , ,

1 звезда2 звезды3 звезды4 звезды5 звезд (5 голосов, средний: 5 из 5)
Загрузка ... Загрузка ...
Распределение голосов

Понравилась статья? Подпишись на обновления по RSS/E-mail

Подписаться, не оставляя комментарий

Все комментарии (2) к “Играем в Ним при помощи ленивых вычислений и бесконечных списков” RSS

  1. dump -0f - /dev/mind - Статья о ленивых вычислениях и бесконечных списках в стиле "tying the knot".

    [...] (в стиле “Tying The Knot”, если кто в курсе). Буду рад, если вы прочтете и [...]

  2. Функциональное программирование » Blog Archive » Раскопано в ru_lambda

    [...] Играем в Ним при помощи ленивых вычислений и бесконечн… [...]

Оставить комментарий

Указать свой сайт могут только зарегистрированные пользователи. Регистрация или вход.

Архив

Добавить статью

Станьте автором нашего сайта!

Какие материалы подходят для публикации? — Такие.

Присылайте статьи на editors@developers.org.ua.

Подробнее.

Популярные теги

Все теги

Комментарии

Последние комментарии

интернет-магазин цифровой техники

Бытовая техника
Холодильники
Купить часы
Телевизоры ЖК
Стиральные машины
Швейцарские часы