2005

我最近偶然发现了游戏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 分。AFAIK 的最高分数略高于 20,000 分,这比我目前的分数要高得多。有没有比上面更好的算法?

4

14 回答 14

1323

我使用expectimax优化开发了一个 2048 AI ,而不是 @ovolve 算法使用的极小极大搜索。AI 简单地对所有可能的移动执行最大化,然后对所有可能的瓦片生成进行预期(由瓦片的概率加权,即 4 为 10%,2 为 90%)。据我所知,不可能修剪 expectimax 优化(除了删除极不可能的分支),因此使用的算法是经过仔细优化的蛮力搜索。

表现

默认配置(最大搜索深度为 8)中的 AI 需要 10 毫秒到 200 毫秒来执行移动,具体取决于棋盘位置的复杂性。在测试中,人工智能在整个游戏过程中达到每秒 5-10 步的平均移动速度。如果搜索深度限制在 6 个动作,AI 每秒可以轻松执行 20+ 个动作,这使得观看有些有趣

为了评估 AI 的得分表现,我将 AI 运行了 100 次(通过遥控器连接到浏览器游戏)。对于每个图块,以下是该图块至少获得一次的游戏比例:

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

所有运行的最低分数为 124024;最高得分为 794076。中位得分为 387222。AI 从未失败过获得 2048 块(因此它在 100 场比赛中从未输过一次);事实上,它在每次运行中至少达到了8192瓦!

这是最佳运行的屏幕截图:

32768 瓦,得分 794076

这场比赛在 96 分钟内完成了 27830 步,即平均每秒 4.8 步。

执行

我的方法将整个板(16 个条目)编码为单个 64 位整数(其中瓷砖是 nybbles,即 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) 使用了我的 AI 并添加了两个新的启发式方法。第一个启发式是对非单调行和列的惩罚,随着等级的增加而增加,确保小数字的非单调行不会强烈影响分数,但大数字的非单调行会严重损害分数。第二个启发式计算了除了开放空间之外的潜在合并(相邻相等值)的数量。这两种启发式方法将算法推向单调板(更容易合并),以及具有大量合并的板位置(鼓励它在可能的情况下对齐合并以获得更大的效果)。

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

这些变化的影响是极其显着的。该算法从大约 13% 的时间达到 16384 瓦片到超过 90% 的时间达到它,并且算法开始在 1/3 的时间内达到 32768(而旧的启发式从未产生过 32768 瓦片) .

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


AI 在超过三分之一的游戏中达到 32768 块是一个巨大的里程碑;如果有人在正式游戏中达到 32768(即不使用保存状态或撤消之类的工具),我会很惊讶。我认为65536瓷砖触手可及!

你可以自己试试人工智能。该代码可在https://github.com/nneonneo/2048-ai获得。

于 2014-03-19T07:22:15.070 回答
1278

我是其他人在此线程中提到的 AI 程序的作者。您可以查看 AI 的运行情况阅读源代码

目前,该程序在我笔记本电脑的浏览器中以 javascript 运行的胜率约为 90%,每次移动的思考时间约为 100 毫秒,因此虽然并不完美(还没有!),但它的表现相当不错。

由于游戏是一个离散状态空间、完美信息、回合制游戏,如国际象棋和跳棋,我使用了已被证明适用于这些游戏的相同方法,即带有alpha-beta pruning 的minimax search。由于已经有很多关于该算法的信息,我将只讨论我在静态评估函数中使用的两个主要启发式方法,它们形式化了其他人在这里表达的许多直觉。

单调性

这种启发式尝试确保瓦片的值都沿左/右和上/下方向增加或减少。仅此启发式就捕捉到了许多其他人提到的直觉,即更高价值的瓷砖应该聚集在一个角落里。它通常会防止价值较小的瓷砖成为孤立的,并使棋盘保持井井有条,较小的瓷砖层叠并填充到较大的瓷砖中。

这是一个完美单调网格的屏幕截图。我通过运行设置了 eval 函数的算法来忽略其他启发式并只考虑单调性,从而获得了这一点。

完全单调的 2048 板

平滑度

