2048 AI核心原理:Python实现搜索树与期望最大搜索
2026/9/16 6:24:22 网站建设 项目流程

简介:一个将经典2048游戏与人工智能决策相结合的Python完整项目,面向Python开发者、游戏AI爱好者及算法学习者。项目把游戏逻辑与AI策略分层实现,涵盖棋盘状态表示、数字移动合并、分数统计和自动决策等核心环节,通过智能评估指导下一步操作,解决了手动规划长线移动的难题。压缩包共42个文件,以11个Python源文件为主干,另有16个pyc编译文件、XML配置、PNG图片和Markdown说明等辅助内容,整体仅372KB,轻量且目录清晰。目前已有533人学习。源码中同时提供无界面控制台版和带图形界面版本,便于对比纯逻辑实现与界面交互;AI模块拆分为基础接口与具体策略,说明文档简明扼要,读者既能逐步理解搜索决策、评估函数和合并规则,又可在现有框架上扩展强化学习等进阶思路,是一份兼顾入门与二次开发的实用代码库。

1. 为什么2048的AI要拼“搜索树”而不是“运气”

这个源码包里最有价值的不是游戏主循环,而是ComputerAI.pyPlayerAI.py里那套搜索决策框架。普通玩家靠手感堆角,AI靠的是在每一步移动前把未来几步的棋盘状态全部展开,用“期望最大搜索”量化每次滑动带来的期望收益。换句话说,AI不赌下一次出现的是2还是4,而是在搜索树里为每一种可能分配概率,再挑期望值最高的动作。这套思路同样适用于棋类游戏、调度问题和强化学习中的决策部分。适合想学 Python 搜索算法、想看懂 AI 游戏决策逻辑、以及准备做人工智能大作业的开发者——把这个项目拆开,你等于同时复习了递归、缓存、启发式评估和模块化设计。

2. 从 Grid.py 看棋盘状态机:移动、合并与计分的 Python 实现

2.1 棋盘表示:用二维列表还是位压缩

Grid.py里最常见的写法是self.map = [[0] * 4 for _ in range(4)],用二维列表表示 4x4 棋盘,0代表空格。为什么不直接用 NumPy?因为 AI 搜索阶段需要频繁复制棋盘状态,list的浅拷贝比 NumPy 数组快,而且能让后续的期望最大搜索代码保持纯 Python 可读性。如果你从2048AI-master中打开Grid.py,会看到类似下面的结构:

class Grid: def __init__(self, size=4): self.size = size self.map = [[0] * self.size for _ in range(self.size)]

这里用列表推导式生成二维数组,避免使用[[0] * 4] * 4——后者会让每一行引用同一个对象,修改一格会导致整列变化。初始化后棋盘为空,后续通过insertTile在空位写入 2 或 4。选择二维列表还有一个实际原因:评估函数里要频繁遍历行和列,二维索引比一维位运算写法更直观。

2.2 移动与合并:先“滑动”再“合并”

2048 核心规则可以拆成两步:先把所有非零方块滑向目标方向,再合并相邻的相同数字。源码中这一逻辑集中在move方法里。以左移为例,常见实现是对每一行取出非零元素,合并,再补零:

def merge_row(row): # 去掉所有 0,保留非零数字 non_zero = [v for v in row if v != 0] merged = [] i = 0 while i < len(non_zero): if i + 1 < len(non_zero) and non_zero[i] == non_zero[i + 1]: merged.append(non_zero[i] * 2) i += 2 else: merged.append(non_zero[i]) i += 1 # 补零,保证长度固定 merged += [0] * (len(row) - len(merged)) return merged

这段代码的关键在于i += 2而不是i += 1。因为两个相同数字合并后,新生成的方块不能再参与本轮合并,比如[2, 2, 2, 2]应该变成[4, 4, 0, 0]而不是[8, 0, 0, 0]。右移、上移、下移都可以复用merge_row:右移先反转行,左移处理完再反转回来;上移按列取数据,处理完再写回列。Grid中的move还会返回一个布尔值,表示棋盘是否有变化,用于判断玩家是否进行了有效移动。

2.3 随机方块生成与终局判断

AI 模式里的“随机方块”不是用户手动按出来的,而是由ComputerAI.py模拟。从项目结构看,ComputerAI继承BaseAI,负责在 AI 移动后决定在哪个格子生成新方块。真实游戏概率是 90% 生成 2,10% 生成 4,源码里一般通过random.random()实现:

