对抗搜索和博弈 - 移动顺序优化,评价函数设计,前向剪枝

上一章,我们介绍了基本极小化极大算法的ALPHA BETA剪枝。以及如何以在线更新和缓存的方式维护每个格子落子的得分。但是即使这样AI也没办法完成全部搜索直到终局。因为这个极小化极大算法的时间复杂度是指数级的。每一个节点继续搜索,都要额外考虑平均8-10步的量。那么第一层是1个节点,第二层是10个节点,第三层就是100个节点。以此类推。
而我们希望我们的AI 在有限时间内,可以提前截断搜索,并对状态应用启发式评价函数,从而有效地将非终止节点变为终止节点。比如当到了一定的深度,或者是终止状态(某个棋子连成5个)。我们都会从递归中退出。只不过前者返回的是当前棋局的启发式得分,而后者会返回必胜的分数(往往是最大的)

评价函数

一个评价函数,首先要满足他返回的分数必然是介于输的分数 和 赢的分数之间。并且计算时间不能不太长。其次,必须可以反映出得分与实际的获胜机会密切相关。

而评价函数则需要计算状态的各种特征,每个特征会反应出一个得分,最后我们运用得分相加的策略。这是最常见的一种评价函数的设计。对五子棋来说,我们则需要考虑不同特征的子力价值(活二,活三,双活三等)根据人类的经验依次对这些特征打分。然后评判棋盘状态下这些特征有多少,代表自己的局面有多好。当然如果是对手的一些特征,那么对我们不利,我们需要去分数减法。

下面是基于这一思想,实现的代码,因为是和分数相关,我们把这个代码写进CachedScoreManager.java 这个文件里

@Override
    public int evaluation(boolean isAI, int humanColor) {
        int blackMaxScore = 0, whiteMaxScore = 0;
        for (int i = 0; i < size; i++) {
            for (int j = 0; j < size; j++) {
                if (chessBoardAlgo.getValInBoard(j, i) == Player.BLACK.getId()) {
                    blackMaxScore += this.blackScore[i][j];
                } else if (chessBoardAlgo.getValInBoard(j, i) == Player.WHITE.getId()) {
                    whiteMaxScore += this.whiteScore[i][j];
                }
            }
        }
        if (isAI) {
            if (humanColor != Player.BLACK.getId())
                return blackMaxScore - whiteMaxScore;
            else return whiteMaxScore - blackMaxScore;
        } else {
            if (humanColor == Player.BLACK.getId())
                return blackMaxScore - whiteMaxScore;
            else return whiteMaxScore - blackMaxScore;
        }
    }

现在我们的AI程序算是彻底可以工作了,如果我们把评价函数的深度设在5。应该可以在1秒内完成计算。此时的AI已经有不错的棋力。不过普通人类玩家在经过思考后,还是有机会下赢。我们介绍进一步的优化手段,可以让层数增加到9层。

移动顺序

alpha-beta剪枝的有效性很大程度上依赖状态的检查顺序。如果最差的后继最先生成,因为他的得分最小,我们根本没法用他去对后面的搜索做剪枝。如果我们一开始就得到了最优的后继,那么后面我们发现已经有一个分数比之前最优的小了,RUN MIN层的时候,后面就不用算下去了。可以直接剪枝。
如果能够完美实现这1点,那么整个搜索算法只需要检查O(b^(m/2))个节点就能宣传最佳移动。假设M是6层, B是10. 原来要搜1000000次,现在只要搜1000次。可以让我们的深度扩大一倍。

那么我们就需要对我们的候选方案进行排序。使得能够使局面更加优势的落子排在前面,从而逼近这个完美顺序。

那么思路就是我们在遍历棋盘时,把不同子力的棋子放入不同的桶中,最后按照桶的顺序就JOIN成最后的结果。大概代码思想如下