仅上述启发式方法往往会创建相邻图块的值递减的结构,但当然为了合并,相邻图块需要具有相同的值。因此,平滑启发式只是测量相邻瓦片之间的值差异,试图最小化这个计数。

Hacker News 的一位评论者从图论的角度对这个想法进行了有趣的形式化。

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

完美光滑的 2048 板

免费瓷砖

最后,免费瓷砖太少会受到惩罚,因为当游戏板过于狭窄时,选项会很快用完。

就是这样!在优化这些标准的同时搜索游戏空间会产生非常好的性能。使用像这样的通用方法而不是明确编码的移动策略的一个优点是该算法通常可以找到有趣和意想不到的解决方案。如果你看着它运行,它通常会做出令人惊讶但有效的动作,比如突然切换它正在建造的墙壁或角落。

编辑:

这里展示了这种方法的威力。我取消了平铺值的上限(因此它在达到 2048 后一直保持不变),这是经过八次试验后的最佳结果。

4096

是的,这是一个 4096 和一个 2048。=)这意味着它在同一个板上三次实现了难以捉摸的 2048 瓷砖。

于 2014-03-13T20:04:42.130 回答
164

我开始对这个游戏的人工智能的想法很感兴趣,它不包含硬编码的智能(即没有启发式、评分函数等)。AI 应该只“知道”游戏规则,并“弄清楚”游戏玩法。这与大多数人工智能(如本线程中的人工智能)形成鲜明对比,其中游戏本质上是由代表人类对游戏理解的评分函数控制的蛮力。

人工智能算法

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

每次移动只需 100 次运行(即在记忆游戏中),AI 达到 80% 的次数和 4096 次的次数分别为 80% 和 50%。使用 10000 次运行可以获得 2048 瓦片 100%,4096 瓦片为 70%,8192 瓦片约为 1%。

看到它在行动

最佳成绩如下所示:

最佳得分

关于这个算法的一个有趣的事实是,虽然随机游戏非常糟糕,但选择最好(或最不糟糕)的棋子会导致很好的游戏玩法:典型的 AI 游戏可以达到 70000 点并持续 3000 步,然而任何给定位置的内存随机游戏在死亡前大约 40 次额外移动平均产生 340 额外点数。(您可以通过运行 AI 并打开调试控制台亲自查看。)

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

评分图

我发现该算法不需要实际预见到好的游戏玩法就可以选择产生它的动作,这让我感到非常惊讶。

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

实施和链接

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

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

结果

令人惊讶的是,增加跑步次数并没有显着改善游戏玩法。这个策略似乎有一个限制,大约 80000 点,4096 瓦和所有较小的瓦,非常接近实现 8192 瓦。将跑步次数从 100 次增加到 100000 次会增加达到这个分数限制(从 5% 到 40%)但不会突破它的几率。

运行 10000 次并在关键位置附近临时增加到 1000000 次,成功打破这一障碍的次数不到 1%,最高得分为 129892 和 8192 平铺。

改进

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

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

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

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

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

2048 变种和克隆

只是为了好玩,我还将AI 实现为小书签,连接到游戏的控件中。这允许 AI 与原始游戏及其许多变体一起工作。

由于 AI 的领域独立性,这是可能的。一些变体非常不同,例如六边形克隆。

于 2014-05-25T09:25:52.430 回答
130

编辑:这是一种幼稚的算法,对人类有意识的思维过程进行建模,与搜索所有可能性的人工智能相比,它得到的结果非常弱,因为它只向前看一个瓷砖。它是在响应时间表的早期提交的。

我已经完善了算法并击败了游戏!它可能会由于接近尾声的简单坏运气而失败(你被迫向下移动,这是你永远不应该做的,并且一个瓷砖出现在你应该最高的地方。试着保持第一行填满,所以向左移动不会打破模式),但基本上你最终有一个固定的部分和一个移动的部分可以玩。这是你的目标:

准备完成

这是我默认选择的模型。

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

选择的角落是任意的,你基本上从不按一个键(禁止移动),如果你这样做,你再按相反的键并尝试修复它。对于未来的图块,模型总是期望下一个随机图块是 2,并出现在当前模型的对面(虽然第一行不完整,在右下角,一旦第一行完成,在左下角角落)。

