Я только лишь передвигал нужную шашку на нужное поле…

(ответ Мариона Тинсли на вопрос, как ему удалось победить)

Об идее

В Интернете можно откопать сотни, а в англоязычном его сегменте — тысячи, статей о разработке алгоритмов и ИИ для игры в шахматы. Однако шашки почему-то не привлекают такого интереса. В Рунете мне не удалось найти почти ни одной интересной статьи о последовательной разработке алгоритма для игры в шашки, поэтому мне захотелось его создать самому.

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

  • Шашки ходят только по клеткам черного цвета по диагонали.

  • Простая шашка ходит только вперед на одно поле, а бьет — вперед и назад, перепрыгивая одно поле (в checkers — бьет только вперед)

  • Дамка ходит и бьет вперед и назад на любое количество полей (в checkers — ходит вперед и назад только на 1 поле; бьет, перепрыгивая только 1 поле)

  • Бить обязательно! При наличии нескольких вариантов боя — можно выбрать любой.

  • Проигрывает тот, кто не может сделать ход.

Изначально я хотел писать на python, но потом решил сделать крутую красивую игру и выбрал Unity (C#). Спойлер: красивую игру я так и не сделал.

Реализация алгоритма

Разумеется классы, отвечающие за шашки на экране и в памяти алгоритма, разные. Я не буду останавливаться на MonoBehaviour Unity-скриптах и подробнее расскажу именно про мою реализацию алгоритма.

Сначала опишу, как я храню доску в памяти.

Класс шашки довольно прост: содержит, главное, тип шашки и ее позицию на поле, а также несколько вспомогательных переменных:

public enum PieceType
{
    EMPTY, WHITE_MAN, WHITE_KING, BLACK_MAN, BLACK_KING
}

public class Piece
{
    public PieceType Type { get; private set; }
    public Vector2Int Pos { get; private set; }
    public bool IsWhite { get; private set; }
    public bool IsKing { get; private set; }


    public Piece(PieceType type, Vector2Int pos)
    {
        Type = type;
        Pos = pos;
        IsWhite = type == PieceType.WHITE_MAN || type == PieceType.WHITE_KING;
        IsKing = type == PieceType.WHITE_KING || type == PieceType.BLACK_KING;
    }

    public void ChangePos(Vector2Int newPos)
    {
        Pos = newPos;
    }
    public void BecomeKing()
    {
        Type = IsWhite ? PieceType.WHITE_KING : PieceType.BLACK_KING;
        IsKing = true;
    }
    public void BecomeMan()
    {
        Type = IsWhite ? PieceType.WHITE_MAN : PieceType.BLACK_MAN;
        IsKing = false;
    }
}

Думаю, тут комментировать нечего.

Доска же это набор шашек и их ходов. Это не полный код доски, а только важное:

    public class Board
    {
        private Piece[,] _board = new Piece[8, 8]; // фигуры
        public List<Piece> Pieces { get; private set; } = new List<Piece>(); // те же фигуры, но в виде списка
        private List<Move> _currentMoves; // список возможных ходов
        private int[] _countCheckers = new int[5]; // счетчик шашек определенных групп (всех, белых обычных, белых дамок, черных обычных, черных дамок)
        private List<MemorisedMove> LastMoves = new List<MemorisedMove>();

      ...

        // Конструктор для установки доски по строке
        // searchAllMoves -- надо ли искать возможных ходы
        public Board (string arr, bool whitesMove = true, bool searchAllMoves = true)
        {
            int index = 0;
            // Проход по всем клеткам
            for (int y = 0; y < 8; y++)
            {
                for (int x = (y+1) % 2; x < 8; x += 2)
                {
                    if (arr[index] != '0')
                    {
                      // Индекс фигуры
                        int num = int.Parse(arr[index].ToString());
                      // Получаем фигуру
                        Piece piece = new Piece((PieceType) num, new Vector2Int(x, y));

                      // Устанавливаем и заносим в списки
                        _board[y, x] = piece;
                        Pieces.Add(piece);
                        _countCheckers[num]++;
                    }

                    index++;
                }
            }
            WhitesMove = whitesMove;
            _rowKingsMoves = 0;
            _jumpIndex = 0;
            _countCheckers[0] = _countCheckers[1] + _countCheckers[2] + _countCheckers[3] + _countCheckers[4];

            // Если нужно, ищем возможные ходы
            if (searchAllMoves)
                FindAllMoves();
        }

      ...
    }
  • Конструктор Board() здесь строит доску по строке из цифр, где каждая цифра обозначает конкретную шашку (см. перечисление PieceType в классе Piece).

  • Также есть конструктор, создающий глубокую копию доски.

(Разобью весь класс на части, чтобы не было пелены кода и можно было дать пояснения)

Следующие функции используются для поиска возможных ходов.

public void FindAllMoves ()
{
    List<Move> takingMoves = new List<Move>(); // взятия
    List<Move> simpleMoves = new List<Move>(); // простые ходы

    foreach (Piece piece in Pieces)
    {
        if (piece.IsWhite == WhitesMove)
        {
          // Для каждой фигуры сначала ищем все взятия
            takingMoves.AddRange(GetAllTakingMovesOfPiece(piece));
          // Если взятий нет, ищем простые ходы
            if (takingMoves.Count == 0)
                simpleMoves.AddRange(GetAllSimpleMovesOfPiece(piece));
        }
    }

    // Если есть взятия, отбрасываем простые ходы; иначе есть только простые ходы
    if (takingMoves.Count > 0)
    {
        // Взятия сортируем по убыванию количеству побитых шашек, чтобы сначала шли самые лучшие
        // Это поможет нам более эффективно искать сильнейшие ходы, оценивая потенциально лучшие первыми
        takingMoves.Sort((Move a, Move b) => -a.Taken.Count.CompareTo(b.Taken.Count));

        AllMoves = _currentMoves = takingMoves;
    }
    else
        AllMoves = _currentMoves = simpleMoves;
}

// Рекурсивная функция поиска взятий фигуры
// В массиве exc хранятся поля, шашки на которых мы уже побили, так как в русских шашках,
// согласно турецкому правилу, шашки снимаются с доски уже после хода (см. комментарий под кодом)
private List<Move> GetAllTakingMovesOfPiece (Piece piece, List<Vector2Int> exc = null)
{
    if (exc == null)
        exc = new List<Vector2Int>();
    List<Move> moves = new List<Move>(); // все взятия
    List<Move> movesWithFollowingTake = new List<Move>(); // взятия, после которых можно побить еще

    if (piece.IsKing)
    {
      // Перебираем все 4 направления движения
        for (int x = 1; x > -2; x -= 2)
        {
            for (int y = 1; y > -2; y -= 2)
            {
                bool opp_found = false;
                Vector2Int pos_opp = Vector2Int.zero;

              // Куда дамка встанет после прыжка
                Vector2Int target = piece.Pos + new Vector2Int(x, y);
                while (InField(target)) // Функция InField проверяет, что координаты (x, y) принадлежат полю
                {
                    if (IsEmpty(target)) // Функция IsEmpty проверяет, что поле не занято
                    {
                        if (opp_found) // Если, прыгнув на клетку target мы перепрыгнем шашку соперника, то это взятие
                            AddMove(piece.Pos, target, pos_opp); // Косвенно рекурсивно продолжаем поиск дальнейших прыжков со взятием
                    }
                    else if (_board[target.y, target.x].IsWhite == piece.IsWhite) // Если уперлись в свою шашку — то усё
                        break;
                    else
                    {
                        if (!opp_found) // Если уперлись в шашку соперника, запоминаем это
                        {
                            opp_found = true;
                            pos_opp = target;
                        }
                        else // Если уткнулись во 2-ю шашку соперника, то дальше прыгнуть не получится
                            break;
                    }
                    target += new Vector2Int(x, y);
                }
            }
        }
    }
    else
    {
      // Тут перебираем все 4 варианта взятия обычной шашки (для краткости показано только одно)
        // target - поле куда приземлимся, middle - поле, которое перепрыгнем. В данном случае прыгаем на увеличение обеих координат (вниз вправо)
        Vector2Int target = new Vector2Int(piece.Pos.x + 2, piece.Pos.y + 2);
        Vector2Int middle = new Vector2Int(piece.Pos.x + 1, piece.Pos.y + 1);
        if (InField(target) && IsEmpty(target) && !IsEmpty(middle) && _board[middle.y, middle.x].IsWhite != piece.IsWhite)
            AddMove(piece.Pos, target, middle);
        ...
        ...
        ...
    }
    if (movesWithFollowingTake.Count > 0)
        return movesWithFollowingTake;
    return moves;



    bool AddMove (Vector2Int fr, Vector2Int to, Vector2Int taken)
    {
      // Турецкий удар (см. в комментарии ниже)
        if (exc.Contains(taken))
            return false;

      // Моделируем доску, на которйо этот ход сделан
        Board nextBoard = new Board(this, deepCopyMoves:false);
        Piece thisPiece = nextBoard.MovePiece(fr, to);
        List<Vector2Int> newExc = new List<Vector2Int>(exc);
        newExc.Add(taken);

      // Проверяем, не превратилась ли наша шашка в дамку этим ходов
        bool isThisMoveKinging = !piece.IsKing && IsKinging(to, piece.IsWhite);
        List<Move> nextTakes = nextBoard.GetAllTakingMovesOfPiece(thisPiece, newExc);

        if (nextTakes.Count == 0)
        {
            moves.Add(new Move(new List<Vector2Int>() { fr, to }, new List<Vector2Int>() { taken }, isThisMoveKinging));
            return false;
        }
        else
        {
            foreach (Move nextTake in nextTakes)
            {
                List<Vector2Int> pos = nextTake.Pos;
                pos.Insert(0, fr);
                List<Vector2Int> takes = nextTake.Taken;
                takes.Add(taken);
                moves.Add(new Move(pos, takes, isThisMoveKinging || nextTake.IsKinging));
                movesWithFollowingTake.Add(new Move(pos, takes, isThisMoveKinging || nextTake.IsKinging));
            }
            return true;
        }
    }
}
// Эта функция ищет все простые ходы шашки. Она очень простая и не представляет особого интереса
private List<Move> GetAllSimpleMovesOfPiece (Piece piece)
{
    ...
}
  • Здесь стоит обратить внимание на то, что все обычные ходы считаются равноценными, а взятия — нет: сильнейшие взятия это те, которые бьют больше шашек соперника.

  • В функции GetAllTakingMoves, которая ищет все ходы-взятия, важную роль играет т.н. турецкое правило, согласно которому побитые шашки снимаются с доски после хода и могут мешать продолжить взятия. Например в позиции ниже, если белые возьмут дамкой e1:a5:d8:f6:d4, они не смогут взять еще и шашку c5, так как, хотя шашка b6 к тому времени уже будет побита, она все еще будет стоять на доске, мешаясь дамке белых.

    Пример турецкого удара
    Пример турецкого удара
  • В функции AddMove() интерес также представляет отдельная обработка ситуации, когда шашка своим ходом превращается в дамку — в таком случае можно продолжить взятие по ее правилам.

Функция MakeMove совершает ход на доске:

public void MakeMove(Move move, bool memoriseMove = false)
{
    // Хапоминаем ход, если надо
    if (memoriseMove)
        LastMoves.Add(new MemorisedMove(move.Fr, move.To, null, move.IsKinging, _rowKingsMoves));

    // Двигаем фигуру (обновляет массив _board и позицию  в экземпляре самой фигуры,
    // возможно, также превращает экземпляр фигуры в дамку)
    MovePiece(move.Fr, move.To);

    // Удаляем из массивов побитые шашки
    foreach (Vector2Int taken in move.Taken)
    {
        Piece takenPiece = GetPiece(taken);
        _countCheckers[(int)takenPiece.Type]--;
        _countCheckers[0]--;

        Pieces.Remove(takenPiece);
        _board[taken.y, taken.x] = null;

        if (memoriseMove)
            LastMoves[LastMoves.Count - 1].AddTakenPiece(takenPiece);
    }
}

Разумеется, это только основные функции. Полный код, мониторящий доску, занимает почти 500 строк. Это довольно много, но не думаю, что можно как-то разделить ответственность: все относится непосредственно к свойствам нынешнего состояния игры.

Это все не очень интересно, и я даже мог бы найти и скачать готовый код шашек в Unity, но я решил написать его сам, чтобы лучше понимать игру. Возможно, тут можно что-то как-то кардинально улучшить и ускорить.

Перейдем к более интересным вещам

Разработка победного алгоритма

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

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

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

Итак, к алгоритму.

Мне очень хочется назвать это не алгоритмом, а искусственным интеллектом, хотя он таковым не является. Это потому, что при разработке я назвал класс ИИ и так и продолжал о нем думать, поняв ошибку только позже.

Сначала разберемся, как оценивать конкретную позицию на доске, а уже потом будет учиться думать наперед, оценивая получаемые позиции.

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

Качество шашки = value * _squareBonus * yBonus

Значения стоимостей и бонусов я выбрал такие:

int _checkerValue = 100; // стоимость простой шашки
int _kingValue = 250; // стоимость короля
float[,] _squareBonus = new float[8, 4] // бонус клетки
    {
        { 1.2f, 1.2f, 1.2f, 1.2f },
        { 1.15f, 1.2f, 1.2f, 1.15f },
        { 1.15f, 1.2f, 1.2f, 1.13f },
        { 1.0f, 1.2f, 1.15f, 1.0f },
        { 1.0f, 1.2f, 1.2f, 1.0f },
        { 1.0f, 1.0f, 1.0f, 1.0f },
        { 1.0f, 1.0f, 1.0f, 1.0f },
        { 1.0f, 1.0f, 1.0f, 1.0f },
    }; 

private float[] _yBonus = new float[8]; // Y-бонус
public float EvaluateMaterialAndPosition (Board board)
    {
        float eval = 0;
        // Рассчитываем качество каждой шашки
        foreach (Piece piece in board.Pieces)
        {
            Vector2Int coord = piece.Pos;
            switch (piece.Type)
            {
                case PieceType.WHITE_MAN:
                    eval += _checkerValue * _yBonus[coord.y] * _squareBonus[coord.y, coord.x / 2];
                    break;
                case PieceType.BLACK_MAN:
                    eval -= _checkerValue * _yBonus[7 - coord.y] * _squareBonus[7 - coord.y, 3 - coord.x / 2];
                    break;
                case PieceType.WHITE_KING:
                    eval += _kingValue;
                    break;
                case PieceType.BLACK_KING:
                    eval -= _kingValue;
                    break;
            }
        }
        return eval;
    }

Теперь, когда мы умеем оценивать позицию, будем рекурсивно перебирать все наши ходы, ответы на них соперника, наши ответы на ответы соперника и т.д.

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

// Функция активного поиска хода запускает поиск лучшего ход в позиции
public void ActiveSearch ()
{
    int depth = 0,  startDepth = 2;
    CurrentBestMove = Move.None;
  // Единственный в позиции ход делается без раздумий
    if (_board.AllMoves.Count == 1)
    {
        CurrentBestMove = _board.AllMoves[0];
        return;
    }
    // Делаем копию доски, на которой будет проводить анализ
    // Это нужно, так как во время анализа мы будем передвигать фигуры
    Board boardCopy = new Board(_board, deepCopyMoves: true);
    _searchStartTime = DateTime.Now;
    IterativeDeepeningMinimax(boardCopy, _timeLimit, startDepth, ActiveSearchDepth, ref CurrentBestMove, ref depth, true);

    if (CurrentBestMove == Move.None)
        CurrentBestMove = boardCopy.AllMoves[new System.Random().Next(0, boardCopy.AllMoves.Count)];
}

// Функция минимакса с итеративным углублением: запускает минимакс со все большей и большей глубиной,
// при этом следя за ограничением во времени
public void IterativeDeepeningMinimax (Board board, float timeLimit, int minDepth, int maxDepth, ref Move bestMove, ref int depth, bool isWhileActiveSearch)
    {
        for (depth = minDepth; depth <= maxDepth; depth++)
        {  
            (float eval, Move tempBestMove) = Minimax(board, depth, board.WhitesMove, timeLimit);
            // Если успели полностью завершить итерацию, сохраняем ее результат
            if ((DateTime.Now - _searchStartTime).TotalSeconds < timeLimit && tempBestMove is not null && tempBestMove != Move.None)
            {    
                bestMove = (Move) tempBestMove.Clone();
            }
          // Если не успели и итерация завершилась экстренно, она неполная и ее результат нам не нужен
            else
            {
                depth -= 1;
                break;
            }
  
            // Мы перестаем искать, если на какой-то итерации найдем форсированный выигрыш
            if (eval >= Infinity && board.WhitesMove || eval <= -Infinity && !board.WhitesMove)
                break;
        }
    }

// Функция минимакса находит лучший ход в позиции за конкретного игрока
// Возвращает сам ход, а также оценку позиции, которая получится, если этот ход сделать
// depth показывает, на сколько еще итераций-рекурсий нам осталось углубиться (с каждым новым рекурсивным вызовом depth уменьшается)
// maximizingPlayer показывает, за какого игрока мы ищем лучший ход, т.е. позицию для какого игрока мы пытаемся улучшить
public (float, Move) Minimax (Board board, int depth, bool maximizingPlayer, float timeLimit)
        {
          // Проверка времени 
            if ((DateTime.Now - _searchStartTime).TotalSeconds >= timeLimit)
                return (0, null);

          // Проверяем нынешнее состояние позиции (возможно, уже гейм овер)
            GameState state = board.GetGameState();
            if (state != GameState.IN_PROCESS)
            {
                if (state == GameState.WHITE_WIN)
                    return (Infinity + depth, Move.None);
                if (state == GameState.BLACK_WIN)
                    return (-Infinity - depth, Move.None);
                else
                    return (0, Move.None);
            }

            // Если это последняя итерация, просто возвращаем оценку позиции
              // Ход здесь не важен, так как лучшим станет именно ход, ведущий к позиции с наилучшей оценкой
            if (depth == 0)
            {
                float eval = Evaluate(board);
                return (eval, Move.None);
            }

            // Если ход единственный -- см. комментарии под кодом
            if (board.AllMoves.Count == 1)
            {
                Move move = board.AllMoves[0];

                board.MakeMove(board.AllMoves[0], memoriseMove: true);
                board.OnMoveFinished(board.AllMoves[0]);
                float eval = Minimax(board, depth, alpha, beta, !maximizingPlayer, timeLimit, isWhileActiveSearch).Item1;
                board.UnmakeLastMove();
                _transpositions.Add(new Transposition(PositionCache, eval, Infinity, board.AllMoves[0]));
                return (eval, board.AllMoves[0]);
            }

            // Ищем лучший ход (за белых)
            Move bestMove = Move.None;
            if (maximizingPlayer)
            {
                float maxEval = -Infinity;
              // Проходимся по всем ходам
                foreach (Move move in board.AllMoves)
                {
                  // Делаем его
                    board.MakeMove(move, memoriseMove: true);
                    board.OnMoveFinished(move);
                  // И запускаем минимакс из полученной позиции, но со стороны ПРОТИВНИКА
                    (float eval, Move compMove) = Minimax(board, depth - 1, alpha, beta, false, timeLimit, isWhileActiveSearch);

                  // Отменяем сделанный ход
                    board.UnmakeLastMove();

                  // Проверка, что минимакс со стороны противника не завершился экстренно из-за нехватки времени
                    if (compMove == null)
                        return (0, null);
                  // Проверяем, является ли этот ход лучше наилучшего найденного
                    if (eval > maxEval)
                    {
                        maxEval = eval;
                        bestMove = move;
                    }
                }
                return (maxEval, bestMove);
            }
          // Аналогично за черных
            else
            {
                float minEval = Infinity;
                foreach (Move move in board.AllMoves)
                {
                    board.MakeMove(move, memoriseMove: true);
                    board.OnMoveFinished(move);

                    (float eval, Move compMove) = Minimax(board, depth - 1, alpha, beta, true, timeLimit, isWhileActiveSearch);
                    board.UnmakeLastMove();

                    if (compMove == null)
                        return (0, null);
                    if (eval < minEval)
                    {
                        minEval = eval;
                        bestMove = move;
                    }
                }
                return (minEval, bestMove);
            }
        }
  • В функции IterativeDeepeningMinimax наглядно показано, как мы постепенно углубляемся в поиске. Если очередная итерация завершилась успешно, мы запоминаем лучший согласно ней ход; если она завершилась досрочно и экстренно из-за истечения времени поиска, то она неполная и ее результат, некорректный, нам не нужен.

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

Я считаю очень важным здесь то, что я не копирую доску, чтобы проанализировать каждый ход: ВЕСЬ поиск лучшего хода выполняется на одной доске (копии игровой), просто мы умеем совершать (make) и отменять ходы (unmake). Таким образом тяжелая функция глубокого копирования доски вызывается лишь единожды.

В целом, такой код уже способен более менее играть и даже не зевать фигуры в один-два хода!

Profit!

Улучшениe №1: alpha-beta pruning

Первым улучшением, которое в разы улучшило скорость анализа, стало альфа-бета отсечение.

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

Например, на картинке ниже мы (играя за максимизирующую оценку сторону (за белых)), просчитав первые 2 варианта хода, видим, что можем получить позицию с оценкой 6. Поэтому, когда мы рассчитываем третий ход и видим, что одна из его дальнейших ветвей приводит к позиции с оценкой 5, то дальше мы даже не рассчитываем, так как, даже если там и будет что-то повыше, соперник лучше выберет 5, ведь он — минимизирующая сторона (черные). А потому мы даже не будем дальше анализировать 3-ю ветку, ведь лучше просто пойти по 2-й.

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

Функции минимакс я добавил аргументы alpha и beta, которые при первом вызове из IterativeDeepeningMinimax передаются как -Infinity и Infinity соответственно.

После 115-й строки я добавил проверку на отсечение по альфе:

...
alpha = Mathf.Max(alpha, eval);
if (beta <= alpha)
  break;
...

А после 139-й — по бете:

...
beta = Mathf.Min(beta, eval);
if (beta <= alpha)
    break;
...

Double profit!

Улучшениe №2: транспозиции

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

Стоит понимать, что если в позиции X на глубине d найден лучший ход n, то этот же ход является лучшим в той же позиции и на глубинах меньше d. А вот на глубинах больше d — не факт: возможно, нам казалось, что он лучший, но мы просто не досчитали и на самом деле ход проигрывающий. Если же в позиции X ход n ведет к форсированной победе, то глубину можно считать бесконечной, так как мы точно знаем, что ход выигрывающий.

Позиции считаются одинаковыми, если не только все шашки стоят на одинаковых местах, но и очередность хода совпадает.

Транспозиции будут храниться в списке private List<Transposition> _transpositions = new List<Transposition>();

Где класс транспозиции выглядит так:

public class Transposition
{
    public string Pos { get; private set; }
    public float Eval { get; private set; }
    public int Depth { get; private set; }
    public Move BestMove { get; private set; }

    public Transposition(string pos, float eval, int depth, Move bestMove)
    {
        Pos = pos;
        Eval = eval;
        Depth = depth;
        BestMove = bestMove;
    
    }
    public bool IsSameTo (string otherPos)
        => Pos == otherPos;
}

В начале функции Minimax() (после, конечно, проверки на истечение времени) будем проверять, не встречали ли мы ранее данную позицию на подходящей глубине:

string PositionCache = board.Board2Number(); // Переводит позицию в строку

Transposition pos_trans = null;
pos_trans = _transpositions.FirstOrDefault(tr => tr.IsSameTo(PositionCache));
if (pos_trans != null)
{
    if (pos_trans.Depth >= depth)
    {
        return (pos_trans.Eval, pos_trans.BestMove);
    }
}

Позиции, кстати, сравниваются как строки, т.к. каждую позицию можно однозначно представить как строку из 32 символов - фигур на черных полях (+1 символ для очередности хода).

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

// за белых
AddTransposition(new Transposition(PositionCache, maxEval, depth, bestMove));
return (maxEval, bestMove);
// за черных
AddTransposition(new Transposition(PositionCache, minEval, depth, bestMove));
return (minEval, bestMove);

Функция AddTransposition просто добавляет транспозицию в список, не забывая убедиться, что там уже нет такой же позиции, но на меньшей глубине. В таком случае она стирается, так как зачем нам менее глубокий анализ, если уже подоспел более глубокий?!

Итак, это улучшение позволяет выжать еще больше скорости из нашего алгоритма.

Triple profit!

Улучшение №3: книга дебютов и эндшпилей

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

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

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

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

private OpeningBook _openingBook;
private EndgameBook _endgameBook;

Классы OpeningBook и EndgameBook наследуются от абстрактного TheoryBook, который умеет искать данную позицию в справочнике и возвращать лучший в ней ход:

public abstract class TheoryBook
{
    protected abstract string TheoryPath { get; }
    private BookRecord _records;

    public TheoryBook()
    {
        Debug.Log(TheoryPath);
        using (StreamReader reader = new StreamReader(TheoryPath))
        {
            _records = JsonUtility.FromJson<BookRecord>(reader.ReadToEnd());
        }
        _records.BuildUpDictionary();
    }

    public bool TryGetBestMove(string pos, out string move)
    {
        if (_records.ContainsPosition(pos))
        {
            move = _records.GetMoveFor(pos, BookRecord.BookMoveSelector.Random);
            return true;
        }
        else
        {
            move = null;
            return false;
        }
    }
}

[System.Serializable]
public class BookRecord
{
    public List<string> positions;
    public List<string> moves;
    public int CountRecords => moves.Count;

    private Dictionary<string, List<string>> _pairs;

    public enum BookMoveSelector
    {
        Random, First, Last
    }

    public BookRecord()
    {
        positions = new List<string>();
        moves = new List<string>();
    }

    public void AddRecord(string pos, string move)
    {
        positions.Add(pos);
        moves.Add(move);
    }

    public void BuildUpDictionary()
    {
        _pairs = new Dictionary<string, List<string>>();
        for (int i = 0; i < CountRecords; i++)
        {
            if (_pairs.ContainsKey(positions[i]))
                _pairs[positions[i]].Add(moves[i]);
            else
                _pairs.Add(positions[i], new List<string>() { moves[i] });
        }
    }

    public bool ContainsPosition(string pos)
    {
        return _pairs.ContainsKey(pos);
    }
    public string GetMoveFor(string pos, BookMoveSelector selector)
    {
        switch (selector)
        {
            case BookMoveSelector.Random:
                return _pairs[pos][new System.Random().Next(0, _pairs[pos].Count)];
            case BookMoveSelector.First:
                return _pairs[pos][0];
            case BookMoveSelector.Last:
                return _pairs[pos][_pairs[pos].Count - 1];
            default:
                return _pairs[pos][0];
        }
    }
}

Теперь наша программа умеет делать первые 4-5 ходов моментально, пока у нее не закончится теория.

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

Анализ результатов

Мы получили работающую программу для игры в русские шашки.

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

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

Например, возможно, отношение стоимости обычной шашки к дамке должно быть не 100:250, а 100:150 или 100:500. Возможно, стоять в центре, а не на краю шашкам выгоднее не в 1.25 раза, а в 1.1 или 1.5.

Возможно, возможно, возможно...

Разумеется, это все можно настроить, если реализовать "турнир" между компьютерами и постепенно мутировать эти числа, однако чтобы программа могла адекватно играть, ей нужно 10-15 секунд на КАЖДЫЙ ХОД (что дает глубину анализа 9-10 ходов вперед). Так как в шашечной партии в среднем ходов 30, одна такая партия может занять 5-8 минут, а чтобы построить нормальный процесс мутационной эволюции потребуется организовать, пожалуй, сотни и тысячи партий.

Надеюсь, кому-то мой опыт окажется полезным и интересным. Самому мне также интересна эта тема, поэтому я буду рад, если в комментариях найдутся знающие люди, которые смогут подсказать другие возможные улучшения и ускорения алгоритма. Я также не отрицаю, что определенный потолок скорости может задавать Unity, так как, скорее всего, на чистом C++ алгоритм будет работать быстрее, чем на Unity+C#, но переписывать все на С++ я не хочу, к тому же я не знаком с графическими библиотеками С++, чтобы выводить все это на экран.

P.S. Предложениям буду рад, конечно, всем, но что-то высшематематическое вряд ли смогу реализовать. Уровень продвинутой школьной математики:)

