我最近偶然发现了2048游戏。通过在四个方向中的任何一个方向上移动类似的平铺,可以合并它们,以生成“更大”的平铺。每次移动后,一个新的平铺显示在随机的空位置,值为2或4。当所有的方块都被填满并且没有可以合并平铺的移动,或者你创建了一个值为2048的平铺时,游戏终止。

首先,我需要遵循一个明确的战略来实现目标。所以,我想为它写一个程序。

我当前的算法:

while (!game_over) {
    for each possible move:
        count_no_of_merges_for_2-tiles and 4-tiles
    choose the move with a large number of merges
}

我所做的是,在任何时候,我都会尝试将值为2和4的平铺合并,也就是说,我尝试尽可能少地使用2和4个平铺。如果我这样做,所有其他平铺都会自动合并,策略似乎很好。

但是,当我实际使用这个算法时,在游戏结束之前,我只得到了大约4000分。AFAIK的最高分数略高于20000分,这比我目前的分数要大得多。有比上述更好的算法吗?


当前回答

算法

while(!game_over)
{
    for each possible move:
        evaluate next state

    choose the maximum evaluation
}

评价

Evaluation =
    128 (Constant)
    + (Number of Spaces x 128)
    + Sum of faces adjacent to a space { (1/face) x 4096 }
    + Sum of other faces { log(face) x 4 }
    + (Number of possible next moves x 256)
    + (Number of aligned values x 2)

评估详细信息

128 (Constant)

这是一个常数,用作基线和其他用途,如测试。

+ (Number of Spaces x 128)

更多的空间使状态更灵活,我们乘以128(这是中值),因为填充了128个面的网格是最佳的不可能状态。

+ Sum of faces adjacent to a space { (1/face) x 4096 }

这里,我们评估有可能合并的面,通过向后评估它们,平铺2的值为2048,而平铺2048的值为2。

+ Sum of other faces { log(face) x 4 }

在这里,我们仍然需要检查堆叠的值,但以一种较小的方式,这不会中断灵活性参数,因此我们得到了[4,44]中的{x的和}。

+ (Number of possible next moves x 256)

如果一个国家对可能的转变有更大的自由度,它就会更灵活。

+ (Number of aligned values x 2)

这是对在该状态内合并的可能性的简化检查,而无需进行前瞻。

注意:常数可以调整。。

其他回答

算法

while(!game_over)
{
    for each possible move:
        evaluate next state

    choose the maximum evaluation
}

评价

Evaluation =
    128 (Constant)
    + (Number of Spaces x 128)
    + Sum of faces adjacent to a space { (1/face) x 4096 }
    + Sum of other faces { log(face) x 4 }
    + (Number of possible next moves x 256)
    + (Number of aligned values x 2)

评估详细信息

128 (Constant)

这是一个常数,用作基线和其他用途,如测试。

+ (Number of Spaces x 128)

更多的空间使状态更灵活,我们乘以128(这是中值),因为填充了128个面的网格是最佳的不可能状态。

+ Sum of faces adjacent to a space { (1/face) x 4096 }

这里,我们评估有可能合并的面,通过向后评估它们,平铺2的值为2048,而平铺2048的值为2。

+ Sum of other faces { log(face) x 4 }

在这里,我们仍然需要检查堆叠的值,但以一种较小的方式,这不会中断灵活性参数,因此我们得到了[4,44]中的{x的和}。

+ (Number of possible next moves x 256)

如果一个国家对可能的转变有更大的自由度,它就会更灵活。

+ (Number of aligned values x 2)

这是对在该状态内合并的可能性的简化检查,而无需进行前瞻。

注意:常数可以调整。。

许多其他答案使用人工智能,对可能的未来、启发式、学习等进行计算成本高昂的搜索。这些令人印象深刻,可能是正确的前进方向,但我想提出另一个想法。

模拟游戏中优秀玩家使用的策略。

例如:

13 14 15 16
12 11 10  9
 5  6  7  8
 4  3  2  1

按照上面显示的顺序读取正方形,直到下一个正方形值大于当前值。这就带来了试图将另一个具有相同值的平铺合并到此方形中的问题。

