【问题标题】:Tic-Tac-Toe minimax algorithm doesn't work with 4x4 boardTic-Tac-Toe minimax 算法不适用于 4x4 棋盘
【发布时间】:2015-11-03 06:54:28
【问题描述】:

所以在过去的 3 周里,我一直致力于这个项目。我设法让 minimax 函数在早期用于 3x3 板,但是当我尝试将它用于 4x4 板时开始出现问题,即 Java 堆空间错误。从那时起,在 Alpha beta pruning 的帮助下,我设法从 aprox 中减少了 minimax 函数中所需的 minimax 调用的数量。 59000 到 16000 到 11000,然后最后到 8000 次调用(这是假设一个插槽已经被填满的板的初始最小最大调用)。然而,现在的问题是该方法只能在 4x4 游戏中继续运行。它只是不停地自称,没有错误,没有结果,什么都没有。从理论上讲,按照我的看法,我的功能应该适用于任意板尺寸,唯一的问题是内存。现在,由于我已经大大减少了函数的内存贪婪,我希望它能够工作。嗯,它适用于 3x3。但是,它不适用于 4x4。 简要说明该函数的作用: 该函数返回一个大小为 2 的数组,其中包含所有可能的下一步移动中最有利的下一步移动以及预期从该移动中获得的分数。评分系统很简单。 +10 表示 O 胜,-10 表示 X 胜,0 表示平局。该函数当然是递归的。在其中,您会发现某些快捷方式可以减少对其自身的所需调用次数。例如,如果轮到 X 并且返回的分数是 -10(这是 X 的最佳分数),则退出循环,即停止从该状态观察其他潜在的移动。这是 State 类的代码:

private String [] state;    //Actual content of the board
private String turn;        //Whose turn it is
private static int n;       //Size of the board

public State(int n) {
    state = new String[n*n];
    for(int i = 0; i < state.length; i++) {
        state[i] = "-";
    }
    State.n = n;
}


public int[] newminimax47(int z) {
    int bestScore = (turn == "O") ? +9 : -9;    //X is minimizer, O is maximizer
    int bestPos = -1;
    int currentScore;
    int lastAdded = z;
    if(isGameOver() != "Not Gameover") {
        bestScore= score();
    }
    else {
        int i = 0;
        for(int x:getAvailableMoves()) {
            if(turn == "X") {   //X is minimizer
                setX(x);
                currentScore = newminimax47(x)[0];
                if(i == 0) {
                    bestScore = currentScore;
                    bestPos = x;
                    if(bestScore == -10)
                        break;
                }
                else if(currentScore < bestScore) {
                    bestScore = currentScore;
                    bestPos = x;
                    if(bestScore == -10)
                        break;
                }
            }
            else if(turn == "O") {  //O is maximizer
                setO(x);
                currentScore = newminimax47(x)[0];
                if(i == 0) {
                    bestScore = currentScore;
                    bestPos = x;
                    if(bestScore == 10)
                        break;
                }

                else if(currentScore > bestScore) {
                    bestScore = currentScore;
                    bestPos = x;
                    if(bestScore == 10)
                        break;
                }
            }
            i++;
        }
    }
    revert(lastAdded);
    return new int [] {bestScore, bestPos};
}

newminimax47() 使用的补函数:

isGameOver():

