«IT-Планета 2024»: задачи третьего этапа по PostgreSQL

от автора

Или, точнее, задача, поскольку в этом году мы попробовали другой формат: задача была всего одна, но большая. Требовалось написать SQL-запрос, играющий в крестики-нолики «пять в ряд».

Условие задачи

Общие правила игры

Игра ведется на поле размером 19×19 клеток, пронумерованных от 1 до 19 по горизонтали и вертикали. Два игрока по очереди ставят на любую из свободных клеток свой знак: первый игрок ставит крестики, второй — нолики. Побеждает игрок, первым построивший пять знаков в ряд по вертикали, горизонтали или диагонали.

Если игрок не может сделать ход из-за отсутствия на поле пустых клеток, он проигрывает.

Каждому игроку отведено на партию 5 минут (время отдельных ходов не ограничивается). Игрок, не успевший совершить свой ход, проигрывает.

Требования к решению

Решение должно состоять из одного SQL-запроса. Запрос имеет доступ к единственной таблице:

CREATE TABLE field (   x integer NOT NULL CHECK (x BETWEEN 1 AND 19),   y integer NOT NULL CHECK (y BETWEEN 1 AND 19),   my boolean NOT NULL,   UNIQUE (x, y) );

Запрос должен вернуть одну строку с двумя координатами x и y очередного хода. Названия столбцов не играют роли. Если координаты нарушают ограничения целостности таблицы, игрок проигрывает.

Веб-приложение

Организаторы предоставляют веб-приложение, в котором необходимо регистрировать свои запросы. Приложение также может упростить отладку, позволяя пробовать и визуализировать произвольное количество вариантов. Один из вариантов, отмеченный вами как итоговый, будет участвовать в определении победителей: после окончания финала между итоговыми запросами всех участников будет проведен турнир, в котором каждый запрос сыграет с каждым несколько партий.

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

Разбор решения

Я расскажу про алгоритм, который мы назвали XO-ботом и с которым можно было играть в приложении.

Но давайте забудем пока про SQL и подумаем, как вообще решают такие задачи.

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

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

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

Но тут, конечно, надо призадуматься. Реалистично ли за восемь часов реализовать такой алгоритм на SQL, да еще и так, чтобы вписаться в ограничение на время партии? Вот и участники решили, что не надо — никто не стал пытаться.

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

Давайте для удобства иллюстрации сразу предположим, что какие-то крестики и нолики уже стоят:

INSERT INTO field(x, y, my) VALUES   (10,10,false), (10,9,false), (9,9,true);

Вот эта позиция на поле (я обозначил наш знак буквой X, знак противника — буквой O, а точкой . я просто выделил десятую вертикаль и горизонталь):

J |         . I |         . H |         . G |         . F |         . E |         . D |         . C |         . B |         . A |.........O.......... 9 |        XO 8 |         .  7 |         . 6 |         .       5 |         . 4 |         . 3 |         . 2 |         . 1 |         .    -------------------    123456789ABCDEFGHIJ

Начнем с того, что учтем два соображения:

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

  2. Исключаем из рассмотрения уже занятые клетки.

Получается примерно такой запрос:

WITH neighbors(x,y) AS (   -- ближайшие к занятым пустые клетки или центр пустого поля   SELECT x, y    FROM (     SELECT x+dx x, y+dy y      FROM field f, generate_series(-1,1) dx, generate_series(-1,1) dy       UNION -- убираем дубликаты     SELECT 10, 10   )   WHERE (x,y) NOT IN (SELECT x,y FROM field)   AND x BETWEEN 1 AND 19   AND y BETWEEN 1 AND 19 ) SELECT * FROM neighbors;

Подзапрос neighbors сдвигает все заполненные клетки по всем направлениям и добавляет центральную клетку (10,10) на тот случай, если еще не сделано ни одного хода. Затем из получившихся вариантов клеток удаляются уже занятые и выходящие за пределы поля.

Вот полученные поля на картинке:

J | I | H | G | F | E | D | C | B |        ... A |       ..O. 9 |       .XO. 8 |       .... 7 | 6 | 5 | 4 | 3 | 2 | 1 |    -------------------    123456789ABCDEFGHIJ

(Не факт, что все лучшие ходы расположены именно вплотную к уже сделанным, так что можно попробовать сдвиг и на две клетки: generate_series(-2,2)).