def insert_random_tile(self): available = self.get_available_cells() if not available: return False cell = random.choice(available) self.map[cell[0]][cell[1]] = 2 if random.random() < 0.9 else 4 return True

get_available_cells()返回所有self.map[i][j] == 0的坐标列表。当这个列表为空时,游戏再检查是否还能发生合并,如果也不能,就返回失败。终局判断需要区分“无空格”和“有可合并方块”两种情况,Grid里通常有can_move()或者is_game_over()方法。如果只是判断空格为零就直接结束,会漏掉仍能通过合并腾出空间的局面。

2.4 移动操作的正确性测试

修改完Grid后,最好用一个极简测试用例验证合并逻辑。项目根目录下有test.py,但这里给出一个不依赖外部测试框架的断言脚本:

from Grid import Grid def test_left_merge(): g = Grid() g.map = [[2, 2, 4, 0], [4, 0, 4, 0], [2, 2, 2, 2], [0, 0, 0, 0]] g.move(0) # 0 表示左移 assert g.map[0] == [4, 4, 0, 0] assert g.map[1] == [8, 0, 0, 0] assert g.map[2] == [4, 4, 0, 0]

这里的move(0)方向约定一般与GameManager里一致:0左,1上,2右,3下。测试覆盖了两个场景:[2, 2, 4, 0]中相邻元素合并一次后不连续合并,[2, 2, 2, 2]则验证偶数长度行的分段合并。跑这个脚本时不需要 GUI 环境,直接python test.py即可。

方法名输入参数返回值作用
merge_row长度为4的list合并后的list单行去零合并
move方向整数0~3bool值执行移动并判断棋盘是否变化
get_available_cells坐标list返回空位列表
insert_random_tilebool值按90%/10%概率插入2或4

3. PlayerAI 与 ComputerAI:期望最大搜索的决策循环

3.1 从 Minimax 到 Expectimax:为什么多了一个“随机节点”

如果只看2048AI-master的目录,你会看到PlayerAI.pyComputerAI.py分别对应两个决策角色:PlayerAI决定玩家怎么滑动,ComputerAI决定新方块出现在哪里。与围棋或五子棋不同,2048 的“对手”不是一个有明确敌意的智能体,而是一个随机过程。因此在搜索树里,普通 Minimax 的 MIN 节点要替换成机会节点:遍历所有可能的空位,按 90%/10% 的概率加权计算期望值。这就是 Expectimax 的核心思想。

源码中BaseAI.py通常只定义了一个接口:

class BaseAI: def get_move(self, grid): pass

PlayerAI重写get_move,返回 0~3 之一;ComputerAI重写get_move,返回一个(agent, x, y)形式的动作,表示在坐标(x, y)放置方块。如果直接把BaseAI当成抽象基类使用,可以在get_move里抛NotImplementedError,防止误实例化。

3.2 搜索深度与递归终止条件

期望最大搜索的深度直接影响 AI 强度和运行时间。深度 2 时 AI 只能看到“这一步移动 + 下一步随机方块”,经常在后期做出短视决策;深度 4 时 AI 会考虑两步移动,计算量已经达到数十万节点;深度 6 在普通笔记本上会明显卡顿。项目默认深度一般取 3~4,并在递归函数中做剪枝。

def expectimax(self, grid, depth, player): if depth == 0 or grid.is_game_over(): return self.evaluate(grid) if player: # 玩家移动阶段,取最大期望值 best = -float('inf') for direction in range(4): next_grid = grid.clone() if next_grid.move(direction): v = self.expectimax(next_grid, depth - 1, False) best = max(best, v) return best else: # 随机方块阶段,计算期望值 available = grid.get_available_cells() if not available: return self.evaluate(grid) total = 0.0 cells = grid.get_available_cells() for cell in cells: grid.map[cell[0]][cell[1]] = 2 total += 0.9 * self.expectimax(grid, depth - 1, True) grid.map[cell[0]][cell[1]] = 4 total += 0.1 * self.expectimax(grid, depth - 1, True) grid.map[cell[0]][cell[1]] = 0 return total / len(cells)