public String isGameOver() {
    if(n == 3) {
        //Rows 1 to 3
        if((state[0] != "-") && (state[0] == state[1]) && (state[1] == state[2]))
            return (state[0] == "X") ? "X Won" : "O Won";
        else if((state[3] != "-") && (state[3] == state[4]) && (state[4] == state[5]))
            return (state[3] == "X") ? "X Won" : "O Won";
        else if((state[6] != "-") && (state[6] == state[7]) && (state[7] == state[8]))
            return (state[6] == "X") ? "X Won" : "O Won";

        //Columns 1 to 3
        else if((state[0] != "-")&&(state[0] == state[3]) && (state[3] == state[6]))
            return (state[0] == "X") ? "X Won" : "O Won";
        else if((state[1] != "-") && (state[1] == state[4]) && (state[4] == state[7]))
            return (state[1] == "X") ? "X Won" : "O Won";
        else if((state[2] != "-") && (state[2] == state[5]) && (state[5] == state[8]))
            return (state[2] == "X") ? "X Won" : "O Won";

        //Diagonals
        else if((state[0] != "-") && (state[0]==state[4]) && (state[4] == state[8]))
            return (state[0] == "X") ? "X Won" : "O Won";
        else if((state[6] != "-") && (state[6] == state[4]) && (state[4] == state[2]))
            return (state[6] == "X") ? "X Won" : "O Won";

        //Checking if draw
        else if((state[0] != "-") && (state[1]!="-") && (state[2] != "-") && (state[3]!="-") &&
                (state[4] != "-") && (state[5] != "-") && (state[6] != "-") && (state[7] != "-") &&
                (state[8] != "-"))
            return "Draw";
        else
            return "Not Gameover";
    }
    else {
        //Rows 1 to 4
        if((state[0] != "-") && (state[0] == state[1]) && (state[1] == state[2]) && (state[2] == state[3]))
            return (state[0] == "X") ? "X Won" : "O Won";
        else if((state[4] != "-") && (state[4] == state[5]) && (state[5]==state[6]) && (state[6] == state[7]))
            return (state[4] == "X") ? "X Won" : "O Won";
        else if((state[8] != "-") && (state[8] == state[9]) && (state[9]==state[10]) && (state[10] == state[11]))
            return (state[8] == "X") ? "X Won" : "O Won";
        else if((state[12] != "-") && (state[12] == state[13]) &&(state[13] == state[14]) && (state[14] == state[15]))
            return (state[12] == "X") ? "X Won" : "O Won";

        //Columns 1 to 4
        else if((state[0] != "-") && (state[0] == state[4]) && (state[4] == state[8]) && (state[8] == state[12]))
            return (state[0] == "X") ? "X Won" : "O Won";
        else if((state[1] != "-") && (state[1] == state[5]) && (state[5] == state[9]) && (state[9] == state[13]))
            return (state[1] == "X") ? "X Won" : "O Won";
        else if((state[2] != "-") && (state[2] == state[6]) && (state[6] == state[10]) && (state[10] == state[14]))
            return (state[2] == "X") ? "X Won" : "O Won";
        else if((state[3] != "-") && (state[3] == state[7]) && (state[7] == state[11]) && (state[11] == state[15]))
            return (state[3] == "X") ? "X Won" : "O Won";

        //Diagonale
        else if((state[0] != "-") && (state[0] == state[5]) && (state[5] == state[10]) && (state[10] == state[15]))
            return (state[0] == "X") ? "X Won" : "O Won";
        else if((state[12] != "-") && (state[12] == state[9]) &&  (state[9] == state[6]) && (state[6] == state[3]))
            return (state[0] == "X") ? "X Won" : "O Won";

        //Pruefe ob Gleichstand
        else if((state[0] != "-") && (state[1] != "-") && (state[2] != "-") && (state[3]!="-") &&
                (state[4] != "-") && (state[5] != "-") && (state[6] != "-") && (state[7] != "-") &&
                (state[8] != "-") && (state[9] != "-") && (state[10] != "-") && (state[11] != "-") &&
                (state[12] != "-") && (state[13] != "-") && (state[14] != "-") && (state[15] != "-")) 
            return "Draw";
        else
            return "Not Gameover";
    }   
}

请原谅 isGameOver() 方法的生硬,它只是检查棋盘的状态(即获胜、平局、游戏未结束)

getAvailableMoves() 方法:

public int[] getAvailableMoves() {
    int count = 0;
    int i = 0;
    for(int j = 0; j < state.length; j++) {
        if(state[j] == "-")
            count++;
    }
    int [] availableSlots = new int[count];
    for(int j = 0; j < state.length; j++){
        if(state[j] == "-")
            availableSlots[i++] = j;        
    }
    return availableSlots;
}

此方法仅返回一个包含所有可用下一步移动的 int 数组(关于当前状态),如果没有可用移动或游戏结束,则返回空数组。

