前言
上一篇文章已经有了蒙特卡洛树搜索算法了,再加上五子棋的局面状态的话,基本就是一个简单的 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;
}