为了解决这个问题,他们有两种移动方式,没有留下或更糟,检查这两种可能性可能会立即发现更多问题,这形成了一个依赖关系列表,每个问题都需要先解决另一个问题。我认为我在决定下一步行动时,特别是在被卡住的时候,会有一条链条,或者在某些情况下,是内部的依赖树。


瓷砖需要与邻居合并,但太小:将另一个邻居与此邻居合并。

较大的平铺:增加较小的周围平铺的值。


整个方法可能比这更复杂,但并不复杂。这可能是一种机械的感觉,缺乏分数、体重、神经和对可能性的深入探索。可能性之树甚至需要足够大,完全需要分支。

我的尝试与上面的其他解决方案一样使用expectimax,但没有比特板。Nneonneo的解决方案可以检查1000万次移动,大约深度为4,剩余6个平铺,可能移动4次(2*6*4)4。在我的情况下,这个深度需要很长时间来探索,我根据剩余的空闲平铺数调整expectimax搜索的深度:

depth = free > 7 ? 1 : (free > 4 ? 2 : 3)

板的得分通过自由瓷砖数量的平方和2D网格的点积的加权和计算,如下所示:

[[10,8,7,6.5],
 [.5,.7,1,3],
 [-.5,-1.5,-1.8,-2],
 [-3.8,-3.7,-3.5,-3]]

这迫使从左上角的瓦片以蛇形的方式向下组织瓦片。

下面或github上的代码:

