什么是游戏2048的最佳算法?

2064

最近我偶然发现了一个游戏2048。你通过在任意四个方向上移动相似的方块来合并它们,以形成"更大"的方块。每次移动后,一个新的方块将随机出现在空位置,其值为24之一。当所有方块都被填满且没有可以合并的方块时,或者您创建了一个值为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
}

我所做的是在任何时候,我都会尝试合并值为24的方块,也就是说,我会尽可能地只保留24的方块。如果我这样做,所有其他方块将自动合并,这种策略似乎很好。

但是,当我实际使用此算法时,在游戏终止之前,我只能获得大约4000分。据我所知,最高得分略高于20,000分,远远超过我的当前得分。是否有比上述算法更好的算法?


94
这可能会有所帮助!http://ov3y.github.io/2048-AI/ - cegprakash
5
顺便提一下,你的算法是贪心的,因为你选择具有大量合并的移动,这很快导致局部最优解。 - Khaled.K
25
如果我要实现一个具有alpha-beta游戏树剪枝的AI,那么它将假定新方块是被对手敌意地放置的。这是一种最坏情况的假设,但可能是有用的。 - Charles
9
在你没有时间追求高分时,一种有趣的消遣方式是尝试获得尽可能低的分数。理论上来说,它应该是交替的2分和4分。 - Mark Hurd
7
有关此问题是否合法的讨论可以在 meta 上找到:http://meta.stackexchange.com/questions/227266/is-the-2048-thread-really-on-topic - Jeroen Vannevel
显示剩余5条评论
14个回答

1365

我使用了expectimax优化来开发2048人工智能,而不是@ovolve算法中使用的minimax搜索。该人工智能简单地在所有可能的移动中执行最大化操作,然后在所有可能的方块生成中执行期望操作(根据方块的概率进行加权,即4的概率为10%,2的概率为90%)。据我所知,无法对expectimax优化进行剪枝(除了删除极不可能的分支),因此使用的算法是经过精心优化的暴力搜索。

性能

默认情况下,该人工智能的最大搜索深度为8,执行一次移动需要10毫秒至200毫秒不等,具体取决于棋盘局面的复杂程度。在测试中,该人工智能平均每秒执行5-10次移动,整个游戏过程中的平均移动次数。如果将搜索深度限制为6次,则该人工智能可以轻松地执行20多次移动,这使得观察变得有趣

为了评估该人工智能的得分表现,我通过远程控制运行了100次该人工智能与浏览器游戏相连。针对每个方块,在以下比例游戏中至少获得过该方块一次:

2048: 100%
4096: 100%
8192: 100%
16384: 94%
32768: 36%

最低得分为124024;最高得分为794076。中位数得分为387222。AI从未未能获得2048块(因此它在100次游戏中从未输过一次),事实上,它每次运行至少一次获得8192块!以下是最佳运行的屏幕截图:

32768 tile, score 794076

This game took 27830 moves over 96 minutes, or an average of 4.8 moves per second.
实现方式:
我的方法是将整个棋盘(16个条目)编码为单个64位整数(其中瓷砖是nybble,即4位块)。在64位机器上,这使得整个棋盘可以在一个机器寄存器中传递。
使用位移操作提取单个行和列。单个行或列是16位数量,因此大小为65536的表格可以编码作用于单个行或列的转换。例如,移动是通过4个查找预先计算的“移动效果表”来实现的,该表描述了每个移动如何影响单个行或列(例如,“向右移动”表包含条目“1122 -> 0023”,描述当向右移动时行[2、2、4、4]变为行[0、0、4、8])。
评分也是使用表查找完成的。表格包含在所有可能的行/列上计算出来的启发式分数,对于一个棋盘的结果得分是简单地在每行和每列上累加表格值。
这个棋盘表示法和运用查找表的方法进行移动和得分计算,使得AI能够在短时间内搜索大量游戏状态(在我2011年笔记本电脑的一个核上每秒可达到超过10,000,000个游戏状态)。
expectimax搜索本身是编码为递归搜索的,它在“期望”步骤和“最大化”步骤之间交替进行。“期望”步骤测试所有可能的出现位置和取值,并通过每种可能性的概率加权优化它们的得分。“最大化”步骤测试所有可能的移动并选择具有最佳得分的移动。当树搜索看到以前见过的位置(使用置换表),达到预定义的深度限制或到达极不可能的棋盘状态时(例如,从起始位置连续获得6个“4”方块),树搜索终止。通常的搜索深度为4-8个移动。
启发式算法
几种常用的启发式方法被用于指导优化算法朝着有利位置前进,启发式方法的具体选择对算法的性能有很大影响。各种启发式方法被加权并组合成一个位置得分,该得分决定了给定棋盘位置的“好坏”。优化搜索将旨在最大化所有可能棋盘位置的平均得分。实际得分(如游戏所示)用于计算棋盘得分,因为它过于偏重于合并方块(当延迟合并可以产生巨大的好处时)。
最初,我使用了两个非常简单的启发式方法,为开放方块和在边缘上具有大值授予“奖励”。这些启发式方法表现出色,经常达到16384,但从未达到32768。
Petr Morávek (@xificurk)对我的人工智能进行了改进,增加了两个新的启发式方法。第一个启发式方法是对非单调行和列进行惩罚,随着等级的提高而增加,确保小数字的非单调行不会严重影响分数,但大数字的非单调行会严重损害分数。第二个启发式方法是计算潜在合并(相邻相等值)的数量,除了空格。这两个启发式方法有助于将算法推向单调板(更容易合并),并朝着具有许多合并的板位(鼓励尽可能对齐合并以获得更大的效果)。