Что ж, начало положено. Теперь нам надо научиться оценивать ходы.

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

  1. Если к четырем нашим крестикам можно пристроить пятый, надо ставить и выигрывать. Тут могут быть разные конфигурации: XXXX*, XXX*X или XX*XX.

  2. Если на поле стоят четыре нолика, надо помешать сопернику поставить пятый: OOOO* и т. п.

  3. Если на поле стоят три крестика, и к ним можно поставить четвертый так, чтобы они оставались открытыми с двух сторон, это надо сделать — на следующем ходу выиграем. Тут тоже возможны разные конфигурации типа .XXX*. или .XX*X..

  4. Если на поле стоят три нолика, надо помешать им дорасти до четырех.

  5. И так далее по убыванию количества знаков.

Если просуммировать веса всех найденных конфигураций, то «вилки» будут естественным образом получать больше баллов. Но надо следить за тем, чтобы несколько шаблонов типа .XXX*. не перевесили OOOO*.

Эти рассуждения наводят на мысль, что с поиском шаблонов должен неплохо справляться обычный LIKE, если представить горизонтали, вертикали и диагонали в виде текстовых строк. Многие участники пытались анализировать длины непрерывных цепочек знаков в традиционно-реляционном стиле, но это очень громоздко и не позволяет легко оперировать шаблонами типа XX.*X.

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

WITH neighbors(x,y) AS (   ... ), filled(x,y,pos) AS (   -- поле, дополненное пустыми клетками   -- и представленное символами вместо boolean   SELECT xx, yy, CASE WHEN my THEN 'X' WHEN NOT my THEN 'O' ELSE '.' END   FROM generate_series(1,19) xx     CROSS JOIN generate_series(1,19) yy     LEFT JOIN field f ON f.x = xx AND f.y = yy ) ...

Следующий шаг — сформировать все возможные ряды: горизонтали, вертикали и диагонали. Потом нам придется находить в этих длинных рядах клетку с заданными координатами, поэтому вместе с рядом я сохраняю координаты его «головы» (x,y) и направление (dx,dy) к «хвосту». (Вместо этого можно было бы сразу нарезать короткие ряды отдельно для каждой клетки, но подозреваю, что так выйдет затратнее.)

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

WITH neighbors(x,y) AS (   ... ), filled(x,y,pos) AS (   ... ), lines(x,y,dx,dy,line) AS (   -- линии клеток:   -- горизонтали   SELECT 1, y, 1, 0, string_agg(pos,'' ORDER BY x) FROM filled GROUP BY y   UNION ALL    -- вертикали   SELECT x, 1, 0, 1, string_agg(pos,'' ORDER BY y) FROM filled GROUP BY x   UNION ALL    -- диагонали   SELECT CASE WHEN x+y > 20 THEN x+y-19 ELSE 1 END,          CASE WHEN x+y > 20 THEN 19 ELSE x+y-1 END,          1, -1, string_agg(pos,'' ORDER BY x) FROM filled GROUP BY x+y HAVING x+y BETWEEN 6 AND 34   UNION ALL    SELECT CASE WHEN x-y >= 0 THEN 1+(x-y) ELSE 1 END,          CASE WHEN x-y >= 0 THEN 1 ELSE 1-(x-y) END,          1, 1, string_agg(pos,'' ORDER BY x) FROM filled GROUP BY x-y HAVING x-y BETWEEN -14 AND 14 ) SELECT * FROM lines WHERE line !~ '^\.*$'; -- показываю только непустые ряды
 x  | y  | dx | dy |        line          ----+----+----+----+---------------------   1 |  9 |  1 |  0 | ........XO.........  горизонтали   1 | 10 |  1 |  0 | .........O.........   9 |  1 |  0 |  1 | ........X..........  вертикали  10 |  1 |  0 |  1 | ........OO.........   1 | 17 |  1 | -1 | ........X........    диагонали в одну стторону   1 | 18 |  1 | -1 | .........O........   1 | 19 |  1 | -1 | .........O.........   1 |  1 |  1 |  1 | ........XO.........  диагонали в другую сторону   2 |  1 |  1 |  1 | ........O......... (9 rows)

Вот эти ряды на картинке, чтобы было понятнее. Символом @ я обозначил «головы» рядов c координатами (x, y).

