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

极小化极大搜索

这一章,我们开始介绍博弈论里一个非常经典的算法,叫极小化极大搜索。
首先同样,无论是用何种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 剪枝是如何工作,以及代码该如何实现
第三个就是如何利用对每步棋的分数缓存来加速搜索,同时也为我们下一章的评估函数做一个铺垫。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容