Или, точнее, задача, поскольку в этом году мы попробовали другой формат: задача была всего одна, но большая. Требовалось написать 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
Начнем с того, что учтем два соображения:
Первый ход лучше делать в центр поля, а лучшие ответные ходы будут располагаться где-то неподалеку от уже поставленных знаков. На анализ других позиций можно не тратить время.
Исключаем из рассмотрения уже занятые клетки.
Получается примерно такой запрос:
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)
).
Что ж, начало положено. Теперь нам надо научиться оценивать ходы.
Немного подумав (или погуглив), можно прийти к тому, что надо анализировать определенные шаблоны стоящих подряд (по горизонталям, вертикалям или диагоналям) знаков. Они образуют иерархию, которую можно преобразовать в веса:
Если к четырем нашим крестикам можно пристроить пятый, надо ставить и выигрывать. Тут могут быть разные конфигурации:
XXXX*
,XXX*X
илиXX*XX
.Если на поле стоят четыре нолика, надо помешать сопернику поставить пятый:
OOOO*
и т. п.Если на поле стоят три крестика, и к ним можно поставить четвертый так, чтобы они оставались открытыми с двух сторон, это надо сделать — на следующем ходу выиграем. Тут тоже возможны разные конфигурации типа
.XXX*.
или.XX*X.
.Если на поле стоят три нолика, надо помешать им дорасти до четырех.
И так далее по убыванию количества знаков.
Если просуммировать веса всех найденных конфигураций, то «вилки» будут естественным образом получать больше баллов. Но надо следить за тем, чтобы несколько шаблонов типа .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), устроим еще один турнир.