首页 > 技术文章 > 基于决策树的五子棋

Qi-Lin 2022-02-20 15:31 原文

人工智能五子棋

背景介绍

五子棋是两名玩家使用黑白两种棋子轮流在15*15的棋盘上下棋,只要白方或黑方的棋子在横,竖或斜三个方向上任意一个方向能够连成五子,则判定为胜利。
本次设计五子棋游戏为真人玩家与AI对战,真人持黑棋先下,AI持白棋后下。

程序设计思路及主要方法

整个游戏框架采用pygame框架实现。

棋盘

棋盘界面使用网上棋盘的图片,图片要相对规整,格子大小分布均匀。
此次使用棋盘图片大小为664*669,棋盘中每个小格长约为44,宽约为45,左右上下两侧余24,16。

棋子

黑白棋子采用绘制黑白实心圆圈代替,每个棋子的半径为16。
棋子的位置:每次落子时,记录鼠标的位置,因为人点击的位置不可能正好落在某点上,将点击位置转化为15*15的坐标,如最中心的位置为(7,7),代码如下,通过减去两侧的多余位置,再除以格子的大小,最后四舍五入,化为整数坐标。(round((i - 16) / 45), (round((j - 24) / 44)) 最后绘制时,只要采用逆过程即可。

算法设计与实现

算法采用基于博弈树的MAX-MIN算法。

生成博弈树

针对每一种状态,以当前状态为根生成2步的博弈树,以下图为例,因为真人玩家先下,所以起始棋局上已经有落子。之后针对该棋局生成多个或树节点,每个节点就是叉号可能落子的位置。最后再向下进行生成,生成与树节点,每个节点是针对父节点的棋局,圆圈所有可能落子的位置。

计算代价函数值

因为从智能一方考虑,代价函数值越大,对智能一方越有利。
本次设计的代价函数算法如下所示:
首先针对当前棋局计算所有落子的周边位置,如图,三角形所标位置为落子周边的所有位置,即每个落子的上下左右,左上,左下,右上,右下。

之后计算这些位置摆放对方棋子的得分fail和摆放自己棋子的得分win,每个位置得分计算规则为,以摆放棋子处位置为中心,如果能连成1个记一分,2个记两分,依次类推。
摆放对方棋子如图所示:

计算得分以上图中1号位置所示,它只能与下面棋子连成2个,所以记两分,其它位置同理,最后得出一个总分:

最后将摆自己棋子的得分win减去摆对方棋子的得分fail,得到每个叶子结点的得分。
以下图为例,假设第一个得分5,然后等等。父节点如果是与结点,那么它的得分为所有子子结点的最小值,假设为2,父节点如果为或结点,那么它的得分为所有子节点的最大值。因此最后得到根节点的值。

确定下一步

最后选择使根节点得分最高的那一步,如下红箭头所示,即机器的下一步要下在叉号的位置。

不断循环

之后,对方每下一步,算法就以当前棋局为根节点,进行如上的步骤,不断循环。

游戏输赢判定设计与实现

每下一步棋,就是用上一节所述算法,以这步棋的位置为中心,向八个方向进行搜索,判定是否存在五个相同棋子相连的状况,黑棋成功则更新游戏状态为1,判定成功,输出获胜信息,白棋成功则更新游戏状态为2,判定成功,输出获胜信息。否则游戏状态为0,继续进行游戏。

实验结果展示及分析

如下所示,为一局棋的对弈,能够较好的完成自动与人对弈的任务,但是多次运行后发现也存在着几个问题,可以进一步改进。
(1)程序运行速度慢。每一步棋计算都有明显的停顿,给人体验不好。我分析可能一是15*15的棋盘导致运算量过大。二是我在编程时在每一个节点都额外存储了一个当前的棋盘状况。解决这个问题可能采用α-β剪枝的方法,减少计算量,也可能需要进一步优化我的代码。
(2)自动下棋的第一步总是在左上角。我分析由于本次算法只采取了两步推理(多步推理却又造成程序运行过慢),所以在最开始计算时,白棋的的很多位置的得分都是一样的,所以按顺序挑选了一个位置,但是按照常识,越靠中间的位置才是得分越好的,这一步需要继续改进。
(3)白棋的行为总是在堵。每次下棋即使白棋能够胜利,它也要下堵黑棋的一步。我分析一方面是推理步数过少,一方面是代价函数,即得分规则,还是不够细致。当连成3个或4个时,往往会产生比1个,2个更大的威胁,所以响应的得分应该相差更高,同时能成5个的状态应该是一个最大的值。并且我查阅资料,五子棋的规则其实应该更加复杂和细致。比如连成4个时,一侧被堵,两侧被堵,和没有被堵的情况应该是完全不一样的,连成3个时,也是如此,所以应该定义更细致的状态判断和得分规则。

代码

import sys
import numpy as np
import pygame
import copy

#树结构
class Tree:
    def __init__(self):
        self.weight = None#节点分值
        self.child = []#孩子节点列表
        self.father = None #父节点
        self.manual = None #当前节点对应棋谱
        self.flag = None #0为或树根节点,1为与树根节点
        self.move = None  # 当前节点对应的一步棋

#构建树结构
def build_tree(node, step=0):
    #step表示分析深度
    if (step == 2):
        return
    #遍历15*15棋盘,穷举当前状态所有可能的下一步
    for i in range(15):
        for j in range(15):
            if (node.manual[i][j] == 0):
                child_node = Tree()
                child_node.flag = (1 + node.flag) % 2
                manual = copy.deepcopy(node.manual)
                manual[i][j] = child_node.flag + 1
                child_node.move = (i, j)
                child_node.manual = manual
                child_node.father = node
                node.child.append(child_node)
                #深度迭代
                build_tree(child_node, step + 1)

# 八个方位的迭代搜索,输入当前棋盘state和下的一步棋(i,j),这步棋对应的颜色flag,得到以这步棋为中心在这个反向与这个棋颜色一样的个数number
def find_up(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_up(state, i - 1, j, flag, number)


def find_down(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_down(state, i + 1, j, flag, number)


def find_left(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_left(state, i, j - 1, flag, number)


def find_right(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_right(state, i, j + 1, flag, number)


def find_l_u(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_l_u(state, i - 1, j - 1, flag, number)


def find_r_u(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_r_u(state, i - 1, j + 1, flag, number)


def find_l_d(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_l_d(state, i + 1, j - 1, flag, number)


def find_r_d(state, i, j, flag, number=0):
    if (i < 0 or i >= 15):
        return number
    if (j < 0 or j >= 15):
        return number
    if (state[i][j] != flag):
        return number
    number = number + 1
    return find_r_d(state, i + 1, j + 1, flag, number)

#根据探测的位置,以(i,j)为中心打分,总分为连续个数的和,如组成2个三个,1个1个,则总分为3+3+1
def score(state, position, flag):
    sum = 0
    for p in position:
        i, j = p
        if (i >= 0 and j >= 0 and i < 15 and j < 15 and state[i][j] == 0):
            a = find_up(state, i - 1, j, flag)
            b = find_down(state, i + 1, j, flag)
            c = find_left(state, i, j - 1, flag)
            d = find_right(state, i, j + 1, flag)
            e = find_l_u(state, i - 1, j - 1, flag)
            f = find_r_d(state, i + 1, j + 1, flag)
            g = find_l_d(state, i + 1, j - 1, flag)
            h = find_r_u(state, i - 1, j + 1, flag)
            sum = (a + b + 1)  + (c + d + 1)  + (e + f + 1)  + (g + h + 1)  + sum
    return sum

# 根据当前棋盘探测位置,即在下过的棋子周围描边,记录这些位置
def probe(state):
    position = []
    for i in range(15):
        for j in range(15):
            if (state[i][j] != 0):
                position.extend(
                    [(i - 1, j - 1), (i - 1, j + 1), (i + 1, j - 1), (i + 1, j + 1), (i + 1, j), (i - 1, j),
                     (i, j + 1), (i, j - 1)])
    return set(position)


#检查是否连成5个
def check(state, position, flag):
    i, j = position
    a = find_up(state, i - 1, j, flag)
    b = find_down(state, i + 1, j, flag)
    c = find_left(state, i, j - 1, flag)
    d = find_right(state, i, j + 1, flag)
    e = find_l_u(state, i - 1, j - 1, flag)
    f = find_r_d(state, i + 1, j + 1, flag)
    g = find_l_d(state, i + 1, j - 1, flag)
    h = find_r_u(state, i - 1, j + 1, flag)
    if (a + b + 1 == 5 or c + d + 1 == 5 or e + f + 1 == 5 or g + h + 1 == 5):
        return True
    else:
        return False


pygame.init()
#敞口大小
size = width, height = 664, 669
screen = pygame.display.set_mode(size)
pygame.display.set_caption('五子棋')
#设置背景棋盘
background = 'C:/Users/DELL/Desktop/2.png'
chessboard = pygame.image.load(background)
#记录棋位置,用于绘图
chess = []
#游戏状态,0进行中,1黑骑生,2白棋胜
game_state = 0
#逻辑棋盘
state=list(np.zeros((15,15)))


while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()
        #点击鼠标下棋
        if (game_state == 0) and (event.type == pygame.MOUSEBUTTONDOWN):
            #黑棋下棋。得到鼠标点击位置,棋盘中每个小格长约为44,宽约为45,左右上下两侧余24,16
            i, j = pygame.mouse.get_pos()
            chess.append((round((i - 16) / 45), (round((j - 24) / 44)), 1))
            state[round((i - 16) / 45)][round((j - 24) / 44)]=1
            #判断是否获胜
            if(check(state,(round((i - 16) / 45), (round((j - 24) / 44))),1)):
                game_state=1
            # 建树
            node = Tree()
            node.manual = state
            node.flag = 0
            node.move = (round((i - 16) / 45), round((j - 24) / 44))
            node_list = []
            best = 0
            build_tree(node)
            #相当于对树层次遍历
            for i in node.child:
                node_list.append(i)
                for j in i.child:
                    node_list.append(j)
                    for n in j.child:
                        node_list.append(n)
            #从叶子结点计算
            for m in reversed(node_list):
                #判断是否为叶子结点
                if(m.child==[]):
                    position = probe(m.manual)
                    #对手为1,黑棋
                    fail = score(m.manual, position, 1)
                    win = score(m.manual, position, 2)
                    # 得分
                    m.weight = win-fail
                #更新父节点
                if (m.father.flag == 0):
                    if (m.father.weight == None):
                        m.father.weight = m.weight
                    elif (m.weight > m.father.weight):
                        m.father.weight = m.weight
                if (m.father.flag == 1):
                    if (m.father.weight == None):
                        m.father.weight = m.weight
                    elif (m.weight < m.father.weight):
                        m.father.weight = m.weight
            #找出下一步棋
            best = node.child[0].weight
            best_node = node.child[0]
            for i in node.child:
                if (i.weight > best):
                    best = i.weight
                    best_node = i
            i,j=best_node.move
            #白棋下棋
            chess.append((i,j, 2))
            state[i][j]=2
            #判断白棋是否获胜
            if(check(state,(round((i - 16) / 45), (round((j - 24) / 44))),2)):
                game_state=2
    #绘制棋盘
    screen.blit(chessboard, (0, 0))
    for i, j, flag in chess:
        if (flag == 1):
            pygame.draw.circle(screen, (0, 0, 0), [i * 45 + 16, j * 44 + 24], 16, 0)
        else:
            pygame.draw.circle(screen, (255, 255, 255), [i * 45 + 16, j * 44 + 24], 16, 0)
    #宣布获胜方
    if game_state == 1:
        font = pygame.font.Font(None, 60)
        text = font.render('black win', True, (0, 0, 0))
        screen.blit(text, (250, 250))
    if game_state == 2:
        font = pygame.font.Font(None, 60)
        text = font.render('white win', True, (255, 255, 255))
        screen.blit(text, (250, 250))
    pygame.display.update()

推荐阅读