此外,Petr还使用“元优化”策略(使用称为CMA-ES的算法),优化了启发式权重,通过调整权重本身来获得最高可能的平均分数。

这些变化的影响非常显著。该算法从13%的情况下实现16384个瓷砖,提高到90%以上,并且算法开始在1/3的时间内实现32768(而旧的启发式方法从未产生过32768个瓷砖)。

我相信启发式算法还有改进的空间。这个算法肯定还不是“最优解”,但我感觉它已经接近了。


AI在超过三分之一的游戏中获得32768块方块是一个巨大的里程碑;如果有任何人类玩家在官方游戏(即没有使用savestates或undo等工具)中达到32768,我会感到惊讶。我认为65536块方块是可以实现的!您可以自己尝试AI。代码可在https://github.com/nneonneo/2048-ai上找到。

18
@RobL:数字2的出现频率为90%,数字4的出现频率为10%。这在源代码中已有说明(https://github.com/gabrielecirulli/2048/blob/master/js/game_manager.js#L75):`var value = Math.random() < 0.9 ? 2 : 4;`。 - nneonneo
42
目前正在将代码移植到CUDA上,以便让GPU处理工作,以获得更快的速度! - nimsson
32
@nneonneo,我使用emscripten将您的代码移植到了JavaScript,并且现在它在浏览器中运行得非常好!很酷的观看体验,无需编译等一系列操作...在Firefox中,性能相当不错... - reverse_engineer
7
4x4网格的理论极限实际上是131072而不是65536。但这需要在正确的时刻获得一个4(即整个板填满了4..65536,每个数字出现一次-15个空格被占据),并且必须在那个时刻设置好板,以便您可以进行组合。 - Bodo Thiesen
9
你可能想要查看我们的AI,它似乎更好,能够在60%的游戏中达到32k分数:https://github.com/aszczepanski/2048。 - cauchy
显示剩余30条评论

1289
我是这个帖子中提到的AI程序的作者。您可以在action中查看AI或阅读source
目前,该程序在我的笔记本电脑上以javascript在浏览器中运行,在每次移动时给予大约100毫秒的思考时间,实现了约90%的胜率,虽然还不完美(但)表现得相当不错。

由于这个游戏是一个离散状态空间、完全信息和回合制的游戏,就像国际象棋和跳棋一样,我使用了已被证明在这些游戏中有效的方法,即采用minimax searchalpha-beta pruning。由于该算法已经有很多相关信息,因此我只会谈论两个主要启发式方法,它们在static evaluation function中使用,将许多其他人所表达的直觉形式化。

单调性

这种启发式方法试图确保所有方块的值在左/右和上/下方向上都是递增或递减的。这种启发式方法单独捕捉到了许多其他人所提到的直觉,即更高价值的方块应该聚集在一个角落里。它通常会防止较小价值的方块被孤立,并保持板子非常有组织性,使较小的方块不断地移动并填充到较大的方块中。

这是一个完美单调网格的屏幕截图。我通过将eval函数设置为忽略其他启发式规则并仅考虑单调性来运行算法来获得此结果。

A perfectly monotonic 2048 board

平滑度

仅使用上述启发式方法往往会创建相邻瓷砖值递减的结构,但是为了合并,相邻瓷砖需要具有相同的值。因此,平滑度启发式方法仅测量相邻瓷砖之间的值差异,试图最小化此计数。

Hacker News的一位评论者以图论的形式给出了有趣的正式化

这是一个完美平滑的网格的屏幕截图。

A perfectly smooth 2048 board

空闲方块

最后,当游戏板块变得过于拥挤时,拥有过少空闲方块会受到惩罚,因为选项很快就会用完。

至此为止!在优化这些标准的同时搜索游戏空间可以得到非常好的性能。使用类似这样的通用方法而不是明确编码的移动策略的一个优点是,算法通常可以找到有趣且意外的解决方案。如果你观察它运行,它经常会做出令人惊讶但有效的移动,例如突然切换构建墙壁或角落的方式。

编辑:

这里展示了这种方法的强大之处。我取消了方块值的限制(因此在达到2048后继续),以下是进行八次尝试后的最佳结果。

4096

是的,4096和2048并排在一起。这意味着它在同一个游戏板上三次获得了令人艳羡的2048块。


92
你可以把电脑放置 '2' 和 '4' 方块的操作视为“对手”。 - Wei Yen
31
可以,但是把它视为极大极小化问题并不符合游戏逻辑,因为计算机是根据一定的概率随机放置方块,而不是有意地最小化分数。 - koo
62
尽管 AI 是随机放置方块,但目标并不是失败。运气不好与对手选择你的最差着法相同。“min” 部分意味着你要保守地下棋,以避免出现可怕的着法,这样你就不会因为不幸而输掉比赛。 - FryGuy
205
我有一个想法,要创建一个2048的分支版本,在这个版本中,电脑不再随机地放置数字2和4,而是使用你的AI来决定在哪里放置数字。结果是:难度极高。可以在这里尝试:http://sztupy.github.io/2048-Hard/ - SztupY
35
哇,这太邪恶了。让我想起了 http://qntm.org/hatetris 的“仇恨俄罗斯方块”,它也会试图放置对你情况改善最小的方块。 - Patashu
显示剩余24条评论

168

我对于一个不包含硬编码智能(即没有启发式、评分函数等)的AI对这个游戏感到兴趣。该AI只应该“了解”游戏规则,然后“推断”游戏玩法。这与大多数AI(如本主题中的AI)相反,其中游戏玩法基本上是由代表人类对游戏理解的评分函数粗暴地控制。

AI算法

我找到了一个简单但出奇地好的玩法算法:为了确定给定棋盘的下一步移动,AI使用随机移动在内存中进行游戏,直到游戏结束。这样做几次,同时跟踪最终比分。然后计算每个起始移动的平均结局分数。选择平均结局得分最高的起始移动作为下一步。

仅使用100次运行(即在内存中进行的游戏),该AI达到2048分80%的次数,4096分50%的次数。使用10000次运行可以获得2048分100%,4096分70%,8192分约1%。

查看它的演示

最佳得分如下所示:

best score

这个算法的一个有趣事实是,虽然随机游戏玩法可预料地非常差,但选择最佳(或最不差)的移动会导致非常好的游戏玩法:典型的AI游戏可以达到70000分,持续3000次移动,然而从任何给定位置开始的内存中随机游戏玩法在死亡前大约还能额外获得340分,在大约40次额外移动中。 (您可以运行AI并打开调试控制台自行查看。)

这张图说明了这一点:蓝色线显示每步后的棋盘得分,红色线显示算法从该位置开始的最佳随机运行终局得分。实质上,红色值将蓝色值向上拉,因为它们是算法的最佳猜测。有趣的是,红线在每个点上仅略高于蓝线,但蓝线仍然不断增加。

评分图

我觉得很惊讶的是,算法并不需要预见良好的游戏操作才能选择产生它的移动。

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

实现和链接

首先我创建了一个JavaScript版本,可以看到这里的演示。此版本可以在合理的时间内运行数百次。打开控制台获取额外信息。(源代码

后来,为了进一步尝试,我使用了@nneonneo的高度优化基础设施,并在C ++中实现了我的版本。该版本允许每次移动最多运行100,000次,如果您有耐心,甚至可以运行1,000,000次。提供构建说明。它在控制台中运行,并且还具有远程控制以播放Web版。(源代码

结果

令人惊讶地,增加运行次数并不能显著改善游戏玩法。在获得4096方块和所有较小方块的80000分左右时,似乎存在一个策略限制,非常接近达到8192方块的目标。将运行次数从100增加到100000会增加达到此得分上限(从5%增加到40%)的概率,但不会突破它。

通过在关键位置临时增加到1000000的情况下运行10000次,成功打破了这一壁垒不到1%的时间,并达到了129892的最高分和8192的方块。

改进

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

后来,我实现了一个评分树,考虑到在给定移动列表后能够玩出移动的条件概率。

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

我添加了一个“深度搜索”机制,当任何一个运行意外地达到下一个最高的方块时,将暂时将运行次数增加到1000000。这提供了时间改进。

如果有人有其他保持AI领域独立性的改进想法,我会很感兴趣听到。

2048变种和克隆

仅仅为了好玩,我还将AI作为书签小工具实现了,与游戏的控件相连。这使得AI能够与原始游戏及其许多变体一起使用。

这是由于AI的领域独立性质所造就的。其中一些变体非常不同,例如六角形克隆。


7
作为一名人工智能学生,我觉得这非常有趣。我会在空闲时间仔细研究一下。 - Isaac
5
太神奇了!我刚花了几个小时来优化期望最大值算法的一个好启发式函数的权重,然后我只用了三分钟将它实现,这完全超越了我之前的成果。 - Brendan Annable
11
很好的蒙特卡罗模拟运用。 - nneonneo
7
观看这个游戏是在呼唤启示。它打破了所有的试探法则,但仍然有效。恭喜! - Stéphane Gourichon
5
到目前为止,这是最有趣的解决方案。 - shebaw
显示剩余5条评论

130
编辑: 这是一个天真的算法,模拟人类意识思考过程,与搜索所有可能性的AI相比,其结果非常弱,因为它只看向一个瓷砖。它在响应时间线早期被提交。

我改进了算法并打败了游戏!由于接近结尾时运气不好(你被迫向下移动,这是不应该做的,并且一个瓷砖出现在你最高分数的位置上。只要尝试保持顶部行填满,左移不会破坏模式),但基本上你最终会有一个固定的部分和一个可以操作的移动部分。这是你的目标:

准备完成

这是我默认选择的模型。

1024 512 256 128
  8   16  32  64
  4   2   x   x
  x   x   x   x
所选角落是任意的,你基本上不会按一个键(禁止移动),如果你确实这样做了,你会再次按相反的键,并尝试修复它。对于未来的方块,该模型始终期望下一个随机方块是2,并出现在当前模型的相反侧(当第一行不完整时,在右下角,一旦第一行完成,在左下角)。
以下是算法。大约80%的胜率(似乎总是有可能通过更“专业”的AI技术获胜,不过我不确定。)
initiateModel();

while(!game_over)
{    
    checkCornerChosen(); // Unimplemented, but it might be an improvement to change the reference point

    for each 3 possible move:
        evaluateResult()
    execute move with best score
    if no move is available, execute forbidden move and undo, recalculateModel()
 }

 evaluateResult() {
     calculatesBestCurrentModel()
     calculates distance to chosen model
     stores result
 }

 calculateBestCurrentModel() {
      (according to the current highest tile acheived and their distribution)
  }

关于缺失的步骤,有几点建议。这里:model change

由于更接近预期模型,模型已经发生了变化。AI正在尝试实现的模型是

 512 256 128  x
  X   X   x   x
  X   X   x   x
  x   x   x   x

现在到那里的链条变成了:

 512 256  64  O
  8   16  32  O
  4   x   x   x
  x   x   x   x

O代表禁止的空间......

因此,它将向右压缩,然后再向右压缩,然后(根据4号方块创建位置是向右还是向上)向右或向上压缩,然后一直继续链条直到获得:

Chain completed

现在,模型和链条回到如下状态:

 512 256 128  64
  4   8  16   32
  X   X   x   x
  x   x   x   x

第二个指针不太幸运,它的主要位置已经被占据了。它可能会失败,但仍然有机会成功:

输入图片描述

这里是模型和链:

  O 1024 512 256
  O   O   O  128
  8  16   32  64
  4   x   x   x

当它成功达到128时,它又再次获得了一整行:

  O 1024 512 256
  x   x  128 128
  x   x   x   x
  x   x   x   x

14
我非常喜欢这个策略,因为我可以在手动玩游戏时使用它,这让我得了37k分。 - Cephalopod
你能统计一下你得到1024、2048、4096等数字的频率吗?我认为这是比较不同引擎的好指标。当然,还要考虑移动速度。 - Thomas Ahle
我的代码与其他真正的代码相比很糟糕... :( 我很想删除这个答案。它可能是天真的最佳答案,但仍然比典型的AI实现差得多。这是在问题的最开始发布的。nneonneo的回答目前是最好的,我个人认为。 - Daren
@Daren,你是否将你的代码托管在任何外部仓库(如Github、Code.google等)?如果是,请粘贴链接。 - Antony Thomas
@Daren 你疯了吗,不要删除!这是为了科学啊。不要因为你没有使用过inityminmax-o-duplex-solver™和强化学习而感到羞耻;简单的解决方案通常是最好的商业意义。还有Cephalopod说的话。 - v.oddou
显示剩余5条评论

102

我在此复制了一篇博客文章的内容。


我提出的解决方案非常简单易实现。尽管如此,已经达到了131040的分数。算法性能的几个基准测试也被展示出来。

分数

算法

启发式打分算法

我的算法基于一个相当简单的假设:如果你想获得更高的分数,就必须尽可能地保持棋盘整洁。特别是,最佳布局由瓷砖值的线性和单调递减序列给出。

这种直觉还可以为瓷砖值的上限提供一个界限:s,其中n是棋盘上的瓷砖数量。

(如果在需要时随机生成4号瓷砖而不是2号瓷砖,则有可能达到131072的瓷砖)

下面两张图片展示了棋盘的两种可能排布方式:

输入图像描述

为了强制实现瓷砖值单调递减序列的排序,分数si被计算为乘以一个公比r<1的等比数列的值的线性化值之和。

s

s

可以同时评估若干条线路,最终得分将是任何线路的最大得分。

决策规则

实现的决策规则并不是特别聪明,Python代码在这里呈现:

@staticmethod
def nextMove(board,recursion_depth=3):
    m,s = AI.nextMoveRecur(board,recursion_depth,recursion_depth)
    return m

@staticmethod
def nextMoveRecur(board,depth,maxDepth,base=0.9):
    bestScore = -1.
    bestMove = 0
    for m in range(1,5):
        if(board.validMove(m)):
            newBoard = copy.deepcopy(board)
            newBoard.move(m,add_tile=True)

            score = AI.evaluate(newBoard)
            if depth != 0:
                my_m,my_s = AI.nextMoveRecur(newBoard,depth-1,maxDepth)
                score += my_s*pow(base,maxDepth-depth+1)

            if(score > bestScore):
                bestMove = m
                bestScore = score
    return (bestMove,bestScore);

实现minmax或Expectiminimax算法肯定会改善算法。显然,更复杂的决策规则会减慢算法运行速度,并需要一些时间来实现。我将在不久的将来尝试实现minimax。敬请期待。

基准测试

  • T1 - 121个测试 - 8种不同路径 - r=0.125
  • T2 - 122个测试 - 8种不同路径 - r=0.25
  • T3 - 132个测试 - 8种不同路径 - r=0.5
  • T4 - 211个测试 - 2种不同路径 - r=0.125
  • T5 - 274个测试 - 2种不同路径 - r=0.25
  • T6 - 211个测试 - 2种不同路径 - r=0.5

enter image description here enter image description here enter image description here enter image description here

对于T2,在十次测试中,有四次生成4096方块,平均分数约为s42000

代码

代码可以在GitHub的以下链接中找到:https://github.com/Nicola17/term2048-AI 它基于term2048,使用Python编写。我将尽快实现更有效率的C++版本。


不错,你的图示给了我一个想法,即将合并向量纳入评估中。 - Khaled.K
你好。您确定 Github 页面提供的说明适用于您的项目吗?我想尝试一下,但那些似乎是原始可玩游戏的说明,而不是 AI 自动运行的说明。您能否更新一下呢?谢谢。 - JD Gamboa

52

我是一款2048控制器的作者,它的得分比本帖中提到的任何其他程序都要好。该控制器的高效实现可在github上找到。在另一个库中,还提供了用于训练控制器状态评估函数的代码。训练方法在论文中有描述。

该控制器使用期望最大化搜索(expectimax search)以及从头开始学习状态评估函数(不需要人类2048专业知识)的强化学习技术中的一种变体——时间差分学习(temporal difference learning)。状态值函数使用n元组(n-tuple)网络,基本上是观察到的棋盘图案的加权线性函数。它总共涉及了10亿个权重

性能

每秒1步: 609104 (100把游戏平均)

每秒10步: 589355 (300把游戏平均)

3-ply时(约1500步/秒): 511759 (1000把游戏平均)

每秒10步的方块统计数据如下:

2048: 100%
4096: 100%
8192: 100%
16384: 97%
32768: 64%
32768,16384,8192,4096: 10%

(最后一句话的意思是同时在棋盘上放置给定的瓷砖。)

对于3层:

2048: 100%
4096: 100%
8192: 100%
16384: 96%
32768: 54%
32768,16384,8192,4096: 8%

然而,我从未观察到它获得了65536方块。


6
结果相当令人印象深刻。不过,您能否更新答案并以简单的方式解释(粗略地,简单地...我相信完整的细节在此处发布会太长),您的程序如何实现?换句话说,能否粗略解释学习算法的工作原理? - Cedric Mamo

49

我使用的方法与其他解决方案一样,使用expectimax算法,但没有使用位板(bitboards)。Nneonneo的解决方案可以检查1000万个移动,这大约是深度为4,剩下6个tiles和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上查看:

var n = 4,
 M = new MatrixTransform(n);

var ai = {weights: [1, 1], depth: 1}; // depth=1 by default, but we adjust it on every prediction according to the number of free tiles

var snake= [[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(function(a){return a.map(Math.exp)})

initialize(ai)

function run(ai) {
 var p;
 while ((p = predict(ai)) != null) {
  move(p, ai);
 }
 //console.log(ai.grid , maxValue(ai.grid))
 ai.maxValue = maxValue(ai.grid)
 console.log(ai)
}

function initialize(ai) {
 ai.grid = [];
 for (var i = 0; i < n; i++) {
  ai.grid[i] = []
  for (var j = 0; j < n; j++) {
   ai.grid[i][j] = 0;
  }
 }
 rand(ai.grid)
 rand(ai.grid)
 ai.steps = 0;
}

function move(p, ai) { //0:up, 1:right, 2:down, 3:left
 var newgrid = mv(p, ai.grid);
 if (!equal(newgrid, ai.grid)) {
  //console.log(stats(newgrid, ai.grid))
  ai.grid = newgrid;
  try {
   rand(ai.grid)
   ai.steps++;
  } catch (e) {
   console.log('no room', e)
  }
 }
}

function predict(ai) {
 var free = freeCells(ai.grid);
 ai.depth = free > 7 ? 1 : (free > 4 ? 2 : 3);
 var root = {path: [],prob: 1,grid: ai.grid,children: []};
 var x = expandMove(root, ai)
 //console.log("number of leaves", x)
 //console.log("number of leaves2", countLeaves(root))
 if (!root.children.length) return null
 var values = root.children.map(expectimax);
 var mx = max(values);
 return root.children[mx[1]].path[0]

}

function countLeaves(node) {
 var x = 0;
 if (!node.children.length) return 1;
 for (var n of node.children)
  x += countLeaves(n);
 return x;
}

function expectimax(node) {
 if (!node.children.length) {
  return node.score
 } else {
  var values = node.children.map(expectimax);
  if (node.prob) { //we are at a max node
   return Math.max.apply(null, values)
  } else { // we are at a random node
   var avg = 0;
   for (var i = 0; i < values.length; i++)
    avg += node.children[i].prob * values[i]
   return avg / (values.length / 2)
  }
 }
}

function expandRandom(node, ai) {
 var x = 0;
 for (var i = 0; i < node.grid.length; i++)
  for (var j = 0; j < node.grid.length; j++)
   if (!node.grid[i][j]) {
    var grid2 = M.copy(node.grid),
     grid4 = M.copy(node.grid);
    grid2[i][j] = 2;
    grid4[i][j] = 4;
    var child2 = {grid: grid2,prob: .9,path: node.path,children: []};
    var child4 = {grid: grid4,prob: .1,path: node.path,children: []}
    node.children.push(child2)
    node.children.push(child4)
    x += expandMove(child2, ai)
    x += expandMove(child4, ai)
   }
 return x;
}

function expandMove(node, ai) { // node={grid,path,score}
 var isLeaf = true,
  x = 0;
 if (node.path.length < ai.depth) {
  for (var move of[0, 1, 2, 3]) {
   var grid = mv(move, node.grid);
   if (!equal(grid, node.grid)) {
    isLeaf = false;
    var child = {grid: grid,path: node.path.concat([move]),children: []}
    node.children.push(child)
    x += expandRandom(child, ai)
   }
  }
 }
 if (isLeaf) node.score = dot(ai.weights, stats(node.grid))
 return isLeaf ? 1 : x;
}



var cells = []
var table = document.querySelector("table");
for (var i = 0; i < n; i++) {
 var tr = document.createElement("tr");
 cells[i] = [];
 for (var j = 0; j < n; j++) {
  cells[i][j] = document.createElement("td");
  tr.appendChild(cells[i][j])
 }
 table.appendChild(tr);
}

function updateUI(ai) {
 cells.forEach(function(a, i) {
  a.forEach(function(el, j) {
   el.innerHTML = ai.grid[i][j] || ''
  })
 });
}


updateUI(ai);
updateHint(predict(ai));

function runAI() {
 var p = predict(ai);
 if (p != null && ai.running) {
  move(p, ai);
  updateUI(ai);
  updateHint(p);
  requestAnimationFrame(runAI);
 }
}
runai.onclick = function() {
 if (!ai.running) {
  this.innerHTML = 'stop AI';
  ai.running = true;
  runAI();
 } else {
  this.innerHTML = 'run AI';
  ai.running = false;
  updateHint(predict(ai));
 }
}


function updateHint(dir) {
 hintvalue.innerHTML = ['↑', '→', '↓', '←'][dir] || '';
}

document.addEventListener("keydown", function(event) {
 if (!event.target.matches('.r *')) return;
 event.preventDefault(); // avoid scrolling
 if (event.which in map) {
  move(map[event.which], ai)
  console.log(stats(ai.grid))
  updateUI(ai);
  updateHint(predict(ai));
 }
})
var map = {
 38: 0, // Up
 39: 1, // Right
 40: 2, // Down
 37: 3, // Left
};
init.onclick = function() {
 initialize(ai);
 updateUI(ai);
 updateHint(predict(ai));
}


function stats(grid, previousGrid) {

 var free = freeCells(grid);

 var c = dot2(grid, snake);

 return [c, free * free];
}

function dist2(a, b) { //squared 2D distance
 return Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2)
}

function dot(a, b) {
 var r = 0;
 for (var i = 0; i < a.length; i++)
  r += a[i] * b[i];
 return r
}

function dot2(a, b) {
 var r = 0;
 for (var i = 0; i < a.length; i++)
  for (var j = 0; j < a[0].length; j++)
   r += a[i][j] * b[i][j]
 return r;
}

function product(a) {
 return a.reduce(function(v, x) {
  return v * x
 }, 1)
}

function maxValue(grid) {
 return Math.max.apply(null, grid.map(function(a) {
  return Math.max.apply(null, a)
 }));
}

function freeCells(grid) {
 return grid.reduce(function(v, a) {
  return v + a.reduce(function(t, x) {
   return t + (x == 0)
  }, 0)
 }, 0)
}

function max(arr) { // return [value, index] of the max
 var m = [-Infinity, null];
 for (var i = 0; i < arr.length; i++) {
  if (arr[i] > m[0]) m = [arr[i], i];
 }
 return m
}

function min(arr) { // return [value, index] of the min
 var m = [Infinity, null];
 for (var i = 0; i < arr.length; i++) {
  if (arr[i] < m[0]) m = [arr[i], i];
 }
 return m
}

function maxScore(nodes) {
 var min = {
  score: -Infinity,
  path: []
 };
 for (var node of nodes) {
  if (node.score > min.score) min = node;
 }
 return min;
}


function mv(k, grid) {
 var tgrid = M.itransform(k, grid);
 for (var i = 0; i < tgrid.length; i++) {
  var a = tgrid[i];
  for (var j = 0, jj = 0; j < a.length; j++)
   if (a[j]) a[jj++] = (j < a.length - 1 && a[j] == a[j + 1]) ? 2 * a[j++] : a[j]
  for (; jj < a.length; jj++)
   a[jj] = 0;
 }
 return M.transform(k, tgrid);
}

function rand(grid) {
 var r = Math.floor(Math.random() * freeCells(grid)),
  _r = 0;
 for (var i = 0; i < grid.length; i++) {
  for (var j = 0; j < grid.length; j++) {
   if (!grid[i][j]) {
    if (_r == r) {
     grid[i][j] = Math.random() < .9 ? 2 : 4
    }
    _r++;
   }
  }
 }
}

function equal(grid1, grid2) {
 for (var i = 0; i < grid1.length; i++)
  for (var j = 0; j < grid1.length; j++)
   if (grid1[i][j] != grid2[i][j]) return false;
 return true;
}

function conv44valid(a, b) {
 var r = 0;
 for (var i = 0; i < 4; i++)
  for (var j = 0; j < 4; j++)
   r += a[i][j] * b[3 - i][3 - j]
 return r
}

function MatrixTransform(n) {
 var g = [],
  ig = [];
 for (var i = 0; i < n; i++) {
  g[i] = [];
  ig[i] = [];
  for (var j = 0; j < n; j++) {
   g[i][j] = [[j, i],[i, n-1-j],[j, n-1-i],[i, j]]; // transformation matrix in the 4 directions g[i][j] = [up, right, down, left]
   ig[i][j] = [[j, i],[i, n-1-j],[n-1-j, i],[i, j]]; // the inverse tranformations
  }
 }
 this.transform = function(k, grid) {
  return this.transformer(k, grid, g)
 }
 this.itransform = function(k, grid) { // inverse transform
  return this.transformer(k, grid, ig)
 }
 this.transformer = function(k, grid, mat) {
  var newgrid = [];
  for (var i = 0; i < grid.length; i++) {
   newgrid[i] = [];
   for (var j = 0; j < grid.length; j++)
    newgrid[i][j] = grid[mat[i][j][k][0]][mat[i][j][k][1]];
  }
  return newgrid;
 }
 this.copy = function(grid) {
  return this.transform(3, grid)
 }
}
body {
 font-family: Arial;
}
table, th, td {
 border: 1px solid black;
 margin: 0 auto;
 border-collapse: collapse;
}
td {
 width: 35px;
 height: 35px;
 text-align: center;
}
button {
 margin: 2px;
 padding: 3px 15px;
 color: rgba(0,0,0,.9);
}
.r {
 display: flex;
 align-items: center;
 justify-content: center;
 margin: .2em;
 position: relative;
}
#hintvalue {
 font-size: 1.4em;
 padding: 2px 8px;
 display: inline-flex;
 justify-content: center;
 width: 30px;
}
<table title="press arrow keys"></table>
<div class="r">
    <button id=init>init</button>
    <button id=runai>run AI</button>
    <span id="hintvalue" title="Best predicted move to do, use your arrow keys" tabindex="-1"></span>
</div>


6
不确定为什么这个没有更多的点赞。它的简单性非常有效。 - David Greydanus
谢谢,回答晚了,而且它的表现并不是很好(几乎总在[1024, 8192]之间),成本/统计函数需要更多的工作。 - caub
你是如何权衡空白空间的? - David Greydanus
1
我们只需简单地使用公式 cost=1x(空格数量)²+1x蛇权重与网格的点积 并试图最大化这个代价。 - caub
谢谢@Robusto,我应该有一天改进代码,它可以简化。 - caub

28

我认为我找到了一个表现相当不错的算法,因为我经常得分超过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);
}

5
我运行了 100,000 局游戏测试这个策略和简单的循环策略 "上、右、上、左..."(必要时加上下)。循环策略完成了一个“平均方块得分”为 770.6,而这个策略只得到了 396.7。你有猜想为什么会这样吗?我认为这个策略做了太多的向上移动,即使向左或向右合并更多。 - Thomas Ahle
1
如果不在多个方向上进行移动,瓷砖往往会以不兼容的方式堆叠。通常,使用循环策略会导致较大的瓷砖位于中心位置,这使得操纵更加拥挤。 - bcdan

25

这个游戏已经有了一个AI实现,在此。摘自README:

该算法采用迭代加深的深度优先Alpha-Beta搜索。评估函数试图保持行和列单调(要么都递减,要么都递增),同时最小化网格上的方块数量。

Hacker News上还有关于这个算法的讨论,您可能会发现它有用。


5
这应该是最佳答案,但是增加更多有关实现的细节会更好:例如,游戏板如何建模(作为一个图形),采用了哪些优化方法(最小化和最大化瓷砖之间的差异)等等。 - Alceu Costa
3
对于未来的读者:这是程序作者(ovolve)在此处第二条最高答案中解释的同一程序。这个答案和讨论中提到ovolve程序的其他地方促使ovolve出现并编写了他的算法工作原理;那个答案现在得分为1200。 - MultiplyByZer0

23

算法

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 }

在这里,我们仍需要检查堆叠值,但以不打断灵活性参数的方式进行,因此我们有{ x 在 [4,44] 中求和}。

+ (Number of possible next moves x 256)

如果一个状态有更多可能的转移自由,则它更加灵活。

+ (Number of aligned values x 2)

这是一个简化的检查,用于在不进行前瞻的情况下确定该状态是否存在合并。

注意:常量可以微调。


2
我稍后会编辑此内容,以添加实时代码 @nitish712 - Khaled.K
9
这个算法的胜率是多少? - cegprakash
为什么需要“常量”?如果你所做的只是比较分数,那么这会如何影响这些比较的结果呢? - bcdan
@bcdan 启发式(也称为比较分数)取决于比较未来状态的预期值,类似于国际象棋启发式的工作方式,但这是一种线性启发式,因为我们不建立树来知道最佳的下N步。 - Khaled.K

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接