五子棋AI - 局面状态

前言

 

上一篇文章已经有了蒙特卡洛树搜索算法了,再加上五子棋的局面状态的话,基本就是一个简单的 AI 了。这篇文章主要介绍如何实现局面状态。

局面状态

根据五子棋的玩法,局面状态应该包含如下数据:

  • 当前行棋方
  • 当前局面是否终结
  • 获胜玩家
  • 棋子位置

对于行棋方的表示,我采用 1 表示黑方,-1 表示白方,这样我就可以使用 player = -player 来交换行棋方(有点符合直觉的味道);局面是否终结自然是一个 bool 值;获胜玩家同行棋方一样,1 表示黑方获胜,-1 表示白方获胜,0 表示平局或者未终局(视局面终结状态而定);对于棋子位置,前采用了两个 std::bitset 来分别表示黑子和白子的位置。不采用字节数组表示棋盘状态是为了节省一点内存,而且根据要解决的问题,采用 bitset 也还不错。于是棋盘状态数据结构看起来如下:

class gomoku_state_t {
private:
  std::bitset<16*15> _black;
  std::bitset<16*15> _white;
  int _turn;
  int _winner;
  bool _gameover;
};

bitset 大小设置为 16*15 是为了方便把 xy 坐标变换为索引,这里棋盘的大小为 15x15。

局面状态有了之后,接下来就是添加必要的接口了,清单如下:

class gomoku_state_t {
public:
  typedef move_t action_type;

  void put_black(int x, int y);
  void put_white(int x, int y);
  void set_turn(int player);

  void play(int x, int y);

  bool gameover() const;
  int turn() const;
  int winner() const;

  bool has_stone(int x0, int x1, int y0, int y1) const;

  template<class _FwIt, class _Fnc>
  _FwIt avail_moves(_FwIt dest, _Fnc&& pred = [](int x, int y) { return true; }) const;

private: // data members
  //...
};

其中 play 的行为是为当前行棋方在指定的位置下子,然后判断输赢,再交换行棋方。avail_moves 是根据当前局面获取可行着法,dest 是一个前向迭代器,pred 是一个筛选器。

move_t 表示一个着法,有一些函数负责获取着法的信息,比如 move_x()、move_y() 这两个函数用于获取 X Y 坐标。

小优化

在执行蒙特卡洛树搜索时,会不断地展开子节点从而占用较大内存。根据五子棋的玩法,可以帮助控制好子节点的个数。

五子棋有较强的局部性,棋子一般不会相距太远,因此在获取可行着法时不用把所有空白点位返回。我的经验是,一个点位周围 2 格范围内如果没有棋子就不用考虑。

在判断输赢时,也不用全盘查找,只用考虑每方最后一手棋 8 个方向 4 格范围内的情况,这样只用扫描 33 个点位,一方成 5 棋局就结束了(现只考虑自由规则)。

接入蒙特卡洛搜索

在前面的文章中留了 3 个函数待实现,现在就可以接入了。

std::vector<move_t> actions(const gomoku_state_t& s) {
  std::vector<move_t> moves;
  s.avail_moves(std::back_inserter(moves), [&s](int x, int y) {
    int minx = std::max(0, x - 2);
    int maxx = std::min(13, x + 2);
    int miny = std::max(0, y - 2);
    int maxy = std::min(13, y + 2);
    return !s.has_stone(minx, maxx, miny, maxy);
  });
  return moves;  
}

gomoku_state_t next(const gomoku_state_t& s, const move_t& a) {
  if (s.gameover()) return s;
  gomoku_state_t r = s;
  r.play(move_x(a), move_y(a));
  return r;
}

int playout(const gomoku_state_t& s) {
  gomoku_state_t g = s;
  if (!g.gameover()) {
    std::vector<move_t> moves;
    s.avail_moves(std::back_inserter(moves));
    std::random_shuffle(moves.begin(), moves.end());
    
    for (auto it = moves.begin(); !g.gameover(); ++it)
      g.play(move_x(*it), move_y(*it));
  }

  // 因为要评估 s 的结果,所以比较 s.turn() 
  if (s.turn() == g.winner()) return 1;
  else (-s.turn() == g.winner()) return -1;
  return 0;
}

