在井字棋的实现中,我认为最具挑战性的部分是确定机器要播放的最佳移动。
有哪些算法可以追求?我正在研究从简单到复杂的实现。我该如何解决问题的这个部分?
在井字棋的实现中,我认为最具挑战性的部分是确定机器要播放的最佳移动。
有哪些算法可以追求?我正在研究从简单到复杂的实现。我该如何解决问题的这个部分?
来自维基百科的完美游戏策略(每次赢或打平)似乎是简单明了的伪代码:
来自维基百科(井字棋策略)的引用
如果玩家每回合都按照Newell和Simon的1972年井字棋程序中使用的以下列表中的第一个可用移动进行游戏,则可以玩出完美的井字棋(赢或至少平局)。
胜利:如果您有两个相邻的棋子,下一步落子在这两个棋子间的空位上,以获得三连胜。
阻止对手获胜:如果对手有两个相邻的棋子,下一步落子在这两个棋子间的空位上,以阻止对手获胜。
叉:创造一个使您可以有两种方式获胜的机会。
阻止对手叉:
选项1:创建两个相邻的棋子,迫使对手进行防守,只要这不会导致对手叉或获胜。例如,如果“X”占据了一个角,“O”占据了中心,“X”也占据了对角线的另一个角,“O”必须不下角,才能获胜。(在这种情况下下角会为“X”创造叉来获胜。)
选项2:如果存在一种情况使对手可以叉,就阻止这个叉。
中心:下在中心的位置。
对角线上的空位:如果对手占据了一个角落,下在对角线上相对的角落。
空角落:下在一个空的角落上。
空边:下在一条空边上。
可以采用暴力方式来识别"fork"情况,如建议所说。
注意:追求“完美”的对手是一种不错的练习,但最终不值得与之“对弈”。但是,您可以改变上述优先级,赋予对手个性特征的弱点。
使用暴力方法生成每个可能的棋盘,并根据它所产生的后续棋盘进行评分,这种方法不需要太多的内存,特别是一旦您认识到90度的棋盘旋转是冗余的,以及关于垂直、水平和对角线轴的翻转。
一旦达到此点,树形图中描述结果和计算机的最佳移动的数据少于1k。
-Adam
由于您只需要处理可能位置的3x3矩阵,因此很容易通过搜索所有可能性来计算而不会过度消耗计算机资源。对于每个空格,在标记该空格后计算所有可能的结果(递归),然后使用具有最多获胜可能性的移动。
实际上,优化这将是一种浪费精力的行为。尽管有些简单的方法可能是:
一个典型的井字棋算法应该长这样:
棋盘:一个包含9个元素的向量,表示棋盘。我们存储2(表示空格)、3(表示X)或5(表示O)。
回合:一个整数,表示即将进行的游戏步骤。第一步为1,最后一步为9。
算法
主要算法使用了三个函数。
Make2
:如果棋盘中心的方块为空格(即board[5]=2
),则返回5。否则,此函数返回任何一个非角落方块(2, 4, 6 or 8)
。
Posswin(p)
:如果玩家p
在下一步无法获胜,则返回0;否则,它会返回构成获胜移动的方块号码。这个函数可以让程序既能赢又能阻止对手获胜。该函数通过检查每行、每列和每个对角线来操作。通过将整个行(或列或对角线)的每个方块的值相乘,可以检查是否有可能获胜。如果积是18
(3 x 3 x 2
),则X
可以获胜。如果积是50
(5 x 5 x 2
),则O可以获胜。如果找到了一个获胜的行(列或对角线),则可以确定其中的空格,并通过此函数返回该方块的编号。
Go(n)
:在第n个方格中进行移动。如果回合是奇数,则将棋盘[n]
设置为3,如果回合是偶数,则设置为5。它还会将回合加一。
该算法具有每步的内置策略。如果下X
,则进行奇数步骤,如果下O
,则进行偶数步骤。
Turn = 1 Go(1) (upper left corner).
Turn = 2 If Board[5] is blank, Go(5), else Go(1).
Turn = 3 If Board[9] is blank, Go(9), else Go(3).
Turn = 4 If Posswin(X) is not 0, then Go(Posswin(X)) i.e. [ block opponent’s win], else Go(Make2).
Turn = 5 if Posswin(X) is not 0 then Go(Posswin(X)) [i.e. win], else if Posswin(O) is not 0, then Go(Posswin(O)) [i.e. block win], else if Board[7] is blank, then Go(7), else Go(3). [to explore other possibility if there be any ].
Turn = 6 If Posswin(O) is not 0 then Go(Posswin(O)), else if Posswin(X) is not 0, then Go(Posswin(X)), else Go(Make2).
Turn = 7 If Posswin(X) is not 0 then Go(Posswin(X)), else if Posswin(X) is not 0, then Go(Posswin(O)) else go anywhere that is blank.
Turn = 8 if Posswin(O) is not 0 then Go(Posswin(O)), else if Posswin(X) is not 0, then Go(Posswin(X)), else go anywhere that is blank.
Turn = 9 Same as Turn=7.
我已经使用过它,让我知道你们的感受。
注意:当你拥有双倍和叉子时,请检查你的双倍是否给对手带来了双倍。如果是,检查你的新强制点是否包含在你的叉子列表中。
对每个方格进行数字评分。如果一个方格已经被占据,就继续选择下一个(按照排名降序排序)。你需要选择一种策略(先手有两种主要策略,后手有三种(我想))。从技术上讲,你可以编写所有的策略,然后随机选择一个。这将使对手更加不可预测。
本答案假定您已经了解如何实现P1的完美算法,并讨论如何在与普通人类玩家的条件下取得胜利,因为他们会比其他人更常犯错误。
当然,如果两个玩家都以最佳方式进行游戏,则游戏应该以平局结束。在人类水平上,P1在角落里玩游戏往往会赢得更多。由于某种心理原因,P2被诱骗认为在中心玩游戏并不那么重要,这对他们来说是不幸的,因为这是唯一不会为P1创造获胜游戏的反应。
如果P2正确地在中心阻止了,P1应该在相反的角落里玩游戏,因为出于某种心理原因,P2会更喜欢玩角落,这又会为他们产生一个输掉的棋盘。
对于P1可能做出的任何起始移动,如果两个玩家此后都以最佳方式进行游戏,P2可能做出的一步移动将为P1创造一个获胜。从这个意义上说,P1可以随便玩。边缘移动是最弱的,因为对这个移动的可能响应的最大分数产生平局,但仍然有响应会为P1创造一个获胜。
经验上(更准确地说是根据经验),最好的P1起始移动似乎是第一个角落、第二个中心和最后一个边缘。
下一个挑战,你可以通过人工操作或图形用户界面来实现,就是不显示棋盘。人类肯定能记住所有状态,但增加的挑战会导致对称棋盘更受欢迎,因为它们需要记忆的努力较少,从而导致我在第一分支中概述的错误。let gameBoard: [
[null, null, null],
[null, null, null],
[null, null, null]
]
const SYMBOLS = {
X:'X',
O:'O'
}
const RESULT = {
INCOMPLETE: "incomplete",
PLAYER_X_WON: SYMBOLS.x,
PLAYER_O_WON: SYMBOLS.o,
tie: "tie"
}
function checkSuccession (line){
if (line === SYMBOLS.X.repeat(3)) return SYMBOLS.X
if (line === SYMBOLS.O.repeat(3)) return SYMBOLS.O
return false
}
function getResult(board){
let result = RESULT.incomplete
if (moveCount(board)<5){
return result
}
let lines
//first we check row, then column, then diagonal
for (var i = 0 ; i<3 ; i++){
lines.push(board[i].join(''))
}
for (var j=0 ; j<3; j++){
const column = [board[0][j],board[1][j],board[2][j]]
lines.push(column.join(''))
}
const diag1 = [board[0][0],board[1][1],board[2][2]]
lines.push(diag1.join(''))
const diag2 = [board[0][2],board[1][1],board[2][0]]
lines.push(diag2.join(''))
for (i=0 ; i<lines.length ; i++){
const succession = checkSuccesion(lines[i])
if(succession){
return succession
}
}
//Check for tie
if (moveCount(board)==9){
return RESULT.tie
}
return result
}
Our getBestMove function will receive the state of the board, and the symbol of the player for which we want to determine the best possible move. Our function will check all possible moves with the getResult function. If it is a win it will give it a score of 1. if it's a loose it will get a score of -1, a tie will get a score of 0. If it is undetermined we will call the getBestMove function with the new state of the board and the opposite symbol. Since the next move is of the oponent, his victory is the lose of the current player, and the score will be negated. At the end possible move receives a score of either 1,0 or -1, we can sort the moves, and return the move with the highest score.
const copyBoard = (board) => board.map(
row => row.map( square => square )
)
function getAvailableMoves (board) {
let availableMoves = []
for (let row = 0 ; row<3 ; row++){
for (let column = 0 ; column<3 ; column++){
if (board[row][column]===null){
availableMoves.push({row, column})
}
}
}
return availableMoves
}
function applyMove(board,move, symbol) {
board[move.row][move.column]= symbol
return board
}
function getBestMove (board, symbol){
let availableMoves = getAvailableMoves(board)
let availableMovesAndScores = []
for (var i=0 ; i<availableMoves.length ; i++){
let move = availableMoves[i]
let newBoard = copyBoard(board)
newBoard = applyMove(newBoard,move, symbol)
result = getResult(newBoard,symbol).result
let score
if (result == RESULT.tie) {score = 0}
else if (result == symbol) {
score = 1
}
else {
let otherSymbol = (symbol==SYMBOLS.x)? SYMBOLS.o : SYMBOLS.x
nextMove = getBestMove(newBoard, otherSymbol)
score = - (nextMove.score)
}
if(score === 1) // Performance optimization
return {move, score}
availableMovesAndScores.push({move, score})
}
availableMovesAndScores.sort((moveA, moveB )=>{
return moveB.score - moveA.score
})
return availableMovesAndScores[0]
}