ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从2048游戏解析Expectimax算法与状态空间搜索的工程实践

从2048游戏解析Expectimax算法与状态空间搜索的工程实践 1. 从游戏到模型2048背后的数学与算法世界最近在整理过去的项目资料翻到了几年前参与Mathorcup数学建模竞赛时做的一个题目关于2048游戏的玩法分析与推广策略。虽然题目本身是好几年前的但其中涉及的算法思想、建模思路以及用Java实现核心逻辑的过程直到今天看依然很有嚼头。很多人可能觉得2048就是个简单的滑动拼图游戏消磨时间而已。但当你真正尝试去拆解它的规则用代码模拟它的状态甚至去预测最优策略时你会发现这个小小的4x4方格背后藏着一个关于状态空间、搜索算法和概率决策的微型宇宙。这不仅仅是参加一次数学建模竞赛更像是一次对离散数学、算法设计和程序实现的综合演练。无论你是对数学建模感兴趣想找点有挑战性的练手项目还是Java开发者想通过一个具体案例来深入理解算法与面向对象设计的结合甚至是游戏策划想分析经典机制的数学基础这个从“玩游戏”到“解构游戏”的过程都能给你带来不少启发。接下来我就结合当年的解题思路和后续的工程实践把这个“玩具项目”里那些值得深挖的细节掰开揉碎了讲清楚。2. 2048游戏的核心机制与数学模型抽象要分析一个游戏尤其是为其建立数学模型第一步必须是彻底理解并形式化其规则。2048的规则口头描述很简单在一个4x4的网格中每次操作上、下、左、右滑动会使所有格子朝该方向移动并合并相同数字的格子每次操作后会在空白处随机出现一个数字90%概率为210%概率为4。游戏目标是通过合并尽可能创造出数值为2048的格子。但要用数学语言描述它我们需要定义几个关键概念。2.1 游戏状态的形式化定义我们可以将4x4的网格定义为一个4x4的矩阵S。矩阵中的每个元素S[i][j]代表第i行、第j列格子中的数字。通常我们用0来表示空白格。因此一个游戏状态就是一个4x4的整数矩阵。但是直接使用数字不利于计算因为格子中的数字都是2的幂次方2, 4, 8, ..., 2048。一个更高效的表示方法是存储幂指数。例如数字2对应指数14对应指数22048对应指数11。这样合并操作就简化为指数加1因为2^n * 2^n 2^{n1}。在Java实现中我们可以用一个int[4][4]的数组来存储这些指数0仍然代表空白。2.2 滑动与合并操作的确定性算法这是整个游戏逻辑中最核心的部分也是代码实现的关键。以“向左滑动”为例其算法可以分解为以下几个步骤这些步骤对于其他三个方向是类似的只需调整遍历顺序。第一步逐行处理。游戏状态矩阵的每一行是独立进行滑动合并的。我们依次处理第0行到第3行。第二步行内紧凑化。对于单行例如[2, 0, 2, 4]我们首先要移除所有0将有效数字紧凑地排列在左侧。这个过程类似于数组的“去零并左移”。结果是[2, 2, 4, 0]。在代码中我们可以使用一个临时数组或指针来实现。第三步相邻合并。从左到右扫描紧凑后的行。如果当前元素和下一个元素相等且非零则将它们合并。合并后当前元素的值翻倍即指数加1下一个元素被置为0并且扫描索引要跳过下一个元素因为已经被合并了。这是为了防止连锁合并例如[2, 2, 2]在一次滑动中应该变成[4, 2, 0]而不是[8, 0, 0]。实现这个逻辑需要仔细控制循环索引。第四步再次紧凑化。合并操作可能会产生新的0被合并的格子需要再次执行紧凑化操作确保所有数字紧靠左侧对于左滑操作而言。最终这一行变为[4, 4, 0, 0]。第五步生成新方块。在所有行都处理完毕后检查本次滑动是否真正改变了游戏状态即矩阵是否发生了变化。如果状态改变了那么需要在当前所有的空白格值为0的位置中随机挑选一个以90%的概率放入2指数110%的概率放入4指数2。这个过程的确定性在于给定一个输入状态和一个方向输出的状态是唯一确定的除了随机新方块的位置和数值。在Java实现中我们需要为四个方向分别编写处理逻辑或者更优雅地通过矩阵旋转将其他方向的操作都转化为“向左滑动”来处理这能极大减少代码重复。2.3 状态空间与游戏树的复杂性理解了单步操作我们就能看到问题的规模。每个格子有18种可能的值0到2^17但实际游戏中最大到2^112048再大会有更多可能但常见分析到2048。那么理论上的状态空间是巨大的约18^16这是一个天文数字。但实际上可达状态要少得多因为数字必须是2的幂且通过合并产生。即便如此其数量仍然庞大到无法进行穷举搜索。这就引出了我们需要用启发式算法来寻找较优策略的根本原因。我们可以将游戏过程看作一棵树根节点是初始状态每次有四个可能的滑动方向作为边指向新的子节点。每个子节点又根据随机出现的新方块位置和数值衍生出多个可能的后续状态这是一个“机会节点”。我们的目标是在这棵巨大的、带有随机性的游戏中找到一条期望得分最高的路径。3. 解题策略从简单评估到智能搜索在当年的Mathorcup赛题中问题可能不仅要求模拟游戏还要求分析策略、评估算法效率甚至设计推广方案。这里我们聚焦在策略算法本身。如何让程序玩好2048从易到难主要有以下几种思路。3.1 基于简单启发式的贪心算法这是最直观的方法。我们不需要搜索未来多步只根据当前状态为四个可能的滑动方向计算一个“分数”然后选择分数最高的方向。这个分数的设计就是启发函数它体现了我们对“好局面”的理解。常用的启发式评估因子包括空白格数量空白格越多局面越灵活未来操作空间越大。这是最重要的因子之一。单调性评估每一行和每一列的数字是否呈递增或递减排列。一个单调的行/列更容易被合并。例如一行[128, 64, 32, 16]虽然数字不同但是严格递减向左滑动可以依次合并是理想状态。平滑性评估相邻格子之间数值的差异。差异越小越容易在下次滑动时合并。大数位置通常希望最大的数字待在角落比如左上角并且让较大的数字沿着某个边缘排列这样不容易打乱大数形成的“结构”。一个简单的启发函数可以是分数 w1 * 空白格数 w2 * 单调性得分 w3 * 平滑性得分。通过调整权重w1, w2, w3我们可以得到不同的游戏风格。贪心算法实现简单运行速度快但缺点也很明显它目光短浅容易陷入局部最优。比如有时为了获得一个立即的合并可能会破坏掉一个精心构建的单调结构。3.2 期望最大化搜索Expectimax算法为了克服贪心算法的短视我们需要向前看几步。但2048具有随机性新方块随机出现我们不能像在象棋中那样进行确定性的极大极小搜索。这时Expectimax算法就是一个非常合适的模型。它是博弈树搜索算法的一种用于处理带有随机对手或随机事件的游戏。在Expectimax中游戏树包含两种节点MAX节点代表我方决策点。在该节点我们选择能使后续期望效用最大化的动作。机会节点CHANCE节点代表随机事件点在2048中就是滑动后新方块的产生。在该节点我们需要计算所有可能随机结果新方块出现的位置和数值的期望效用。算法流程深度优先搜索可以描述为在MAX节点递归计算每个可能动作上、下、左、右后到达的子节点的效用值然后返回其中的最大值。在CHANCE节点我们需要枚举所有可能的新方块出现情况。对于一个有n个空白格的状态新方块有n个可能位置每个位置有2种可能数字2或4。但通常为了简化计算我们不会枚举所有2n种可能而是进行随机采样。我们假设新方块等概率地出现在每个空白格并且数字2和4按9:1的概率出现。然后我们取这些可能性的效用值的加权平均作为该机会节点的效用值。由于状态空间巨大我们无法搜索到游戏结束。因此需要设置一个搜索深度D。当到达深度D时我们不再继续搜索而是调用一个评估函数就是贪心算法里用的那个启发函数来给当前状态打分。这个评估函数的好坏直接决定了搜索算法的上限。Expectimax的Java实现关键点状态表示与克隆在递归搜索中需要频繁生成子状态。必须实现游戏状态的深拷贝避免修改原始状态。递归与剪枝基本的Expectimax搜索树非常庞大分支因子4个动作 * 随机性分支。即使深度为3计算量也很大。需要进行Alpha-Beta剪枝的变种或者限制随机性的采样数量例如在每个机会节点只随机模拟少数几种新方块出现的情况而不是枚举全部。评估函数优化这是算法的灵魂。除了上述的空白格、单调性等更高级的评估函数可能会用神经网络来学习状态的价值。// Expectimax算法的简化框架伪代码 public double expectimax(GameState state, int depth) { if (depth 0 || state.isGameOver()) { return evaluate(state); // 评估函数 } if (state.isChanceNode()) { // 上一个动作是我方做出的现在该随机事件发生 double totalUtility 0.0; ListGameState possibleNextStates generateRandomTiles(state); for (GameState nextState : possibleNextStates) { // 假设每种可能性的概率是 p totalUtility p * expectimax(nextState, depth); } return totalUtility / possibleNextStates.size(); // 返回期望效用 } else { // MAX节点该我方做决策 double bestUtility Double.NEGATIVE_INFINITY; for (Direction dir : Direction.values()) { GameState nextState state.move(dir); // 执行滑动 if (nextState.equals(state)) { continue; // 无效移动跳过 } double utility expectimax(nextState, depth - 1); if (utility bestUtility) { bestUtility utility; } } return bestUtility; } }在实际编程中为了性能我们通常会将搜索深度限制在3-5层并在机会节点进行蒙特卡洛采样比如只随机模拟4种新方块出现情况而不是求精确期望。即使这样一个优化良好的Expectimax算法也能在相当高的概率下合成2048。3.3 蒙特卡洛树搜索的适应性思考除了Expectimax蒙特卡洛树搜索也是一个值得尝试的方向尤其是在更复杂的游戏变体中。MCTS不依赖于一个手工设计的评估函数而是通过大量随机模拟Rollout来估计一个动作的长期价值。对于2048其基本步骤是选择从根节点当前状态开始使用树策略如UCT公式递归地选择子节点直到到达一个未完全展开的节点或叶子节点。扩展如果当前节点不是终止状态则为其添加一个或多个子节点即执行一个尚未被探索过的滑动动作。模拟从新扩展的节点开始使用一个简单的策略例如纯随机滑动或基于简单启发式的快速策略进行游戏直到游戏结束得到一个结果如最终的最大数字或分数。回溯将模拟得到的结果沿着选择路径反向传播更新路径上所有节点的访问次数和累计价值。经过多次迭代后选择根节点下访问次数最多的动作作为本次的决策。MCTS的优势在于它能动态地聚焦于更有希望的动作分支并且对评估函数的依赖较小。但其在2048中的挑战在于随机模拟直到游戏结束的路径可能很长导致单次迭代较慢。通常需要结合领域知识比如在模拟阶段使用一个快速的启发式策略来加速。4. Java工程实现模块化与性能考量将上述算法思想落地成可运行的Java代码是一个很好的软件工程练习。它涉及到类的设计、算法实现、性能优化和可交互性。4.1 核心类的设计一个清晰的设计通常包含以下几个类Tile: 代表一个格子包含其数值或幂指数属性。也可以简单用int表示。Board: 游戏棋盘的核心类。包含一个4x4的Tile矩阵或int矩阵。它应该提供以下关键方法boolean move(Direction dir): 向指定方向滑动返回滑动是否有效即是否改变了棋盘。void addRandomTile(): 在随机空白位置添加一个数字。boolean canMove(): 判断是否还有合法移动。Board copy(): 创建当前棋盘的深拷贝用于搜索算法。int getScore(): 计算当前分数通常为所有合并操作产生的数字之和。Game: 游戏主控类。包含一个Board实例控制游戏循环处理用户输入如果是人玩或调用AI决策。AI: 人工智能玩家接口。可以有不同的实现如GreedyAI、ExpectimaxAI、MCTSAI。提供一个Direction getMove(Board board)方法。Evaluator: 评估函数接口。不同的AI策略可以使用不同的评估函数实现。4.2 滑动合并算法的高效实现这是性能关键点。以向左滑动为例避免在每次操作中创建大量临时对象。public boolean moveLeft() { boolean changed false; for (int i 0; i SIZE; i) { int[] row grid[i]; // 1. 紧凑化 (左移) int writeIndex 0; for (int j 0; j SIZE; j) { if (row[j] ! 0) { row[writeIndex] row[j]; } } while (writeIndex SIZE) { row[writeIndex] 0; } // 2. 合并相邻相同项 for (int j 0; j SIZE - 1; j) { if (row[j] ! 0 row[j] row[j 1]) { row[j] * 2; // 合并数值翻倍 score row[j]; // 更新分数 row[j 1] 0; changed true; j; // 跳过下一个防止三重合并 } } // 3. 再次紧凑化 (因为合并产生了0) writeIndex 0; for (int j 0; j SIZE; j) { if (row[j] ! 0) { row[writeIndex] row[j]; } } while (writeIndex SIZE) { row[writeIndex] 0; } } return changed; }对于其他方向可以巧妙地通过矩阵旋转复用moveLeft的逻辑。例如向右滑动可以先将每一行反转然后调用moveLeft最后再反转回来。向上滑动可以先将矩阵转置然后调用moveLeft再转置回来。这能保证代码的简洁和正确性。4.3 搜索算法的优化技巧实现Expectimax或MCTS时性能是瓶颈。以下是一些优化手段位板表示高级的2048 AI通常使用位运算。因为数字都是2的幂可以用一个64位长整型long来表示整个4x4棋盘每个格子占用4个比特因为2^1532768指数最大154比特足够。滑动和合并操作可以通过预计算的查找表来实现速度极快。这是性能优化的终极手段。Alpha-Beta剪枝的变种在Expectimax中标准的Alpha-Beta剪枝不直接适用因为有机会节点。但存在一些针对随机性游戏的剪枝算法如*-*剪枝。迭代加深与时间控制不固定搜索深度而是进行迭代加深搜索并在每次迭代中检查是否超时。这样可以在有限时间内给出当前能算出的最好决策。评估函数缓存对经常出现的状态缓存其评估值避免重复计算。并行化搜索在机会节点对不同随机结果的模拟是相互独立的可以并行计算。5. 从模型到推广竞赛题目的延伸思考原赛题可能不仅限于算法还涉及“推广”。这可以从数学建模和软件工程两个角度延伸。5.1 游戏策略的模拟与统计分析我们可以编写程序让不同的AI策略贪心、Expectimax不同深度、MCTS进行大量对局例如10000局并收集数据合成2048的成功率。平均分数、最高分数。达到的最大数字的分布512, 1024, 2048, 4096...。平均游戏步数。通过统计分析我们可以定量比较不同策略的优劣并可能发现一些有趣的规律。例如过于注重当前合并的贪心策略可能平均分数不低但合成2048的概率远低于向前看3步的Expectimax。这些数据可以作为“策略分析报告”的核心内容也是数学建模中“模型检验与评估”环节的体现。5.2 可变规则下的模型泛化能力一个更有深度的研究方向是测试算法的泛化能力。修改游戏规则看我们的AI是否依然有效棋盘大小扩展到5x5或3x3。合并规则是否允许三重合并或者合并后的数字是否一定是2的幂新方块概率改变出现2和4的概率甚至引入数字8。胜利条件目标不再是2048而是别的数字。一个健壮的AI模型或评估函数应该在一定程度上适应这些变化。这考验的是我们对游戏本质机制的理解是否到位。例如如果评估函数过分依赖“空白格数量”那么在5x5棋盘上可能依然有效但如果胜利条件改变评估函数中关于“大数位置”的权重可能需要调整。5.3 构建可交互的演示程序作为推广载体如果目标是“推广”那么一个直观、可交互的演示程序比一份纯论文或代码更有说服力。我们可以用Java Swing或JavaFX开发一个图形界面程序它包含以下功能经典游戏模式用户手动玩。AI演示模式选择不同的AI算法观看AI自动游戏并以可视化的方式如高亮显示AI评估的下一步最佳移动方向、显示当前状态的评估分数展示AI的“思考过程”。对战模式让两个不同的AI同屏竞技比较其表现。数据统计面板实时显示当前算法的成功率、平均分等统计信息。这样的程序不仅能够生动展示数学模型和算法的成果也使得项目从一个单纯的竞赛解题变成了一个完整的、有产品感的软件作品。它可以直接用于教学演示让更多人直观理解搜索算法和启发式函数是如何工作的。在实现这样一个演示程序时需要注意将游戏逻辑Board,AI与界面显示GameView,ControlPanel彻底分离遵循模型-视图-控制器模式。这样更换AI算法或修改游戏规则时只需要改动核心逻辑模块界面部分无需大动。回过头看2048这个项目之所以经典就在于它用一个极其简单的规则搭建了一个足够复杂的系统让从算法新手到资深开发者都能找到挑战和乐趣。从理解规则、实现基础逻辑到设计AI、优化性能再到数据分析、产品化展示它像一条完整的链条贯穿了计算机科学和数学应用的多个层面。在实现过程中我最大的体会是清晰的模块划分是应对复杂逻辑的基础。早期我把所有逻辑塞在一个类里调试滑动算法时痛苦不堪。后来将棋盘状态、游戏规则、AI策略、评估函数彻底解耦不仅代码好维护更便于尝试不同的算法组合。另一个教训是不要过早优化。先用最清晰的方式实现Expectimax搜索让它能正确工作然后再考虑位运算、缓存等高级优化。否则很容易在复杂的优化代码中迷失连基本的正确性都无法保证。最后给想尝试的朋友一个建议不妨从实现一个能玩的、带简单贪心AI的版本开始记录它合成2048的成功率。然后逐步实现Expectimax并观察成功率的提升。这个从10%到80%甚至更高的提升过程会让你对“搜索深度”和“评估函数”的力量有最直观的感受。
返回列表