跑起来

接入好了就可以测试一下了。在我的 i5 电脑上,每秒大约可以进行 3 万次模拟;如果每步棋 15 秒,大约需要 4GB 内存;搜索深度最大大概能达到 7 层。以下是两个蒙特卡洛法AI对弈的一局棋:

这盘棋下得还算可以,黑棋 43 转折点,黑棋胜率由此急剧下落;白棋 44 一套连招还挺意外的。从这盘棋可以看到,除非有无限次的模拟,否则纯粹的蒙特卡洛法会因为水平线效应而错过最优解。要改善这种状况,就需要更准确的局面评估算法,这样即使少量的模拟,也能让搜索更准确。事实上,即使是加入了神经网络评估的蒙特卡洛算法,也有可能会无法看到某个陷阱。

整个速度应该还能进一步提高,比较明显的比如 playout 中 shuffle 与 play 可以合并;再一个,如果不涉及多线程,每次获取 avail_moves 时可以免去创建临时的 std::vector。

附:

胜负判断相关代码

// 判断某个方向上是否有 5 子连珠
bool win_line( const std::bitset<16 * 15>& stones, std::size_t start, std::size_t stop, std::size_t stride )
{
    int cnt = 0;
    for( ; cnt < 5 && start != stop; start += stride )
        if( stones.test[start] )
            cnt++;
        else
            cnt = 0;
    return cnt >= 5;
}

bool win_line_at(const std::bitset<16 * 15>& stones, std::size_t p, std::size_t da, std::size_t db, std::size_t stride) {
    return win_line( stones, p - da*stride, p + stride*(db + 1), stride );
}

// 判断某一方是否赢棋
bool win( const std::bitset<16 * 15>& stones )
{
    // 水平方向
    for( std::size_t row = 0; row < 240; row += 16 )
        if( win_line( stones, row, row + 15, 1 ) )
            return true;

    // 垂直方向
    for( std::size_t col = 0; col < 15; col++ )
        if( win_line( stones, col, col + 240, 16 ) )
            return true;
    
    // ‘\’ 方向对角线上半部分
    for( std::size_t s = 0, e = 255; s != 11; s++, e -= 16 )
        if( win_line( stones, s, e, 17 ) )
            return true;

    // ‘\’ 方向对角线下半部分
    for( std::size_t s = 16, e = 254; s != 176; s += 16, e-- )
        if( win_line( stones, s, e, 17 ) )
            return true;

    // ‘/’ 方向对角线上半部分
    for( std::size_t s = 4, e = 79; s != 15; s++, e += 16 )
        if( win_line( stones, s, e, 15 ) )
            return true;

    // ‘/’ 方向对角线下半部分
    for( std::size_t s = 30, e = 240; s != 190; s += 16, e++ )
        if( win_line( stones, s, e, 15 ) )
            return true;

    return false;
}

// 判断 x, y 位置周围是否存在赢棋
bool win( const std::bitset<16 * 15>& stones, int x, int y )
{
    constexpr std::size_t _4 = 4;
    std::size_t x0 = static_cast<std::size_t>(x);
    std::size_t y0 = static_cast<std::size_t>(y);
    std::size_t x1 = 14 - x0;
    std::size_t y1 = 14 - y0;
    std::size_t p = y0 * 16 + x0;

    // 水平方向
    x0 = std::min( x0, _4 ); x1 = std::min( x1, _4 );
    if( win_line_at( stones, p, x0, x1, 1 ) )
        return true;

    // 垂直方向
    y0 = std::min( y0, _4 ); y1 = std::min( y1, _4 );
    if( win_line_at( stones, p, y0, y1, 16 ) )
        return true;

    // ‘\’ 方向
    std::size_t ma = std::min( x0, y0 ); std::size_t mb = std::min( x1, y1 );
    if( win_line_at( stones, p, ma, mb, 17 ) )
        return true;

    // ‘/’ 方向
    ma = std::min( x1, y0 ); mb = std::min( x0, y1 );
    if( win_line_at( stones, p, ma, mb, 15 ) )
        return true;

    return false;
}
  • 1
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 13
    评论
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值