一、实现内容
- 图形界面
- 局域网联机
- 人机对战
- 悔棋
- 先后手
- 重新开始
- 导出/导入棋盘
游戏规则
假设俩个人轮流报数,可以报 1、2、3 这三个数,然后积分榜累加这俩个人报的数,最先加到 6 的人输
这个游戏存在先手优势,即谁最先报数,就有必胜的方案
博弈树
博弈树的树叶表示游戏的结局
下图中方块表示乙报完数后的局面(此时甲要开始报数了),圆圈表示甲报完数后的局面,由图可知甲先报数
对于甲来说,第一次不能报 2 和 3,因为这样乙总有办法让甲输,即图中红色路线
如果甲报数 1,那么无论第二次乙报什么数,甲总有路线让乙输,即图中蓝色路线
极大极小搜索
将报数游戏中,将甲(自己)获胜用 1 代替,将乙(对手)获胜用 -1 代替
以根节点(树的最顶端)作为第一个极大层(Max),极小层(Min)和极大层交替出现
极小层中选择子节点最小的数,极大层选择子节点最大的数
先手开始选择总是在偶数层(0、2、4…),而第二个选手开始选择总是在奇数层(1、3、5…),对应于先手位于极大层,第二个选手位于极小层,也就意味着,位于极大层的选手需要将自身利益最大化,会选择子节点中较大的那个,而位于极小层的选手会将对手的利益最小化,而选择子节点中最小的那个
根据上图可知,抽离博弈树的一部分后,这个子树的根节点是 -1,也就是说轮到乙选择了,局势变成这样的话,甲是不可能获胜的,因为乙肯定会选择对甲不利的 -1 这条路线
博弈树的最后结果
整个博弈树根节点的值所代表的就是先手在这场博弈中的结果(如果比赛双方都遵循最大最小值原则)
那些 -1 都是对甲不利的局面,也就代表了本场比赛的决胜权被对手掌握
井字游戏
打分函数
乙会尽力让评分降低,甲需要让评分更高,因此通过深度优先遍历,选择一条分值高的路径
打分函数根据日常经验来制定
令 α 为 -∞ 是为了让接下来任意一个比这个大的数可以替换掉 -∞
- 根据给定深度进行遍历(图中仅仅遍历 5 层)
首先将父节点的 α、β 值传递到叶子节点
negamax(board, candidates, role, i, MIN, MAX)
- 1
- 进行回溯,节点处于 Max 层,因此 α 变成 5
if v["score"] > best["score"]:
best = v
# best在遍历子节点时选取分数最高f
- 继续遍历兄弟树
从子节点中 7、4、5 选一个最小的 4 作为 β 的值
由于该兄弟节点的评分较小,回溯时不会改变 Max 层的 α 值
- 继续回溯至第 1 层,由于是最小层,因此 β 改为 5(目前子节点最小只有 5)
- 继续遍历第一层第一个节点的右子树
- 以此类推,获得的最优选择是评分为 6 的路径
这是全部遍历的情况,需要继续优化,参考α-β剪枝
代码实现
# minimax.py
def r(board, deep, alpha, beta, role, step, steps):
# ...
# 获取当前j棋盘分数
_e = board.evaluate(role)
leaf = {"score": _e, "step": step, "steps": steps}
# 搜索到底(搜索范围:给定 depth ~ 0)或者已经胜利, 返回叶子节点
if (deep <= 0 or func.greatOrEqualThan(_e, S["FIVE"])
or func.littleOrEqualThan(_e, -S["FIVE"])):
return leaf
best = {"score": MIN, "step": step, "steps": steps}
onlyThree = step > 1 if len(board.allSteps) > 10 else step > 3
points = board.gen(role, onlyThree) # 启发式评估, 获取整个棋盘下一步可能获胜的节点
# 如果没有节点, 即当前节点是树叶, 直接返回
if len(points) == 0:
return leaf
# 对可能获胜节点进行遍历
for item in points:
board.AIput(item["p"], role) # 在可能获胜的节点上模拟落子
_deep = deep - 1 # 深度减一
_steps = steps.copy() # 复制一下之前的步骤
_steps.append(item) # 步骤增加当前遍历的节点
# 进行递归, 总步数加一
v = r(board, _deep, -beta, -alpha, func.reverse(role), step + 1, _steps)
# 下一步是对手, 对手分数越高, 代表自己分数越低, 所以分数取反
v["score"] = - v["score"]
board.AIremove(item["p"]) # 在棋盘上删除这个子(恢复原来的棋盘)
if v["score"] > best["score"]:
best = v
# ...
return best
# minimax.py
def negmax(..., alpha, beta, ...):
# 当前处于极大层(根节点), 对 candidates 里的落子点进行遍历
# 找到最优解(alpha最大的)
for item in candidates:
# 在棋盘上落子
board.AIput(item["p"], role)
# 注意, alpha/beta 交换了, 因为每一层的 alpha 或 beta 只有一个会变
# 但是递归时需要传那个不变的进去
v = r(board, deep - 1, -beta, -alpha, func.reverse(role), 1, [item])
v["score"] = -1 * v["score"]
alpha = max(alpha, v["score"])
# 从棋盘上移除这个子
board.AIremove(item["p"])
item["v"] = v
return alpha
二、工作量
/(ㄒoㄒ)/~~ 左右互博和局域网联机做了我快一个星期, 一开始用的 pygame, 感觉按钮啊提示框啥的都要自己实现, 有点儿麻烦, 所以改用 tkinter了, 没想到这个也挺麻烦的, 网上的教程也很少
基本原理
根据评分表对某个位置进行评分
图中白子位置上
- —:+++AO+++
- |:+++AO+++
- \:+++O+++
- /:+++AO+++
A 代表敌方棋子,O 代表我方棋子
然后遍历棋盘的每一个位置,找到评分最高的位置落子,缺点是只顾眼前利益,电脑只能预测一步
Alpha Beta 剪枝
核心是固定深度
剪去 MAX 层叫 Alpha 剪枝
剪去 MIN 层叫 Beta 剪枝
触发剪枝的条件
- 当极小层某节点的 α 大于等于 β 时不需要继续遍历其子节点
下图中 α=5,说明我们存在一个使我们得分至少为 5 的情况,如果在遍历子节点的过程中,发现 β 小于 α 了,不会继续遍历后面的节点,因为后面的分数如果更大,对手不可能会选,如果后面的分数更小,对手肯定会选,那我们更加不能选这条路,因此不需要继续考虑了
对于极大极小值搜索一章的博弈树进行剪枝可得
- 当极大层某节点的 α 小于等于 β 时不需要继续遍历
因为如果后面的分数更低,我们没必要选,如果后面的分数高,会导致这条路分数更高,对手不会选这条路,没必要继续考虑
代码实现
# minimax.
def r(..., alpha, ...):
# ...
# 将 alpha 值与子节点分数做比较, 选出最大的分数给 alpha
alpha = max(best["score"], alpha)
# alpha-beta 剪枝
if func.greatOrEqualThan(a, beta):
ABcut += 1 # 剪枝数加一
v["score"] = MAX - 1 # 被剪枝的用极大值来记录, 但是必须比 MAX 小
v["abcut"] = 1 # 剪枝标记
return v
参考资料
Zobrist 散列算法
基本过程
不同的走法最终达到的局势相同, 则可以重复利用缓存中原来计算过的结果
根据 ABC = ACB 可知, 不同步骤只要进行异步运算的值相同, 则最终值相同, 利用 code 作字典的键值可以快速找到缓存中的数据
代码实现
# zobrist.py
class Zobrist:
def __init__(self, n=15, m=15):
# 初始化 Zobrist 哈希值
self.code = self._rand()
# 初始化两个 n × m 的空数组
self._com = np.empty([n, m], dtype=int)
self._hum = np.empty([n, m], dtype=int)
# 数组与棋盘相对应
# 给每一个位置附上一个随机数, 代表不同的状态
for x, y in np.nditer([self._com, self._hum], op_flags=["writeonly"]):
x[...] = self._rand()
y[...] = self._rand()
def _rand(self, k=31):
return secrets.randbits(k)
def go(self, x, y, role):
# 判断本次操作是 AI 还是人, 并返回相应位置的随机数
code = self._com[x, y] if role == R["rival"] else self._hum[x, y]
# 当前键值异或位置随机数
self.code = self.code ^ code
# minimax.py
# 开启缓存
if C["cache"]:
# 获取缓存中与当前 zobrist 散列键值相同的缓存数据
c = Cache.get(board._zobrist.code)
if c:
if c["deep"] >= deep:
# 如果缓存中的结果搜索深度不比当前小, 则结果完全可用
cacheGet += 1 # 缓存命中
return {
"score": c["score"]["score"],
"steps": steps,
"step": step + c["score"]["step"]
}
else:
if (func.greatOrEqualThan(c["score"]["score"], S["FOUR"]) or func.littleOrEqualThan(c["score"]["score"], -S["FOUR"])):
# 如果缓存的结果中搜索深度比当前小
# 那么任何一方出现双三及以上结果的情况下可用
cacheGet += 1
return {
"score": c["score"]["score"],
"steps": steps,
"step": step + c["score"]["step"]
}
# minimax.py
def cache(board, deep, score):
'''
分数缓存
'''
if not C["cache"]:
# 如果不开启缓存, 直接退出
return
if score.get("abcut"):
# 该节点被标记为剪枝的, 直接退出
return
# 利用字典进行缓存
Cache[board._zobrist.code] = {
"deep": deep,
"score": {
"score": score["score"],
"steps": score["steps"],
"step": score["step"]
}
}
# ...
def AIput(self, p, role):
# ...
self._zobrist.go(p[0], p[1], role) # 每次落子后修改 zobrist.code d
迭代加深
每次尝试偶数层, 逐渐增加搜索深度
如果较低的深度能够获胜, 可以不必要增加深度, 提高效率
def deeping(board, candidates, role, deep):
# 每次仅尝试偶数层
for i in range(2, deep, 2):
bestScore = negamax(board, candidates, role, i, MIN, MAX)
if func.greatOrEqualThan(bestScore, S["FIVE"]):
# 能赢了就不用再循环了
break
# 结果重组
def rearrange(d):
r = {
"p": [d["p"][0], d["p"][1]],
"score": d["v"]["score"],
"step": d["v"]["step"],
"steps": d["v"]["steps"]
}
return r
candidates = list(map(rearrange, candidates))
# 过滤出分数大于等于 0 的
c = list(filter(lambda x: x["score"] >= 0, candidates))
if len(c) == 0:
# 如果分数都不大于 0
# 找一个步骤最长的挣扎一下
candidates.sort(key=lambda x: x["step"], reverse=True)
else:
# 分数大于 0, 先找到分数高的, 分数一样再找步骤少的
candidates.sort(key=lambda x: (x["score"], -x["step"]), reverse=True)
return candidates[0]
评分表
特征 | 分数 |
活一 | 10 |
活二 | 100 |
活三 | 1000 |
活四 | 100000 |
连五 | 10000000 |
眠一 | 1 |
眠二 | 10 |
眠三 | 100 |
眠四 | 10000 |
代码实现
# evaluate.py
# (px, py) 位置坐标
def s(b, px, py, role, dir=None):
board = b._board # 当前棋盘
rlen = board.shape[0] # 棋盘行数
clen = board.shape[1] # 棋盘列数
result = 0 # 最后分数
empty = -1
count = 1 # 一侧的连子数(因为包括当前要走的棋子,所以初始为 1)
secondCount = 0 # 另一侧的连子数
block = 0 # 被封死数
# 横向 ——
if dir is None or dir == 0:
# ...
# 落子在这个位置后左右两边的连子数
count += secondCount
# 将落子在这个位置后横向分数放入 AI 或玩家的 scoreCache 数组对应位置
b.scoreCache[role][0][px, py] = countToScore(count, block, empty)
result += b.scoreCache[role][0][px, py]
# 纵向 |
if dir is None or dir == 1:
# ...
count += secondCount
b.scoreCache[role][1][px][py] = countToScore(count, block, empty)
result += b.scoreCache[role][1][px][py]
# 斜向 \
if dir is None or dir == 2:
# ...
count += secondCount
b.scoreCache[role][2][px][py] = countToScore(count, block, empty)
result += b.scoreCache[role][2][px][py]
# 斜向 /
if dir is None or dir == 3:
# ...
count += secondCount
b.scoreCache[role][3][px][py] = countToScore(count, block, empty)
result += b.scoreCache[role][3][px][py]
return result
三、结果
需要先运行 server.py
询问是否接受对战邀请
可边下棋边聊天
可拒绝/接受对方悔棋
- 人机模式
五、总结
大作业害人不浅 (╯°□°)╯︵ ┻━┻
其他说明
- evaluate.py 需要 python >= 3.10.0, 因为使用了 match/case
- 需要 numpy
- AI 部分移植gobang, 并做了一些删减, 不是因为原作不行, 而是我看不懂
附录
- 引言
- 评分函数
- 极大极小值搜索
- alpha-beta剪枝
- Zobrist散列
- 启发式搜索
- 迭代加深