score() 方法:

public int score() {
    if(isGameOver() == "X Won")
        return -10;
    else if(isGameOver() == "O Won")
        return +10;
    else 
        return 0;
}

setO()、setX() 和 revert():

public void setX(int i) {
    state[i] = "X";
    turn = "O";
    lastAdded = i;
}
public void setO(int i) {
    state[i] = "O";
    turn = "X";
    lastAdded = i;
}
public void revert(int i) {
    state[i] = "-";
    if(turn == "X")
        turn = "O";
    else
        turn = "X";
}

对于 3x3 游戏,我的主要方法如下所示:

public static void main(String args[]) {
    State s = new State(3);
    int [] ScoreAndRecommendedMove = new int[2];
    s.setX(8);
    ScoreAndRecommendedMove = s.newminimax47(8);
    System.out.println("Score: "+ScoreAndRecommendedMove[0]+" Position: "+ ScoreAndRecommendedMove[1]);
}

在这个游戏中,X 在第 8 个位置开始了游戏。这种情况下的方法将返回

Score: 0 Position: 4  

这意味着 O 最有希望的举动是在位置 4,在最坏的情况下将产生 0 分(即平局)。

下图旨在说明 newminimax47() 的工作原理。在这种情况下,我们的起始状态(板)被赋予数字 1。注意:数字表示创建所考虑状态的优先级。 1 在 2 之前创建,2 在 3 之前创建,3 在 4 之前创建等等。

在这种情况下,最终返回状态1的分数和位置将是

Score: 0 Position: 6

来自状态 8。

注意:您看到的代码只是实际 State 类的 sn-ps。这些 sn-ps 本身应该允许您毫无问题地重新创建和使用 newminimax47 函数(至少对于 3x3)。您可能发现的任何错误都不是真正的错误,它们根本不包含在我在这里复制的 sn-ps 中,并且代码应该在没有它们的情况下工作。例如 setO 和 setX 函数中的 lastAdded 变量不包含在此处的 sn-ps 中,但我刚刚意识到您不需要它就可以使用 minimax 函数,因此您可以将其注释掉。

【问题讨论】:

  • 将z参数赋予minmax方法的目的是什么?
  • 你的 setX() 和 setO() 方法使用了一个变量 'lastAdded' 但它是未定义的...... newMinimax47() 中只有一个同名的局部变量
  • 在 3x3 棋盘上,第一步有 9 种可能性,第二步有 8 种可能性,依此类推,总共有9! = 362880 步。对于 4x4 棋盘,总移动数为16! = 20922789888000。所以这个算法可能没什么问题,你只需要在一台非常非常快的计算机上运行它:)
  • 分数不只取决于你当前的棋盘状态吗?在这种情况下,您可以拥有 3^16 种不同的状态(“O”、“X”或空)。你可以使用记忆来加速递归。

标签: java algorithm loops tic-tac-toe minimax


【解决方案1】:

正如 user3386109 解释的那样,这里的问题是您计算所有内容的次数。不过,考虑到 N 大小的网格,有几件事可能会对您有所帮助:

  • 如果用户的符号少于 N,则用户无法获胜,因此您可以在 isGameOver 函数中使用它作为第一次检查
  • 您必须做的第一件事是防止对手获胜,如果他的下一步行动有可能获胜
  • 通过每次移动递增计数器来跟踪每行和每列以及两条对角线中有多少“X”和“O”。如果有 N-1 个相同符号,则下一个将是您或您的对手获胜的一步。
  • 通过这样做,您可以轻松判断哪个是最好的移动,因为这样:
    • 如果你有一个获胜的举动,你把符号放在那里
    • 检查你的对手,如果他在同一行/列/对角线上有 N-1 个符号,你就放在那里
    • 如果您的对手在某个位置上的符号比您多,那么您就将这个位置扳平(这意味着 +1 或 +2,取决于谁先发游戏)
    • 如果不是这种情况,请将下一个符号放在有更多符号的行/列/对角线上
    • 如果你在某些地方有相同数量的符号,你只需把它放在对手有更多符号的地方
    • 如果你和你的对手完全对等,那就按照你自己的策略去吧(我想随机也不错:-))