这里是算法。大约 80% 的胜利(似乎总是有可能通过更“专业”的人工智能技术获胜,不过我不确定这一点。)

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)
  }

关于缺少的步骤的一些指示。这里:模型变化

由于更接近预期模型的运气,模型发生了变化。人工智能试图实现的模型是

 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 创建的位置)然后将继续完成链,直到它得到:

链完成

所以现在模型和链又回到了:

 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
于 2014-03-12T16:05:34.827 回答
99

我在这里复制我博客上一篇文章的内容


我提出的解决方案非常简单且易于实施。虽然,它已经达到了 131040 的分数。给出了算法性能的几个基准。

分数

算法

启发式评分算法

我的算法所基于的假设相当简单:如果你想获得更高的分数,棋盘必须尽可能保持整洁。特别是,最佳设置由瓦片值的线性和单调递减顺序给出。这种直觉还将为您提供瓷砖值的上限:s其中 n 是板上的瓷砖数量。

(如果需要时随机生成 4-tile 而不是 2-tile,则有可能到达 131072 tile)

下图显示了两种可能的董事会组织方式:

在此处输入图像描述

为了强制以单调递减顺序排列瓷砖,分数 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

在此处输入图像描述 在此处输入图像描述 在此处输入图像描述 在此处输入图像描述

在 T2 的情况下,十分之四的测试生成 4096 块,平均得分为s42000

代码

代码可以在 GiHub 上的以下链接中找到:https ://github.com/Nicola17/term2048-AI 它基于term2048并且是用 Python 编写的。我会尽快用 C++ 实现一个更高效的版本。

于 2014-03-26T22:13:01.283 回答
49

我的尝试像上面的其他解决方案一样使用 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上的代码:

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>

于 2015-03-03T05:35:00.973 回答
47

我是一个 2048 控制器的作者,它的得分比这个线程中提到的任何其他程序都要好。github上提供了控制器的有效实现。在一个单独的仓库中,还有用于训练控制器状态评估功能的代码。训练方法在论文中有所描述。

控制器使用 expectimax 搜索和状态评估函数,该函数通过时间差异学习(一种强化学习技术)的变体从头开始(无需人工 2048 专业知识)学习。状态值函数使用n 元组网络,它基本上是在棋盘上观察到的模式的加权线性函数。它总共涉及超过10 亿个权重

表现

每秒 1 步:609104(平均 100 场比赛)

10 步/秒:589355(平均 300 场比赛)

在 3 层时(约 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 磁贴。

于 2015-12-21T10:49:45.780 回答
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);
}
于 2014-03-12T18:57:01.497 回答
26

这里已经有这个游戏的 AI 实现。自述文件摘录:

该算法是迭代加深深度优先alpha-beta搜索。评估函数试图保持行和列单调(全部减少或增加),同时最小化网格上的瓦片数量。

Hacker News上也有关于此算法的讨论,您可能会发现它很有用。

于 2014-03-13T09:16:22.763 回答
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 in [4,44] } 的总和。

+ (Number of possible next moves x 256)

如果一个状态有更多的可能转换的自由度,它就会更灵活。

+ (Number of aligned values x 2)

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

注意:可以调整常量..

于 2014-03-12T20:15:23.450 回答
12

这不是对 OP 问题的直接回答,这是迄今为止我尝试解决相同问题并获得了一些结果并有一些我想分享的观察结果的更多东西(实验),我很好奇我们是否可以有一些从中得到进一步的见解。

我刚刚尝试了使用 alpha-beta 修剪和 3 和 5 的搜索树深度截断的 minimax 实现。我试图解决 4x4 网格的相同问题,作为edX 课程 ColumbiaX:CSMM.101x 人工智能(艾)

我应用了几个启发式评估函数的凸组合(尝试了不同的启发式权重),主要来自直觉和上面讨论的那些:

  1. 单调性
  2. 可用空间

在我的情况下,计算机播放器是完全随机的,但我仍然假设对抗设置并将 AI 播放器代理实现为最大播放器。

我有 4x4 网格来玩游戏。

观察:

