☰
2048 AI实现:Expectimax搜索与启发式评估函数调优
2026/9/26 9:06:13 网站建设 项目流程

我做2048的AI,起因特别单纯:某天下午手机上滑到4096,手指一抖把棋盘滑死了,气得我直接打开电脑——你既然是个规则完全确定的游戏(唯一的随机就是新方块掉落),那理论上机器就应该能比我玩得好。事实证明,这玩意确实不难,而且做到稳定8192一点都不玄学。

这篇东西写给三类人:想入门游戏AI但不想一上来啃复杂算法的人;已经在写2048 AI但卡在4096、想冲更高分的人;以及做自动化测试、想给游戏加“机器人陪跑”的开发者。我会把我自己的建模过程、搜索算法、评估函数这些关键部分全部拆开讲,最后附上实测调优记录。

我的最终版本跑出来的成绩大致这样:在搜索深度5到6、带优化评估的情况下,模拟100局里能稳定出现8192方块,最高单局到过16384(再往上没戏,因为4096合成8192之后棋盘基本满了)。下面从每一块核心逻辑说起。

1. 先说结论:8192不是魔法,是“搜索 + 评估”

1.1 人类为什么卡在4096,AI却能继续往上走

先把话说透。人类玩2048的问题在于——我们做的每个决定都只能看到眼前一步。看到一个能合并的块就忍不住点过去,棋盘一旦出现几个孤立的小数,大脑又开始疲劳,然后全局崩盘。AI不一样,它可以预先“脑补”未来几部:

  • 选择某个方向之后,棋盘会变成什么样?
  • 新方块可能出现在哪几个位置?
  • 在这个新局面上再走一步,又有哪些可能?
  • 一直到未来五、六步,把所有可能的路径摊开,然后挑一条“平均展望最好”的路走掉。

这不是什么高深能力,本质上就是“有限深度搜索 + 启发式评估”。任何一个决策游戏,只要状态空间能表示成明确的棋盘数组、走法能枚举、且结果可以打分,这套框架就成立。2048简直就是为这个框架量身定制的教学案例。

1.2 整体框架:三步走

我用到的AI核心分三层:

  1. 状态建模:把4×4棋盘变成一个程序能处理的数据结构,并实现移动、合并、随机掉落、结束判定;
  2. 搜索算法:用期望最大化算法(Expectimax)展开未来若干步的决策树,AI每走一步都在选期望分数最高的方向;
  3. 评估函数:给“某个棋盘状态”打分,分数越高说明局面越好。搜索到叶子节点时,就靠这个分数判断死活。

这三层里,建模决定AI跑得快不快,搜索决定AI看得远不远,评估决定AI“眼光”好不好。我一开始犯的错误就是只在意搜索深度、忽略评估函数,结果深度加到6照样频繁暴毙。后来才明白——没有一套好评估,看得再远也白搭。

2. 棋盘建模:先把“规则”翻译成代码

2.1 状态表示:4×4的二维数组就够了

我用的数据结构非常简单:board[y][x],一个4×4的二维数组,每个格子存当前方块的数值(2、4、8……),空格就是0。没有用什么位运算压缩、哈希优化,至少在这个项目阶段完全不需要。

board = [ [0, 2, 0, 4], [0, 0, 0, 0], [2, 0, 0, 2], [0, 0, 0, 0] ]

这个表示方法足够直观,而且对Python的列表操作来说也很友好。后面要做性能优化时再考虑用整数位运算表示状态,但那是后话。

2.2 移动合并的细节坑:合并只能发生一次

移动逻辑是最容易写错的地方。以向左移动为例,拆成三个动作:

  1. 去掉0:把每一行非零数字按原顺序压到左侧;
  2. 相邻合并:从左往右扫,如果两个相邻数字相同,就合并成它们的和,并且被合并过的格子不能再参与合并;
  3. 补0:把行尾空缺填回0。

举个例子:[2, 2, 2, 2]向左移动,结果是[4, 4, 0, 0],不是[8, 0, 0, 0]。这是2048的硬性规则——合并只做一次,从左到右扫描时,每对数字合并后,后面的数字就不会再跟结果合并。很多初版AI写错全在这,合并逻辑一旦错了,整个AI的决策基础就崩了。

四个方向的处理,我用的经典技巧:

  • 向左:直接按上面的逻辑处理每一行;
  • 向右:把每行反转,按左移逻辑处理完再反转回来;
  • 向上:把整个棋盘转置,按左移处理完再转置回来;
  • 向下:转置+反转,处理完再反向恢复。