这里有两个容易出错的点:随机阶段每个空位生成 2 或 4 的概率需要乘以出现的概率,但不同空位之间的概率是并列的,最终要除以空位数量取平均,而不是把所有可能直接相加。另一个是递归前必须先恢复棋盘状态,否则后续空位计算会读到上一次模拟留下的脏数据。grid.clone()是另一种选择,但 clone 的代价很高,所以在原地写入再回写是一种常见优化。

3.3 评估函数:四项启发式的加权求和

AI 的“棋感”完全由评估函数决定。最常见的 2048 评估函数参考 nneonneo 的开源实现,用四项指标加权:

def evaluate(self, grid): weights = { 'monotonicity': 47.0, 'smoothness': 0.1, 'empty': 270.0, 'max_value': 700.0 } return (weights['monotonicity'] * self.monotonicity(grid) + weights['smoothness'] * self.smoothness(grid) + weights['empty'] * len(grid.get_available_cells()) + weights['max_value'] * self.max_value(grid))

单调性衡量每行每列数字是否沿着一个方向递减或递增;平滑度统计相邻格子数字差值的绝对值和;空位数鼓励 AI 保留操作空间;最高值代表棋盘上最大数字。这些指标都以浮点数返回,max_value直接取最大格子的值。要注意权重是相对关系,不是越大越好——把empty权重调太高会导致 AI 只顾清空小格子,不敢合并大数字。

3.4 代码架构:BaseAI、BaseDisplayer 与 GameManager 的分工

从项目文件结构看,各模块职责很清晰。GameManager.py是总控制器,持有GridPlayerAIComputerAIDisplayer实例。主循环先调用PlayerAI.get_move(grid)获得玩家动作,执行后调用ComputerAI.get_move(grid)获得新方块位置,再刷新显示。BaseDisplayer是显示器抽象,Displayer.py把棋盘打印到终端,2048_GUI/main.py使用colors.py做图形界面渲染。

文件职责关键接口
BaseAI.py定义 AI 父类get_move(grid)
PlayerAI.py玩家方向决策返回 0~3 方向
ComputerAI.py模拟随机方块返回新方块坐标
BaseDisplayer.py显示器接口render(grid)
GameManager.py游戏流程控制start_game()

这种结构让无界面 AI 模式变得非常简单:只要不创建 GUI 窗口,直接把GameManagerrepaint改为空操作,就能跑完一整局不自嗨。2048_noUI.py正是利用了这一点。

4. 去掉图形界面的 AI:2048_noUI.py 与性能优化

4.1 无界面运行的入口与命令行参数

2048_noUI.py是专门为批量测试 AI 设计的入口,它不渲染窗口,只运行游戏循环并输出最终分数。常见做法是把GameManager的显示回调置空,然后循环若干局统计胜率。实际项目中可以用如下结构:

if __name__ == "__main__": total_2048 = 0 games = 100 for i in range(games): gm = GameManager() gm.set_ai(PlayerAI(), ComputerAI()) gm.run(show_display=False) score = gm.score if max(max(row) for row in gm.board) >= 2048: total_2048 += 1 print(f"{games} 局中达到 2048 的次数: {total_2048}")

这里的show_display=False是我习惯的约定,有的版本直接用if noUI: pass跳过渲染。批量测试时不要每局都重新加载模型或缓存,尽量让 AI 对象复用。若GameManager内部每次run()都会清空棋盘,重复使用也能保证每局独立。

4.2 缓存重复状态:用 frozenset 当字典键

期望最大搜索中存在大量重复棋盘状态。比如向左滑动后再向右,可能回到原来的布局;不同移动顺序也可能收敛到相同状态。给expectimax加一个字典缓存,能减少 30% 到 50% 的计算量。但list不能直接作为字典键,所以要把棋盘转换成不可变形式:

cache = {} def expectimax_with_cache(self, grid, depth, player): key = (depth, player, tuple(tuple(row) for row in grid.map)) if key in cache: return cache[key] if depth == 0 or grid.is_game_over(): value = self.evaluate(grid) cache[key] = value return value # ... 原有递归逻辑 ... cache[key] = result return result

这段代码把grid.map中的每一行转换成tuple,再用tuple嵌套成不可变结构。depthplayer也必须放进 key,因为同一棋盘在不同深度下评估值不同。如果同一局游戏不会复用缓存,建议在每局开始时清空cache,避免内存无限制增长——搜索 6 层时缓存可能达到几万条。