变量n=4,M=新矩阵变换(n);var ai={weights:[1,1],深度:1};//默认情况下,depth=1,但我们会根据空闲平铺的数量对每个预测进行调整var蛇=[[10,8,7,6.5],[.5,.7,1,3],[-.5,-1.5,-1.8,-2],[-3.8,-3.7,-3.5,-3]]snake=snake.map(函数(a){return a.map(Math.exp)})初始化(ai)函数运行(ai){变量p;而((p=预测(ai))!=空){移动(p,ai);}//console.log(ai.grid,maxValue(ai.ggrid))ai.maxValue=最大值(ai.grid)控制台日志(ai)}函数初始化(ai){ai.grid=[];对于(变量i=0;i<n;i++){ai网格[i]=[]对于(变量j=0;j<n;j++){ai.grid[i][j]=0;}}兰特(ai网格)兰特(ai网格)ai.steps=0;}函数move(p,ai){//0:向上,1:向右,2:向下,3:向左var newgrid=mv(p,ai.grid);if(!equal(newgrid,ai.grid)){//console.log(stats(newgrid,ai.grid))ai.grid=新网格;尝试{兰特(ai网格)ai.步骤++;}捕获(e){控制台日志(“无房间”,e)}}}函数预测(ai){var free=freeCells(ai.grid);ai.depth=自由>7?1:(自由>4?2:3);var-root={path:[],prob:1,grid:ai.grid,childs:[]};var x=expandMove(根,ai)//console.log(“叶数”,x)//console.log(“叶数2”,countLeaves(根))if(!root.childres.length)返回nullvar values=root.childres.map(expectimax);var mx=最大值;return root.childrens[mx[1]].path[0]}函数countLeaves(节点){变量x=0;if(!node.childres.length)返回1;for(node.childs的var n)x+=叶数(n);返回x;}函数预期值(节点){if(!node.childres.length){返回node.score}其他{var values=node.childres.map(expectimax);如果(node.prob){//我们处于最大节点return Math.max.apply(空,值)}否则{//我们处于随机节点var平均值=0;对于(var i=0;i<values.length;i++)avg+=节点.子项[i].prob*值[i]返回平均值/(values.length/2)}}}函数expandRandom(节点,ai){变量x=0;对于(var i=0;i<node.grid.length;i++)对于(var j=0;j<node.grid.length;j++)if(!node.grid[i][j]){var grid2=M.copy(node.grid),grid4=M.copy(node.grid);网格2[i][j]=2;网格4[i][j]=4;var child2={grid:grid2,prob:.9,path:node.path,childs:[]};var child4={grid:grid4,prob:.1,path:node.path,childs:[]}node.childres.push(child2)node.childres.push(child4)x+=expandMove(child2,ai)x+=expandMove(child4,ai)}返回x;}函数expandMove(node,ai){//node={grid,path,score}var isLeaf=真,x=0;如果(节点路径长度小于ai深度){for(变量移动[0,1,2,3]){var grid=mv(移动,node.grid);if(!equal(grid,node.grid)){isLeaf=false;var child={grid:grid,path:node.path.contat([move]),childs:[]}node.childres.push(child)x+=expandRandom(子级,ai)}}}if(isLeaf)node.score=dot(ai.weights,stats(node.grid))return是Leaf?1:x;}var单元格=[]var table=document.querySelector(“table”);对于(变量i=0;i<n;i++){var tr=document.createElement(“tr”);单元格[i]=[];对于(变量j=0;j<n;j++){cells[i][j]=document.createElement(“td”);tr.appendChild(单元格[i][j])}table.appendChild(tr);}函数更新UI(ai){cells.forEach(函数(a,i){a.forEach(函数(el,j){el.innerHTML=ai.grid[i][j]| |“”})});}更新UI(ai);updateHint(预测(ai));函数runAI(){var p=预测(ai);如果(p!=null&&ai.running){移动(p,ai);更新UI(ai);updateHint(p);requestAnimationFrame(runAI);}}runai.onclick=函数(){if(!ai.running){this.innerHTML=“停止AI”;ai.running=真;runAI();}其他{this.innerHTML=“运行AI”;ai.running=false;updateHint(预测(ai));}}函数updateHint(dir){hintvalue.innerHTML=['↑', '→', '↓', '←'][目录]| |“”;}document.addEventListener(“keydown”,函数(事件){if(!event.target.matches('.r*'))返回;event.prpreventDefault();//避免滚动if(地图中的event.which){移动(map[event.with],ai)console.log(stats(ai.grid))更新UI(ai);updateHint(预测(ai));}})变量映射={38:0,//以上39:1,//右40:2,//向下37:3,//左};init.onclick=函数(){初始化(ai);更新UI(ai);updateHint(预测(ai));}函数统计信息(网格,previousGrid){var free=freeCells(网格);var c=dot2(网格,蛇);返回[c,free*free];}函数dist2(a,b){//2D距离的平方返回Math.pow(a[0]-b[0],2)+Math.pow(a[1]-b[1],2)}功能点(a,b){变量r=0;对于(var i=0;i<a.length;i++)r+=a[i]*b[i];返回r}函数dot2(a,b){变量r=0;对于(var i=0;i<a.length;i++)对于(变量j=0;j<a[0].length;j++)r+=a[i][j]*b[i][j]返回r;}函数积(a){return a.reduce(函数(v,x){返回v*x}, 1)}函数maxValue(网格){return Math.max.apply(null,grid.map(函数(a){return Math.max.apply(null,a)}));}无功能单元格(网格){返回grid.reduce(函数(v,a){返回v+a.reduce(函数(t,x){返回t+(x==0)}, 0)}, 0)}函数max(arr){//返回max的[value,index]var m=[-无限,空];对于(变量i=0;i<arr.length;i++){如果(arr[i]>m[0])m=[arr[i],i];}返回m}函数min(arr){//返回min的[value,index]var m=[无限,空];对于(变量i=0;i<arr.length;i++){如果(arr[i]<m[0])m=[arr[i],i];}返回m}函数maxScore(节点){变量最小值={分数:-无限,路径:[]};for(节点的var节点){如果(node.score>min.score)min=节点;}

我想我找到了一个非常有效的算法,因为我经常得分超过10000分,我个人最好的成绩是16000分左右。我的解决方案并不是要把最大的数字放在角落里,而是要把它放在最前排。

请参见以下代码:

while( !game_over ) {
    move_direction=up;
    if( !move_is_possible(up) ) {
        if( move_is_possible(right) && move_is_possible(left) ){
            if( number_of_empty_cells_after_moves(left,up) > number_of_empty_cells_after_moves(right,up) ) 
                move_direction = left;
            else
                move_direction = right;
        } else if ( move_is_possible(left) ){
            move_direction = left;
        } else if ( move_is_possible(right) ){
            move_direction = right;
        } else {
            move_direction = down;
        }
    }
    do_move(move_direction);
}

