Очень сложные Крестики-Нолики или Монтеки-Карлоки

Доброго времени суток, когда вы в последний раз играли в крестики-нолики? Вопрос действительно важный, не удивляйтесь, всё окажется гораздо хитрее чем вы думали. Полагаю что давно, хорошо, вспомните поле которое вы рисовали на бумаге: 3×3? 5×5? А что вы скажете насчёт 19×19Долго будем играть!» — и это только часть проблемы. Если вы крайне дотошный игрок в крестики-нолики и не можете позволить себе проиграть младшему брату в такую игру, то вы конечно попытаетесь анализировать положение поля, пред рассчитать шаги брата и выбрать наилучший. Ход мыслей отличный, однако это вполне несложно на маленьких полях, а вот 19 на 19… Именно такая задача встала передо мной в ходе хакатона от компании Тинькофф — написать бота для игры в крестики-нолики на доске 19×19 с поиском самого лучшего хода за небольшое время.

Как играть в крестики-нолики?

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

Первая идея — представим доску в виде набора символов и будем смотреть где стоит крестить, а где нолик. Просто? Вполне! Представим себе поле 3×3 где каждый игрок поставил по одной фигуре:

705fb82b399d1b5f99902e317bc9d9fc.png

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 победили!");

Согласитесь — операция побитового умножения гораздо приятнее (быстрее) сравнения строк.

Масштабируем проблему

И тут многие догадались, как же я хочу решить проблему написания бота для игры в крестики-нолики на поле 19×19. Однако, как мы представим такую доску?

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;
}

Но это только в ряд, как же в столбец? Представим, что наше двоичное представление доски 19×19 — это бинарная матрица, ведь правда похоже! Тогда нам достаточно просто транспонировать эту матрицу и запустить функцию проверки сверху — ведь столбцы станут строками!

Хорошо, тут разобрались, а как же проверка по диагонали? Попытаемся представить выигрышную позицию в случае победы в диагональ слева-направо:

    private static readonly uint[] _win_patterns_diagnal =
    {
        0b1000000000000000000,
        0b0100000000000000000,
        0b0010000000000000000,
        0b0001000000000000000,
        0b0000100000000000000,
        // остальные строки такой доски будут нулевыми поэтому нет смысла их заполнять
        // нам важны 5 в диагональ
    };

Проверить конкретно такую выигрышную ситуацию мы сможем — перемножим доску какого-либо игрока на эти значения как мы делали выше. Однако нам нужно проверить все случаи 5 фигур подряд в диагональ. Сделаем очень хитрую вещь — представим опять, что наше поле игрока — это матрица 19×19, разделим эту матрицу на все возможные подматрицы 5×19 (так как нам необходимы только 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(diagnal_check).SetEquals(_win_patterns)) return true;
    }
  }

  return false;
}

Для проверки победы на диагональ справа-налево будем просто реверсировать каждую строку (в коде сверху это не реализовано).

А как же сам алгоритм поиска решений?

Наша команда выбрала два алгоритма для поиска наилучшего хода в такой задаче — Минимакс и Монте-Карло. Мне достался алгоритм Монте-Карло -, но о нём я постараюсь вам рассказать в следующей статье, в контексте данной проблемы.

Для чего это написано?

Во время решения данной проблемы я не нашёл стоящей информации про игры на доске 19×19, их представление в виде битовых досок и ещё очень много чего. Алгоритмы которые я описал были додуманы мною на основе чужих наработок решения той же проблемы на небольших полях (3×3 и 5×5), в частности работа с доской как с набором строк (рядов) и вытекающие отсюда последствия.

Заметки

Так как данная проблема, решалась мной на хакатоне, то время было крайней ограничено — отсюда вытекает то, что описанной мной здесь код может содержать ошибки, буду признателен тем, кто их отыщет!

Также думаю стоит упомянуть, что как и в проверке на 5 в диагональ — так и в проверках в ряд и в столбец можно оптимизировать алгоритм путём динамического битового сдвига.

© Habrahabr.ru