def move_left_row(row): # 去掉0 filtered = [x for x in row if x != 0] # 合并 i = 0 result = [] while i < len(filtered): if i + 1 < len(filtered) and filtered[i] == filtered[i + 1]: result.append(filtered[i] * 2) i += 2 else: result.append(filtered[i]) i += 1 # 补0 return result + [0] * (4 - len(result))

2.3 随机掉落与游戏结束判定

2048唯一的随机性,是每次移动之后在棋盘空位上生成一个新方块,90%概率是2,10%概率是4。搜索算法要把这种随机性当作“对手”来对待,一会儿讲Expectimax时会细说。

结束判定的标准:棋盘满了,且四个方向都不能再移动——也就是说任意相邻格子(上下左右)没有相同数字,行里也没有空格。判断方法其实很简单:尝试执行四个方向的移动,如果移动前后棋盘完全一样,那就说明这个方向是无效走法;四个方向全无效,游戏结束。

def is_game_over(board): for direction in ['up', 'down', 'left', 'right']: if move(board, direction) != board: return False return True

这个判定很方便,因为移动函数自己会返回一个新棋盘,对比一下就知道某个方向有没有触发变化。AI在搜索时也用同一个函数判断走法合法性。

3. 期望最大化(Expectimax):面对随机性的最优决策

3.1 为什么不是Minimax

很多人一听到“博弈搜索”就想到Minimax——两个玩家轮流走,一方最大化分数,另一方最小化分数。但2048有点特殊:它确实有两个“玩家”,一个是你(玩家回合,选择方向),另一个是新方块掉落(随机回合,生成2或4)。但随机掉落不是“故意”来坑你的,它不带恶意,只是随机。

Minimax会把随机掉落当成一个“总是给你最坏结果”的对手,这就太悲观了。比如明明随机掉2的概率是90%,Minimax却只考虑掉4的最坏情况,导致决策过于保守。

正确做法是Expectimax——随机节点不是取最小值,而是取数学期望:把新方块出现在每个空位、每种取值的概率乘上对应的后续收益,最后求和。这样AI既不会盲目乐观,也不会过度悲观,它基于概率分布做最优决策。

3.2 核心递归逻辑

伪代码长这样:

def expectimax(board, depth, is_player_turn): if is_game_over(board): return -100000 # 死局给一个很大的负分 if depth == 0: return evaluate(board) if is_player_turn: # 玩家回合:选四个方向里分数最高的 best = -100000 for direction in ['up', 'down', 'left', 'right']: new_board = move(board, direction) if new_board == board: continue # 无效走法跳过 score = expectimax(new_board, depth - 1, False) best = max(best, score) return best else: # 随机掉落回合:计算期望值 expected = 0 empty_cells = get_empty_cells(board) for cell in empty_cells: for value, prob in [(2, 0.9), (4, 0.1)]: new_board = copy_board(board) new_board[cell.y][cell.x] = value expected += (prob / len(empty_cells)) * expectimax( new_board, depth - 1, True ) return expected

每次AI真正走棋时,就是用当前棋盘对四个方向分别计算一遍期待值,然后选最大的那个方向执行。搜索深度设为3到5就比较合适。

3.3 搜索深度和性能瓶颈:不剪枝就等死

这里有个非常现实的问题:期望节点展开的数目太大了。第1层有4个方向,第2层每个方向都要枚举所有空格×两种取值,第3层再乘以4个方向……理论上到第5层就会产生几十亿个节点。不剪枝,Python当场死给你看。

我实际采用的优化手段有三个:

  1. 采样空位:不枚举所有空位,只随机挑若干位置作为“掉落候选”。深度4时我每层抽8个空位;深度5时抽6个。随着搜索加深,抽的位置太多时间爆炸,抽太少精度下降,8个是一个经验平衡点。
  2. 缓存(transposition table):同一个棋盘状态可能从不同路径到达,把计算结果存在字典里,状态重复时直接返回,避免重复计算。
  3. 无效方向剪枝:四个方向里大多数时候只有两到三个有效方向,先快速算一遍移动结果,过滤掉没有变化的,减少分支数。

这么一改,单步决策时间从十几秒降到了200毫秒以内——对AI来说完全够用,毕竟2048没有超时限制。

4. 评估函数:AI的“价值观”决定上限

4.1 为什么评估函数比搜索深度更关键

搜索只负责“看得远”,但最终要给每个叶子节点打分。如果分数定得不好,看再远也没用。我在调优过程中最大的教训就在这里——最初我把“空格数量”当成唯一指标,AI确实会下意识保留空格,但打法极其保守,经常为了不填空格错失合并机会,最后分数卡在2048。

