Skip to content

Commit 45a5a68

Browse files
committed
优化文档
1 parent 5e11343 commit 45a5a68

File tree

3 files changed

+174
-32
lines changed

3 files changed

+174
-32
lines changed

doc/五子棋AI算法(1).md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44

55
首先,我们计划是做一个五子棋AI,也就是说让玩家和这个AI对下。整个游戏的框架是这样的:
66

7-
![五子棋游戏框架](pic/20191018105636427.png)
7+
```mermaid
8+
flowchart TD
9+
subgraph 棋盘
10+
player1
11+
player2
12+
end
13+
```
814

9-
其中,棋盘是一个Object,存放当前的棋局情况,通知每个Player“轮到你下棋了”、“对方下了什么棋”、“游戏结束,XXX获胜”等消息,并且从每个Player那里获取他下了什么棋。两个Player分别是人类玩家和AI。Player的基类应该是一个interface,里面只有三个方法。人类玩家和AI是它的子类,分别实现这三个方法。
15+
其中,棋盘是一个`Object`,存放当前的棋局情况,通知每个`Player`“轮到你下棋了”、“对方下了什么棋”、“游戏结束,XXX获胜”等消息,并且从每个`Player`那里获取他下了什么棋。两个`Player`分别是人类玩家和AI。`Player`的基类应该是一个`interface`,里面只有三个方法。人类玩家和AI是它的子类,分别实现这三个方法。
1016