我对这款游戏的人工智能的想法产生了兴趣,它不包含硬编码的智能(即没有启发式、评分功能等)。人工智能应该只“知道”游戏规则,并“弄清楚”游戏玩法。这与大多数AI(如本线程中的AI)形成对比,在这些AI中,游戏玩法基本上是由代表人类对游戏理解的评分函数控制的暴力。

AI算法

我发现了一个简单但令人惊讶的好游戏算法:为了确定给定棋盘的下一步,AI使用随机移动在内存中玩游戏,直到游戏结束。这是在跟踪最终比赛分数的同时进行的几次。然后计算每次开始移动的平均结束得分。平均结束得分最高的起始动作被选为下一个动作。

每次移动仅运行100次(即内存游戏),AI可实现2048次平铺80%的次数和4096次平铺50%的次数。使用10000次运行可获得2048个平铺100%,4096个平铺70%,8192个平铺约1%。

在行动中看到它

最佳成绩如下:

关于这个算法的一个有趣的事实是,尽管随机游戏毫无疑问非常糟糕,但选择最佳(或最不糟糕)的招式会带来非常好的游戏效果:一个典型的人工智能游戏可以达到70000点,并持续3000步,但任何给定位置的记忆中随机游戏在死亡前都会在大约40次额外的招式中平均增加340点。(您可以通过运行AI并打开调试控制台自行查看。)

这张图说明了这一点:蓝线显示了每次移动后的棋盘得分。红线显示了该位置的算法的最佳随机运行结束游戏分数。本质上,红色值是将蓝色值向上拉向它们,因为它们是算法的最佳猜测。有趣的是,在每一点上,红线都比蓝线略高一点,但蓝线仍在不断增加。

我觉得很奇怪的是,算法不需要实际预测好的游戏玩法,就可以选择产生它的动作。

后来搜索发现,这个算法可能被归类为纯蒙特卡罗树搜索算法。

实施和链接

首先,我创建了一个JavaScript版本,可以在这里看到。这个版本可以在适当的时间内运行100次。打开控制台获取更多信息。(来源)

后来,为了玩更多,我使用了@nneonneo高度优化的基础设施,并用C++实现了我的版本。这个版本允许每次移动最多100000次,如果你有耐心的话,甚至可以达到1000000次。提供建筑说明。它在控制台中运行,也有一个遥控器来播放网络版本。(来源)

后果

令人惊讶的是,增加跑步次数并不能显著改善比赛。这一策略似乎有一个限制,即4096个区块和所有较小的区块在80000点左右,非常接近8192个区块。将跑步次数从100次增加到100000次会增加达到这一分数限制(从5%增加到40%)但无法突破的几率。

在关键位置临时增加到1000000次的10000次跑步打破了这一障碍,达到129892分的最高得分和8192分的次数不到1%。

改进

在实现这个算法后,我尝试了许多改进,包括使用最小或最大分数,或最小、最大和平均值的组合。我还尝试了使用深度:我没有尝试每次移动K次,而是尝试了给定长度的每次移动列表(例如“向上、向上、向左”)的K次移动,并选择最佳得分移动列表的第一个移动。

后来我实现了一个得分树,它考虑了在给定的移动列表之后能够进行移动的条件概率。

然而,这些想法都没有比简单的第一个想法显示出任何真正的优势。我将这些想法的代码注释在C++代码中。

我确实添加了一个“深度搜索”机制,当任何一次运行意外达到下一个最高的平铺时,该机制将运行次数临时增加到1000000次。这提供了时间上的改进。

我很想知道是否有人有其他改进想法来保持人工智能的领域独立性。

2048个变体和克隆

为了好玩,我还将AI实现为书签,与游戏的控件挂钩。这使得AI可以与原始游戏及其许多变体一起工作。

这是可能的,因为AI的领域独立性。一些变体非常独特,例如六边形克隆。