public List<Integer> generateCandidatePiece(int role, boolean importOnly, int neiDist, int limit) {
        List<Integer> fives = new ArrayList<>();
        List<Integer> myFours = new ArrayList<>();
        List<Integer> enemyFours = new ArrayList<>();
        List<Integer> myBlockedFours = new ArrayList<>();
        List<Integer> enemyBlockedFours = new ArrayList<>();
        List<Integer> myTwoThrees = new ArrayList<>();
        List<Integer> enemyTwoThrees = new ArrayList<>();
        List<Integer> myThrees = new ArrayList<>();
        List<Integer> enemyThrees = new ArrayList<>();
        List<int[]> myTwos = new ArrayList<>();
        List<int[]> enemyTwos = new ArrayList<>();
        List<Integer> ones = new ArrayList<>();
        int white = Player.WHITE.getId();
        int[][] myScore = role == white ? whiteScore : blackScore;
        int[][] enemyScore = role == white ? blackScore : whiteScore;
        for (int y = 0; y < size; y++) {
            for (int x = 0; x < size; x++) {
                if (chessBoardAlgo.getValInBoard(x, y) != 0) continue;

                int scoreMe = myScore[y][x];
                int scoreEnemy = enemyScore[y][x];
                int idx = convertToIdx(y, x, size);
                if (scoreMe >= Score.FIVE.value || scoreEnemy >= Score.FIVE.value) {
                    fives.add(idx);
                } else if (scoreMe >= Score.FOUR.value) {
                    myFours.add(idx);
                } else if (scoreEnemy >= Score.FOUR.value) {
                    enemyFours.add(idx);
           ....
        }
        if (!fives.isEmpty()) return fives;

        // 自己能活四,则直接活四,不考虑冲四
        if (!myFours.isEmpty()) return myFours;

        // 对面有活四,自己冲四都没,
        if (!enemyFours.isEmpty() && myBlockedFours.isEmpty()) {
            // 遇到 XX 0 X 的情况,除了防中间的活四位,也可以防左右的冲四位
            enemyFours.addAll(enemyBlockedFours);
            return enemyFours;
        }

        // 自己没活四,对面有活四且自己有冲四

        if (!enemyFours.isEmpty()) {
            enemyFours.addAll(myBlockedFours);
            enemyFours.addAll(enemyBlockedFours);
            return enemyFours;
        }

        List<Integer> results = new ArrayList<>();
        results.addAll(myTwoThrees);
        results.addAll(enemyTwoThrees);
        results.addAll(myBlockedFours);
        results.addAll(myThrees);
        results.addAll(enemyBlockedFours);
        results.addAll(enemyThrees);
...
       return results;

地平线效应

地平线效应,也称为地平线问题,是一个人工智能问题,其中许多博弈中可能的状态或位置的数量是巨大的,而计算机只能搜索其中的一小部分,通常是几个较低的层。游戏树。因此,如果计算机只搜索 5 层,它可能会做出一些有害的动作,但效果是看不见的,因为计算机不会搜索到错误的深度(即超出“地平线”)。比如我们的搜索深度时,下完某一步棋局面大好,但是如果多看几步就会发现,其实那个局面是对手给的一个陷阱,最终是对手必胜的局面。那么我们在有限的搜索深度下无法获得这个信息,而错误的给了一个高分在这个走法上。这就是目前AI最大的问题。这经常发生在一些冲四应手上。对面对面不停冲四,可以很快消耗掉我们的搜索深度,让我们的棋力明显退步。我们在后面几章中,来解决这个问题。其中一个经典的做法是单步延伸,也就是如果对手冲四,我们只有唯一的应手时,不计入搜索深度中。不过这样也会造成时间上的爆炸。因为对手的冲四策略是可以排列组合的,比如先冲四A再冲四B,和先冲四B再冲四A,带来的局面是一样的,我们则需要为一样的局面计算2次完整深度的搜索。因为之前的冲四我们没计算深度。这个问题我们需要用更好的方式去解决它。

前向剪枝

alpha-beta剪枝剪掉的是对最终评估没有影响的树的分支。但前向剪枝是去剪掉那些看上很草稿但也可能实际很好的移动。因此,这一策略以出错风险增大的代价去节省了计算时间。这也是大多数人类棋手的做法,仅考虑每个局面的几步移动(至少潜意识的)
这个技术其实很好运用。因为要求我们被迫放弃一些不好的移动,而减少每一层搜索的量。假设我们考虑每一层所有落子的可能。那么在棋局一开始我们会有15 * 15 个可能。那么每层的平均深度可以上百。
而很多其实是人类棋手根本不会考虑的落子。所以这个时候,我们应该去丢掉一些明显不是好棋的落子。
这里我们假设,凡是相邻有棋的地方,我们是需要考虑做进攻和防守的。同时我们也要考虑相邻的范围。比如我们封堵单边活三可能的冲四,可以紧挨着封堵,也可以跳开一格封堵(对手依然无法冲四)。同时进攻时也是如此,但是如果我们要考虑跳开一格的落子可能,也会造成候选集大一倍。所以根据人类经验,跳开一步的下法更适合进攻。防守还是紧贴着效果会不太差。
基于上述思考,我们做的第一个优化,是引入一个NEIBOR函数。

这个方法维护在ChessboardByteArrayAlgo

public boolean hasNeighbor(int x, int y, int distance, int count) {
        if (x < 0 || x >= size || y < 0 || y >= size) throw new IllegalArgumentException();
        int sx = x - distance, ex = x + distance;
        int sy = y - distance, ey = y + distance;
        for (int i = sy; i <= ey; i++) {
            if (i < 0 || i >= size) continue;
            for (int j = sx; j <= ex; j++) {
                if (j < 0 || j >= size) continue;
                if (i == y && j == x) continue;
                if (location[getIdx(i,j)] != 0) count--;
                if (count <= 0) return true;
            }
        }
        return false;
    }

下面在生成候选落子时, 加入一个邻居是否有子的判断,我们默认neiDist是1,那么考虑进攻时不能丢弃一些明显的进攻手段,所以在neiDist 为2时,我们看看符不符合进攻的局面。

 int farAttackPotential = chessBoardAlgo.steps() <= 7 ? 2 * Score.TWO.value : Score.TWO.value;
        for (int y = 0; y < size; y++) {
            for (int x = 0; x < size; x++) {
                if (chessBoardAlgo.getValInBoard(x, y) != 0) continue;
                if (!chessBoardAlgo.hasNeighbor(x, y, neiDist, 1)) {
                    if (chessBoardAlgo.hasNeighbor(x, y, 2, 1) && myScore[y][x] >= farAttackPotential) {
                        // exception 1 -> (1) 0 1 1 0

                        // exception 2 -> (1) 0 1 1 2
                        //             ->  0  0 0 0 0
                        //             ->  0  0 1 0 0
                        //             ->  0  0 0 0 0
                    } else {
                        continue;
                    }

                }

最后我们之前已经把移动顺序调整好了,因此在可能的移动列表中,后期才出现的移动不太可能是好的移动。这里有2种做法,一种是直接丢弃,另一种是减少搜索这些移动的深度。这里我们选择前种做法。我们引入一个动态的LIMIT限制,来表示这种丢弃的激进程度。此外如果我们已经发现局面上有双活三的情况,可以不考虑活二的侯选步来加速。这个只是一个基于人类经验的规则,没有普适性。

 if (!results.isEmpty() && (!enemyTwoThrees.isEmpty() || !myTwoThrees.isEmpty()))
            return results;

        List<int[]> twos = new ArrayList<>();
        twos.addAll(myTwos);
        twos.addAll(enemyTwos);
        twos.sort((a,b)-> Integer.compare(b[1],a[1]));
        if (!twos.isEmpty()) {
            for (int[] i : twos) {
                results.add(i[0]);
            }
        } else {
            results.addAll(ones);
        }
        if (results.size() > limit) return results.subList(0, limit);
        return (results.isEmpty() && chessBoardAlgo.getValInBoard(size / 2, size / 2) == 0)
                ?
                List.of(convertToIdx(size / 2, size / 2, size))
                :
                results;
    }

下面我们可以完整的试一下7层深度的算法,他应该除了在初期会略慢一些,下到后面应该都可以在1秒速度内响应。并且已经具备人类普通棋手的智能水平了。

总结

这一章我们完整的把alpha beta 的极小化极大搜索算法的优化给讲完了。我们已经获得了一个还不错的五子棋AI。
希望你们可以在实现别的棋类AI时借鉴这种设计思路。
比如如果获取棋局的特征从而设计出评价函数。如何对每个候选落子评分做排序,以及如何通过前向剪枝,来提升计算时间,而最低概率不影响找到最优落子。

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

推荐阅读更多精彩内容