Или, точнее, задача, поскольку в этом году мы попробовали другой формат: задача была всего одна, но большая. Требовалось написать 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), устроим еще один турнир.