J |        ||            J |@                 / I |        ||            I |@\               // H |        ||            H |@\\             // G |        ||            G | \\\           // F |        ||            F |  \\\         // E |        ||            E |   \\\       // D |        ||            D |    \\\     // C |        ||            C |     \\\   // B |        ||            B |      \\\ // A |@-------+O---------   A |       \\O/ 9 |@-------XO---------   9 |        XO\ 8 |        ||            8 |       //\\\ 7 |        ||            7 |      //  \\\ 6 |        ||            6 |     //    \\\ 5 |        ||            5 |    //      \\\ 4 |        ||            4 |   //        \\\ 3 |        ||            3 |  //          \\\ 2 |        ||            2 | //            \\\ 1 |        @@            1 |@@              \\\    -------------------      -------------------    123456789ABCDEFGHIJ      123456789ABCDEFGHIJ

Теперь собираем для каждого потенциального хода из neighbours набор проходящих через него рядов. Потенциальный ход приходится на пустую клетку (.), сразу заменяем ее маркером рассматриваемого хода (я выбрал *). Для этого подбираем нужное количество клеток от «головы» ряда (delta):

WITH neighbors(x,y) AS (   ... ), filled(x,y,pos) AS (   ... ), lines(x,y,dx,dy,line) AS (   ... ), lines_per_move(x,y,delta,dx,dy,line) AS (   -- линии в группировке по потенциальным ходам   SELECT n.x, n.y, delta, l.dx, l.dy, overlay(l.line PLACING '*' FROM delta+1)   FROM neighbors n     CROSS JOIN generate_series(0,18) delta     JOIN lines l ON n.x = l.x + l.dx*delta AND n.y = l.y + l.dy*delta ) SELECT * FROM lines_per_move WHERE x = 10 AND y = 8; -- показываю только для одной клетки
 x  | y | delta | dx | dy |        line          ----+---+-------+----+----+---------------------  10 | 8 |     9 |  1 |  0 | .........*.........  10 | 8 |     7 |  0 |  1 | .......*OO.........  10 | 8 |     9 |  1 | -1 | ........X*.......  10 | 8 |     7 |  1 |  1 | .......*......... (4 rows)

Вот они на картинке:

J |         . I |         . H |.        .        . G | .       .       . F |  .      .      . E |   .     .     . D |    .    .    . C |     .   .   . B |      .  .  . A |       . O . 9 |        XO. 8 |.........*......... 7 |        ... 6 |       . . . 5 |      .  .  . 4 |     .   .   . 3 |    .    .    . 2 |   .     .     . 1 |  .      .      .    -------------------    123456789ABCDEFGHIJ

Теперь займемся весами. Я на скорую руку набросал такие:

WITH templates(weight,template) AS (   -- шаблоны с весами   VALUES     (100000,  '*XXXX'), (100000,  'X*XXX'), (100000,  'XX*XX'),     ( 31000,  '*OOOO'), ( 31000,  'O*OOO'), ( 31000,  'OO*OO'),     ( 10000, '.*XXX.'), ( 10000, '.X*XX.'),     (  3100, '.*OOO.'), (  3100, '.O*OO.'),     (  1000, '.*XXXO'), (  1000, '.X*XXO'), (  1000, '.XX*XO'), (  1000, '.XXX*O'),     (   310, '.*OOOX'), (   310, '.O*OOX'), (   310, '.OO*OX'), (   310, '.OOO*X'),     (   800, '.*XX.'),  (   800, '.X*X.'),     (   250, '.*OO.'),  (   250, '.O*O.'),     (   500, '.*XXO'),  (   500, '.X*XO'),  (   500, '.XX*O'),     (   150, '.*OOX'),  (   150, '.O*OX'),  (   150, '.OO*X'),     (   100, '.*X.'),     (    31, '.*O.'),     (    10, '.*OX'),   (    10, '.O*X'),     (     5, '.*.'),     (     2, '.*O') ), templates_rev(weight,template) AS (   SELECT weight, template FROM templates   UNION   SELECT weight, reverse(template) FROM templates ) ...

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

Настройка весов и шаблонов — самая важная часть алгоритма. Я этим совсем не занимался, а вот победительница конкурса, Александра Сухотина, отнеслась к задаче серьезно. В результате ее алгоритм, в остальном практически повторяющий тот, что я показываю, не дал ХО-боту ни одного шанса.

Все, что нам осталось, — это просуммировать веса для каждого хода и выбрать наилучший:

WITH neighbors(x,y) AS (   ... ), filled(x,y,pos) AS (   ... ), lines(x,y,dx,dy,line) AS (   ... ), lines_per_move(x,y,delta,dx,dy,line) AS (   ... ), templates(weight,template) AS (   ... ), templates_rev(weight,template) AS (   ... ), costs(x,y,weight) as (   SELECT l.x, l.y, sum(t.weight)   FROM lines_per_move l     JOIN templates_rev t ON l.line LIKE '%'||t.template||'%'   GROUP BY x, y ) SELECT x, y FROM costs ORDER BY weight DESC, random() LIMIT 1;
Весь запрос целиком
WITH neighbors(x,y) AS (   -- ближайшие к занятым пустые клетки или центр пустого поля   SELECT x, y    FROM (     SELECT x+dx x, y+dy y      FROM field f, generate_series(-1,1) dx, generate_series(-1,1) dy       UNION -- убираем дубликаты     SELECT 10, 10   )   WHERE (x,y) NOT IN (SELECT x,y FROM field)   AND x BETWEEN 1 AND 19   AND y BETWEEN 1 AND 19 ), filled(x,y,pos) AS (   -- поле, дополненное пустыми клетками   -- и представленное символами вместо boolean   SELECT xx, yy, CASE WHEN my THEN 'X' WHEN NOT my THEN 'O' ELSE '.' END   FROM generate_series(1,19) xx     CROSS JOIN generate_series(1,19) yy     LEFT JOIN field f ON f.x = xx AND f.y = yy ), lines(x,y,dx,dy,line) AS (   -- линии клеток:   -- горизонтали   SELECT 1, y, 1, 0, string_agg(pos,'' ORDER BY x) FROM filled GROUP BY y   UNION ALL    -- вертикали   SELECT x, 1, 0, 1, string_agg(pos,'' ORDER BY y) FROM filled GROUP BY x   UNION ALL    -- диагонали   SELECT CASE WHEN x+y > 20 THEN x+y-19 ELSE 1 END,          CASE WHEN x+y > 20 THEN 19 ELSE x+y-1 END,          1, -1, string_agg(pos,'' ORDER BY x) FROM filled GROUP BY x+y HAVING x+y BETWEEN 6 AND 34   UNION ALL    SELECT CASE WHEN x-y >= 0 THEN 1+(x-y) ELSE 1 END,          CASE WHEN x-y >= 0 THEN 1 ELSE 1-(x-y) END,          1, 1, string_agg(pos,'' ORDER BY x) FROM filled GROUP BY x-y HAVING x-y BETWEEN -14 AND 14 ), lines_per_move(x,y,delta,dx,dy,line) AS (   -- линии в группировке по потенциальным ходам   SELECT n.x, n.y, delta, l.dx, l.dy, overlay(l.line PLACING '*' FROM delta+1)   FROM neighbors n     CROSS JOIN generate_series(0,18) delta     JOIN lines l ON n.x = l.x + l.dx*delta AND n.y = l.y + l.dy*delta ), templates(weight,template) AS (   -- шаблоны с весами   VALUES     (100000,  '*XXXX'), (100000,  'X*XXX'), (100000,  'XX*XX'),     ( 31000,  '*OOOO'), ( 31000,  'O*OOO'), ( 31000,  'OO*OO'),     ( 10000, '.*XXX.'), ( 10000, '.X*XX.'),     (  3100, '.*OOO.'), (  3100, '.O*OO.'),     (  1000, '.*XXXO'), (  1000, '.X*XXO'), (  1000, '.XX*XO'), (  1000, '.XXX*O'),     (   310, '.*OOOX'), (   310, '.O*OOX'), (   310, '.OO*OX'), (   310, '.OOO*X'),     (   800, '.*XX.'),  (   800, '.X*X.'),     (   250, '.*OO.'),  (   250, '.O*O.'),     (   500, '.*XXO'),  (   500, '.X*XO'),  (   500, '.XX*O'),     (   150, '.*OOX'),  (   150, '.O*OX'),  (   150, '.OO*X'),     (   100, '.*X.'),     (    31, '.*O.'),     (    10, '.*OX'),   (    10, '.O*X'),     (     5, '.*.'),     (     2, '.*O') ), templates_rev(weight,template) AS (   SELECT weight, template FROM templates   UNION   SELECT weight, reverse(template) FROM templates ), costs(x,y,weight) as (   SELECT l.x, l.y, sum(t.weight)   FROM lines_per_move l     JOIN templates_rev t ON l.line LIKE '%'||t.template||'%'   GROUP BY x, y ) SELECT x, y FROM costs ORDER BY weight DESC, random() LIMIT 1;

Как продвинуться дальше

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

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

А вам слабо? Присылайте ваши варианты (в комментарии или на edu@postgrespro.ru), устроим еще один турнир.


ссылка на оригинал статьи https://habr.com/ru/articles/820445/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *