美文网首页
对抗搜索和博弈 - 极小化极大搜索

对抗搜索和博弈 - 极小化极大搜索

作者: 西部小笼包 | 来源:发表于2023-08-04 21:09 被阅读0次

极小化极大搜索

这一章,我们开始介绍博弈论里一个非常经典的算法,叫极小化极大搜索。
首先同样,无论是用何种AI算法,他的目标就是找到下一步最佳的落子位置。我们可以写一个接口。
然后可以用不同的算法去实现,极小化极大搜索只是其中一种算法。

public interface IAIAlgo {

    // 对方落子后,做一些处理
    void setPieceCallBack(int y, int x, int player, boolean isAI);

    // 找到下一步的最佳落子位置
    Position aiFindPos();

    // 得到棋盘的AI交互算法
    IChessboardAIAlgo getChessboardAlgo();

    // 得到每个位置的得分,如这个位置可以构成活四,则给高分
    IScoreManager getScoreManager();

    // 对手的棋子颜色
    int getHumanColor();
}

极小化极大搜索,一方所赢就是另一方所输,对于一棵博弈树,可以被分成max层和min层,所谓max层,在实际应用中就代表博弈时自己的回合,在自己的回合想要将自己的利益最大化,所以会遍历博弈树种孩子孩子结点的权值,并回传权值种最大的值,而对于min层,可以理解为对手回合,对手在自己的回合想要将我们的利益最小化,所以会遍历所有的孩子结点并回传最小值,在算法实现中可以用递归来实现。在这样的原理中,就可以通过遍历博弈树,比较每一种情况的权值,找到当前回合的全局最优解或局部最优解。

protected int negamax(boolean isAI, int depth) {
        if (chessboardAlgo.isTerminal(getX(idx), getY(idx)) || depth == 0) {
            return scoreManager.evaluation(isAI, humanColor);
        }
        // 返回下一步所有能走的位置,用 y * 15 + x 来把位置存进int
        List<Integer> blankList = generateCandidatePiece(isAI);

        int resVal = Integer.MIN_VALUE;
        for (int nextStep : blankList) {
            int y = getY(nextStep), x = getX(nextStep);
            addPiece(y, x, isAI);
            int value = -negamax(!isAI, depth - 1);
            removePiece(y, x, isAI);
            resVal = Math.max(resVal, value);
        }

        return resVal;
    }

我们可以看上面的代码,递归第一层,我们是从第二层的NEGAMAX的值返回上来。第二层返回的是人类棋手认为的最优解;人类棋手认为的最优解,是他下了每一步之后,如果是叶子节点,就是局面得分。如果不是叶子节点,就是AI得分。那么在所有AI得分中,我们要取最小值,因为对人类棋手来说,AI得分越低,对人类优势越大。第一层就是评估每一步AI下完之后,人类用最优策略找到的对AI最小值的分数里面,取最大值。因为AI想最大化收益。这就是negamax 的核心思想。

因为一层判最大,一层判最小。所以比较合理的是,写2个函数,相互递归调用对方。这里用了个代码小技巧,就是通过给每一层返回上来的值取相反数。使得递归只需要判最大,就可以实现一层判最大,一层判最小的效果。

如何计算侯选落子

我们可以看到上述代码我们需要一个函数去产生比较优质的落子位置。一个简单的想法是我们可以枚举所有当前棋子相邻距离为1的棋子,作为候选落子。这样做当然是可以,但是不高效。
因为首先可能随着下的子越来越大,这个候选集会越来越大。其次会漏掉一些比较关键的位置,比如我们需要一个跳一步的冲四来构成杀棋。
所以为了更有针对性的去选择落子。我们需要对每个位置进行评分。这个就是我们为何需要一个分数管理模块。他负责高效的计算和更新棋盘和棋子的分数。

private List<Integer> generateCandidatePiece(boolean isAI) {
        return scoreManager.generateCandidatePiece(isAI ? aiColor : humanColor);
}
public interface IScoreManager {
    // 对棋局进行评分
    int evaluation(boolean isAI, int humanColor);
    
    // 找到优质的落子
    List<Integer> generateCandidatePiece(int role);

    // 下了(y,x)这步棋后,更新相关的落子分数
    void updateScore(int y, int x);

    // 根据当前的棋局,重新初始化落子分数
    void initScore();

    // 获得当前位置,ROLE(黑棋/白棋)下棋的分数
    int getScore(int y, int x, int role);
}