4.3 移动方向生成的位运算技巧

在纯 Python 实现里,遍历四个方向时频繁构建新棋盘是最大瓶颈。一个常用优化是把二维棋盘的每一行用一个整数表示,用位运算完成“去零、平移、合并”。4x4 棋盘、每格 4 bit,正好装进 64 位整数。简化版的行移动可以这样写:

def move_row_left(row_bits): # 提取 4 个方块值 tiles = [(row_bits >> (4 * i)) & 0xF for i in range(4)] non_zero = [t for t in tiles if t] merged = [] i = 0 while i < len(non_zero): if i + 1 < len(non_zero) and non_zero[i] == non_zero[i + 1]: merged.append(non_zero[i] + 1) # 数值翻倍等价于指数 +1 i += 2 else: merged.append(non_zero[i]) i += 1 merged += [0] * (4 - len(merged)) result = 0 for i, val in enumerate(merged): result |= (val & 0xF) << (4 * i) return result

注意这里存储的是指数的近似值:如果直接存方块大小 2、4、8,那么 2+2=4 不是一个简单移位;如果存指数 1、2、3,合并时变成 2,即指数加一,再存回 4 bit。这样既避免了大整数乘法,也让棋盘状态压缩成定长整数,缓存时可以直接用整数当 key,效率远高于tuple

4.4 剪枝与搜索顺序:先走“看起来好”的方向

Expectimax 本身不剪枝,但搜索顺序会影响剪枝效果。如果在玩家节点先计算评估值高的方向,那max阶段会更快找到较优值;在随机节点先处理对期望贡献大的空位,也能提前逼近真实期望。PlayerAI.get_move中可以先用当前评估函数对四个方向排序:

def get_move(self, grid): best_direction = -1 best_score = -float('inf') directions = [0, 1, 2, 3] directions.sort( key=lambda d: self.evaluate(self.simulate(grid, d)), reverse=True ) for direction in directions: next_grid = grid.clone() if next_grid.move(direction): score = self.expectimax(next_grid, self.depth - 1, False) if score > best_score: best_score = score best_direction = direction return best_direction

simulate(grid, d)会临时执行一次移动并返回新棋盘,排序完成后真正搜索时先搜高分方向。这样做并不改变最终结果,但配合缓存时能明显提升缓存命中率——因为高分方向往往是最终选择方向,后续测试中重复状态的访问顺序更稳定。

5. 调参技巧:让 AI 在 4096 分附近稳定下来

5.1 评估函数权重的取值经验

如果你拿到的PlayerAI.py是默认权重,直接运行时可能发现 AI 有时会在一堆小数字里绕圈。调参时优先动单调性权重和空格权重,这两个指标决定 AI 的“大局观”。下面是我在类似项目里常用的一组起始值:

指标低风险权重激进权重调整方向
单调性40~5030越高越保守,锁角
平滑度0.10.3越高越追求相邻相等
空格数250~300150越高越避免填满棋盘
最高值7001000越高越集中堆角

注意平滑度的分母可能影响量级:若你的平滑度指标是所有相邻差值的绝对值之和,这个值通常在几十到几百之间,权重只能给 0.1 量级。如果代码里对平滑度做了归一化处理,权重可以提升到 1.0 以上。改完权重后不要只看一局结果,至少要跑 10 局,因为随机方块分布会导致单局上下浮动很大。

5.2 用 test.py 做回归验证与方向诊断

test.py的作用不只是测 Grid,你可以把它改成一个统计脚本:连续跑 50 局,记录最高分、达到 2048 的次数、以及每一局前 200 步是否出现过“连续三步往同一个方向滑”的行为。有经验的判断方法很简单:观察 AI 的前几步,如果总是往同一方向堆,说明单调性权重过高;如果经常出现最大数字在棋盘中间,说明最高值权重不够。最后给出一个我常用的运行入口:

python 2048_noUI.py --games 20 --depth 4 --monotone 47 --smooth 0.1 --empty 270 --max 700

运行完成后,看终端输出的mean_scoretimes_2048。如果times_2048低于 15/20,先把深度从 3 调到 4,再小幅降低empty权重,每次只改一个参数。这样你能明确知道是哪一项影响最大,而不是一次性改完所有权重后无法定位问题。

本文还有配套的精品资源,点击获取

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

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

立即咨询