除非你真的需要它(例如作为家庭作业),否则我不会为此使用递归。

顺便说一句:我认为让实际上的布尔函数返回一个字符串然后将其与固定值进行比较不是一个好习惯。 isGameOver 函数的真/假返回值在我看来要好得多。

【讨论】:

  • 您的答案的开头看起来不错,但我没有看到任何证据证明您的“最佳移动”规则确实是最佳的(我很确定它们不是,至少不是一般 N-in-a-row 游戏)。特别是,一旦您和您的对手在给定的行(或列或对角线)中都有符号,就没有理由“平局”;混排永远不会赢,而唯一 玩一个的原因是因为它巧合地与另一排仍然可能获胜的行相交。
  • 你是对的,我很清楚它不是最佳的,但这只是一个建议。随意编辑它以添加您自己的建议:-)
  • 其实是可以搜索到16个的!可能性,因为适当的 alpha-beta 算法会将其减少到大约 5*(16!^0.5)。可以立即搜索此数量的变体。除此之外,您还可以通过将板镜像到 5*(16!^0.5)/4 = 5*(15!^0.5) 来进一步减少数量。
  • @xXliolauXx 我的 alpha beta 修剪实现缺少什么。您可以提出任何建议以使其变得更好。因为我最初的想法是已经对 4x4 板使用 alpha beta 修剪完成了,所以问题不可能是它不可行。
  • @Omar 如果你真的想要,你可以将数量减少到大约(16!/8!/8!),从而导致节点数少于 13,000 个。如果您想获得该结果,则必须实现转置表(在tictactoe 中非常有效)并考虑每个位置都可以镜像/翻转。如果您想简单地改进 alpha beta,则需要移动排序。因此,只需编写关于哪些动作“看起来不错”的启发式算法,然后首先搜索这样做的动作(最容易实现,尽管找到适合井字游戏的启发式算法并不容易)
【解决方案2】:

我玩过你的代码,有很多话要说

错误

首先有一个错误。我认为您的代码实际上不适用于 3x3 板。问题是您revert 添加到棋盘的位置。您在 newminimax47 方法的末尾只执行一次此操作,即使在您添加的方法中,您在 for 循环内移动到棋盘。这意味着调用该方法不仅会计算一些东西,还会改变棋盘状态,而其余代码预计不会这样做。

所以删除revert现在所在的位置并尽快恢复:

setX(x);                                                                                                                                                                                                                                             
currentScore = newminimax47(x)[0];                           
revert(x);       

这也意味着您不需要 lastAdded 变量。

播放

如果您实际使用自己的算法,就会更容易看到正在发生的事情。向你的 State 类添加一个方法

public void dump() {                                                        
    for (int y = 0; y < n; y++) {                                           
        for (int x = 0; x < n; x++) {                                       
            System.out.print(state[y * n + x]);                             
        }                                                                   
        System.out.println();                                               
    }                                                                       
}

你主要是这样的

public void play() {                                                        
    State s=new State(3);                                                   
    Scanner in = new Scanner (System.in);                                   
    while (s.isGameOver().equals("Not Gameover")) {                         
        int[] options = s.getAvailableMoves();                              
        s.dump();                                                           
        System.out.println ("Your options are " + Arrays.toString(options));
        int move = in.nextInt();                                            
        s.setX(move);                                                       
        int [] ScoreAndRecommendedMove=new int[2];                          
        ScoreAndRecommendedMove=s.newminimax47(0);                           
        System.out.println("Score: "+ScoreAndRecommendedMove[0]+" Position: "+ ScoreAndRecommendedMove[1]);
        s.setO(ScoreAndRecommendedMove[1]);                                 
    }                                                                       
    s.dump();                                                               
}

你实际上可以对抗它。在 3x3 板上,这对我来说很好。不幸的是,我估计在 4x4 上计算第一步需要我的计算机大约 48 小时。

数据类型

