1. 问题描述
实现一个四子棋落子策略,能够根据当前棋盘状态(动态长宽,含有一个随机不可落子点),自动选择收益较大的落子点。在与测例动态库的博弈中,尽可能多地赢得比赛。
2. 模型与算法
2.1 整体框架
使用“蒙特卡罗搜索”和“信心上限树算法”,构建UCT树,随机模拟对战过程、计算收益,从而确定最佳落子点。
2.2 实现细节
2.2.1 蒙特卡洛搜索
- Selection:每轮开始前依据己方和对方的上一个决策(共两步)对树进行换根,建立UCT 树,根的子节点即每一列可落子的状态。
- Expansion:从根出发,若当前点可以扩展新的子节点,则扩展并选中新扩展的点。若当前点无法扩展新的子节点,则选中以当前点为根的子树中得分最高的叶节点。得分规则(UCB)为
其中 为当前节点, 为考察的子节点。 为 被回溯的次数, 为 回溯总得分。
- Simulation:将新的子状态进行模拟,双方轮流随机落子,并在该过程中添加启发性策略(见2.3.1)进行优化。
- Backpropagation:将模拟产生的结果——胜(1分),平(0分),负(-1分)作为收益回传至根,选择根收益最大的一个子节点作为最佳落子点。
2.2.2 类与方法
class Agent
模型的主体。其中
Search()
作为外部调用的接口,返回每一步的走棋,RootChangeTo(…)
对应MCTS的 Selection 作为公有函数对接评测框架的数据接口。treePolicy(…)
, defaultPolicy(…)
, backTrack(…)
作为私有函数分别对应MCTS的 Expansion,Simulation 和 Backpropagation过程。class Agent { private: ... int treePolicy(int x, Status* board); int defaultPolicy(int x, Status* board); void backTrack(int x, int value); public: ... Agent(int N, int M, int noX, int noY); std::pair<int, int> Search(); void RootChangeTo(std::pair<int, int> decision); ... };
struct Node
UCT树中的节点,包含的属性和方法有
struct Node { int child[MAX_WIDTH]; // 子节点 int cnt, value; // N(v), Q(v) int parent; // 父节点 void refresh(int parent_) // 重用前重置 (见2.3.2) }
struct NodePool
内存池,Queue型数据结构,实现空间的重复高效利用(见2.3.2)
struct NodePool { int q[MAX_TREE_NODE + 10]; int h, w; NodePool() : h(0), w(0) {} void enqueue(int x) {...} int dequeue() {...} bool empty() {...} }
class Status
存储棋局信息,辅助
Agent
中函数。其私有属性中包含当前棋局的落子信息,双方的Urgent Threat,游戏进行状态等。Public中的方法对这些属性进行维护。class Status { private: ... int n, m, noX, noY; enum State {win, draw, running, lose}; unsigned horizon[12], vertical[12], diag1[30], diag2[30]; // 状态压缩(见2.3.3) int top[12]; // 12列(竖向)的顶端位置 UrgentList urgentList[2]; bool urgent[2][13][13]; // Urgent Threat信息 public: ... }
2.3 性能优化
2.3.1 启发性策略
首先引入Vector Allis关于四子棋必胜策略论文中的 threat 概念。
- threat:
如果一个点被A的对手B占领后,B会连起4个子,则这个位置是A的 threat。
- urgent threat:
可以在这一手棋下到的 threat。
基于此,可以将落子策略由单纯依靠UCT算法改进为:
- 如果对手有urgent threat, 则下在那里即可获得胜利。
- 如果自己有urgent threat, 则必须下在那里,否则下一步对手可以下在那里并获胜。
- 按UCT算法进行落子,但不能落在对手threat的下面一格。
此外,还可以对模拟过程进行优化。引入threat和urgent threat概念后,我们可以据此判断是否存在必胜必败态,从而实现剪枝。
- 若玩家落子前,他的对手有urgent threat,则玩家可以将子下在那个位置直接取得胜利。
- 若对手没有urgent threat
- 若某玩家存在两个urgent threat,则无论他将棋子落在何处,对手均可以将棋下在urgent threat处取得胜利。因此,如果下完一手棋后,对手出现两个urgent threat, 且己方没有urgent threat, 则己方必胜。如果己方落子前已有两个urgent threat且对手没有urgent threat,则己方必败。
- 若玩家有一个urgent threat,并且这个位置上面也是他的threat,则玩家必败。反之,若对手出现这种情况,则玩家必胜。
- 玩家A落子后,玩家B所有可行的落子点上方都是自己的threat,则玩家A必胜.
利用必胜/必败态进行随机模拟过程中的剪枝不仅可以节约运算时间,更能比MCTS中随机落子的方式更真实地评价当前局面。
2.3.2 内存池
算法运行过程中,会出现大量的新建节点和删除节点的操作,如果每次都调用new和delete会造成巨大的时间开销。因此选择在开始时建一个大数组作为内存池。此外,通过
NodePool
对内存池进行维护。在新建和删除节点时,为了实现空间的重复利用,进行相应的设计:
- 删除节点时,设原树根为 root1,新树根 root2 为 root1的子节点。则在
RootchangeTo
时需删除 root1 及 root2 的兄弟节点 - 将 root1 的子节点信息清空并放入
NodePool
中 - 将 root2 的兄弟节点放入
NodePool
中
- 新建节点时,直接从
NodePool
中取一个节点,并将其子节点放入内存池中(见Agent::newNode
),再将其重置(refresh
)后即可使用.
这样可以实现内存池的重复利用,并降低新建和删除节点时的均摊复杂度。
2.3.3 状态压缩
由2.3.1可知,使用启发性策略辅助剪枝时最大的瓶颈在于找到 threat 的位置。为了提升每轮的迭代次数从而提升模型性能,使用状态压缩的方法对寻找 threat 的过程进行优化 — 即对棋盘每个落子点进行状态编码,进而可通过位运算加速状态判断过程。具体实现如下:(代码中由
class Status
实现)- 将落子点的四种状态:空位,不可落子点,已有A子,已有B子分别用
00
01
10
11
表示。
- 为实现方便,将棋盘的四周全部列为不可落子点位置,这样每一行和仅需用位的二进制数表示,并可用
int
实现。列和斜向的操作类似,两种斜向可分别视为把 相同的以及把 相同的归为一行。
示意图如下(以4*5棋盘为例)
- 则最低行可用 表示,最右边一列可用橙色对角线可用 表示。
- 落子后,可通过位操作取出落子点及其周围的连续四个位置的编码,若为,,,中的一个,则出现了一个A的threat (即B还差一个位置就可连成四个);而若出现了,,,中的一个则出现了一个B的threat.
- 由于落子点可能位于取出的位置(串)中的任一位置,故对于行和斜向操作共需取四次数。
- 而对于竖向,由于必定是按照从低到高的顺序落下的,因此如果落子后产生threat,则threat一定是落子点之上的一个点,因此仅需进行一次取数判定。
实验结果显示,通过状态压缩可将局面判断的时间开销缩短一半以上,每轮迭代次数增加到原来的近两倍
3. 实验结果
与2.dll ~ 100.dll 分别进行5轮对战,每轮对战包括一次先手,一次后手,总胜率为92.8%。下表为部分胜率统计,未列出的均获得全胜。
编号 | 100 | 98 | 96 | 94 | 92 | 90 | 88 | 86 | 84 | 82 | 80 | 68 | 60 |
胜率 | 0.5 | 0.4 | 0.6 | 0.8 | 0.8 | 0.8 | 0.7 | 0.8 | 0.8 | 0.7 | 0.8 | 0.8 | 0.9 |
4. 总结
通过本次大作业,我进一步加深了对MCTS,UCT算法的理解。同时通过实验中关于启发策略,内存池,状态压缩等优方式的尝试也让我有了很大的收获。
但模型仍有很大改进的空间,比如在扩展和模拟的过程中,仍存在很大的随机性,而对于子节点价值的评估也显得有些简单。之后可以考虑使用AlphaZero的模型,训练策略网络和估值网络,进一步提升模型的性能。
Loading Comments...