如果我给第一个启发式函数或第二个启发式函数分配了过多的权重,那么这两种情况下 AI 玩家获得的分数都会很低。我对启发式函数进行了许多可能的权重分配并采用凸组合,但很少有 AI 玩家能够得分 2048。大多数时候它要么停在 1024 或 512。

我也尝试了角落启发式,但由于某种原因它使结果更糟,任何直觉为什么?

此外,我尝试将搜索深度截止值从 3 增加到 5(我不能再增加它,因为即使进行修剪,搜索空间也超过了允许的时间),并添加了一个查看相邻图块值并给出的启发式方法如果它们可以合并,则可以获得更多积分,但我仍然无法获得 2048。

我认为使用 Expectimax 而不是 minimax 会更好,但我仍然想只用 minimax 解决这个问题并获得高分,例如 2048 或 4096。我不确定我是否遗漏了什么。

下面的动画显示了 AI 代理与计算机玩家玩游戏的最后几个步骤:

在此处输入图像描述

任何见解都会非常有帮助,在此先感谢。(这是我这篇文章的博客文章的链接:https ://sandipanweb.wordpress.com/2017/03/06/using-minimax-with-alpha-beta-pruning-and-heuristic-evaluation-to-solve -2048-game-with-computer/和 youtube 视频:https ://www.youtube.com/watch?v=VnVFilfZ0r4 )

下面的动画显示了游戏的最后几个步骤,其中 AI 玩家代理可以获得 2048 分,这一次也添加了绝对值启发式:

在此处输入图像描述

下图显示了玩家 AI 代理探索的博弈树,假设计算机为对手,只需一步:

在此处输入图像描述 在此处输入图像描述 在此处输入图像描述 在此处输入图像描述 在此处输入图像描述 在此处输入图像描述

于 2017-03-06T21:37:45.817 回答
9

我在 Haskell 中写了一个 2048 求解器,主要是因为我现在正在学习这门语言。

我的游戏实现与实际游戏略有不同,因为新的瓷砖总是“2”(而不是 90% 2 和 10% 4)。并且新的瓷砖不是随机的,而是从左上角开始的第一个可用的。此变体也称为Det 2048

因此,这个求解器是确定性的。

我使用了一种详尽的算法,该算法有利于空瓷砖。它在深度 1-4 上执行得非常快,但在深度 5 上它变得相当慢,每次移动大约 1 秒。

下面是实现求解算法的代码。网格表示为一个 16 长度的整数数组。评分只是通过计算空方格的数量来完成。

bestMove :: Int -> [Int] -> Int
bestMove depth grid = maxTuple [ (gridValue depth (takeTurn x grid), x) | x <- [0..3], takeTurn x grid /= [] ]

gridValue :: Int -> [Int] -> Int
gridValue _ [] = -1
gridValue 0 grid = length $ filter (==0) grid  -- <= SCORING
gridValue depth grid = maxInList [ gridValue (depth-1) (takeTurn x grid) | x <- [0..3] ]

我认为它的简单性相当成功。它从一个空网格开始并在深度 5 处求解时达到的结果是:

Move 4006
[2,64,16,4]
[16,4096,128,512]
[2048,64,1024,16]
[2,4,16,2]

Game Over

源代码可以在这里找到:https ://github.com/popovitsj/2048-haskell

于 2014-04-04T00:49:03.077 回答
5

这个算法对于赢得比赛并不是最优的,但在性能和所需的代码量方面是相当最优的:

  if(can move neither right, up or down)
    direction = left
  else
  {
    do
    {
      direction = random from (right, down, up)
    }
    while(can not move in "direction")
  }
于 2014-03-14T21:53:56.790 回答
4

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

对游戏中优秀玩家使用的策略进行建模。

例如:

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

按上面显示的顺序读取方块,直到下一个方块值大于当前值。这提出了尝试将另一个具有相同值的图块合并到该正方形中的问题。

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


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

较大的瓷砖:增加较小的周围瓷砖的价值。

ETC...


整个方法可能会比这更复杂,但不会更复杂。可能是这种机械的感觉缺乏分数、重量、神经元和对可能性的深入搜索。可能性之树甚至需要足够大才能需要任何分支。

于 2015-08-10T14:39:28.703 回答