您对数据类型的选择通常有点……奇怪。如果要记住单个字符,请使用char 而不是String。如果您想返回是/否决定,请尝试使用boolean。程序的某些部分也可以用更少的代码替换。但这不是你的问题,等等……

算法

好的,那么 minimax 解决这个问题有什么问题呢? 假设前四步是 X5、O8、X6 O7。另一种可能性是从 X5、O7、X6、O8 开始比赛。还有一个是X6、O7、X5、O8。最后是 X6、O8、X5、O7。

游戏前四步的所有四种可能性都会导致完全相同的游戏状态。但是 minimax 不会识别它们是相同的(基本上没有并行分支的内存),因此它将计算它们全部四个。如果你搜索得更深,每个棋盘状态的计算次数会迅速增加。

可能的游戏数量远远超过可能的棋盘状态数量。估计游戏的数量:首先有 16 种可能的移动,然后是 15,然后是 14、13、……等等。粗略估计是 16!,尽管 minimax 不必计算所有这些,因为它们中的许多将在第 16 步之前完成。

对游戏状态数量的估计是:棋盘上的每个方格都可以是空的,也可以是 X 或 O。所以这是 3^16 个棋盘。并不是所有的都是真正有效的板子,因为板上X的数量最多可以比O的数量多一个,但仍然接近3^16。

16!可能的游戏大约是 3^16 种可能的棋盘状态的 50 万倍。这意味着我们大约要计算每块板 50 万次,而不是只计算一次。

解决方案是开始记住您计算的每个游戏状态。每次调用递归函数时,首先检查您是否已经知道答案,如果是,则返回旧答案。这是一种称为memoization 的技术。

记忆

我将描述如何在使用您已经选择的数据结构时添加记忆(即使我不同意它们)。要进行记忆,您需要一个可以快速添加和快速查找的集合。列表(例如ArrayList)对我们没有任何好处。添加值很快,但在长列表中查找非常慢。有一些选项,但最容易使用的是HashMap。为了使用HashMap,您需要创建一些代表您的状态的东西,您可以将其用作键。最直接的方法是创建一个 String,其中包含代表您的板的所有 X/O/- 符号。

所以添加

Map<String,int[]> oldAnswers = new HashMap<String,int[]>();                                                                  

到您的State 对象。

然后在您的 newminimax47 方法的开头创建代表状态的字符串并检查我们是否已经知道答案:

    String stateString = "";                                                
    for (String field : state) stateString += field;                        
    int[] oldAnswer = oldAnswers.get(stateString);                          
    if (oldAnswer != null) return oldAnswer;

最后,当你在newminimax47 的末尾计算一个新答案时,你不仅应该返回它,还应该将它存储在地图中:

    int[] answer = {bestScore, bestPos};                                    
    oldAnswers.put (stateString, answer);                                   
    return answer;

有了 memoization,我可以对你的代码玩 4x4 游戏。第一步仍然很慢(20 秒),但之后一切都被计算出来并且非常快。如果您想进一步加快速度,可以查看alpha beta pruning。但改进不会像记忆一样多。另一种选择是使用更有效的数据类型。它不会降低算法的理论阶数,但仍可以轻松使其速度提高 5 倍。

【讨论】:

  • 感谢您的启发性回答。不过,您将如何在我的程序中实现它?我现在想到的是在我的 State 类中创建一个字段,该字段将是一个字符串数组列表,其中将存储我创建的所有状态。然后每次在自己内部调用 newminimax47 之前,我都会检查列表中是否有重复项。你会推荐这个吗?我有一种感觉,名单会变得非常大。 PS:您对使用字符而不是字符串的评论已得到适当注意。
  • 不错的答案,有关转置表和速度增益的更多信息,请参阅我对另一个答案的评论...
  • @Stef 关于您的回答的几点说明:记忆化似乎与 alpha beta 修剪不兼容(即它们不能同时以相同的方法实现)。此外,使用 alpha beta 剪枝的 minimax 方法似乎比使用 memoization 的方法运行得快得多。有关更多详细信息,请参阅我对此post 的回答
猜你喜欢
  • 2019-03-27
  • 2018-10-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多