1117
```java
1218
public interface Player {
@@ -31,13 +37,13 @@ public final class Point {
3137

3238
解释一下:
3339

34-
- *play* 方法,告知“轮到你下棋了”,并且返回一个*Point*,也就是下一步下的棋。对于人类玩家,则就是阻塞,等待玩家在界面上选取一个点,并且将这个点的坐标返回。对于AI,则是直接开始用我们的AI算法进行计算,并返回计算结果。
35-
- *void display(Point p)* 方法,告知“对方下了什么棋”。对于人类玩家,则就是将对方下了的棋在界面上显示出来。对于AI,则是将对方下了的棋记在AI的缓存中,以便后续的计算。
36-
- *void notifyWinner(int color)* 方法,告知“游戏结束,XXX玩家赢了”。对于人类玩家,则就是在界面上展示谁赢了的文字及特效,并且从此之后再点击棋盘就不再有反应了。对于AI,则是通知AI不要再计算了。
40+
- `play`方法,告知“轮到你下棋了”,并且返回一个`Point`,也就是下一步下的棋。对于人类玩家,则就是阻塞,等待玩家在界面上选取一个点,并且将这个点的坐标返回。对于AI,则是直接开始用我们的AI算法进行计算,并返回计算结果。
41+
- `void display(Point p)`方法,告知“对方下了什么棋”。对于人类玩家,则就是将对方下了的棋在界面上显示出来。对于AI,则是将对方下了的棋记在AI的缓存中,以便后续的计算。
42+
- `void notifyWinner(int color)`方法,告知“游戏结束,XXX玩家赢了”。对于人类玩家,则就是在界面上展示谁赢了的文字及特效,并且从此之后再点击棋盘就不再有反应了。对于AI,则是通知AI不要再计算了。
3743

38-
当然了,如果打算连续下多盘棋,可能还需要一个*reset*方法,通知人类玩家和AI清空当前棋盘。当然了,这个和我们的算法关系不大就不列出来了。
44+
当然了,如果打算连续下多盘棋,可能还需要一个`reset`方法,通知人类玩家和AI清空当前棋盘。当然了,这个和我们的算法关系不大就不列出来了。
3945

40-
然后,我们的*interface Player*需要两个实现类,分别叫做*HumanPlayer**RobotPlayer*,这个*RobotPlayer**play*方法将是五子棋AI算法的核心内容,后面会花费大量篇幅进行讲解。
46+
然后,我们的`interface Player`需要两个实现类,分别叫做`HumanPlayer``RobotPlayer`,这个`RobotPlayer``play`方法将是五子棋AI算法的核心内容,后面会花费大量篇幅进行讲解。
4147

4248
接下来就是我们的棋盘:
4349

@@ -93,14 +99,14 @@ public class ChessBoard {
9399

94100
棋盘的代码确实很简单易懂,也做了很多注释,就不多介绍了。
95101

96-
接下来,就只剩下*HumanPlayer**RobotPlayer*的实现了。
102+
接下来,就只剩下`HumanPlayer``RobotPlayer`的实现了。
97103

98104
# 人类玩家
99105

100106
人类玩家无非就是实现三个方法:
101-
- *play* 方法,阻塞等待玩家点击棋盘上的一个点并返回这个点
102-
- *display* 方法,将AI下的棋展示在界面上
103-
- *notifyWinner* 方法,显示一行字“你赢(输)了”
107+
- `play`方法,阻塞等待玩家点击棋盘上的一个点并返回这个点
108+
- `display`方法,将AI下的棋展示在界面上
109+
- `notifyWinner`方法,显示一行字“你赢(输)了”
104110

105111
这段代码与本文无关,就不贴出来了,我把我做的这个丑陋的界面贴出来展示一下,哈哈。
106112

doc/五子棋AI算法(2).md

Lines changed: 121 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
上一章进行了一个五子棋游戏框架的搭建。应该来说,除开AI以外,其他的部分全部写完了。从这章开始,就详细介绍一下五子棋的AI算法。这里说一件非常令人振奋的消息:这章看完之后,你的五子棋AI已经可以下棋了,唯一的缺点就是奇慢无比,但是只要你愿意让他思考足够长的时间,他的棋力绝对是非常棒的了。优化算法将在后续的章节慢慢讲解。
22

3-
回顾一下上一章的内容,我们需要一个RobotPlayer类,实现Player接口的三个方法
3+
回顾一下上一章的内容,我们需要一个`RobotPlayer`类,实现`Player`接口的三个方法
44

55
```java
66
public class RobotPlayer implements Player {
@@ -32,17 +32,17 @@ public class RobotPlayer implements Player {
3232
}
3333
```
3434

35-
在讲这个play方法之前,我们首先需要掌握一些数学知识。
35+
在讲这个`play`方法之前,我们首先需要掌握一些数学知识。
3636

3737
# 完全信息动态零和博弈
3838

3939
五子棋按照分类属于完全信息动态零和博弈。这里就涉及了很多看上去貌似很复杂的词汇。
4040

41-
首先,博弈就是下棋,或者说就是game,这个很好理解。前面三个词语就需要一一解释了:
41+
首先,**博弈**就是下棋,或者说就是game,这个很好理解。前面三个词语就需要一一解释了:
4242

43-
- 完全信息:游戏进行的任何时候,双方参与者对棋盘上的全部信息都完全了解。这一点就不像军旗、斗地主、麻将,我并不知道对方的一些信息。
44-
- 动态:双方是轮流下子,每一步棋做决策之前,需要上一步棋的局面状态做出相应的选择。而非石头剪刀布那样,双方同时做决策。
45-
- 零和(zero sum):或者说是“非合作性”,五子棋不存在任何“双方共同目标”。换句话说,某步棋对这个玩家产生了正价值,则一定对他的对手产生了等量的负价值。关于什么是“非零和博弈”,可以自行搜索一下“囚徒困境”。
43+
- **完全信息**:游戏进行的任何时候,双方参与者对棋盘上的全部信息都完全了解。这一点就不像军旗、斗地主、麻将,我并不知道对方的一些信息。
44+
- **动态**:双方是轮流下子,每一步棋做决策之前,需要上一步棋的局面状态做出相应的选择。而非石头剪刀布那样,双方同时做决策。
45+
- **零和(zero sum)**:或者说是“非合作性”,五子棋不存在任何“双方共同目标”。换句话说,某步棋对这个玩家产生了正价值,则一定对他的对手产生了等量的负价值。关于什么是“非零和博弈”,可以自行搜索一下“囚徒困境”。
4646

4747
对于这类“完全信息动态零和博弈”问题,我们有一种通用的解法。因为是“完全信息”的,我们不需要考虑复杂的概率问题,只需要找到那个最好的决策即可。因为是“零和”的,我们只需要一个评估函数就可以对场上的局面进行评价(我的评分和对方的评分一定互为相反数)。因为是“动态”的,所以要考虑的问题很多,就需要用到后文的“博弈树”进行解决了。
4848

@@ -99,27 +99,133 @@ public class RobotPlayer implements Player {
9999

100100
循序渐进,我们先考虑一步棋,如下图所示:
101101

102-
![图1](pic/20191020153520680.png)
102+
```mermaid
103+
graph TD
104+
当前盘面 --> 10
105+
当前盘面 --> 15
106+
当前盘面 --> 7
107+
当前盘面 --> 9
108+
```
103109

104-
假设在当前盘面下,我们有四种走法,对于每种走法我们调用上文的评估函数*evaluateBoard*,得到四个得分,显然,我们更倾向于选择最高分15对应的那个走法。换句话说,我们可以认为以当前局面发展,可以到达15分的局面。
110+
假设在当前盘面下,我们有四种走法,对于每种走法我们调用上文的评估函数`evaluateBoard`,得到四个得分,显然,我们更倾向于选择最高分15对应的那个走法。换句话说,我们可以认为以当前局面发展,可以到达15分的局面。
105111

106112
现在我们开始考虑两步棋,如下图所示:
107113

108-
![图2](pic/20200122114705945.png)
114+
```mermaid
115+
flowchart TD
116+
subgraph MAX 1
117+
10
118+
15
119+
7
120+
9
121+
end
122+
subgraph MIN 2
123+
6
124+
5
125+
2
126+
8
127+
0
128+
4
129+
3
130+
1
131+
end
132+
当前盘面 --> 10
133+
10 --> 6
134+
10 --> 5
135+
当前盘面 --> 15
136+
15 --> 2
137+
15 --> 8
138+
当前盘面 --> 7
139+
7 --> 0
140+
7 --> 4
141+
当前盘面 --> 9
142+
9 --> 3
143+
9 --> 1
144+
```
109145

110146
假设对于我的这四种走法,对方分别有两种走法进行应对。现在情况开始变得复杂了。我们重新强调一下,这是一个“零和博弈”,也就是说,我的正分一定等于对方的负分。如果我选择了15分这种走法,对方肯定不傻,一定会选择2分这种走法,想让我的分更低。如果我选择了10分这种走法,对方一定会选择5分这种走法。想要将局面变成6分或者8分的结果,是不可能的(除非对面犯傻)。那么对于图上的那种情况,我们分析一下:如果我选第一种走法,则会得到5分;如果我选第二种走法,则会得到2分;如果我选第三种走法,则会得到0分;如果我选第四种走法,则会得到1分。那我到底应该选择哪种走法呢?显然,我更希望两步棋后,局面是5分,我选择了第一种走法。
111147

112148
重新审视一下这个问题,我们不难发现,如果我考虑两步棋,那么第一步棋的得分是没有用的。我的实际求解过程是:先通过每种第一步棋,求得对应的第二步棋的最小得分,再从这些最小得分中,找到那个最大得分。
113149

114-
好了,为了游戏更加精确,我们继续尝试考虑4步棋。自己画图太过麻烦,我就随便搜索了一张图片:
115-
116-
![图3](pic/20191020160454176.png)
150+
好了,为了游戏更加精确,我们继续尝试考虑4步棋:
151+
152+
```mermaid
153+
flowchart TD
154+
subgraph MAX 1
155+
b0
156+
b1
157+
end
158+
subgraph MIN 2
159+
c0
160+
c1
161+
c2
162+
c3
163+
end
164+
subgraph MAX 3
165+
d0
166+
d1
167+
d2
168+
d3
169+
d4
170+
d5
171+
d6
172+
d7
173+
d8
174+
end
175+
subgraph MIN 4
176+
e0
177+
e1
178+
e2
179+
e3
180+
e4
181+
e5
182+
e6
183+
e7
184+
e8
185+
e9
186+
e10
187+
e11
188+
e12
189+
e13
190+
e14
191+
end
192+
当前盘面 --> b0
193+
当前盘面 --> b1
194+
b0 --> c0
195+
b0 --> c1
196+
b1 --> c2
197+
b1 --> c3
198+
c0 --> d0
199+
c0 --> d1
200+
c0 --> d2
201+
c1 --> d3
202+
c1 --> d4
203+
c2 --> d5
204+
c2 --> d6
205+
c3 --> d7
206+
c3 --> d8
207+
d0 --> e0
208+
d1 --> e1
209+
d1 --> e2
210+
d1 --> e3
211+
d2 --> e4
212+
d3 --> e5
213+
d3 --> e6
214+
d4 --> e7
215+
d4 --> e8
216+
d5 --> e9
217+
d5 --> e10
218+
d6 --> e11
219+
d7 --> e12
220+
d8 --> e13
221+
d8 --> e14
222+
```
117223

118224
同样,按照上面的思路,我们需要反着考虑。首先考虑第四步棋,这是对方选择的一步棋。对于每一种第三步的局面,对方肯定选择分数最低的一步棋,我们把同一个第三步下的所有第四步的最小值求出来,作为第三步的分数即可。然后对于每个第二步的局面,我肯定选择分数最高的那个第三步,因此只需要求出同一个第二步下的所有第三步的最大值求出来,即可作为第二步的分数。同理,我们继续找第二步的最小值当做第一步的分数。最后再找到第一步的最大值,作为我决策的下一步棋。
119225

120226
以上,就是我们所说的“极小极大值搜索”算法。
121227

122-
值得一提的是,如果我优先下出了五连珠,游戏会立即结束,如果下一步棋对方也下出了五连珠,则我的五连珠调用evaluateBoard(1)减去对方的五连珠调用evaluateBoard(2)等于0,这个情况我们要排除掉,因为我已经先下出五连珠了,游戏已经结束了,对方再下出来的棋是无效的。
228+
值得一提的是,如果我优先下出了五连珠,游戏会立即结束,如果下一步棋对方也下出了五连珠,则我的五连珠调用`evaluateBoard(1)`减去对方的五连珠调用`evaluateBoard(2)`等于0,这个情况我们要排除掉,因为我已经先下出五连珠了,游戏已经结束了,对方再下出来的棋是无效的。
123229

124230
推广到连续考虑N步棋,我们可以得到这样的代码:
125231

@@ -181,7 +287,7 @@ public class RobotPlayer implements Player {
181287
}
182288
```
183289

184-
到此为止,我们就可以用*getMaxEvaluate*方法求出一个“最好的策略”了。把这个方法再进一步组合,我们可以得到play方法
290+
到此为止,我们就可以用`getMaxEvaluate`方法求出一个“最好的策略”了。把这个方法再进一步组合,我们可以得到`play`方法
185291

186292
```java
187293
import java.util.*;
@@ -223,4 +329,4 @@ public class RobotPlayer implements Player {
223329

224330
好了,至此为止,恭喜你,我们已经可以和AI进行五子棋对弈了。
225331

226-
写完了算法,我们大致看看这个算法的计算量。尽管我们可以排除距离局面太远的点,但是下到中局的时候,我们每一步棋起码也要考虑三四十个点。假设要考虑八步棋,就是30^8^=6561亿个分支,简直庞大的计算量。目前这个算法在搜索深度是4的情况下,可以保证在几秒钟最多一分钟之内计算出结果,如果想要考虑八步甚至十步棋,显然远远是不够的。下一章会介绍α-β剪枝、启发式搜索等一些算法,对目前的算法进行优化。
332+
写完了算法,我们大致看看这个算法的计算量。尽管我们可以排除距离局面太远的点,但是下到中局的时候,我们每一步棋起码也要考虑三四十个点。假设要考虑八步棋,就是 $30^8=6561亿$ 个分支,简直庞大的计算量。目前这个算法在搜索深度是4的情况下,可以保证在几秒钟最多一分钟之内计算出结果,如果想要考虑八步甚至十步棋,显然远远是不够的。下一章会介绍α-β剪枝、启发式搜索等一些算法,对目前的算法进行优化。

doc/五子棋AI算法(3).md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,37 @@
44

55
回顾一下我们上一章说的“极大值极小值搜索”:
66

7-
![图1](pic/20200122114705945.png)
7+
```mermaid
8+
flowchart TD
9+
subgraph MAX 1
10+
10
11+
15
12+
7
13+
9
14+
end
15+
subgraph MIN 2
16+
6
17+
5
18+
2
19+
8
20+
0
21+
4
22+
3
23+
1
24+
end
25+
当前盘面 --> 10
26+
10 --> 6
27+
10 --> 5
28+
当前盘面 --> 15
29+
15 --> 2
30+
15 --> 8
31+
当前盘面 --> 7
32+
7 --> 0
33+
7 --> 4
34+
当前盘面 --> 9
35+
9 --> 3
36+
9 --> 1
37+
```
838

939
按照上一章的方法,首先我们从第一个分支6和5中找到最小值5。接下来,我们找第二个分支,结果我们一上来就看到了2。这个时候我们思考一下,如果后面的数比2大,那么2是最小值;如果后面的数比2小,那么说明最小值比2小。这样一来,我在第二个分支中得到的最小值肯定不会超过2。在第一个分支我们已经得到最小值5了,由于MAX层要找最大值,第二个分支是不会超过2的,所以整个第二个分支下面的所有分支我们都可以放弃了。这就是所谓的“剪枝”。同理,第三个分支找到了0,第四个分支找到了3,都比5小,这两个分支也都不用看了。
1040

@@ -45,7 +75,7 @@ private PointAndValue getMaxEvaluate(int leftStep, int color) {
4575
}
4676
```
4777

48-
观察一下,for循环就相当于博弈树的一个节点下的不同分支,而中间的递归调用相当于进入下一个子节点。我们需要修改的是,在递归调用的时候,将已经遍历过的子节点中得到过的极大值(或极小值)传给未遍历过的节点,若未遍历过的节点中已经发现满足剪枝的条件时进行剪枝即可。
78+
观察一下,`for`循环就相当于博弈树的一个节点下的不同分支,而中间的递归调用相当于进入下一个子节点。我们需要修改的是,在递归调用的时候,将已经遍历过的子节点中得到过的极大值(或极小值)传给未遍历过的节点,若未遍历过的节点中已经发现满足剪枝的条件时进行剪枝即可。
4979

5080
我们把上面的代码稍加修改
5181

@@ -234,11 +264,11 @@ private int evaluatePoint(Point p, int me, int plyer) {
234264
}
235265
```
236266

237-
参数*p*表示接下来要计算的点,*me*表示调用这个函数的那一方自己的颜色,*plyer*代表接下来要计算的人的编号。一个点对两方的重要性之和,就是我们需要的结果。这样我们就可以大致得到了一个对单个点的价值评估函数,也就是“启发式搜索”函数。
267+
参数`p`表示接下来要计算的点,`me`表示调用这个函数的那一方自己的颜色,`plyer`代表接下来要计算的人的编号。一个点对两方的重要性之和,就是我们需要的结果。这样我们就可以大致得到了一个对单个点的价值评估函数,也就是“启发式搜索”函数。
238268

239269
值得一提的是,这里的这些数字,其实都是凭个人感觉写的(例如四肯定比三重要,例如活四就直接赢了那我肯定优先走活四),并且多次测试后慢慢调整的,所以不精确。将来如果有机会,我打算用其他的方法来给这些数字一些更加科学的值。
240270

241-
然后我们在遍历之前,排个序即可。这里偷点懒,直接用了优先队列*PriorityQueue*
271+
然后我们在遍历之前,排个序即可。这里偷点懒,直接用了优先队列`PriorityQueue`
242272

243273
```java
244274
private PointAndValue max(int leftStep, int color, int passValue) {
@@ -261,7 +291,7 @@ private PointAndValue max(int leftStep, int color, int passValue) {
261291

262292
repo中的代码还用到了两个额外的技巧:
263293

264-
- 关于棋盘的Hash算法——Zobrist哈希算法。用了这个算法,我们可以快速把棋盘当前状态hash成一个int值,作为map的key,可用于存储已算出的数据,不需要每次再重复计算。
265-
- 除了减枝外,还有一个增枝的算法,就是对于重要的枝叶进行增长以提高准确度。当然了,我们肯定是对那些带有杀招(冲四、活三等)的棋进行增枝。
294+
- 关于棋盘的Hash算法——**Zobrist哈希算法**。用了这个算法,我们可以快速把棋盘当前状态hash成一个`int`值,作为`map`的key,可用于存储已算出的数据,不需要每次再重复计算。
295+
- 除了**减枝**外,还有一个**增枝**的算法,就是对于重要的枝叶进行增长以提高准确度。当然了,我们肯定是对那些带有杀招(冲四、活三等)的棋进行增枝。
266296

267297
如果后续有时间的话我会再把这两种算法的文字说明补上。

0 commit comments

Comments
 (0)