五子棋小游戏-Python基础练习tkinter版

一、实现内容

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

游戏规则

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

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

博弈树

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

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

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

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

极大极小搜索

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

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

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

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

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

博弈树的最后结果

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

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

井字游戏

打分函数

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

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

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

  1. 根据给定深度进行遍历(图中仅仅遍历 5 层)

    首先将父节点的 α、β 值传递到叶子节点

    negamax(board, candidates, role, i, MIN, MAX)
    
    • 1

  2. 进行回溯,节点处于 Max 层,因此 α 变成 5

    if v["score"] > best["score"]:
        best = v
    # best在遍历子节点时选取分数最高f
    
  3. 继续遍历兄弟树

    从子节点中 7、4、5 选一个最小的 4 作为 β 的值

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

  4. 继续回溯至第 1 层,由于是最小层,因此 β 改为 5(目前子节点最小只有 5)

  5. 继续遍历第一层第一个节点的右子树

  6. 以此类推,获得的最优选择是评分为 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

参考资料

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

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

参考资料

  1. 维基百科
  2. Zobrist缓存
  3. Zobrist哈希

迭代加深

每次尝试偶数层, 逐渐增加搜索深度

如果较低的深度能够获胜, 可以不必要增加深度, 提高效率

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. 首页

  2. 本地开局

  3. 获胜界面

  4. 网络联机

    需要先运行 server.py

    询问是否接受对战邀请

    可边下棋边聊天

    可拒绝/接受对方悔棋

  5. 人机模式

五、总结

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

其他说明

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

附录

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

参考资料

  1. lihongxun945/gobang
  2. colingogogo/gobang_AI
  3. 如何设计一个还可以的五子棋AI

除了 AI 有参考资料,其他的都太零碎了,面向百度编程,也没什么好引用的…

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值