后来我把评估拆成了五个维度的加权和。下面是我最终用的组合:

  • 空格数:空格多意味着未来操作空间大,必须给正权重;
  • 单调性:每一行、每一列的数字尽量从大到小或从小到大排列,不要忽高忽低。2048的棋盘本质是“蛇形路径”,数字应该像河流一样从高到低流动;
  • 平滑性:相邻格子的数字差距越小越好。数字相近意味着这些格子将来有机会合并,棋盘不会出现“断层”;
  • 角落加成:最大方块放在角落是经典策略,这样大数周围的空间不容易被“搅乱”;
  • 死局惩罚:如果当前状态已经无路可走,直接给很低的分数,这比等到搜索到底再判负要高效得多。

每个维度都需要调权重。我自己的调参过程特别笨:先固定搜索深度为4,然后一次只调一个权重,跑100局看平均分和最高方块,反复迭代。最终用的权重大致是:

评估维度权重说明
空格数270给未来留空间
单调性470最核心的指标
平滑性140让数字流动顺畅
最大方块位置100鼓励大数在角上
死局惩罚-100000极端负分

这几个权重的绝对值不是关键,关键是它们之间的比例关系。单调性权重最大,因为真正决定你能否上8192的,是你能不能把棋盘维持成一条“从大到小流动”的蛇形。

4.2 权重矩阵:让高分值“长”在正确的位置

上面说的“角落加成”比较粗糙,还可以进一步细化成一个4×4的权重矩阵。AI给棋盘状态打分时,不光看数字本身,还要看数字落在哪个格子。我们希望大数出现在某个固定的角落,小数渐渐往反方向排开。这个排布刚好对应2048玩家常说的“蛇形排列”:

weight_matrix = [ [16, 15, 14, 13], [ 9, 10, 11, 12], [ 8, 7, 6, 5], [ 1, 2, 3, 4] ]

权重值越大,说明这个位置“越尊贵”。评估时把棋盘每个格子的数值乘上对应权重,全部加起来,得到一个位置偏向分。这样AI会慢慢学会把大数堆在权重最高的左上角,形成蛇形结构。

但这里必须提醒一个坑:权重矩阵别给得太极端。我试过把左上角的权重设置成其他格子的十倍,结果AI死守左上角,连右侧的合并机会都放弃,最终整个棋盘右上区域全是小碎片,积重难返。蛇形排列是一个整体策略,不能只靠“某一格特别值钱”来驱动。

4.3 平滑性和单调性的实现细节

平滑性我的实现思路是:遍历所有相邻格子,计算每对相邻数字之差的绝对值,然后求个平均值。差的绝对值越小,平滑性越好。实际实现时我还会给“差值小的对”更高的权重——比如相邻数字是2和4,和相邻数字是128和256,虽然差值的绝对值都是2,但后者的合并潜力其实更大,这需要按数值比例来调整。

单调性的实现稍微绕一点。最直接的做法是逐行逐列扫描:统计“数字从大到小”和“从小到大”两种方向的连续程度,取较大值作为得分。比如一行是[1024, 512, 256, 128],这就是非常标准的单调递减,给高分;反过来[1024, 2, 512, 4],就零分。很多实现直接用“与该行期望排列的总差值”来算一个惩罚项,差值越小惩罚越少,效果也不错。

def monotonicity(board): # 每行每列,如果数字顺着某方向单调,加分 score = 0 for i in range(4): row_count = 0 col_count = 0 for j in range(3): if board[i][j] >= board[i][j + 1]: row_count += 1 if board[j][i] >= board[j + 1][i]: col_count += 1 score += max(row_count, col_count) return score

这是简化版,但思路没错——它判断的是“整行整列的数字到底有没有形成一个有序梯度”。

5. 实测记录与调优过程

5.1 从经常翻车到稳定8192:我经历的几个阶段

我把调优过程分阶段列出来,每个阶段都对应一版明显的成绩变化:

阶段配置100局平均分最高方块
1深度2 + 只考虑空格数4600512偶尔1024
2深度4 + 空格+单调210002048基本稳定,4096偶发
3深度5 + 完整评估函数520004096稳定,8192偶发
4深度6 + 权重精调 + 采样优化860008192稳定,16384偶发

看得出,第一阶段的AI基本是个“智障”,只会保空格不懂布局;第二阶段加入了单调性之后,AI才算真正“会玩”;第三阶段加入平滑性和角落加成以后,AI才真正摸到8192的门槛;第四阶段则是在性能允许范围内把深度拉高,把稳定指数又提了一截。

5.2 翻车案例:AI为什么会在“快成功”时突然暴毙