Комментарии (24)


  1. Elfet
    16.01.2023 23:49

    Отличная статья, я недавно тоже делал свои русские шашки: https://medv.io/шашки/


  1. mostodont32
    17.01.2023 00:53
    +2

    Для начала можно попробовать хранить кешированные позиции в хешмапе, а не в листе. Это должно дать неплохой такой прирост производительности:)


    1. sovaz1997
      17.01.2023 03:08
      +2

      Есть ещё такая вещь, как Zobrist-хеширование


  1. janatem
    17.01.2023 03:22

    Довольно странно брать эндшпили из базы партий. Гораздо эффективней построить свою версию таблиц Налимова, там в основе довольно простая идея.


  1. sancoder
    17.01.2023 04:20
    +3

    У вас в правилах ошибка. В русских шашках нет обязанности бить большинство. Это правило из международных шашек (поле 10 на 10, "стоклетки"). Другое отличие стоклеток от русских - это правило боя через дамочное поле (в русских шашка становится дамкой и продолжает бой как дамка; в стоклетках бой продолжается как шашка если возможен). Если применить правила стоклеток на малую доску (8 на 8), то получатся бразильские шашки.

    Есть еще такая тема, как эндшпильные базы. Для количества фигур меньше определенного можно расчитать все позиции. В Тундре (шашечная программа, я - один из авторов) полная сжатая 8-фигурка была около 5ГБ (это половина базы, которой достаточно программе для получения результата позиции мгновенно). Было развитие темы в сторону неполных эндшпильных баз, но перспективы этого направления ждут очередного исследователя. В вашей статье рассматривается дебютная библиотека, а не эндшпильная. В эндшпильных базах не хранится позиция, там строится индекс позиции, а в базе хранятся только значения В/Н/П (или количество ходов до выигрыша/проигрыша или перехода в следующий подкласс ЭБ).

    PS. Еще кажется, что у вас алгоритм боя неправильно закодирован. Навскидку, он должен быть сложнее. Например, в позиции б.д. a1, ч.ш. b2 доступно 6 ходов (бой с a1 на c3, d4, e5, f6, g7, h8). Если добавить черную на f4, то должно быть 2 хода (a1:e5:g3; a1:e5:h2).


    1. FismanMaxim Автор
      17.01.2023 07:32

      Спасибо за комментарий.

      Насчет правил — у меня все так и есть. В начале статьи написано: "Если есть несколько вариантов боя — можно выбрать любой". И насчёт дамки -- если шашка превратилась в дамку, она у меня продолжает бить по правилам дамки. Об этом написано в третьей bullet-point после функции GetAllMoves()

      Про эндпильные базы -- интересно. А что за программа такая, Тундра? Она открыта? Можно где-то посмотреть, как она работает?

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


      1. sancoder
        17.01.2023 08:09
        +1

        Насчет правил - когда я читал было написано про большинство. Вы исправили в статье или я ошибся?

        Тундра - программа для игры и анализа шашечных позиций (и партий). Последняя версия 2.4.6, вышла в феврале 2006. Первая версия (не публичная) была - в 2002м. Последняя демо-версия была 2.4.5, в ней был таймер на 10 минут (после этого программа завершала работу). Демо играла хуже, чем проф. версия, которая продавалась до 2009-го примерно.

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


        1. FismanMaxim Автор
          17.01.2023 16:11

          Ничего не исправлял.

          Интересно, получается, шашки все же привлекли чье-то внимание:) Интернет многое помнит, это правда. Поищу, если время будет. Однако, если не знаешь, что ищешь, то сложно найти. Наверно, поэтому я и не смог найти эту программу раньше)


  1. Zara6502
    17.01.2023 06:06
    -1

    чтобы программа могла адекватно играть, ей нужно 10-15 секунд на КАЖДЫЙ ХОД (что дает глубину анализа 9-10 ходов вперед)

    Это на i386 16МГц? У вас 12 шашек, а значит 22 ходов максимум, 22 ответов на ход и 10 ходов в глубину - итого 22*22*10=4840 возможных ходов (утрирую, так как взятие двух шашек приводит к двум ходам, но!!! взятая шашка не будет потом ходить в ответ, плюс у вас пока много фигур на доске и нет развития, то и ходов у вас не так и много, например для первого хода у вас всего 4 варианта начала партии, плюс - вы можете запустить сразу 1000 партий на ночь, сохранять в базу все ходы, как базу дебютов, а потом просто пользоваться базой с заведомо известным весом хода, и ходы у вас будут просто мгновенные. Этим кстати шашки выгодно отличаются от шахмат, можно все варианты ходов записать и считать не придется совсем - никогда, я бы наверное на этом и заморочился, формально игровое поле 8*4, то есть 3^32 состояний на поле, но даже с вашими оптимизациями львиная доля состояний вообще никогда не попадёт в базу, они либо невозможны, либо имеют заведомо низкий вес).

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

    Для С#, который очень быстрый если им правильно пользоваться, это (15 секунд на ход) очень медленно. Я код не читал, у меня проблемы с чтением чужого кода, но в комментариях написали, что вы пользуетесь списками, это плохой вариант, C# для любого нового объекта создает копию . Ну и зачем вам Unity если есть чистый C#, а рисовать можно на канвасе формы, хоть битмапы, хоть по пикселам.


    1. FismanMaxim Автор
      17.01.2023 07:36
      +2

      Я что-то не понял, как вы посчитали на 10 ходов вперед. На конкретном ходе у нас есть 22*22=484 варианта хода. Но из каждой из этих позиций погружаясь на новую глубину, мы получаем новую позицию, в которой в нас опять есть 484 варианта хода. Т.е. мы должны возводить в степень, а не умножать, не так ли?


      1. Zara6502
        17.01.2023 08:24

        На конкретном ходе у нас есть 22*22=484 варианта хода. Но из каждой из этих позиций погружаясь на новую глубину, мы получаем новую позицию, в которой в нас опять есть 484 варианта хода. Т.е. мы должны возводить в степень, а не умножать, не так ли?

        Да. Но вы пропустили слово "утрирую" и текст в скобках, который в 10 раз больше чем этот расчёт. Фишка как раз в том, что 12 шашек у вас никогда одновременно ходить не смогут, а значит у вас, если вы например скормите центр врагу, будет 5-7 ходов, но через 10 ходов у вас будет еще меньше ходов, поэтому у вас, опять же, не будет стабильно 5^10 ходов. Думаю если исключить ходы с дамками, то партия будет приходить за <100 ходов, причем при формировании базы дебютов каждая последующая партия будет в явном виде производить расчёт всё меньшего и меньшего числа ходов и в конечном итоге все ходы будут в базе (повторюсь - без дамок).


      1. Zara6502
        17.01.2023 09:33

        <занудство ON>

        учитывая шашки справа и слева я уменьшил число ходов с 24 (12*2) до 22, но это неправильно, так как из 12 шашек шесть штук не будут иметь позиции для второго хода, поэтому будет 6*2+6=18. Но в процессе игры конечно не всегда будет сохраняться константное значение шашек с одним полем для хода и с двумя. Так что моё уточнение всё же это занудство.

        <занудство OFF>


        1. FismanMaxim Автор
          17.01.2023 16:19

          Ничего не редактировал.

          У меня была идея, дать компу играть самим с собой, но непонятно, что с этим делать дальше.

          Вы предлагаете дать компу играть самому с собой, пока на доске нет дамок, и формировать таким образом базу дебютов. Но как компу понять, что дебют действительно хороший? То есть даже если позиция после 10-15 ходов равна по материалу, это не значит, что она равна действительно. Как компьютеру, разыграв партию, понять, что этот дебют действительно хороший и его стоит запомнить... Не совсем интуитивно понятно...


          1. Zara6502
            17.01.2023 17:25
            +1

            Во-первых вы, как я понял, считаете вес того или иного хода или состояния, что мешает это продолжать делать?

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

            В-третьих, узнать (эмпирически) - сколько существует состояний доски с появлением первой дамки. Интуитивно я не могу утверждать даже порядок. И если таких состояний например 30, то сколько бы вы ходов не делали до этого у вас все равно всё будет приходить к этим 30 состояниям.

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

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

            Для хранения состояния доски достаточно 8 байт (доска 4х8, 2 бита на состояние: пусто, белая, черная, это DWORD, это же хеш), мне кажется сами ходы совсем не важны, достаточно выстроить последовательность состояния доски - двунаправленное дерево. В C# это Dictionary по DWORD (uint кажется для 8 байт). Смотрите словарь, если нет совпадения, то применяете ваше решение как в этой статье, если есть совпадение, то вам отдается список хешей следующих возможных состояний.

            В общем это мои размышления, не претендую на истину.


            1. Zara6502
              18.01.2023 04:55

              я тут очевидно ошибся когда писал, так как DWORD это Double Word или 4 байта. Поэтому для 64-битной системы это всё же Unsigned Int (uint). Это один регистр процессора, любое сравнение за одну операцию.


  1. Didimus
    17.01.2023 06:49

    Я правильно понимаю, что сейчас выиграть у компьютера в шашки невозможно? В отличии от шахмат


    1. FismanMaxim Автор
      17.01.2023 07:23
      +1

      Выиграть у компьютеры в шахматы — невозможно. После победы компьютера DeepBlue над Каспаровым в 1996 году, компьютеры развились настолько, что могут победить даже чемпиона мира, еще и с форой.

      Английские шашки, как я написал, постигла ничейная смерть. Это значит, что были полностью рассчитаны варианты, гарантированно обеспечивающие игроку ничью, за какую сторону бы он не играл.

      Насчет русских шашек -- не уверен. Как я и сказал, я не нашел каких-либо готовых программ.


  1. Deosis
    17.01.2023 08:50
    +2

    ей нужно 10-15 секунд на КАЖДЫЙ ХОД

    Для начала стоит поискать профайлером горячие места.

    Но линейный поиск транспозиций через сравнение строк? Тем более в шашках только ходы дамок могут привести к повтору.

    Возможно, требуется много памяти. Можно хранить положение всех белых шашек в виде одного числа, тогда на все состояние потребуется 3 числа (белые, черные, признак дамок) и байт хода.

    При таком хранении большую часть ходов можно получить несколькими битовыми операциями.


    1. FismanMaxim Автор
      17.01.2023 16:22

      Спасибо за советы.

      Насчет транспозиций, не совсем так: одинаковые позиции могут возникнуть, например, если шашка пойдет налево или направо: e3-d4-e5 или e3-f4-e5.


  1. domix32
    17.01.2023 11:48
    +1

    @GlukKazanтут в шашки играют


    1. GlukKazan
      17.01.2023 12:11

      Да, я видел. Но моя программа не самая сильная.


  1. dimas846
    17.01.2023 11:54
    +1

    А тут https://ru.wikipedia.org/wiki/Plus600_(шашечная_программа) наверное самая сильная программа по русским шашкам.



  1. sancoder
    17.01.2023 19:06
    +1

    По большому счету, у вас самые большие оптимизации выполнены.

    Основная цель оптимизации алгоритма перебора - это увеличение количества отсечений.
    То есть лучше не делать работу по перебору позиций, если вся ветка будет впоследствии отсечена другим ходом.
    Можно еще чуть дожать отсечения, если использовать NegaScout алгоритм (вариация альфа-беты, но симметричная).
    Я еще пробовал MTD(f), но каких-то существенных улучшений от использования алгоритма не заметил.

    Что у вас не хватает - это отсечения по границам. Например, в переборе на определенную глубину вы нашли, что позиция оценена как 200.
    Если эта оценка между альфой и бетой, то вы сохраняете в хэш точную оценку позиции. Потом ее прочитаете - и используете, здесь все правильно.
    Но если вы нашли оценку 200, когда ваша альфа 200, то у вас отсечение, а не оценка позиции. В будущем - иногда вы можете отсечь (если следующий перебор будет с более режущими условиями), а иногда - нет.

    Вам тут в соседних комментариях указывают на то, что списком хранить хэш-таблицу не стоит, а стоит использовать что-то более оптимальное.
    Также стоит обратить внимание на представление доски. Использование битовых масок, если на доске нет дамок, позволяет определить наличие возможности боя (бой обязателен) без перебора каждой шашки. Скорость генератора ходов на частоте процессора 2ГГц была около 10 млн генераций/сек. Это на одном ядре, конечно же.

    Оценочная функция - это основное в шашечной программе (имхо), если все оптимизации сделаны. У вас ее уровень - слабый. Учитывание материальных факторов (стремление к материальному перевесу) - это база игры в шашки, но оценка позиции должна быть намного более комплексной. Здесь остро проявляется отличие шахмат от шашек: в шахматах фигуры разные, а в шашках - одинаковые. Это делает игру интереснее и сложнее в плане оценки позиции.
    В подавляющем большинстве случаев дамки появляются либо только у одной из сторон, либо в эндшпилях (кол-во фигур менее 10). Если дамка появляется в середине партии, да еще и у каждой из играющих сторон, то такие позиции оценить крайне тяжело. Но таких партий очень мало.

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

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

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