设计好了接口,就是考虑如何实现。一种简单粗暴的方法,就是每次重新计算落子分数。比如说算落子分数,我们可以看这个位置如果下了黑棋,那么能构成几个活三,几个活四,几个活二。这样其实不太高效,其实我们可以把这个信息缓存下来。那么实际要用的时候,就可以在O(1)的时间去从缓存里读出来。
基于这种方法可以把上面的NEGAMAX的算法深度从3层优化到7层。可见缓存的提速效果是非常大的。
一个更直观的例子是,大概是快了10000倍;(假设一层平均有10个分叉,那么就是10^(7-3))

我们看下这个类需要哪些字段

public class CachedScoreManager implements IScoreManager {
    // 用于缓存棋局评分
    protected int[][][][] scoreCache;
    // 用于缓存落子评分
    protected int[][] blackScore;
    protected int[][] whiteScore;
    // 实时计算落子评分
    protected PointEvaluator pointEvaluator;
    protected int size;
    // 棋盘接口
    protected IChessboardAIAlgo chessBoardAlgo;
    public CachedScoreManager(IChessboardAIAlgo chessBoardAIAlgo) {
        chessBoardAlgo = chessBoardAIAlgo;
        size = chessBoardAlgo.getSize();
        scoreCache = new int[3][4][size][size];
        blackScore = new int[size][size];
        whiteScore = new int[size][size];
        // 计算完落子得分后,增量更新棋局评分
        pointEvaluator = new PointEvaluator(size, chessBoardAlgo, scoreCache);
        initScore();
    }

下面讲述了如何给定一个棋局,初始化所有的评分。这个方法实现完后,如果我们不写增量更新,依然可以计算,完成AI走棋,就是速度会慢很多。我们运用了多个数组进行缓存,就是为了将来的增量更新。我们先看下全量更新是怎么做的?

    @Override
    public void initScore() {
        for (int j = 0; j < size; j++) {
            for (int i = 0; i < size; i++) {
                int val = chessBoardAlgo.getValInBoard(i, j);
                if (val == 0) {
                    // 该位置没有棋子的时候,我们只考虑边上2格有棋子时的空位
                    if (chessBoardAlgo.hasNeighbor(i, j, 2, 1)) {
                        blackScore[j][i] = pointEvaluator.scorePoint(j, i, Player.BLACK.getId());
                        whiteScore[j][i] = pointEvaluator.scorePoint(j, i, Player.WHITE.getId());
                    }
                } else if (val == Player.BLACK.getId()) {
                    blackScore[j][i] = pointEvaluator.scorePoint(j, i, Player.BLACK.getId());
                    whiteScore[j][i] = 0;
                } else if (val == Player.WHITE.getId()) {
                    blackScore[j][i] = 0;
                    whiteScore[j][i] = pointEvaluator.scorePoint(j, i, Player.WHITE.getId());
                } else {
                    throw new IllegalStateException("invalid player error");
                }
            }
        }
    }

然后我们说下增量更新怎么做。每当有一个新棋子落下时,他可能会影响4个方向的变化,分别是水平,垂直,正对角线,反对角线。我们依次更新这4个方向的相关分数。

@Override
    public void updateScore(int y, int x) {
        int radius = 10;
        for (Direction dir : Direction.values()) {
            for (int i = -radius; i <= radius; i++) {
                int ny = y + dir.dy * i;
                int nx = x + dir.dx * i;
                if (ny < 0 || nx < 0 || ny >= size || nx >= size) continue;
                updatePointInDirection(ny, nx, dir);
            }
        }
    }

    private void updatePointInDirection(int y, int x, Direction dir) {
        int role = chessBoardAlgo.getValInBoard(x, y);
        if (role != Player.WHITE.getId()) {
            int bs = pointEvaluator.scorePoint(y, x, Player.BLACK.getId(), dir);
            blackScore[y][x] = bs;
        } else {
            blackScore[y][x] = 0;
        }
        if (role != Player.BLACK.getId()) {
            int ws = pointEvaluator.scorePoint(y, x, Player.WHITE.getId(), dir);
            whiteScore[y][x] = ws;
        } else {
            whiteScore[y][x] = 0;
        }
    }

那么其实最核心的我们可以看到,就是如何计算每个落子的分数。
要计算落子分数,我们就需要枚举各种情况。
第一种情况,落子下去到凑成5子连珠,左边和右边都是空的。

第二种情况,或者一边有BLOCK,BLOCK的意思就是走到边界 或者 有对手颜色的棋子。

第三种情况,2边都BLOCK

如果是空的,我们还要考虑空的之后,还有没有自己的连子。比如下图这种情况, 表面上看是个双边空的活三,其实2边都是冲四了;是必胜局势。
[... X _ X X X _ X ...]
那么我们左右分别需要3个变量,记录