调优过程中最刺激的就是眼看合成出4096,然后十步之内全盘崩掉。我复盘了三个典型的翻车模式:

翻车模式一:死守角落,失去全局调度能力。AI一旦把大数锁在左上角,就极度抗拒往右、往下移动。但2048这个游戏,有时候你必须先“乱动”,让新的小方块出现在合适的位置,才能继续合成。如果AI过于保守,棋盘右侧会堆满小碎片,最终所有方向都失去变化空间。

翻车模式二:过度追求平滑性,不敢破坏“接近合并”的布局。比如棋盘上有两组128,它们离得很近但不在同一直线上。理论上的最优做法是先把其他区域稍微打乱一下,让两组128同列再合并。但“打乱”的行为扫描到评估函数里,平滑性分数会掉一点,AI于是选择维持现状,结果就是两组128永远合并不了,整盘棋盘跟着一起僵死。

翻车模式三:采样位置不均匀,导致AI短视。深度搜索时如果只采样前几个空位,AI可能会“看不见”某个关键空位上随机掉落带来的风险,从而选了一个局部安全的走法,结果下一步被随机掉落堵死。后来我把采样逻辑改成“分层采样”——在棋盘的四个象限里均匀抽空位,避免扎堆。

这三个翻车模式说明:评估函数不只是“一堆权重加一加”,它本质上是在给AI灌输一种大局观。权重调歪了,即使算法一样,表现也会天差地别。

5.3 性能优化一览

真正做项目时,AI的性能直接决定了搜索深度能用多少。我做的优化可以总结成一张表:

优化点做法收益
移动逻辑用行操作+反转/转置,避免写四份重复代码代码简洁且不易出错
状态缓存字典哈希缓存节点的评估结果深度5时减少60%+重复计算
空位采样每层抽6-8个空位而非全部单步决策从3秒降到0.3秒
无效方向剪枝先判断移动后棋盘是否变化平均每次决策少算一半分支
深度限制固定深度+动态深度混合接近后期用更浅深度,保证每步反应快速

这中间“状态缓存”特别有意思:2048的棋盘状态虽然多,但许多不同的移动序列会殊途同归落到同一个局面。第一次算出这个局面的分数后存起来,后面再用就直接取,省掉了整整一棵子树的计算。实测这个优化在搜索深度5时能把期望时间缩短一半以上。

6. 技术延伸:这套方法不止能玩2048

做完这个项目之后,我最直观的感受是:Expectimax + 启发式评估这个组合,几乎可以推广到任何“单机策略决策”场景。

  • 自动解谜游戏:像“华容道”“推箱子”这类游戏,同样是有限状态空间,同样可枚举行动,换一套评估函数就能套用;
  • 机器人路径规划:如果状态里有随机障碍物,期望最大化天然能处理“某条路有概率被堵”的不确定性;
  • 游戏测试领域:现在很多游戏公司开始用AI做“自动跑测”,比如用AI替代人工去反复通关关卡。2048 AI就是一个绝佳的入门练习——规则简单但决策空间足够复杂,完全可以在它上面验证各种算法后,再迁移到正式项目里。
  • 进阶方向:把启发式评估函数换成神经网络打分(用深度学习拟合状态价值),搜索框架不动,效果还能更猛。这个方向就是AlphaGo系列里“价值网络”的思想雏形。

如果你只是想让AI玩得不错,Expectimax就足够了,没必要上强化学习。我见过不少朋友一上来就搞DQN,花了一整天调网络,最后跑出来的效果可能还不如一个写了几百行的Expectimax。因为2048的随机性比较可控,枚举和期望计算本身就是最高效的手段。强化学习真正发挥优势的场景,是状态空间大到你无法展开搜索决策树的时候。

最后再分享一个我测试时的技巧:不要只看“100局平均分”,要额外关注“最高方块分布”。平均数高可能只是稳定在4096,但如果你想知道自己的评估函数有没有能力冲击8192甚至16384,就应该单独记录每局最高方块,做一个分布统计。我从阶段2到阶段3的最大变化不是平均分涨了多少,而是最高方块从“偶尔4096”变成了“偶发8192”——这才是评估函数真正变强的信号。

另外,如果你想看AI有没有“灵性”,可以把搜索深度临时加到8,然后开一局让AI自己玩,观察它遇到交叉路口时犹豫选哪条路。深度大幅增加之后AI的走法会明显变得更“有耐心”——它愿意花好几步去铺垫一次关键合并,这正是评估函数生效的表现。如果这个行为没有出现,说明你的评估权重依然有问题,别加深度了,回头调参吧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询