一、实现内容

  •  图形界面
  •  局域网联机
  •  人机对战
  •  悔棋
  •  先后手
  •  重新开始
  •  导出/导入棋盘

游戏规则

假设俩个人轮流报数,可以报 1、2、3 这三个数,然后积分榜累加这俩个人报的数,最先加到 6 的人输

这个游戏存在先手优势,即谁最先报数,就有必胜的方案

博弈树

博弈树的树叶表示游戏的结局

下图中方块表示乙报完数后的局面(此时甲要开始报数了),圆圈表示甲报完数后的局面,由图可知甲先报数

python五子棋素材 python五子棋游戏_缓存

对于甲来说,第一次不能报 2 和 3,因为这样乙总有办法让甲输,即图中红色路线

如果甲报数 1,那么无论第二次乙报什么数,甲总有路线让乙输,即图中蓝色路线

python五子棋素材 python五子棋游戏_搜索_02

极大极小搜索

将报数游戏中,将甲(自己)获胜用 1 代替,将乙(对手)获胜用 -1 代替

以根节点(树的最顶端)作为第一个极大层(Max),极小层(Min)和极大层交替出现

极小层中选择子节点最小的数,极大层选择子节点最大的数

先手开始选择总是在偶数层(0、2、4…),而第二个选手开始选择总是在奇数层(1、3、5…),对应于先手位于极大层,第二个选手位于极小层,也就意味着,位于极大层的选手需要将自身利益最大化,会选择子节点中较大的那个,而位于极小层的选手会将对手的利益最小化,而选择子节点中最小的那个

python五子棋素材 python五子棋游戏_搜索_03

根据上图可知,抽离博弈树的一部分后,这个子树的根节点是 -1,也就是说轮到乙选择了,局势变成这样的话,甲是不可能获胜的,因为乙肯定会选择对甲不利的 -1 这条路线

博弈树的最后结果

python五子棋素材 python五子棋游戏_算法_04

整个博弈树根节点的值所代表的就是先手在这场博弈中的结果(如果比赛双方都遵循最大最小值原则)

那些 -1 都是对甲不利的局面,也就代表了本场比赛的决胜权被对手掌握

井字游戏

python五子棋素材 python五子棋游戏_子节点_05

打分函数

乙会尽力让评分降低,甲需要让评分更高,因此通过深度优先遍历,选择一条分值高的路径

打分函数根据日常经验来制定

令 α 为 -∞ 是为了让接下来任意一个比这个大的数可以替换掉 -∞

  1. 根据给定深度进行遍历(图中仅仅遍历 5 层)
    首先将父节点的 α、β 值传递到叶子节点
negamax(board, candidates, role, i, MIN, MAX)
  • 1

python五子棋素材 python五子棋游戏_搜索_06

  1. 进行回溯,节点处于 Max 层,因此 α 变成 5
if v["score"] > best["score"]:
    best = v
# best在遍历子节点时选取分数最高f
  1. 继续遍历兄弟树
    从子节点中 7、4、5 选一个最小的 4 作为 β 的值

由于该兄弟节点的评分较小,回溯时不会改变 Max 层的 α 值

  1. 继续回溯至第 1 层,由于是最小层,因此 β 改为 5(目前子节点最小只有 5)
  2. 继续遍历第一层第一个节点的右子树
  3. 以此类推,获得的最优选择是评分为 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了, 没想到这个也挺麻烦的, 网上的教程也很少

基本原理

根据评分表对某个位置进行评分

python五子棋素材 python五子棋游戏_python五子棋素材_07

图中白子位置上

  • —:+++AO+++
  • |:+++AO+++
  • \:+++O+++
  • /:+++AO+++

A 代表敌方棋子,O 代表我方棋子

然后遍历棋盘的每一个位置,找到评分最高的位置落子,缺点是只顾眼前利益,电脑只能预测一步

Alpha Beta 剪枝

核心是固定深度

剪去 MAX 层叫 Alpha 剪枝

剪去 MIN 层叫 Beta 剪枝

触发剪枝的条件

  • 当极小层某节点的 α 大于等于 β 时不需要继续遍历其子节点

下图中 α=5,说明我们存在一个使我们得分至少为 5 的情况,如果在遍历子节点的过程中,发现 β 小于 α 了,不会继续遍历后面的节点,因为后面的分数如果更大,对手不可能会选,如果后面的分数更小,对手肯定会选,那我们更加不能选这条路,因此不需要继续考虑了


对于极大极小值搜索一章的博弈树进行剪枝可得

python五子棋素材 python五子棋游戏_python五子棋素材_08

  • 当极大层某节点的 α 小于等于 β 时不需要继续遍历

因为如果后面的分数更低,我们没必要选,如果后面的分数高,会导致这条路分数更高,对手不会选这条路,没必要继续考虑

代码实现

# 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

参考资料

  1. 极大极小值搜索和alpha-beta剪枝

Zobrist 散列算法

基本过程

python五子棋素材 python五子棋游戏_算法_09

不同的走法最终达到的局势相同, 则可以重复利用缓存中原来计算过的结果

根据 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

三、结果

  1. 首页

    python五子棋素材 python五子棋游戏_子节点_10

  2. 本地开局
  3. 获胜界面
  4. 网络联机

需要先运行 server.py


询问是否接受对战邀请

python五子棋素材 python五子棋游戏_算法_11

可边下棋边聊天

python五子棋素材 python五子棋游戏_算法_12

可拒绝/接受对方悔棋

python五子棋素材 python五子棋游戏_子节点_13

  1. 人机模式

五、总结

大作业害人不浅 (╯°□°)╯︵ ┻━┻

其他说明

  1. evaluate.py 需要 python >= 3.10.0, 因为使用了 match/case
  2. 需要 numpy
  3. AI 部分移植gobang, 并做了一些删减, 不是因为原作不行, 而是我看不懂

附录

  1. 引言
  2. 评分函数
  3. 极大极小值搜索
  4. alpha-beta剪枝
  5. Zobrist散列
  6. 启发式搜索
  7. 迭代加深