  1. 左边空在哪
  2. 左边空了一格后,后面接了几个自己的子
  3. 左边是不是BLOCK
for (int i = 1; true; i++) {
    int ny = y + i * curDir.dy, nx = x + i * curDir.dx;
    if (outOfBound(ny, nx)) {
        rightBlock = true;
        break;
    }

    int val = chessBoardAlgo.getValInBoard(nx, ny);
    if (val == 0) {
        // 发现一个空格,看空格后面那格还是不是自己的棋
        int nny = ny + curDir.dy, nnx = nx + curDir.dx;
        if (rightEmptyPos == -1 && !outOfBound(nny, nnx)
                && chessBoardAlgo.getValInBoard(nnx, nny) == role) {
            // 如果是自己的棋,这个rightEmptyPos >= 0, 然后把后面的子也统计进rightCnt
            rightEmptyPos = rightCnt;
            continue;
        } else {
            break;
        }
    } else if (val == role) {
        rightCnt++;
        continue;
    } else {
        rightBlock = true;
        break;
    }
}

左边也同理,有了上述信息后,我们需要枚举4类情况。根据左右分别是不是存在隔着1个棋的连子。

    private int countToScore() {
        if (leftEmptyPos == -1 && rightEmptyPos == -1) {
            int count = leftCnt + rightCnt + 1;
            if (count >= 5) return Score.FIVE.value;
            if (!leftBlock && !rightBlock) return Score.calculateNonEmpty(count, false);
            else if (!leftBlock || !rightBlock) return Score.calculateNonEmpty(count, true);
        } else if (leftEmptyPos >= 0 && rightEmptyPos == -1) {
            int count = leftCnt + rightCnt + 1;
            return Score.calculateSingleEmpty(leftEmptyPos, count, leftBlock, rightBlock);
        } else if (leftEmptyPos == -1 && rightEmptyPos >= 0) {
            int count = leftCnt + rightCnt + 1;
            return Score.calculateSingleEmpty(rightCnt - rightEmptyPos, count, rightBlock, leftBlock);
        } else if (leftEmptyPos >= 0 && rightEmptyPos >= 0) {
            if (leftEmptyPos > rightCnt - rightEmptyPos)
                return Score.calculateDoubleEmpty(rightCnt - rightEmptyPos, rightEmptyPos, rightBlock, leftCnt - leftEmptyPos, leftEmptyPos, leftBlock);
            return Score.calculateDoubleEmpty(leftEmptyPos, leftCnt - leftEmptyPos, leftBlock, rightEmptyPos, rightCnt - rightEmptyPos, rightBlock);
        } else {
            throw new IllegalStateException("!!!");
        }
        return 0;
    }

最简单的是2边都没这种情况,那么只要根据2侧有没有被BLOCK。就可以直接算分。

public static int calculateNonEmpty(int count, boolean blocked) {
        assert count >= 1 && count <= 4;
        switch (count) {
            case 1 : return (blocked ? BLOCKED_ONE : ONE).value;
            case 2 : return (blocked ? BLOCKED_TWO : TWO).value;
            case 3 : return (blocked ? BLOCKED_THREE : THREE).value;
            case 4 : return (blocked ? BLOCKED_FOUR : FOUR).value;
        }
        throw new IllegalStateException("impossible");
    }

后面几种情况,处理起来需要分类讨论的会多一些。可以直接到GITHUB上看分类讨论的代码。花一点时间逐个理解各个局势的分数,很快就能看懂。

https://github.com/yixuaz/GomokuAI/blob/main/src/main/java/scorecalculator/PointEvaluator.java

基于缓存的落子的计算,我们已经很大程度提高了搜索速度。
下面为了进一步加速搜索,我们可以做剪枝。在MINMAX中,alpha-beta 剪枝是一种经典的剪枝优化。

alpha-beta 剪枝优化

Alpha-Beta剪枝用于裁剪搜索树中不需要搜索的树枝,以提高运算速度。它基本的原理是:

  • 当一个 Min 节点的 β值≤任何一个父节点的α值时 ,剪掉该节点的所有子节点
  • 当一个 Max 节点的 α值≥任何一个父节点的β值时 ,剪掉该节点的所有子节点

下面为只使用 MiniMax 和使用 Alpha-Beta 剪枝的简单对比。

简书贴不了图了今天,想学习的小伙伴可以看这篇博客:
https://oi-wiki.org/search/alpha-beta/

那么运用了alpha beta的剪枝搜索代码如下:

protected int negamax(boolean isAI, int depth, int alpha, int beta, int idx) {
        
        if ((chessboardAlgo.isTerminal(getX(idx), getY(idx))) || depth == 0) {
            return scoreManager.evaluation(isAI, humanColor);
        }

        List<Integer> blankList = generateCandidatePiece(isAI);

        int resVal = Integer.MIN_VALUE;
        for (int nextStep : blankList) {

            int y = getY(nextStep), x = getX(nextStep);
            addPiece(y, x, isAI);
            int value = -negamax(!isAI, depth - 1, -beta, -alpha, nextStep);
            removePiece(y, x, isAI);
            
            resVal = Math.max(resVal, value);
            if (value > alpha) {
                if (depth == firstDepth) {
                    nextPoint = nextStep;
                    // 如果已经必胜,不用继续搜索了
                    if (resVal >= Score.FIVE.value) return resVal;
                }
                if (value >= beta) return value;
                alpha = value;
            }
        }

        return resVal;
    }

总结

综上,我们完成了五子棋最基础AI算法的框架,这2步优化,可以让我们的AI 5秒内枚举7步的棋子,但是七步并不能结束游戏时,我们的AI就需要一个评估局面的函数。然后根据这个函数输出的分数,去分析每一步的好坏。
下一章,我们会去介绍,如果设计一个评估函数,和在当前优化的基础上进一步加速我们的搜索,使得可以在10秒内做到9层的搜索。
要知道这个还是非常难的,因为基本上每加一层,耗时就会是原来的10倍,加2层会是100倍。

这一章我希望,你能够从代码中学习到几个基本算法。

第一个就是了解极小化极大搜索的基本思想。
第二个是alpha-beta 剪枝是如何工作,以及代码该如何实现
第三个就是如何利用对每步棋的分数缓存来加速搜索,同时也为我们下一章的评估函数做一个铺垫。

相关文章

  • 人工智能期末复习笔记2018-01-03

    对抗搜索 博弈问题博弈问题穷举有时可以获得必胜结果,但是稍微复杂一点的问题就很难穷举了。 极大极小过程 alpha...

  • 高级搜索

    剪枝 Alpha-beta 剪枝是一种搜索算法,用以减少极小化极大算法(Minimax 算法)搜索树的节点数。这是...

  • 《游戏设计的100个原理》笔记——第一章.游戏创新的一般原理(下

    16.“极小极大”和“极大极小”在一个零和博弈中,每个博弈者会选择一个能最大化他们回报的混合策略,由此产生的策略和...

  • 10.28 - 九章高级算法班题目大总结(5,6课)

    课程5: dp问题1,滚动数组优化,博弈类,记忆化搜索 Longest Increasing Continuous...

  • 极小,极大

    我们每人都是这个世界上渺小微茫的存在。 该不该亲近这个世界,该不该善待这个世界。 该不该抽离这个世界,该不该诋毁这...

  • 博弈树搜索算法

    姓名:李嘉蔚 学号16020520034 【嵌牛导读】:通过讨论人工智能中用于计算机博奕的一般技术如极大极小搜索、...

  • GAN思想分析+代码

    [Paper] [Code] GAN背后的思想其实非常朴素和直观,就是生成器和判别器两个极大极小的博弈。下面我们进...

  • 几个搜索的相关话题

    结构化搜索与全文搜索 ES在搜索时有两种类型,即全文搜索与结构化搜索。其相对应于"term"系列的查询 和 "ma...

  • 结构化搜索

    总结 本章介绍es的结构化搜索,通过demo演示es的结构化搜索 结构化搜索 什么是结构化搜索结构化搜索就是对结构...

  • 7.7-第二部分总结与测验

    回顾总结:搜索与算分 结构化搜索与⾮结构化搜索Term 查询和基于全⽂本 Match 搜索的区别对于需要做精确匹配...

网友评论

      本文标题:对抗搜索和博弈 - 极小化极大搜索

      本文链接:https://www.haomeiwen.com/subject/auqypdtx.html