Доброго времени суток, когда вы в последний раз играли в крестики-нолики? Вопрос действительно важный, не удивляйтесь, всё окажется гораздо хитрее чем вы думали. Полагаю что давно, хорошо, вспомните поле которое вы рисовали на бумаге: 3x3? 5x5? А что вы скажете насчёт 19x19? "Долго будем играть!" - и это только часть проблемы. Если вы крайне дотошный игрок в крестики-нолики и не можете позволить себе проиграть младшему брату в такую игру, то вы конечно попытаетесь анализировать положение поля, пред рассчитать шаги брата и выбрать наилучший. Ход мыслей отличный, однако это вполне несложно на маленьких полях, а вот 19 на 19... Именно такая задача встала передо мной в ходе хакатона от компании Тинькофф - написать бота для игры в крестики-нолики на доске 19x19 с поиском самого лучшего хода за небольшое время (на вход поступает состояние всей доски на данный момент - на выход также вся доска с новой фигурой).
Как играть в крестики-нолики?
Давай-те теперь сменим обстановку и скажем, что вы, чтобы точно обыграть младшего брата написали бота который играет за вас и ищет самые лучшие шаги в некой ситуации. Как мы представим в программе эту самую игру, её правила и доску?
Первая идея - представим доску в виде набора символов и будем смотреть где стоит крестик а где нолик. Просто? Вполне! Представим себе поле 3x3 где каждый игрок поставил по одной фигуре:
string board_state = "x_o______";
Выглядит неплохо, однако как проверить что кто-то выиграл? Например, можно сравнить наш board_state с примерами выигрышных строк:
string board_state = "x_o______";
string win_patterns[0] = "xxx______";
if(board_state.Equals(win_patterns[0]))
Console.Write("Крестики победили!");
Однако, неудобно очевидно то, что таких выигрышных ситуаций будет многовато: в ряд, в столбец, в диагональ слева направо и справа налево - и каждую из таких строк придётся хранить, да и сравнение строк операция не очень приятная. Рассмотрим интересный способ упростить такую программу.
Битовые доски
Подход заключается в том, чтобы представить доску игроков в виде двухзначного числа, где каждый разряд бита - стоит ли фигура на данной позиции или нет. Повторим положение доски с картинки в данном представлении:
uint x_board = 0b100_000_000; // X стоит на 9-ой клетке (считая с правого угла), т.е. в 9 разряде числа
uint o_board = 0b001_000_000; // О стоит на 7-ой клетке, т.е. в 7 разряде числа
Таким образом, вместо строк мы храним одно число, но представляя его в двоичной форме получаем информацию где стоит фигура игрока. Как узнать полное положение доски? Не хитро:
uint full_board = x_board | o_board; // Просто битово умножаем два числа!
Элегантно? Это ещё не всё! Для проверки того, выиграл ли кто-то мы также будем сравнить положения доски с выигрышной ситуацией, но делать мы это будем с помощью операции побитового умножения:
uint x_board = 0b100_000_000;
uint o_board = 0b001_000_000;
uint[] win_patterns =
{
0b111_000_000, // 3 в ряд на позициях 9, 8 и 7
0b000_111_000, // 3 в ряд на позициях 6, 5, 4
0b100_010_001, // 3 в диагональ слева-направо
// и так далее
};
if (win_patterns.Any(w => (x_board & w) == w)) Console.Write("X победили!");
else if(win_patterns.Any(w => (x_board & w) == w)) Console.Write("O победили!");
Согласитесь - операция побитового умножения гораздо приятнее (быстрее) сравнения строк.
Масштабируем проблему
И тут многие догадались, как же я хочу решить проблему написания бота для игры в крестики-нолики на поле 19x19. Однако, как мы представим такую доску?
ulong x_board = 0b0000000000000000000_0000000000000000000_... // 19 рядов по 19 ноликов
Такое число превышает 64 бита, что уже больше ulong! А ведь нам ещё хранить победные ситуации! Как будем выбираться? Можно подключить какую-нибудь библиотеку для больших типов данных но хранить такие переменные? Да и для внешнего восприятия выглядит действительно ужасно. Тогда стоит подумать, доска состоит из строк, почему бы и нам это огромное число разделить не разделить на строки и хранить состояние доски в виде девятнадцати 19-разрядных битовых чисел - строк (рядов) доски?
uint[] x_board = new uint[19]
{
0b1000000000000000000,
0b0110000000000000000,
0b0000000000000000000,
0b0000000000000000000,
... // 19 чисел
}
Тогда спокойно можно использовать uint. Отлично, а как проверять такую красоту на выигрыш (при условии что победа - 5 фигур в ряд, в столбец и диагональ) ? Давайте разбираться, для начала представим все случаи победы в ряд для i-ой строки доски:
private static readonly uint[] _win_patterns_rows =
{
0b1111100000000000000,
0b0111110000000000000,
0b0011111000000000000,
0b0001111100000000000,
0b0000111110000000000,
0b0000011111000000000,
0b0000001111100000000,
...
};
Тогда чтобы проверить выиграл ли какой-либо игрок будем побитового перемножать i-ую строку из доски игрока на j-ую строку из всевозможных выигрышных строк:
private bool check_if_board_win_pattern_horizontal(uint[] board)
{
uint horizontal_check;
uint win_pattern;
// проходим по каждой строке состояния доски игрока
for (byte j = 0; j < 19; j++)
{
// проверяем каждый выигрышный случай данной строки
for(byte i = 0; i < 15; i++)
{
// проверяем все возможные состояние доски при победе в ряд
horizontal_check = board[j] & _win_patterns_rows[i];
win_pattern = _win_patterns_rows[i];
if (horizontal_check == win_pattern) return true;
}
}
return false;
}
Но это только в ряд, как же в столбец? Представим, что наше двоичное представление доски 19x19 - это бинарная матрица, ведь правда похоже! Тогда нам достаточно просто транспонировать эту матрицу и запустить функцию проверки сверху - ведь столбцы станут строками!
Для транспонирования будем умножать i-ое число j-ой строки доски на 0b1000000000000000000 попутно побитово сдвигая это j-ую строку на i бит вправо:
private bool check_if_board_win_pattern_vertical(uint[] board)
{
// По сути транспонирование матрицы (если представить числа как биты в 19) 19х19
// ну или поворот доски)
uint[] transp_board = new uint[19];
for(byte i = 0; i < 19; i ++)
{
for(byte j = 0; j < 19; j++)
{
transp_board[i] |= ((board[j] << i) & (uint)0b1000000000000000000) >> j;
}
}
return check_if_board_win_pattern_horizontal(transp_board);
}
Хорошо, тут разобрались, а как же проверка по диагонали? Попытаемся представить выигрышную позицию в случае победы в диагональ слева-направо:
private static readonly uint[] _win_patterns_diagnal =
{
0b1000000000000000000,
0b0100000000000000000,
0b0010000000000000000,
0b0001000000000000000,
0b0000100000000000000,
// остальные строки такой доски будут нулевыми поэтому нет смысла их заполнять
// нам важны 5 в диагональ
};
Проверить конкретно такую выигрышную ситуацию мы сможем - перемножим доску какого-либо игрока на эти значения как мы делали выше. Однако нам нужно проверить все случаи 5 фигур подряд в диагональ. Сделаем очень хитрую вещь - представим опять, что наше поле игрока - это матрица 19х19, разделим эту матрицу на все возможные подматрицы 5x19 (так как нам необходимы только 5 столбцов для проверки) и будем умножать их на наш _win_patterns_diagnal, однако затем заново пойдём делать тоже самое применим к каждой строке _win_patterns_diagnal сдвиг вправо на i количество битов. Звучит сложно, посмотрим в виде кода:
private bool check_if_board_win_pattern_diagnall(uint[] board, bool left_to_right)
{
uint[] _win_patterns;
uint[] diagnal_check = new uint[5];
for(byte h = 0; h < 19; h++)
{
// Сдвигаем матрицу победы наискок чтобы посмотреть все варианты a.k.a
// 1, 0, 0, 0, 0 0, 1, 0, 0, 0
// 0, 1, 0, 0, 0 => 0, 0, 1, 0, 0
// 0, 0, 1, 0, 0 0, 0, 0, 1, 0
// .... ....
_win_patterns = (uint[])_win_patterns_diagnal.Clone();
// Реверсировать каждую строку (число) доски игрока
// код затычка - нужно будет дописать...
if (left_to_right) _win_patterns.Reverse();
_win_patterns[0] >>= h;
_win_patterns[1] >>= h;
_win_patterns[2] >>= h;
_win_patterns[3] >>= h;
_win_patterns[4] >>= h;
// Заполняем проверочную матрицу 5x19 (т.к. 5 наискосок)
for (byte i = 0; i < 5; i++)
{
// Проходимся шагом 5, иначе говоря рассматриваем все подматрицы 5x19 матрицы 19x19
for (byte count = 0; count < 14; count += 5)
{
// проверяем все возможные состояние доски при победе в ряд
diagnal_check = new uint[5];
diagnal_check[i] = board[count] & _win_patterns[i];
}
if (new HashSet<uint>(diagnal_check).SetEquals(_win_patterns)) return true;
}
}
return false;
}
Для проверки победы на диагональ справа-налево будем просто реверсировать каждую строку (в коде сверху это не реализовано).
А как же сам алгоритм поиска решений?
Наша команда выбрала два алгоритма для поиска наилучшего хода в такой задаче - Минимакс и Монте-Карло. Мне достался алгоритм Монте-Карло - но о нём я постараюсь вам рассказать в следующей статье, в контексте данной проблемы.
Для чего это написано?
Во время решения данной проблемы я не нашёл стоящей информации про игры на доске 19х19, их представление в виде битовых досок и ещё очень много чего. Алгоритмы которые я описал были додуманы мною на основе чужих наработок решения той же проблемы на небольших полях (3х3 и 5х5), в частности работа с доской как с набором строк (рядов) и вытекающие отсюда последствия.
Заметки
Так как данная проблема, решалась мной на хакатоне, то время было крайней ограничено - отсюда вытекает то, что описанной мной здесь код может содержать ошибки, буду признателен тем, кто их отыщет!
Также думаю стоит упомянуть, что как и в проверке на 5 в диагональ - так и в проверках в ряд и в столбец можно оптимизировать алгоритм путём динамического битового сдвига.
Многие решения в реализации были приняты исходя из:
Сжатого срока хакатона
Жёсткие рамки на ресуры, выделяемые для бота (память + время)
Условием задачи: на вход поступает нынешнее состояние доски, а на выход новое состояние доски с новой фигурой
Комментарии (28)
Alohahwi
24.11.2023 17:22+2Пару лет назад просчитал пьяницу. Играл с детьми, захотелось выигрывать. Единственное что там можно делать - это порядок карт убираемых в низ колоды менять. Результат был неожиданным. Если начинать вводить любую сортировку карт, идущих в низ колоды - вероятность бесконечной партии сразу становится 90+ процентов. Было пару вариантов, где вероятность своего выигрыша можно было увеличить на 5%, но это при 80% вероятности бесконечной партии.
aeder
24.11.2023 17:22Нахрена хранить позицию в виде битовой карты? Я понимаю, если бы вы играли на поле мегабайт на мегабайт. Но поле 19 на 19 - это всего лишь ~400 байт в виде двумерного массива.
dacsson Автор
24.11.2023 17:22Потому как крайне ограничены ресурсы для бота заданием и необходимо было использовать все доступные средства оптимизации
ri1wing
24.11.2023 17:22+2Игра на поле 19x19, где надо поставить пять в ряд, называется гомоку.
klimkinMD
24.11.2023 17:22Она же рэндзю, и теория говорит, что без дополнительных ограничений (правил) выигрывает тот, кто ходит первым ("чëрные", в оригинале). Я, к тому, что теория игры достаточно разработана. Про "Алгоритм лучшего хода" пока -- ни слова. По-мне, так с него и нужно начинать серию статей, а танцы с битами оставить на финал потому как это давно известное дело. Хотя, наверное, от командного мировоззрения зависит.
wataru
24.11.2023 17:22+4Ваши битовые оптимизации - нифига не оптимизации.
Если немного быть знакомыми с динамическим программированием, то гораздо эффективнее было бы хранить поле в виде матрицы 19x19 и считать наибольшую длину горизонтального/вертикального/диагонального куска из заданного символа, заканчивающегося в каждой клетке.
Например, для горизонтальных проверок:
for (int i = 0; i < 19; ++i) { int cur_len = board[i][0] == cur_char; for (int j = 1; j < 19; ++j) { cur_len = (board[i][j] == cur_char) ? cur_len+1 : 0; if (cur_len == 5) { // нашли пять в ряд. } } }
Если вместо переменной cur_len завести матрицу и хранить значения в каждой клетке, то вообще один и тот же код проверяет по всем трем направлениям:
const int dx[3] = {1, 0, 1}; const int dy[3] = {0, 1, 1}; for (int k = 0; k < 3; ++k) { for (int i = 0; i < 19; ++i) { cur_len[i][0] = (board[i][0] == cur_char); cur_len[0][i] = (board[0][i] == cur_char); } for (int i = 1; i < 19; ++i) { for (int j = 1; j < 19; ++j) { cur_len[i][j] = (board[i][j] == cur_char) ? cur_len[i-dx[k]][j-dy[k]]+1 : 0; if (cur_len[i][j] == 5) { // нашли пять в ряд 'cur_char'. } } } }
Если еще перевести поле в 2 булевых матрицы для X и O, то точно будет быстрее вашего подхода. Особенно для диагональных проверок. И не надо десятков шаблонов.
dacsson Автор
24.11.2023 17:22-3Не думаю, что то,что вы написали - верно, напишите такого же бота вашим методом - проверим)
А вам советую больше про битовые операции почитать над bitboards почитать
wataru
24.11.2023 17:22+2Нет, это вам стоит почитать про динамическое программирование.
Про битовые трюки я отлично знаю. Вы их, кстати, тоже неправильно применяете. Вы заменяете 5 сравнений на одно за счет прикладывания маски с 5-ю единичными битами. Динамическое программирование, что я написал, тоже заменяет 5 сравнений одним, но за счет переиспользования предыдущих результатов сравнения.
А битовыми трюками можно ускорить не в 5, а в 19 раз. И не надо никаких масок. Если у вас каждая строка хранится в битах одного uint, то взяв
board[i]&board[i+1]&board[i+2]&board[i+3]&board[i+4]
вы получите единичные биты в тех столбцах, где в первых 5 строках есть символы. Если получили не 0 - то любой единичный бит - это искомое начало 5 элементов вертикально в ряд.Соответственно, вам надо транспанировать матрицу для проверки горизонтальных пятерок, а не вертикальных. Для диагональных проверок вам надо брать
board[i] & (board[i+1]<<1) & (board[i+2] << 2)...
Для другого наклона диагонали - сдвигайте вправо, а не влево.
dacsson Автор
24.11.2023 17:22как мне кажется, я делаю нечто подобное что вы написали, но может я не до конца вас понял
wataru
24.11.2023 17:22+3Подобного у вас только то, что вы битовые операции используете. У меня циклы 14*5 и всего 14 проверок. У вас же циклы 19*15 и столько же проверок. А в диагональных случаях у вас вообще циклы 19*5*3 (кстати, как у вас там вообще работает? Вы к board обращаетесь только к строкам count, кратным 5 - вообще не проверяя все, кроме трех строк).
Главная ваша проблема, что вы пытаетесь прикладывать жесткие маски. Это имеет смысл делать, только когда вам важны символы, скажем именно в 4-ом ряду. Нет, в этой задаче 5 в ряд могут быть где угодно. Поэтому ваш подход заставляет прикладывать одну и ту же маску ко всем позициям. Хотя можно наоборот, для всех позиций сразу проверить, а проходит ли через них пять в ряд.Вот код для проверки вертикальных 5-в-ряд:
bool CheckVertical (int board[19]) { for (int i = 0; i < 14; ++i) { int acc = board[i]; for (int j = 1; j < 5; ++j) { acc &= board[i+j]; } if (acc != 0) return true; } return false; }
Это работает, потому что если где-то есть 5 в ряд, то там board[i], board[i+1]... board[i+4] все будут иметь 1 в одном и том же разряде. И мы получим 1 в acc в этом разряде. И наоборот, если мы получили где-то 1 в acc, то значит на доске в 5 строках в этом столбце есть 1.
Вот диагонали. Вместо диагональной маски, мы сдвигаем строки поля и потом ищем тут вертикальные 5-в-ряд:
bool CheckDiagLeft (int board[19]) { for (int i = 0; i < 14; ++i) { int acc = board[i]; for (int j = 1; j < 5; ++j) { acc &= board[i+j] << j; } if (acc != 0) return true; } return false; } bool CheckDiagRight (int board[19]) { for (int i = 0; i < 14; ++i) { int acc = board[i]; for (int j = 1; j < 5; ++j) { acc &= board[i+j] >> j; } if (acc != 0) return true; } return false; }
Вот с горизонтальными чуть сложнее, но можно и без транспанирования, основываясь на той же идее. Опять же, не надо никаких масок прикладывать к каждой позиции, а надо лишь проверить сдвинутые строки. Вроде как взять 5 копий строк: eсли там есть диагональ, то в строке есть 5-в-ряд:
bool CheckHorizontal (int board[19]) { for (int i = 0; i < 19; ++i) { int acc = board[i]; for (int j = 1; j < 5; ++j) { acc &= board[i] << j; } if (acc != 0) return true; } return false; }
dacsson Автор
24.11.2023 17:22+1Теперь дошло! Спасибо за комментарии, действительно так выходит гораздо лучше
VladimirFarshatov
24.11.2023 17:22+1Когда-то писал подобное для игры в крестики-нолики "по 5" на бесконечном поле, ещё для ЕС .. деталей уже не помню к сожалению. Но, как-бы там памяти с гулькин нос было, хранить пустые клетки вообще незачем (поле безрамерно) и оценивать надо далеко не всю массу крестиков и ноликов, а только "краевые", которые могут участвовать в возможных вариантах.
Так, на вскидку что вспомнилось..
dacsson Автор
24.11.2023 17:22вот это действительно интересно реализовать на бесконечном поле, надо будет покумекать
VladimirFarshatov
24.11.2023 17:22+1Ещё как подсказка: клетки, удаленные от края дальше 5 - хранить нет никакого смысла, они уже не участвуют ни в чем. ;)
appet1te
24.11.2023 17:22Возможно стоит добавить какие-то эвристические алгоритмы. Роевой интеллект или генетические алгоритмы. Я знаю, что в задачах которые слабо поддаются перебору, хорошо заходит такого рода варианты.
VladimirFarshatov
24.11.2023 17:22+1Смутно вспоминаю, что алгоритм был тупым как палка: ищем куда можно воткнуть 5-й элемент, если такого места нет, то ищем .. где можно воткнуть третий, но так чтобы получилось 2 тройки, открытых с обоих концов, если нет то ищем 3+4 или просто 4. Если нет ничего "подходящего выше", то ищем подобное в других элементах и затыкаем возможный проигрыш своим. как-то так..
wataru
24.11.2023 17:22+1Есть очень простой алгоритм основанный на оценках: Для каждого множества из 5-в-ряд на доске проверим, состоит ли оно лишь из 'O' или лишь из 'X'. Если там есть и те и другие, то это множество никогда не станет победным ни для одного из игроков. В противном случае, добавим всем пустым клеткам в этом множестве очков, в зависимости от количества стоящих заполненных символов. Самая большая оценка должна быть у множества из 4 'X' (если мы за крестики играем). Тогда одним ходом мы можем закрыть пятерку и победить. Допустим 10^9. Потом идет 4 'O'. Надо обязательно закрыть эту клетку, ведь иначе враг следующим ходом выиграет. Можно тут назначить 10^8 Потом должны идти те клетки, которые лежат на пересечении двух множеств из троек. Ведь поставив сюда, мы получим 2 пересекающиеся четверки, что даст нам "вилку": что бы враг следующим ходом не закрыл - мы победим на следующем ходу. Поэтому тройке 'O' назначим 10^7. И так далее, чередуем символ и уменьшаем количество и назначаем ситуации какую-то степень 10. Просуммировав эти оценки повсем пятеркам на поле для всех клеток останется только выбрать максимум.
Тут, конечно, много пространства для эвристик. Например, можно пустым пятеркам тоже назначать какие-то очки. Чтобы, первый ход был подальше от границы. Надо сильнее оценивать 2 пересекающиеся тройки, если они не параллельны, чем если они параллельны... Т.е. для каждой клетки надо не сумму очков считать, а сколько там горизонтальных, вертикальных и т.д. единиц-двоек-троек-четверок каждого типа и как-то на основе этого комбинировать итоговую оценку.
VladimirFarshatov
24.11.2023 17:22+1кмк, можно проще: перебирая пустые "соседние" клетки не далее +4 от края проверяем на возможные комбинации в ней, соответственно увеличивая вес "хода сюда". Меньше определенного можно даже не хранить, ибо все равно мимо кассы.
Cheater
Имхо ваш алгоритм неэффективен. Вместо того чтобы перебирать всё поле, достаточно проходиться по всем позициям, на которых стоит крестик или нолик, и для каждой такой позиции проверять, не стоит ли она в ряду из 5 таких же символов, вертикальном горизонтальном или диагональном. (прямой проверкой индексов во все стороны). Символы, провалившие проверку, можно помечать, чтобы не проверять много раз. На пометки нужна доп. память и нужны 4 вида пометок тк позиция, помеченная например "не в вертикальном ряду из 5", может находиться в горизонтальном ряду из 5). Это решение в лоб, думаю есть ещё эффективнее
amphasis
Если задача стоит в проверке наличия выигрышной позиции после хода, то достаточно все эти проверки производить относительно последнего поставленного крестика/нолика
dacsson Автор
Условности задания, там к боту приходит конкретно вся доска, а он отвечает новой доской с новой фигурой
amphasis
Контекст вообще никакой нельзя хранить?
dacsson Автор
хранить то можно - но боту даётся 1 ядро, 1 gb и 1 секунда на ответ
amphasis
Ну, кажется даже в таких условиях можно хранить в каком-нибудь локальном memory cache. С другой стороны, при небольшом количестве клиентов, вероятно такая оптимизация и не имеет смысла, поскольку алгоритм не упрется в лимит по CPU.
Будет интересно почитать следующую статью по алгоритму выбора хода.
dacsson Автор
Вопрос будет ли это быстрее побитового умножения и по загруженности памяти, не совсем уверен